Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

DocWhat it covers
ArchitectureThe layered model (core → semantic → component → atoms → cells → molecules → organisms), dependency direction, the primordial atomic-design law.
GovernanceThe law — use first, extend second, create last: the decision ladder, what is forbidden in studio chrome, the escapes, the component contribution pipeline, enforcement.
UsageInstall, bootstrap the theme, the builder pattern, common recipes, how to consume the crate.
Guards & conventionsThe two enforcement tests (no_raw_values, no_painter_in_molecules), what they forbid, how to add a component without tripping them.

Foundation reference

DocWhat 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.
TypographyIosevka faces, weights, the named type styles (displaykbd), icon fonts.
Layout & auto-layoutPanel/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_extras 0.34 · egui-phosphor 0.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 Theme resolved into GraphTokens, geometry from core). The no_raw_values guard is extended to scan src/graph, so it has the same purity contract as atoms.
  • The no_painter_in_molecules guard 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 — scans src/atoms/** and src/graph/**, fails on hardcoded Color32::from_rgb, named Color32 constants, or raw FontId::new. Atoms (and the graph layer) must source colors from Theme/core and fonts from theme::typography.
  • tests/no_painter_in_molecules.rs — scans src/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/graph is deliberately not scanned — it is the sanctioned exception that paints (still via tokens, enforced by no_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_layout module even mirrors the engine HUD’s LayoutDirection/MainAlign/SizeMode vocabulary).
  • Theme-able — swap Mode::Dark/Light (or the zinc-neutral variants) at runtime with no consumer changes, because everything resolves through Theme::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 a Theme field (for colors). The no_raw_values guard enforces it.


Color ramps

Neutral base — Zinc (cool-neutral)

The temperature of every gray surface/border/text token. Tailwind/shadcn zinc, 50→950.

ConstRGBConstRGB
ZINC_50250 250 250ZINC_60082 82 91
ZINC_100244 244 245ZINC_70063 63 70
ZINC_200228 228 231ZINC_80039 39 42
ZINC_300212 212 216ZINC_90024 24 27
ZINC_400161 161 170ZINC_9509 9 11
ZINC_500113 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).

ConstRGB
TEAL_200153 246 228
TEAL_30094 234 212
TEAL_40045 212 191
TEAL_50020 184 166
TEAL_60013 148 136

Status hues — Tailwind 500

The semantic layer composites the soft *_bg variants by applying ~15% alpha (STATUS_BG_ALPHA = 38) to these.

ConstMeaningRGB
GREEN_500success34 197 94
RED_500error / destructive239 68 68
AMBER_500warning245 158 11
BLUE_400info (text)96 165 250
BLUE_500info (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.

ConstpxConstpx
SPACE_00SPACE_520
SPACE_14SPACE_624
SPACE_28SPACE_832
SPACE_312SPACE_1040
SPACE_416SPACE_1248

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.

ConstpxConstpx
RADIUS_NONE0RADIUS_LG8
RADIUS_SM4RADIUS_XL12
RADIUS_MD6RADIUS_FULL9999

RADIUS_NONE is the “square corners” sentinel (full-bleed rows, flush panels).

Shadows

Dark-tuned (high alpha to read on the zinc background).

ConstUseoffset / blur / alpha
SHADOW_SMfields, chips[0,1] / 2 / 61
SHADOW_MDcards, pills, popovers[0,2] / 4 / 82
SHADOW_LGmodals, 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).

ConstpxConstpx
TEXT_XS12TEXT_XL20
TEXT_SM13TEXT_2XL24
TEXT_BASE14TEXT_3XL30
TEXT_LG16

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 heightsCONTROL_SM 26 · CONTROL_MD 32 · CONTROL_LG 38. Icon boxICON_SM 14 · ICON_MD 16 · ICON_LG 20 · ICON_XL 24. StrokesBORDER_THIN 1 (divider) · BORDER_FOCUS 2 (focus ring) · RING_OFFSET 2 (gap to ring). Hit targetHIT_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 }
}
MethodSmMdLg
height()263238
icon_size()141620
pad_x()121616
text_style()¹labellabelbody_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.

DurationsDURATION_INSTANT 0 · DURATION_FAST 0.10 · DURATION_NORMAL 0.18 · DURATION_SLOW 0.30. DelaysDURATION_DELAY_SHORT 0.15 · DURATION_DELAY_LONG 0.50.

Easing enum

#![allow(unused)]
fn main() {
pub enum Easing { Linear, EaseOut /* default */, EaseInOut, Spring, Bounce }
}
VariantCurveUse
Linearidentityconstant motion
EaseOutdecelerateenter/hover (the default)
EaseInOutaccel then decelmoves/reorders
Springovershoot then settle (ease-out-back)playful enters, springy toggles
Bouncedecaying bounces (ease-out-bounce)drops, attention pulls

Easing::apply(t) maps a normalized progress t ∈ 0..=1 through the curve.


Opacity & overlays

ConstValueMeaning
OPACITY_DISABLED0.5the disabled veil
OPACITY_MUTED0.7secondary/muted content
HOVER_OVERLAY0.06white veil over a surface on hover
PRESS_OVERLAY0.12stronger veil on press
SCRIMblack @ 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 Theme field references a core::* 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.

TokenDarkRole
background / foregroundZINC_950 / ZINC_50deepest layer — panels, window fill
card / card_foregroundZINC_900 / ZINC_50raised surface — cards, elevated panels
popover / popover_foregroundZINC_900 / ZINC_50floating surface — popovers, menus, tooltips
muted / muted_foregroundZINC_800 / ZINC_400inputs, secondary fills / labels, placeholders
disabled_foregroundZINC_600disabled text

Interactive

TokenDarkRole
primary / primary_foregroundTEAL_200 / ZINC_950default action — turquoise fill, dark text
secondary / secondary_foregroundZINC_800 / ZINC_50secondary action
accent / accent_foregroundZINC_800 / ZINC_50hover/active surface (shadcn accent, not brand)
destructive / destructive_foregroundRED_500 / ZINC_50destructive action

Borders & focus

TokenDarkRole
borderZINC_800default border / divider
border_strongZINC_700emphasized border
inputZINC_800input border
ringTEAL_300focus ring
hover_overlaywhite @ 6%hover veil (dark veil in light mode)
press_overlaywhite @ 12%pressed veil
scrimblack @ 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).

TokenSolidSoft *_bg
successGREEN_500green @ 15%
warningAMBER_500amber @ 15%
errorRED_500red @ 15%
infoBLUE_400blue @ 15%
neutralZINC_500zinc @ 15%

The four palettes

ConstructorLook
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 Mode doc-comment still describe Light as a “stub that resolves to Dark.” That is staleTheme::light() is fully populated and Theme::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()
}
}

resolve covers the two Mode variants (dark/light). The zinc-neutral palettes are opt-in via the constructors directly — install them with apply after 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:

  1. typography::register(&mut fonts) — loads the bundled Iosevka faces + Phosphor icons.
  2. 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

  1. Add the field to Theme in semantic.rs.
  2. Populate it in all four constructors (dark, light, zinc_dark, zinc_light), referencing only core::* — never a raw color (the guard will reject raw colors in atoms, and convention keeps semantic clean).
  3. 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.

StyleFace / weightSizeTrackingUse
display()Iosevka Bold30normallargest title
h1()Iosevka SemiBold24normalpage title
h2()Iosevka SemiBold20normalsection title
heading()Iosevka SemiBold16smsub-section heading
body()Iosevka Light14mddefault body text
body_strong()Iosevka Medium14mdemphasized body
label()Iosevka Light13lgdefault label
label_strong()Iosevka Medium13lgemphasized label
caption()Iosevka Regular12widesmall / caption
code()IosevkaTerm Regular13lginline code
kbd()IosevkaTerm Bold12widekeyboard 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).
  • kbd is 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)

ConstpxRole
SIDEBAR_WIDTH240left nav / tree sidebar
INSPECTOR_WIDTH300right properties / inspector
PANEL_MIN180min a resizable panel may shrink to
PANEL_MAX480max a resizable panel may grow to
TOOLBAR_HEIGHT40top toolbar
STATUSBAR_HEIGHT24bottom status bar

Content grid

GRID_COLUMNS 12 · GRID_GUTTER 16 (= SPACE_4) · CONTAINER_MAX 1200 (max readable width before centering).

Breakpoints (window width, px)

ConstpxBelow this
BREAKPOINT_COMPACT720compact — single column, collapsed panels
BREAKPOINT_NORMAL1024normal — one side panel
BREAKPOINT_WIDE1440wide — 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 }
}
MethodReturns
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

TypeVariantsMeaning
LayoutDirectionHorizontal, Vertical (default)the main axis children flow along
MainAlignStart (default), Center, Endalignment of the child block on the main axis
CrossAlignStart (default), Center, Endper-child alignment on the cross axis
GapFixed(px) (default 0), Autospacing; Auto = space-between (distributes leftover, ignores MainAlign)
SizeModeFixed(px), Hug (default), Fillper-child main-axis sizing
Sizing{ mode, min, max }a SizeMode plus optional px clamps (a bare SizeMode converts)

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

ModeWithout clampsminmax
Fixed(px)exactly pxfloors pxcaps px
Hugsizes to content (bounded by the budget)never shrinks below min, even when content is smallercaps content, even when content wants more
Fillshares leftover space with other fillsnever shrinks below min — a responsive column that won’t collapsestops 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:

  1. Measure pass A (bounded) — render each Fixed/Hug child invisibly, bounded on both axes by the frame’s budget (the available space), and clamp Hug by its min/max. Content can never measure wider than the panel it lives in.
  2. Resolve Fill — distribute the leftover main-axis space among Fill children, min/max-aware: whoever clamps is pinned and its excess is redistributed among the rest (the HUD solver’s distribute_fill).
  3. Measure pass B — measure each Fill child’s cross size at its resolved main size, so wrapping content (labels, alerts) reports its real height.
  4. Container sizing — the frame never exceeds a finite budget: with Fill, Gap::Auto, or non-Start align it claims the available main axis; otherwise it hugs content, clamped to the budget.
  5. Distribution — leftover space goes to: Auto → even gaps between children; Fill children → already consumed; otherwise → a start offset per MainAlign.
  6. 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_ds components. 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 patternUse 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
DragValueatoms::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_button is allowed — as a container. egui owns the popup placement; the rows inside it are cells::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_molecules guard. 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)

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:

  1. Chrome class — the raw-widget patterns from the table above, scanned across every studio crate. Escape: // ds-allow: <reason>.
  2. Color/paint class — literal Color32 values and FontId construction outside the CANVAS_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 (via GraphTokens) and geometry from core. That is why this guard scans it too.

Pattern flaggedWhyUse instead
Color32::from_*(…)hardcoded colora Theme field or core::* color
Color32::<CONST> (e.g. Color32::WHITE)named color constanta Theme/core color
FontId::new(…)hand-built fonttheme::typography (TypeStyle::font_id, icon_font)
Stroke::new(<digit>…)raw stroke widthcore::BORDER_THIN / BORDER_FOCUS
CornerRadius::same(<digit>…)raw radiuscore::RADIUS_*
.expand(<digit>…)raw offseta 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/graph is 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

  1. Builder + show(ui) -> Response. Every component, every layer. Required args in new, optional props as chainable setters, show consumes self and returns an egui::Response.
  2. 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.
  3. State transitions go through the shared helpers. Hover via core::hover_t, disabled via core::disabled_color, so every component animates identically.
  4. Domain extensions are marked. Where the system extends shadcn (status variants, Size densities), the addition is ours by intent — base variants/anatomy follow shadcn.
  5. 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

  1. src/atoms/<name>.rs — builder struct, show(self, ui) -> Response.
  2. Source all colors from Theme::get(ui) / core::*, fonts from typography, strokes/radii from core::*. No Color32::from_, no FontId::new, no raw-digit Stroke::new/CornerRadius::same/.expand.
  3. Animate state with core::hover_t / core::disabled_color.
  4. Register in src/atoms/mod.rs (pub mod + pub use).
  5. Add a storybook page.
  6. cargo test (guard 1 must stay green) + cargo fmt.

A new cell / molecule / organism

  1. File under the right layer dir; builder + show.
  2. Compose atoms only — no painting calls from the forbidden list. If you reach for a painter, stop and extract an atom first.
  3. Use auto_layout / egui layout for arrangement; use Surface (atom) for any fill/border/casing.
  4. Overlay organisms place with egui Modal/Area/Popup; the casing is either a Surface atom or themed egui visuals driven by Theme tokens — never hand-painted.
  5. 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

Display & feedback

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.


Molecules

14 compositions of atoms (and smaller molecules).

Forms

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, with fixed(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

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
  • identityNodeId/PortId/NodeKindId/PortSide/Port/Connection
  • canvasGraphView, GraphCtx, GraphResponse
  • stateGraphViewState + drag structs
  • tokensGraphTokens
  • nodeNodeFrame/NodeResult/NodeStatus + ctx.node
  • edgeEdgeStyle/EdgeResult + ctx.edge
  • handleHandleSpec/HandleVariant (ports)
  • searchNodeSearch palette
  • viewport — standalone world↔screen transform helper
  • extrasgrid, resizer, minimap, toolbar, controls

Page template

Every component page follows the same structure: what it isDesign (purpose, anatomy, variants/sizes/states, tokens consumed, a11y) → API (builder methods) → Usage (minimal + realistic examples) → CompositionNotes.

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 in theme.foreground. Initials are uppercased at render time.

  • Variants / sizes / states — three sizes, no interactive states (sense is hover only):

    SizeDiameter tokenType style
    Smcore::CONTROL_SM (26px)typography::caption()
    Md (default)core::CONTROL_MD (32px)typography::label()
    Lgcore::CONTROL_LG (38px)typography::body_strong()
  • Tokens consumedtheme.muted (disc fill), theme.foreground (initials), core::CONTROL_* (diameter), typography styles via theme::typography.

  • Accessibility — none beyond the bare Response; it allocates with Sense::hover() and emits no widget_info.

API

SignatureEffect
Avatar::new(initials: impl Into<String>) -> SelfConstruct with initials text.
.size(size: AvatarSize) -> SelfSet the size.
.sm(self) -> SelfShorthand for AvatarSize::Sm.
.lg(self) -> SelfShorthand for AvatarSize::Lg.
.show(self, ui: &mut Ui) -> ResponseAllocate, 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.tracking and 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 Button or Toggle.

  • 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 hover only).

    Variants (BadgeVariant): Default, Secondary, Destructive, Outline, Ghost, Link, Success, Warning, Info. Each maps to a BadgeTokens::* constructor; Link carries underline = true.

    SizePadding (x, y)Text style
    Sm(SPACE_1, SPACE_1)caption()
    Md (default)(SPACE_2, SPACE_1)caption()
    Lg(SPACE_3, SPACE_1)label()
  • Tokens consumedBadgeTokens (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; no widget_info.

API

SignatureEffect
Badge::new(text: impl Into<String>) -> SelfConstruct with label text.
.variant(variant: BadgeVariant) -> SelfSet variant.
.size(size: Size) -> SelfSet size (core::Size).
.sm(self) -> Self / .lg(self) -> SelfSize shorthands.
.secondary() / .destructive() / .outline() / .ghost() / .link() / .success() / .warning() / .info()Variant shorthands.
.dot(self) -> SelfShow a leading colored status dot.
.show(self, ui: &mut Ui) -> ResponsePaint 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_2 diameter and is filled with bt.foreground.
  • Link is the only variant that draws an underline (via BadgeTokens.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 use Toggle; for a boolean setting use Switch.

  • 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 a ButtonTokens::* constructor; Link underlines its label.

    SizeHeightIcon sizePad-xText style
    SmCONTROL_SM 26ICON_SM 14SPACE_3Size::text_style()
    Md (default)CONTROL_MD 32ICON_MD 16SPACE_4
    LgCONTROL_LG 38ICON_LG 20SPACE_4

    States: hover (theme.hover_overlay ramped by core::hover_t), pressed (theme.press_overlay), focused (focus ring), disabled (enabled(false) → colors via core::disabled_color, sense drops to hover), loading (spinner arc, clicks ignored, width preserved).

  • Tokens consumedButtonTokens (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 via focus::focus_ring_rect. Hit target = full allocated rect (square when icon_only).

API

SignatureEffect
Button::new(label: impl Into<String>) -> SelfConstruct with a label.
.variant(v: ButtonVariant) -> SelfSet variant.
.secondary() / .destructive() / .outline() / .ghost() / .link()Variant shorthands.
.size(s: Size) -> Self / .sm() / .lg()Size (core::Size).
.icon_only(self) -> SelfSquare button, label dropped.
.loading(loading: bool) -> SelfReplace content with a spinner; ignore clicks; preserve width.
.icon_left(glyph: &'static str) -> SelfLeading Phosphor glyph.
.icon_right(glyph: &'static str) -> SelfTrailing Phosphor glyph.
.enabled(enabled: bool) -> Self / .disabled()Enable/disable.
.id_source(id: impl Hash) -> SelfStable id for the hover animation (else response.id).
.show(self, ui: &mut Ui) -> ResponsePaint 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 of enabled; 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 in widget_info — pass a meaningful label for a11y even with icon_only.
  • The hover animation keys off id_source if set; give icon-only buttons in a loop a stable id_source to 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 use Radio.

  • Anatomy — square box (left, vertically centered) → optional primary fill when on → border stroke → hover veil → check/minus glyph when on → focus ring → optional label Text to the right.

  • Variants / sizes / states

    SizeBox size
    SmICON_SM 14
    Md (default)ICON_MD 16
    LgICON_LG 20

    States: checked (primary fill + light::CHECK), indeterminate (light::MINUS, takes visual precedence over checked, presentational only), hover (hover_t veil), focus (ring), disabled (disabled_color dim; sense drops to hover), non-interactive (interactive(false) — display-only, no toggle).

  • Tokens consumedtheme.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 via focus::focus_ring_rect. Hit target spans box + gap + label.

API

SignatureEffect
Checkbox::new(checked: &mut bool) -> SelfBind to a boolean.
.interactive(interactive: bool) -> SelfDisplay-only when false (no click/toggle).
.indeterminate(indeterminate: bool) -> SelfShow the mixed/dash state (visual precedence).
.size(s: Size) -> Self / .sm() / .lg()Size (core::Size).
.label(label: impl Into<String>) -> SelfAdd a trailing label.
.enabled(enabled: bool) -> Self / .disabled()Enable/disable.
.id_source(id: impl Hash) -> SelfStable id (else response.id).
.show(self, ui: &mut Ui) -> ResponseToggle 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 *checked and calls mark_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_SM corners) or circle, with a theme.border stroke. Fill = the supplied Color32.
  • Variants / sizes / states — square (default) or circle(). Size is a free f32 (default core::ICON_LG = 20px). Senses click but paints no hover/focus/disabled state.
  • Tokens consumedtheme.border (stroke), core::RADIUS_SM (square corners), core::ICON_LG (default size). The fill is intentionally non-token (consumer color).
  • Accessibility — bare Response from Sense::click(); no widget_info.

API

SignatureEffect
ColorSwatch::new(color: Color32) -> SelfConstruct with the color to display.
.size(size: f32) -> SelfSet side/diameter in px (default ICON_LG).
.circle(self) -> SelfRender as a circle instead of a rounded square.
.show(self, ui: &mut Ui) -> ResponsePaint 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 Color32 from the consumer — it deliberately bypasses the token system, unlike every other painted atom.
  • No picker is built in; show only returns the click. Wire it to your own color-editing UI.

See tokens · theming.

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 use SplitterHandle.

  • Anatomy — a single hline/vline stroke. Horizontal: width = ui.available_width(), allocated height = weight. Vertical: height = ui.available_height(), allocated width = weight.

  • Variants / sizes / states

    BuilderEffect
    horizontal()Axis::Horizontal, border color, BORDER_THIN.
    vertical()Axis::Vertical, border color, 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 hover only).

  • Tokens consumedtheme.border (default color), theme.destructive (when destructive()), core::BORDER_THIN (default weight), core::BORDER_FOCUS (when thick()).

  • Accessibility — bare hover Response; no widget_info.

API

SignatureEffect
Divider::horizontal() -> SelfConstruct a horizontal rule.
Divider::vertical() -> SelfConstruct a vertical rule.
.color(color: Color32) -> SelfOverride color.
.destructive(self) -> SelfUse the destructive token.
.thick(self) -> SelfUse BORDER_FOCUS weight.
.show(self, ui: &mut Ui) -> ResponsePaint 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.
  • Axis is re-exported and reused by SplitterHandle.

See tokens · theming.

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::Label built from a RichText carrying the level’s font_id, line_height, tracking, and theme.foreground. Wrap mode is Extend (no wrap).

  • Variants / sizes / states

    LevelType style
    Displaytypography::display() (largest)
    H1typography::h1()
    H2 (default)typography::h2()
    Headingtypography::heading() (smallest)

    No interactive states.

  • Tokens consumedtheme.foreground (color), the per-level typography style (font/size/line-height/tracking).

  • Accessibility — renders a non-selectable label; returns the Label’s Response.

API

SignatureEffect
Heading::new(content: impl Into<String>) -> SelfConstruct with title text.
.level(level: HeadingLevel) -> SelfSet the level.
.display() / .h1() / .h2() / .heading()Level shorthands.
.show(self, ui: &mut Ui) -> ResponseRender 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 always theme.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::Label with RichText::new(glyph).font(icon_font(size)).color(color).

  • Variants / sizes / states

    Size shorthandToken
    .sm()ICON_SM 14
    .md() (default)ICON_MD 16
    .lg()ICON_LG 20
    .xl()ICON_XL 24
    .size(f32)arbitrary px

    Color: defaults to theme.foreground; .muted()theme.muted_foreground; .color(c) → explicit. No interactive states.

  • Tokens consumedtheme.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 Label Response.

API

SignatureEffect
Icon::new(glyph: &'static str) -> SelfConstruct from a light::* constant.
.size(size: f32) -> SelfSet size in px.
.sm() / .md() / .lg() / .xl()Token-size shorthands.
.muted(self) -> SelfUse muted_foreground.
.color(color: Color32) -> SelfExplicit color.
.show(self, ui: &mut Ui) -> ResponseRender 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

  • glyph must 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 use NumericField.

  • Anatomy — filled rect (muted) → hover veil → inner frameless TextEdit (left-aligned, body font, foreground text, muted_foreground placeholder) → border stroke whose color/weight encodes state.

  • Variants / sizes / states

    SizeHeightPad-x
    SmCONTROL_SM 26SPACE_3
    Md (default)CONTROL_MD 32SPACE_4
    LgCONTROL_LG 38SPACE_4

    Border state (precedence): error → destructive @ BORDER_THIN; else focused → ring @ BORDER_FOCUS; else input @ BORDER_THIN. Disabled dims fill + border + text via disabled_color and uses add_enabled(false, …).

  • Tokens consumedtheme.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 TextEdit focus (the border switches to the ring). Width = ui.available_width().

API

SignatureEffect
Input::new(buf: &mut String) -> SelfBind to a string buffer.
.placeholder(text: impl Into<String>) -> SelfHint text when empty.
.error(error: bool) -> SelfForce the destructive border.
.size(s: Size) -> Self / .sm() / .lg()Size (core::Size).
.enabled(enabled: bool) -> Self / .disabled()Enable/disable.
.id_source(id: impl Hash) -> SelfStable id for the inner TextEdit (else auto-id).
.show(self, ui: &mut Ui) -> ResponseReturn 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 returned Response is the inner TextEdit’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_source so 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 (muted fill + border stroke, RADIUS_SM) with (SPACE_2, SPACE_1) padding around a centered kbd-style galley in muted_foreground.
  • Variants / sizes / states — none; single appearance, sense is hover only.
  • Tokens consumedtheme.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; no widget_info.

API

SignatureEffect
Kbd::new(keys: impl Into<String>) -> SelfConstruct with the key text.
.show(self, ui: &mut Ui) -> ResponsePaint 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 several Kbds 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 use Input.

  • Anatomy — filled rect (muted) → hover veil → inner content: a right-aligned DragValue, optionally flanked by (left) and + (right) ghost Buttons → border stroke encoding state.

  • Variants / sizes / states

    SizeHeight
    SmCONTROL_SM 26
    Md (default)CONTROL_MD 32
    LgCONTROL_LG 38

    Border state (precedence): error → destructive; else focused → ring @ BORDER_FOCUS; else input. Disabled dims and disables the DragValue + stepper buttons.

  • Tokens consumedtheme.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

SignatureEffect
NumericField::new(value: &mut f32) -> SelfBind to an f32.
.range(min: f32, max: f32) -> SelfClamp range (default -INF..INF).
.speed(speed: f32) -> SelfDrag sensitivity (default 0.1).
.step(step: f32) -> SelfStepper increment (default 1.0).
.stepper(self) -> SelfFlank with /+ buttons.
.suffix(suffix: impl Into<String>) -> SelfAppend a unit string.
.full_width(self) -> SelfFill the available width (drops the FIELD_NUM_W cap; the floor still applies).
.fixed_width(self) -> SelfPin 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) -> SelfForce the destructive border.
.size(s: Size) -> Self / .sm() / .lg()Size (core::Size).
.show(self, ui: &mut Ui) -> ResponseReturn 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 returned Response is the DragValue’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_width clamped to NUMERIC_MIN_W..=FIELD_NUM_W (stepper floors higher at NUMERIC_STEPPER_MIN_W) so numbers stay column-aligned. .full_width() drops the cap; .fixed_width() ignores available_width entirely and pins NUMERIC_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) + a primary fill rect from the left, width = track × fraction.
    • Bar (stepped): n equal pills with SPACE_1 gaps; the first round(fraction × n) are primary, the rest muted.
    • Ring: a muted circle stroke + a primary arc sweeping fraction × 360° from 12 o’clock.
  • Variants / sizes / states

    BuilderRender
    new(fraction)continuous bar, SPACE_2 tall, full width
    .steps(n)n segments (≥1)
    .circular()ring at CONTROL_LG (38px) diameter
    .circular_size(d)ring at diameter d

    No interactive states (sense is hover).

  • Tokens consumedtheme.muted (track), theme.primary (fill/arc), core::SPACE_2 (bar height / circular thickness via SPACE_1), core::SPACE_1 (segment gap & ring thickness), core::CONTROL_LG (default ring size).

  • Accessibility — bare hover Response; no widget_info.

API

SignatureEffect
Progress::new(fraction: f32) -> SelfConstruct; fraction clamped to 0..=1.
.steps(n: usize) -> SelfRender n discrete segments (min 1).
.circular(self) -> SelfRender as a ring at the default diameter.
.circular_size(size: f32) -> SelfRender as a ring at size diameter.
.show(self, ui: &mut Ui) -> ResponsePaint 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

  • fraction is 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.

See tokens · theming.

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 primary when selected else input) → hover veil → inner primary dot (radius × 0.5) when selected → focus ring → optional label Text.

  • Variants / sizes / states

    SizeCircle size
    SmICON_SM 14
    Md (default)ICON_MD 16
    LgICON_LG 20

    States: selected (border primary + inner dot), hover (hover_t veil), focus (ring), disabled (disabled_color; sense → hover), non-interactive (interactive(false) — display-only).

  • Tokens consumedtheme.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 via focus::focus_ring_circle. Hit target spans circle + gap + label.

API

SignatureEffect
Radio::new(selected: bool) -> SelfConstruct with current selection state.
.interactive(interactive: bool) -> SelfDisplay-only when false.
.size(s: Size) -> Self / .sm() / .lg()Size (core::Size).
.label(label: impl Into<String>) -> SelfAdd a trailing label.
.enabled(enabled: bool) -> Self / .disabled()Enable/disable.
.id_source(id: impl Hash) -> SelfStable id (else response.id).
.show(self, ui: &mut Ui) -> ResponsePaint 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, Radio does not take a &mut binding and does not mutate state — it only reports .clicked(). The caller updates the selected value. (No mark_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 use Progress.
  • Anatomy — a single muted-filled rounded rect (RADIUS_SM). When pulsing, its opacity oscillates via a sine of ui.input(time) between OPACITY_MUTED and 1.0, requesting a repaint each frame.
  • Variants / sizes / stateswidth(f32) (default: ui.available_width()), height(f32) (default SPACE_4 = 16px), still() to disable the pulse. No interactive states (sense hover).
  • Tokens consumedtheme.muted (fill), core::RADIUS_SM, core::SPACE_4 (default height), core::OPACITY_MUTED (pulse floor).
  • Accessibility — bare hover Response; no widget_info.

API

SignatureEffect
Skeleton::new() -> SelfConstruct (pulsing, full-width, 16px tall).
Skeleton::default()Same as new().
.width(width: f32) -> SelfFixed width.
.height(height: f32) -> SelfSet height.
.still(self) -> SelfDisable the pulse animation.
.show(self, ui: &mut Ui) -> ResponsePaint 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.

See tokens · theming.

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_1 tall, muted) → primary fill from left to thumb → circular primary thumb with a background stroke ring → hover veil on the thumb → focus ring on the thumb.

  • Variants / sizes / states

    SizeControl height (thumb area)
    SmICON_SM 14
    Md (default)ICON_MD 16
    LgICON_LG 20

    States: drag/click sets value (Sense::click_and_drag, mark_changed); hover (hover_t veil on thumb); focus (ring on thumb, only when enabled); disabled (disabled_color, sense → hover).

  • Tokens consumedtheme.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 explicit widget_info is set.)

API

SignatureEffect
Slider::new(value: &mut f32) -> SelfBind to an f32 (range defaults 0..=1).
.range(min: f32, max: f32) -> SelfSet the range.
.step(step: f32) -> SelfQuantize 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) -> ResponseDrag/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 (if step > 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 at BORDER_FOCUS, with the start angle = time × TAU so it spins.
  • Variants / sizes / states.sm() (ICON_SM 14), default ICON_MD 16, .lg() (ICON_LG 20), or .size(f32). Color defaults to muted_foreground, overridable via .color(c). No interactive states.
  • Tokens consumedtheme.muted_foreground (default color), core::ICON_SM/MD/LG (size), core::BORDER_FOCUS (stroke width + radius inset).
  • Accessibility — bare hover Response; no widget_info. Animates continuously (request_repaint).

API

SignatureEffect
Spinner::new() -> SelfConstruct (ICON_MD, muted_foreground).
Spinner::default()Same as new().
.size(size: f32) -> SelfSet diameter.
.sm() / .lg()Token-size shorthands.
.color(color: Color32) -> SelfOverride color.
.show(self, ui: &mut Ui) -> ResponsePaint 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.

See tokens · theming.

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 a click_and_drag hit-target → a centered border hairline (vline/hline) → on hover/drag/active, a ring overlay line at BORDER_FOCUS, ramped by hover_t. Sets ResizeHorizontal/ResizeVertical cursor while interacting.
  • Variants / sizes / states
    • line: Axis — orientation of the visible rule: Vertical for a left/right (horizontal) split, Horizontal for 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 consumedtheme.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 Response for the organism to interpret (drag_delta, double_clicked).

API

SignatureEffect
SplitterHandle::new(line: Axis) -> SelfConstruct; line = orientation of the visible rule.
.active(active: bool) -> SelfForce the highlighted state.
.show(self, ui: &mut Ui) -> ResponseAllocate 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::Vertical for a horizontal (left/right) split. This is the documented gotcha.
  • show consumes the whole ui.max_rect() as the hit-target; give it a dedicated child Ui sized to the desired grab width.

See tokens · theming.

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::Frame with corner radius, inner margin (padding), optional fill, optional stroke, optional shadow; runs content inside. When interactive, re-interacts the painted rect for clicks and draws a border_strong outline 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) (default RADIUS_LG), elevated() (adds SHADOW_MD), pad(f32) (default SPACE_4), interactive(), selected(bool).

    States: selected(true) overrides the border with a ring stroke at BORDER_FOCUS; interactive adds a border_strong hover outline.

  • Tokens consumedtheme.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 with Sense::click(); the returned response carries the click. Non-interactive surfaces return the frame’s response.

API

SignatureEffect
Surface::new() -> Self / Surface::default()Card fill, default border, RADIUS_LG, SPACE_4 padding.
.fill(f: SurfaceFill) -> SelfSet fill.
.muted() / .background() / .fill_none()Fill shorthands.
.border(b: SurfaceBorder) -> SelfSet border.
.border_none() / .border_strong()Border shorthands.
.radius(radius: f32) -> SelfCorner radius.
.elevated(self) -> SelfAdd SHADOW_MD.
.pad(padding: f32) -> SelfInner margin.
.interactive(self) -> SelfSense clicks + hover outline.
.selected(selected: bool) -> SelfRing border (overrides border).
.id_source(id: impl Hash) -> SelfStable 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

  • show returns egui::InnerResponse<R>.inner is your closure’s value, .response is the surface’s (click-bearing when interactive).
  • selected(true) takes precedence over any border(..) setting.
  • For repeated/clickable surfaces in a list, set id_source to 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 use Toggle.

  • 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

    SizeTrack heightThumb diameter
    SmICON_MD 16ICON_SM 14
    Md (default)ICON_LG 20ICON_MD 16
    LgICON_XL 24ICON_LG 20

    States: on (primary track) / off (border_strong track — chosen over muted so the thumb stays legible in dark mode); hover (hover_t veil); focus (ring); disabled (disabled_color, sense → hover).

  • Tokens consumedtheme.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 via focus::focus_ring_rect.

API

SignatureEffect
Switch::new(on: &mut bool) -> SelfBind to a boolean.
.enabled(enabled: bool) -> Self / .disabled()Enable/disable.
.size(s: Size) -> Self / .sm() / .lg()Size (core::Size).
.id_source(id: impl Hash) -> SelfStable id for animation/interaction (else response.id).
.show(self, ui: &mut Ui) -> ResponseToggle 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 *on and calls mark_changed(); check .changed().
  • The thumb position animates over DURATION_FAST keyed by id_source (or response.id) — set id_source for switches in loops to avoid animation cross-talk.
  • Off-state track is border_strong, intentionally not muted, 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::Label built from RichText with the role’s font_id + tracking + color, optional underline, and a wrap mode.

  • Variants / sizes / states

    TextRole → type style:

    RoleStyle
    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 consumedtheme.foreground / theme.muted_foreground (color), the per-role typography style.

  • Accessibility — rendered non-selectable so UI text never captures the pointer (it would steal clicks from interactive parents and show a text cursor).

API

SignatureEffect
Text::new(content: impl Into<String>) -> SelfConstruct with text.
.role(role: TextRole) -> SelfSet role.
.body_strong() / .label() / .label_strong() / .caption() / .code() / .kbd()Role shorthands.
.muted(self) -> SelfUse muted_foreground.
.color(color: Color32) -> SelfExplicit color (e.g. theme.success).
.wrap(self) -> SelfWrap on available width (default: extend/no wrap).
.underline(self) -> SelfUnderline (e.g. a link).
.italic(self) -> SelfItalicize (e.g. an aside/hint nuance).
.show(self, ui: &mut Ui) -> ResponseRender 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 multiline TextEdit (body font, foreground text, muted_foreground placeholder, SPACE_2 inset) → 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; else input. Disabled dims and uses add_enabled(false, …). (No size-scale variants, unlike Input.)
  • Tokens consumedtheme.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 TextEdit focus; the border switches to the ring on focus.

API

SignatureEffect
Textarea::new(buf: &mut String) -> SelfBind to a string buffer (3 rows).
.rows(rows: usize) -> SelfVisible row count (min 1).
.placeholder(text: impl Into<String>) -> SelfHint text when empty.
.error(error: bool) -> SelfForce the destructive border.
.enabled(enabled: bool) -> Self / .disabled()Enable/disable.
.id_source(id: impl Hash) -> SelfStable id for the inner TextEdit (else auto-id).
.show(self, ui: &mut Ui) -> ResponseReturn 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 returned Response is the inner TextEdit’s.
  • Height is fixed by rows (it does not auto-grow with content); width is ui.available_width().
  • Unlike Input, there is no Size variant — only rows.

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 use Button.
  • Anatomy — a rounded rect (RADIUS_MD) that is transparent off, hover_overlay on hover, and accent-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 size ICON_MD (16). No size variants. States: on (accent fill), hover (hover_overlay), disabled (disabled_color, sense → hover). No focus ring.
  • Tokens consumedtheme.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

SignatureEffect
Toggle::new(on: &mut bool) -> SelfBind to a boolean.
.icon(glyph: &'static str) -> SelfLeading Phosphor glyph.
.label(label: impl Into<String>) -> SelfAdd a label.
.enabled(enabled: bool) -> Self / .disabled()Enable/disable.
.id_source(id: impl Hash) -> SelfStable id.
.show(self, ui: &mut Ui) -> ResponseToggle 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 *on and calls mark_changed() — check .changed().
  • With no label, it renders icon-only and square; the id_source field exists but the on/hover fill is not animated (it is an instantaneous overlay, no hover_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 Text atom (default Body role / foreground color).
  • Variants / sizes / states — none; shown only while the host response is hovered.
  • Tokens consumed — indirectly via the Text atom (typography body, theme.foreground). The hover container chrome is egui’s default styling.
  • Accessibility — uses egui’s on_hover_ui (hover-triggered).

API

SignatureEffect
Tooltip::new(text: impl Into<String>) -> SelfConstruct with the tooltip text.
.show(self, response: Response) -> ResponseAttach 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

  • show takes and returns a Response — it wraps an already-shown widget rather than allocating in a Ui. 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: optional Icon (muted) + a vertical stack of Text (title) and an optional muted caption Text (subtitle).

  • Variants / states

    StateEffect
    defaultSurface::fill_none().border_none() (transparent)
    selected (selected(true))Surface::muted() fill
    hover/clickSurface::interactive() provides hover feedback + sense; the returned Response carries .clicked()
  • Tokens / layout consumedcore::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 (muted fill), so pair with a real selection model in the consumer.

API

MethodSignatureEffect
newnew(title: impl Into<String>) -> SelfConstruct with a title; icon/subtitle/selected default off.
iconicon(self, glyph: &'static str) -> SelfLeading muted icon (a phosphor glyph).
subtitlesubtitle(self, subtitle: impl Into<String>) -> SelfSecond line, rendered caption + muted.
selectedselected(self, selected: bool) -> SelfToggle the muted selected fill.
id_sourceid_source(self, id: impl std::hash::Hash) -> SelfStable id for the underlying Surface (needed when rows share otherwise-equal layout).
showshow(self, ui: &mut Ui) -> ResponseRender; 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: ListItem never remembers it. Drive it from a usize/HashSet in 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 muted Icon + a Text label + (right-to-left) an optional Kbd shortcut.

  • Variants / states

    StateEffect
    default (enabled(true))Surface::interactive() — hover feedback + click sense
    disabled (enabled(false))Surface is non-interactive (no hover/sense)
    with shortcuttrailing Kbd pinned right via Layout::right_to_left(Align::Center)
    checkable (checked(true))leading check-mark Icon before icon/label (shadcn CheckboxItem)
    checkable (checked(false))the mark’s slot (core::ICON_MD + core::SPACE_2) is reserved so checked/unchecked siblings stay aligned
  • Tokens / layout consumedcore::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

MethodSignatureEffect
newnew(label: impl Into<String>) -> SelfConstruct with a label; enabled defaults true.
iconicon(self, glyph: &'static str) -> SelfLeading muted icon (phosphor glyph).
shortcutshortcut(self, shortcut: impl Into<String>) -> SelfRight-aligned Kbd shortcut text.
enabledenabled(self, enabled: bool) -> SelfWhen false, the surface is not interactive.
checkedchecked(self, checked: bool) -> SelfCheckable 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_sourceid_source(self, id: impl std::hash::Hash) -> SelfStable id for the underlying Surface.
showshow(self, ui: &mut Ui) -> ResponseRender; 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 by Kbd; it does not bind a real accelerator — wire the key handling separately.
  • Give each row a distinct id_source to avoid surface id collisions.
  • checked only displays state — flip your own bool on .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_MD holding a muted Text, followed by the consumer-provided control.
  • Variants / states — None of its own; visual state lives in the control closure.
  • Tokens / layout consumedlayout::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

MethodSignatureEffect
newnew(label: impl Into<String>) -> SelfConstruct with the label; label_width defaults to layout::PROPERTY_LABEL_WIDTH (120px).
label_widthlabel_width(self, width: f32) -> SelfOverride the label column width (e.g. for wider inspectors).
showshow(self, ui: &mut Ui, control: impl FnOnce(&mut Ui) -> Response) -> ResponseRender 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 control closure must return a Response (e.g. the inner widget’s show(ui) result); PropertyRow::show forwards 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_width consistent 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 PropertyRow is fine.
  • Anatomy
    • Wide (available_width >= threshold): a label slot of label_width × core::CONTROL_MD holding a muted Text, 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_1 gap, then the control filling the width.
  • 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 consumedlayout::PROPERTY_LABEL_WIDTH (default label column), layout::INSPECTOR_ROW_STACK_MIN (default stack threshold), core::CONTROL_MD, core::SPACE_1.
  • Layering — cell: composes the Text atom + the consumer control; never paints (the no_painter_in_molecules guard scans cells).
  • Accessibility — inherits from the embedded control.

API

SignatureEffect
ResponsiveRow::new(label: impl Into<String>) -> SelfA row labelled label; defaults PROPERTY_LABEL_WIDTH / INSPECTOR_ROW_STACK_MIN.
.label_width(width: f32) -> SelfOverride the aligned label-column width (wide layout).
.threshold(px: f32) -> SelfOverride the available width below which the row stacks.
.show(self, ui: &mut Ui, control: impl FnOnce(&mut Ui) -> Response) -> ResponseLay 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.
  • PropertyRow is not replaced: pick ResponsiveRow when the row sits in a resizable inspector that can get narrow; keep PropertyRow for 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 Table organism (and ad-hoc fixed-width row layouts). Use text(...) for the common case, custom(...) to embed any widget.

  • Anatomy — A ui.with_layout(...) block: leading core::SPACE_2 pad, optional circular ColorSwatch status dot + core::SPACE_1 gap, then the content — a Text atom (weight/muted per flags) or the custom closure’s widget.

  • Variants / states

    ModifierEffect
    text(s)text content (default)
    custom(add)arbitrary widget content
    header()Text::label_strong() (stronger weight)
    muted()Text::muted() foreground (text content only; ignored for custom)
    status(color)leading circular color dot, core::SPACE_2 diameter
    align: align(CellAlign) / center() / end()content alignment within the cell
  • Tokens / layout consumedcore::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:

VariantLayout
Start (default)Layout::left_to_right(Align::Center)
CenterLayout::centered_and_justified(Direction::LeftToRight)
EndLayout::right_to_left(Align::Center)

API

MethodSignatureEffect
texttext(text: impl Into<String>) -> SelfText cell (the default content kind).
customcustom(add: impl FnMut(&mut Ui) + 'a) -> SelfCell holding an arbitrary widget via the closure.
headerheader(self) -> SelfRender as a header (strong text weight).
alignalign(self, align: CellAlign) -> SelfSet horizontal alignment.
centercenter(self) -> SelfShorthand for align(CellAlign::Center).
endend(self) -> SelfShorthand for align(CellAlign::End).
statusstatus(self, color: Color32) -> SelfLeading status dot in color.
mutedmuted(self) -> SelfMuted text foreground (text cells only).
showshow(self, ui: &mut Ui) -> ResponseFill 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 for custom content (only text honors it).
  • The cell fills the width it is handed; the surrounding column width is set by the Table organism (via egui_extras) — the cell itself does not size the column.
  • 'a lifetime: a custom closure may borrow from the surrounding scope, which propagates through TableRow<'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 rendererTableRow 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 one TableRow per data row and pass the collection to Table::rows(...).

  • Anatomy — Fields (crate-visible, consumed by the organism): cells: Vec<TableCell<'a>>, selected: bool, selectable: bool (default true), key: Option<u64>.

  • Variants / states

    ModifierEffect
    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

MethodSignatureEffect
newnew(cells: impl IntoIterator<Item = TableCell<'a>>) -> SelfBuild a row from its cells; selected=false, selectable=true, key=None.
selectedselected(self, selected: bool) -> SelfMark the row selected (highlighted by the organism).
selectableselectable(self, selectable: bool) -> SelfWhether the row may be selected (default true).
keykey(self, key: u64) -> SelfStable identity for selection / tree operations.

There is no showTableRow 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 key when the table supports selection or tree state so identity survives reordering.
  • The 'a lifetime flows from TableCell<'a> (a custom cell 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 Toggle in .icon(glyph) mode bound to the &mut bool, optionally decorated by a Tooltip over the resulting response.

  • Variants / states

    StateSource
    active / inactivethe bound &mut bool (mutated in place by Toggle)
    tooltip on hoverpresent only when tooltip(...) was set
  • Tokens / layout consumed — Inherited from the Toggle atom (sizing/fill); ToolbarButton adds none of its own. See tokens.

  • Accessibility — Tooltip provides the textual label for an otherwise icon-only control; prefer always setting tooltip(...).

API

MethodSignatureEffect
newnew(active: &'a mut bool, glyph: &'static str) -> SelfBind to the active flag with a phosphor glyph.
tooltiptooltip(self, tooltip: impl Into<String>) -> SelfHover tooltip text.
id_sourceid_source(self, id: impl std::hash::Hash) -> SelfStable id forwarded to the underlying Toggle.
showshow(self, ui: &mut Ui) -> ResponseRender 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: active is &mut bool — the toggle flips it in place; there is no selected/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 caret Icon (CARET_DOWN/CARET_RIGHT, small, muted) or an core::ICON_SM spacer when not expandable, optional muted Icon, then the Text label.

  • Variants / states

    StateEffect
    defaultSurface::fill_none().border_none()
    selected (selected(true))Surface::muted() fill
    expandable + collapsedlight::CARET_RIGHT caret
    expandable + expandedlight::CARET_DOWN caret
    not expandablecore::ICON_SM spacer (keeps labels aligned with carets)
    indentdepth as f32 * core::SPACE_4 leading space
  • Tokens / layout consumedcore::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

MethodSignatureEffect
newnew(label: impl Into<String>) -> SelfConstruct at depth=0, not expandable, not selected.
depthdepth(self, depth: usize) -> SelfIndent level (× core::SPACE_4).
iconicon(self, glyph: &'static str) -> SelfLeading muted icon (after the caret slot).
expandableexpandable(self, expanded: bool) -> SelfMark expandable and set the expanded flag (caret direction) in one call.
selectedselected(self, selected: bool) -> SelfToggle the muted selected fill.
id_sourceid_source(self, id: impl std::hash::Hash) -> SelfStable id for the underlying Surface.
showshow(self, ui: &mut Ui) -> ResponseRender; 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_SM spacer 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.

  • AnatomySurface container, padded SPACE_3 → horizontal row of a status Icon (accent-colored) + a vertical text stack of an optional accent Text title (body_strong) and the muted message body.

  • Variants / states

    VariantGlyph (egui_phosphor::light)Theme color
    Info (default)INFOtheme.info
    SuccessCHECK_CIRCLEtheme.success
    WarningWARNINGtheme.warning
    ErrorWARNING_CIRCLEtheme.error
  • Tokens / layout consumedcore::SPACE_3 (surface pad), SPACE_2 (icon→text gap), SPACE_1 (title→message gap); colors from Theme. See tokens.

API

MethodEffect
Alert::new(message: impl Into<String>) -> SelfConstruct with the body message; variant defaults to Info.
.title(title: impl Into<String>) -> SelfOptional accent-colored title above the message.
.variant(variant: AlertVariant) -> SelfSet the variant explicitly.
.info() / .success() / .warning() / .error() -> SelfSugar for .variant(...).
.show(self, ui: &mut Ui) -> ResponseRender; returns the surface’s Response.

AlertVariantInfo (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 show time via Theme::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.
  • Anatomyui.horizontal row of: per non-last item a small link Button followed by a muted CARET_RIGHT Icon; the final item a body_strong Text.
  • Tokens / layout consumed — none directly; relies on atom sizing (Button::sm, Icon::sm). See tokens.

API

MethodEffect
Breadcrumb::new() -> SelfEmpty trail. (Default also available.)
.items<S: Into<String>>(items: impl IntoIterator<Item = S>) -> SelfSet 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 a Response — 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.

  • AnatomySurface::elevated() (padded by size) → vertical stack of:

    1. Header (only if title/description/action set): horizontal row of a vertical Heading title + muted caption Text description, with the action closure laid out right-to-left at the top-right.
    2. Content — your content closure, always run.
    3. Footer (if set): a Divider then your footer closure.
  • Sizes

    CardSizepadheader/footer gap
    DefaultSPACE_4SPACE_3
    SmSPACE_3SPACE_2
  • Tokens / layout consumedcore::SPACE_4 / SPACE_3 / SPACE_2 / SPACE_1; elevation from Surface. See tokens.

API

MethodEffect
Card::new() -> SelfEmpty card, CardSize::Default. (Default also available.)
.title(title: impl Into<String>) -> SelfHeader title (renders a Heading).
.description(description: impl Into<String>) -> SelfHeader sub-text (muted caption).
.action(action: impl FnOnce(&mut Ui) + 'a) -> SelfTop-right header slot — a button/menu/badge.
.footer(footer: impl FnOnce(&mut Ui) + 'a) -> SelfFooter slot below a divider.
.size(size: CardSize) -> SelfSet the spacing scale.
.sm() -> SelfSugar for CardSize::Sm.
.show(self, ui: &mut Ui, content: impl FnOnce(&mut Ui)) -> ResponseRender; content is the card body. Returns the surface Response.

CardSizeDefault (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: the action/footer closures are boxed (Box<dyn FnOnce(&mut Ui) + 'a>) and may borrow from the surrounding scope.
  • content is a plain impl 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.
  • AnatomySurface::new().interactive().selected(checked) padded SPACE_3 → horizontal row of a display Checkbox (.interactive(false)) + a vertical stack of a body_strong Text label and an optional muted caption description.
  • States — selected visual driven by Surface::selected(*checked); hover/press handled by Surface::interactive().
  • Tokens / layout consumedcore::SPACE_3 (pad + checkbox→text gap). See tokens.

API

MethodEffect
CheckboxCard::new(checked: &'a mut bool, label: impl Into<String>) -> SelfBind state + set label.
.description(description: impl Into<String>) -> SelfOptional muted caption under the label.
.id_source(id: impl std::hash::Hash) -> SelfStable surface id (use when several share a frame).
.show(self, ui: &mut Ui) -> ResponseRender; 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: show writes *self.checked = !*self.checked when the surface is clicked.
  • The inner checkbox is mirrored display state (.interactive(false)), so it doesn’t compete for the click.
  • Set id_source to 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_DOWN when open, CARET_RIGHT when closed) + SPACE_1 + a body_strong Text title. When open, SPACE_2 then the content closure.
  • States — open / closed, toggled by clicking the header. Persisted via ui.data temp storage under the resolved id.
  • Tokens / layout consumedcore::SPACE_1 (caret→title), SPACE_2 (header→content). See tokens.

API

MethodEffect
Collapsible::new(title: impl Into<String>) -> SelfConstruct; defaults to closed.
.default_open(open: bool) -> SelfInitial open state when no persisted value exists.
.id_source(id: impl std::hash::Hash) -> SelfExplicit id for persistence + interaction (defaults to format!("collapsible::{title}")).
.show(self, ui: &mut Ui, content: impl FnOnce(&mut Ui)) -> ResponseRender; 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.data temp memory under the resolved id; the interaction id is id.with("header").
  • The returned Response is 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.
  • Anatomyui.horizontal row of: a ColorSwatch showing the current color, SPACE_2, then an Input holding the hex string (#RRGGBB). A popover menu anchored on the swatch hosts egui’s color_picker_color32.
  • States — alpha editing optional via .alpha(true) (switches the picker’s Alpha mode from Opaque to OnlyBlend).
  • Tokens / layout consumedcore::SPACE_2 (swatch→input gap), core::CONTROL_LG * 6.0 (picker popover max width). See tokens.

API

MethodEffect
ColorField::new(color: &'a mut Color32) -> SelfBind the color. Alpha off by default.
.alpha(alpha: bool) -> SelfEnable editing the alpha channel in the picker.
.id_source(id: impl std::hash::Hash) -> SelfStable id for the hex input (defaults to "color_field").
.show(self, ui: &mut Ui) -> ResponseRender; 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_source per field — the default "color_field" collides if several share a frame.
  • The returned Response is 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 by FieldGroup/FieldSet, divided by FieldSeparator.
  • Anatomy (Field) — A label row (Text::label + an optional * in theme.destructive when required) and a control closure, with a hint or error caption below. Layout is vertical or horizontal per orientation.
  • Tokens / layout consumedcore::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.error and 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

MethodEffect
Field::new(label: impl Into<String>) -> SelfConstruct with a label; orientation Vertical.
.required() -> SelfAppend a destructive * after the label.
.hint(hint: impl Into<String>) -> SelfMuted caption below the control (suppressed if an error is set).
.error(error: impl Into<String>) -> SelfError caption below the control (takes priority over hint).
.orientation(orientation: FieldOrientation) -> SelfSet layout mode.
.horizontal() -> SelfSugar for FieldOrientation::Horizontal.
.responsive() -> SelfSugar for FieldOrientation::Responsive.
.show(self, ui: &mut Ui, control: impl FnOnce(&mut Ui) -> Response) -> ResponseRender; returns the control’s Response (not a wrapper).

Note the closure signature: control must return a Response (e.g. the return of an Input/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.

VariantBehavior
Vertical (default)Label above, control below.
HorizontalLabel and control side by side (SPACE_4 gap), hint/error under the control.
ResponsiveHorizontal 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

MethodEffect
FieldGroup::new() -> SelfConstruct. (Default also available.)
.show(self, ui: &mut Ui, content: impl FnOnce(&mut Ui)) -> ResponseRender 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

MethodEffect
FieldSet::new() -> SelfConstruct. (Default also available.)
.legend(legend: impl Into<String>) -> SelfOptional heading label above the content (Text::label).
.show(self, ui: &mut Ui, content: impl FnOnce(&mut Ui)) -> ResponseRender 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

MethodEffect
FieldSeparator::new() -> SelfPlain divider. (Default also available.)
.label(label: impl Into<String>) -> SelfAdd a centered muted caption (e.g. "OR").
.show(self, ui: &mut Ui) -> ResponseRender; 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’s control closure must return a Response — 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.
  • Responsive reads available_width live, 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.
  • AnatomySurface::muted().pad(SPACE_1).radius(RADIUS_MD) → vertical stack: a BlockStart row (if any) → the field → a BlockEnd row (if any). In single-line mode the field is a fixed-height (CONTROL_MD) center-aligned row of LeadingInline addons + a frameless TextEdit::singleline (placeholder + text styled from typography) + TrailingInline addons. In multiline mode it’s a Textarea (inline addons ignored).
  • Slots (Slot)LeadingInline, TrailingInline, BlockStart, BlockEnd.
  • Addon kinds — icon (muted Icon), text (muted Text), button (ghost icon-only Button, runs an FnMut on click).
  • Tokens / layout consumedcore::SPACE_1 (surface pad / block gaps), SPACE_2 (addon spacing + trailing reserve), CONTROL_MD (inline row height), RADIUS_MD. See tokens.

API

MethodEffect
InputGroup::new(buf: &'a mut String) -> SelfBind the text buffer.
.placeholder(text: impl Into<String>) -> SelfHint text shown when empty.
.multiline(rows: usize) -> SelfSwitch to a Textarea of rows rows (≥1; inline addons ignored).
.id_source(id: impl std::hash::Hash) -> SelfStable id for the editor.
.icon(slot: Slot, glyph: &'static str) -> SelfAdd an icon addon in slot.
.text(slot: Slot, text: impl Into<String>) -> SelfAdd a text addon in slot.
.button(slot: Slot, glyph: &'static str, action: impl FnMut() + 'a) -> SelfAdd a clickable icon-button addon; action runs on click.
.leading_icon(glyph: &'static str) -> SelfSugar — icon in LeadingInline.
.trailing_icon(glyph: &'static str) -> SelfSugar — icon in TrailingInline.
.leading_text(text: impl Into<String>) -> SelfSugar — text in LeadingInline.
.show(self, ui: &mut Ui) -> ResponseRender; returns the field Response (.changed() on edit).

SlotLeadingInline, 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 button action is Box<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 Response is the field’s; .changed() is true when the text was edited this frame.
  • SearchField is 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.
  • AnatomySurface::new().interactive().selected(selected) padded SPACE_3 → horizontal row of a display Radio (.interactive(false)) + a vertical stack of a body_strong Text label and an optional muted caption description.
  • States — selected visual driven by Surface::selected(selected); hover/press by Surface::interactive(). Selection itself is caller-managed.
  • Tokens / layout consumedcore::SPACE_3 (pad + radio→text gap). See tokens.

API

MethodEffect
RadioCard::new(selected: bool, label: impl Into<String>) -> SelfSet the selected visual + label. Note selected is a plain bool, not a binding.
.description(description: impl Into<String>) -> SelfOptional muted caption under the label.
.id_source(id: impl std::hash::Hash) -> SelfStable surface id (use one per card).
.show(self, ui: &mut Ui) -> ResponseRender; 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 takes selected: 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 Radio atoms (Radio::new(selected == i).label(option)), each spaced by SPACE_1.
  • States — exactly one radio reflects *selected == i.
  • Tokens / layout consumedcore::SPACE_1 (inter-option gap). See tokens.

API

MethodEffect
RadioGroup::new(selected: &'a mut usize) -> SelfBind the selected index.
.options<S: Into<String>>(options: impl IntoIterator<Item = S>) -> SelfSet the option labels.
.horizontal() -> SelfLay out in a row instead of a column.
.show(self, ui: &mut Ui) -> ResponseRender; 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: show mutates *selected to the clicked index.
  • Per-radio ids are derived via .id_source(("radio_group", i)), so a single group is collision-free; nest under FieldSet for 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 InputGroup with a search glyph.
  • Anatomy — An InputGroup bound to your buffer, with leading_icon(light::MAGNIFYING_GLASS) and an optional placeholder.
  • Tokens / layout consumed — inherited from InputGroup. See tokens.

API

MethodEffect
SearchField::new(buf: &'a mut String) -> SelfBind the search buffer.
.placeholder(text: impl Into<String>) -> SelfHint text shown when empty.
.show(self, ui: &mut Ui) -> ResponseRender; 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 InputGroup falls back to egui’s auto id. If you need a stable id or trailing clear button, use InputGroup directly.
  • The returned Response is 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

    • Container: Surface::pad(SPACE_1).radius(RADIUS_MD) → horizontal row of small Buttons; the active tab is ButtonVariant::Secondary (raised), others Ghost.
    • Line: a horizontal row of ghost Buttons; under the active one, a primary thick Divider sized to the tab width (BORDER_FOCUS tall).
  • Variants (TabsVariant)

    VariantLook
    Container (default)Segmented chips in a surface; active = raised secondary button.
    LineUnderlined row; active = ghost button + primary underline.
  • Tokens / layout consumedcore::SPACE_1 (surface pad / underline gap), RADIUS_MD, BORDER_FOCUS (underline thickness); theme.primary (underline color). See tokens.

API

MethodEffect
Tabs::new(selected: &'a mut usize) -> SelfBind the active index.
.tabs<S: Into<String>>(tabs: impl IntoIterator<Item = S>) -> SelfSet tabs from labels (no icons).
.tab(label: impl Into<String>, icon: &'static str) -> SelfAppend one tab with a leading icon.
.variant(variant: TabsVariant) -> SelfSet the look.
.line() -> SelfSugar for TabsVariant::Line.
.show(self, ui: &mut Ui) -> ResponseRender; writes *selected = i on click. Returns the layout Response.

TabsVariantContainer (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.
  • AnatomySurface::pad(SPACE_1).radius(RADIUS_MD) → horizontal row of small Buttons; active = ButtonVariant::Secondary, others ButtonVariant::Ghost.
  • States — exactly one segment is the raised secondary button (*selected == i).
  • Tokens / layout consumedcore::SPACE_1 (surface pad), RADIUS_MD. See tokens.

API

MethodEffect
ToggleGroup::new(selected: &'a mut usize) -> SelfBind the active index.
.options<S: Into<String>>(options: impl IntoIterator<Item = S>) -> SelfSet the segment labels.
.show(self, ui: &mut Ui) -> ResponseRender; 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 Tabs Container, 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.
  • Anatomyui.horizontal row; per component: a muted caption Text axis label ("X"/"Y"/"Z"/"W", or "·" beyond 4) + SPACE_1 + a width-allocated NumericField (CONTROL_MD tall) + SPACE_2.
  • Tokens / layout consumedcore::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

MethodEffect
VectorField::new(values: &'a mut [f32]) -> SelfBind the component slice; default drag speed 0.1.
.speed(speed: f32) -> SelfDrag 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 ·).
  • show returns (), not a Response — 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 a Divider::horizontal() + SPACE_2 padding inserted before every section after the first. Optionally wrapped in a card Surface.

  • Variants / states

    Variant / stateHow
    plainAccordion::new() — bare vertical stack
    card.card() — wraps the stack in a Surface::new() card
    section open/closedowned per-Collapsible in egui memory (not by Accordion)
  • Tokens / layout consumedcore::SPACE_2 (inter-section gap); card casing via Surface.

  • Accessibility — folding handled by the Collapsible molecule (click header to toggle).

API

Accordion

MethodEffect
Accordion::new() -> SelfBare (no card).
Accordion::default()Same as new().
.card() -> SelfWrap the section group in a card Surface.
.show(ui, build: impl FnOnce(&mut AccordionCtx)) -> ResponseRun 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.

MethodEffect
.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 by Accordion. Identical section titles within one accordion would collide on memory id.
  • section takes FnOnce bodies — the body closure runs immediately during show.
  • The first section never gets a leading divider; ordering of section calls 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.

  • Anatomyegui::Modal casing → Heading (h2, the title) → optional Text (muted, the description) → SPACE_4 → the body closure (your buttons/fields). Max width clamped to layout::PANEL_MAX.

  • Variants / states

    StateHow
    openrender Dialog this frame
    closeddon’t render it (consumer owns the flag)
    with/without description.description(...) optional
    dismissedshow returns true on backdrop click / Esc (Modal::should_close())
  • Tokens / layout consumedcore::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 manual Surface); content atoms supply the casing.

  • AccessibilityModal provides the focus scrim and Esc-to-dismiss; backdrop click also closes. Both surface through the bool return.

API

MethodEffect
Dialog::new(title: impl Into<String>) -> SelfNew dialog. Default id is Id::new(format!("dialog::{title}")).
.id_source(id: impl Hash) -> SelfOverride the Modal id (use when titles collide).
.description(description: impl Into<String>) -> SelfAdd a muted sub-line under the title.
.show(ctx: &Context, body: impl FnOnce(&mut Ui)) -> boolRender 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. Dialog only reports the should-close signal; act on both that return and your own button-driven dismissals (the storybook pattern ORs close || dismiss).
  • Default id derives from the title — give two same-titled dialogs distinct id_source values.
  • body is FnOnce, 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.

  • Anatomyegui::Popup::menu(trigger) casing → one MenuItem per entry, each keyed ("dropdown", i), optional left glyph.

  • Variants / states

    StateHow
    item with icon.item(icon, label)
    text-only item.text_item(label)
    item clickedreturns Some(index), calls ui.close()
    nothing clicked / closedreturns 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.

  • AccessibilityPopup handles outside-click / Esc dismiss; selection closes it.

API

MethodEffect
DropdownMenu::new() -> SelfEmpty menu.
DropdownMenu::default()Same as new().
.item(icon: &'static str, label: impl Into<String>) -> SelfAdd an item with a leading glyph.
.text_item(label: impl Into<String>) -> SelfAdd 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.

  • Anatomyui.horizontal row → per menu: a ghost-sm Button (keyed ("menubar", mi)) → Popup::menu on its response → one MenuItem per entry (keyed ("menubar_item", mi, ii)).

  • Variants / states

    StateHow
    menu triggerghost sm button
    item chosenreturns Some((menu_idx, item_idx)), calls ui.close()
    nothing chosenreturns None
  • Tokens / layout consumed — themed visuals via Button/Popup/MenuItem; horizontal layout spacing from egui defaults.

  • Layering — each menu uses egui::Popup anchored to its trigger button; themed menu frame is the casing.

  • AccessibilityPopup dismiss on outside-click / Esc; selection closes.

API

MethodEffect
Menubar::new() -> SelfEmpty bar.
Menubar::default()Same as new().
.menu<S: Into<String>>(label: impl Into<String>, items: impl IntoIterator<Item = S>) -> SelfAppend 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 use Splitter (a Panel goes inside a band).

  • Anatomy — background Surface filling the mounted rect → optional flush edge Divider carved 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

    AxisOptions
    edge (PanelEdge)None (default) · Left · Right · Top · Bottom
    fill (SurfaceFill)Background (default) · Card · Muted · None (module paints its own bg)
    scrollon (default) · .no_scroll()
    header / footerabsent unless .title()/.action() / .footer() set
  • Tokens / layout consumedlayout::PANEL_PAD (body/header/footer inset), layout::PANEL_GAP (row gap), core::BORDER_THIN (edge weight), the chosen SurfaceFill token.

  • Layering — organism: composes Surface, Divider, Heading; never paints directly (the no_painter_in_molecules guard 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

SignatureEffect
Panel::new(id: impl Hash) -> SelfNew panel; id keys the body ScrollArea.
.title(title: impl Into<String>) -> SelfHeader title (a Heading) above a full-width divider.
.action(f: impl FnOnce(&mut Ui) + 'a) -> SelfTop-right header slot (button / menu / badge).
.footer(f: impl FnOnce(&mut Ui) + 'a) -> SelfBottom action bar above a full-width divider.
.edge(e: PanelEdge) -> Self / .left_edge() / .right_edge()Flush hairline on the docking edge.
.fill(f: SurfaceFill) -> SelfBackground fill (default Background).
.no_scroll() -> SelfDon’t wrap the body in a ScrollArea.
.body_pad(px: f32) -> SelfBody 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)) -> ResponsePaint 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, manual line_segment/rect_filled edges, 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 for DropdownMenu / Select when the content is a list of items; reach for Popover when the content is arbitrary.

  • Anatomyegui::Popup::menu(trigger) casing → your content closure (any widgets).

  • Variants / states

    StateHow
    closedtrigger not clicked
    openopens 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 trigger Response; the themed menu frame is the casing.

  • AccessibilityPopup handles outside-click / Esc dismiss.

API

MethodEffect
Popover::new() -> SelfConstruct. (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; Popover does not draw the trigger.
  • show returns (); to capture a result (e.g. a picked value), mutate a variable closed over by content.
  • content is FnOnce, 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 use DropdownMenu.

  • Anatomy — trigger: Button (Outline variant, right-side CARET_DOWN glyph, keyed "select_trigger") showing the selected option or placeholder → Popup::menu on its response → one MenuItem per option (keyed ("select", i)).

  • Variants / states

    StateHow
    no/invalid selectiontrigger shows placeholder (default "Select…")
    selectedtrigger shows options[*selected]
    option clickedwrites *selected = i, calls ui.close()
    sizeSize::Sm / Md (default) / Lg via .size / .sm() / .lg()
  • Tokens / layout consumed — trigger height follows the shared Size scale (hover animation lives in Button); themed menu frame via Popup.

  • Layering — uses egui::Popup anchored to the trigger; themed menu frame is the casing.

  • AccessibilityPopup dismiss on outside-click / Esc; selection closes.

API

MethodEffect
Select::new(selected: &'a mut usize) -> SelfBind to a selection index.
.options<S: Into<String>>(options: impl IntoIterator<Item = S>) -> SelfSet the option labels.
.placeholder(text: impl Into<String>) -> SelfText shown when the index is out of range (default "Select…").
.size(size: Size) -> SelfSet trigger size.
.sm() -> Self / .lg() -> SelfSize shortcuts.
.show(ui) -> ResponseRender 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 unique ui.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.

  • Anatomyui.vertical → per entry, either:

    • list mode — a ListItem (.selected(active), keyed ("sidebar", i), optional leading glyph), or
    • icons-only mode — an icon-only Button (Secondary when active else Ghost, keyed ("sidebar_icon", i), optional icon_left).
  • Variants / states

    StateHow
    item with icon.item(icon, label)
    text-only item.text_item(label)
    selected*selected == iListItem.selected(true) / Button Secondary
    icon 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

MethodEffect
Sidebar::new(selected: &'a mut usize) -> SelfBind to a selection index.
.item(icon: &'static str, label: impl Into<String>) -> SelfAdd an entry with a leading glyph.
.text_item(label: impl Into<String>) -> SelfAdd an entry with no glyph.
.icons_only() -> SelfCollapse to an icon-only rail.
.show(ui) -> ResponseRender 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 usize stay 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 another Splitter. Content inside each leaf panel is arranged with AutoLayout.

  • Anatomyui.allocate_exact_size(available) → along the main axis, alternating panel cells (each a clipped child Ui) and a SplitterHandle divider of width core::SPACE_2 after every panel but the last. Cross-axis fills the rect.

  • Variants / states

    StateHow
    orientationSplitter::horizontal() / Splitter::vertical()
    panel sizedPanelSpec::size(fraction) (else equal share of remainder)
    resizingdrag a divider — grows one neighbor, shrinks the other, clamped to both [min, max]
    collapseddouble-click a divider toggles an adjacent collapsible panel (prefers right neighbor)
    non-resizable paira divider is inert unless both adjacent panels are resizable
  • Tokens / layout consumedcore::SPACE_2 (divider thickness); PanelSpec defaults layout::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 active state when a neighbor is collapsed.

API

Splitter<'a>

MethodEffect
Splitter::horizontal() -> SelfPanels left→right, dividers drag horizontally.
Splitter::vertical() -> SelfPanels top→bottom, dividers drag vertically.
.id_source(id: impl Hash) -> SelfKey for session-persisted sizes (defaults to the allocated response id).
.panel(cfg: PanelSpec, add: impl FnMut(&mut Ui) + 'a) -> SelfAdd a panel with its config + content closure.
.show(ui) -> ResponseLay 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.

MethodEffect
PanelSpec::new() -> SelfDefaults: size = None (equal share), min = PANEL_MIN, max = PANEL_MAX, resizable = true, collapsible = false.
PanelSpec::default()Same as new().
.size(fraction: f32) -> SelfInitial size as a main-axis fraction (clamped 0.0..=1.0).
.min(px: f32) -> SelfMinimum size in px.
.max(px: f32) -> SelfMaximum size in px.
.resizable(resizable: bool) -> SelfWhether dividers touching it can drag.
.collapsible(collapsible: bool) -> SelfWhether 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 by id_source). State resets if the panel count changes (stored fracs length must match n). Distinct splitters need distinct id_source — nested splitters especially.
  • Resizing follows the adjacent-pair rule (one neighbor grows, the other shrinks), with apply_drag clamping a to 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.
  • show consumes ui.available_size(); constrain via allocate_ui if 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.

  • Anatomyui.verticalTabs bar (bound to a local copy of the index) → SPACE_2Divider::horizontal()SPACE_3panel(ui, idx) body.

  • Variants / states

    StateHow
    active tab*selected (mirrored into the Tabs molecule)
    panel switchpanel(ui, idx) renders the body for the current index
  • Tokens / layout consumedcore::SPACE_2 (bar→divider) and core::SPACE_3 (divider→body). See tokens.

  • Accessibility — tab keyboard/click behavior comes from the Tabs molecule.

API

MethodEffect
TabView::new(selected: &'a mut usize) -> SelfBind to an active-tab index.
.tabs<S: Into<String>>(tabs: impl IntoIterator<Item = S>) -> SelfSet the tab labels.
.show(ui, panel: impl FnOnce(&mut Ui, usize)) -> ResponseDraw 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. show mirrors it into a local idx, lets Tabs mutate that copy, and writes it back at the end — persist across frames yourself.
  • panel is FnOnce; render the body for idx directly (a match on 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 border wraps everything in a card Surface (pad 0, RADIUS_MD). Inside: a ui.scope with table_visuals applied → TableBuilder with one egui_extras::Column per Column (all .clip(true)) → header row (TableCell::text(label).header().align(col.align)) → body rows (one TableCell per cell). Loading and empty states short-circuit to a centered Spinner / muted Text.

  • Variants / states

    StateHow
    striped.striped(true) — zebra rows
    bordered.border(true) — outer card surface
    sizesSize::Sm / Md (default) / Lg → row height
    fixed height.height(px) — header sticks, body scrolls
    fluid + cap.max_height(px) — grows then scrolls
    selectable.selectable(true) — click a row to select (persisted)
    loading.loading(true) — centered Spinner
    emptyno rows → muted empty_text (default "No data")
  • Tokens / layout consumed — row height from the Size scale (size.height()); core::SPACE_6 (loading/empty padding), core::SPACE_0 + core::RADIUS_MD (border surface). Theme colors via table_visuals.

  • Accessibility — selection via Sense::click() on rows when selectable; header is sticky on scroll.

API

Table<'a>

MethodEffect
Table::new() -> SelfEmpty table; defaults empty_text = "No data", others off.
Table::default()Same as new().
.columns(impl IntoIterator<Item = Column>) -> SelfSet the columns.
.rows(impl IntoIterator<Item = TableRow<'a>>) -> SelfSet the rows.
.row(TableRow<'a>) -> SelfAppend one row.
.size(Size) -> Self / .sm() / .lg()Row height.
.striped(bool) -> SelfZebra rows.
.border(bool) -> SelfOuter card border.
.height(px) -> SelfFixed height; sticky header, body scrolls.
.max_height(px) -> SelfFluid height capped at px.
.selectable(bool) -> SelfClick-to-select rows (persisted for the session).
.loading(bool) -> SelfReplace the grid with a centered spinner.
.empty_text(impl Into<String>) -> SelfPlaceholder when there are no rows.
.id_source(id: impl Hash) -> SelfStable id (drives selection + TableBuilder id salt).
.show(ui) -> ResponseRender; returns the area Response.

Column

A header label + layout descriptor.

MethodEffect
Column::new(label: impl Into<String>) -> SelfNew column; default width Remainder, align Start.
.width(ColWidth) -> SelfSet sizing mode.
.exact(px) / .initial(px) / .auto() / .remainder()Width shortcuts (ColWidth variants).
.min_width(px) -> SelfFloor 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.

VariantMaps toMeaning
AutoExtraColumn::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, or ui.id().with("table") if unset). Distinct tables need distinct id_source to avoid selection-state collisions; the same id also salts TableBuilder.
  • Per-row opt-out: a row is only clickable/selectable if TableRow.selectable is true and Table.selectable is on.
  • Column alignment is header-only; each TableCell carries its own cell alignment.
  • All columns are clipped (.clip(true)); vscroll engages only when height or max_height is set.
  • loading and 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).

  • Anatomyegui::Area (foreground order, anchored RIGHT_TOP with offset (-SPACE_4, SPACE_4)) → ui.set_max_width(INSPECTOR_WIDTH) → an Alert carrying the message + variant.

  • Variants / states

    VariantHow
    defaultToast::new(msg)
    success.success()
    warning.warning()
    error.error()
    custom.variant(AlertVariant)
  • Tokens / layout consumedcore::SPACE_4 (anchor inset from the top-right corner), layout::INSPECTOR_WIDTH (max width). See tokens / layout.

  • Layering — uses egui::Area at Order::Foreground, anchored Align2::RIGHT_TOP. (Not a Modal/Popup — it doesn’t capture input or scrim.)

  • Accessibility — non-blocking overlay; dismissal/timing is the consumer’s responsibility.

API

MethodEffect
Toast::new(message: impl Into<String>) -> SelfNew toast; default id Id::new("toast"), default variant.
.id_source(id: impl Hash) -> SelfOverride the Area id (required for multiple simultaneous toasts).
.variant(variant: AlertVariant) -> SelfSet the alert variant.
.success() -> Self / .warning() -> Self / .error() -> SelfVariant 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 distinct id_source, or they overlap in the same Area.
  • Anchored top-right with a SPACE_4 inset; width capped at INSPECTOR_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 root Splitter.
  • 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 consumedcore::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

MethodEffect
Toolbar::new() -> SelfConstruct. (Unit struct; no fields.)
Toolbar::default()Same as new().
.show(ui, content: impl FnOnce(&mut Ui)) -> ResponseRender 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).
  • content is FnOnce, run inside an ui.horizontal scope.
  • Give each child widget a distinct id_source when 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 ScrollArea for scrolling — the view itself does not scroll.)

  • Anatomy — a loop over the flat items, each rendered as a TreeNode (.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 (deeper depth) are skipped via a collapse_until threshold.

  • Variants / states

    StateHow
    leafTreeItem::new(label) with no .expanded(...)
    expandable.expanded(open) — marks it toggleable, seeds default open state
    open / collapsedtracked in a HashSet<usize> in egui memory
    selected*selected == i
    depth.depth(n) — indentation level
  • Tokens / layout consumed — themed indentation/visuals via the TreeNode cell (no direct token use here).

  • Accessibility — click a row to select; clicking an expandable node both selects and toggles it.

API

TreeView<'a>

MethodEffect
TreeView::new(selected: &'a mut usize) -> SelfBind to a selection index; default id Id::new("tree_view").
.items(impl IntoIterator<Item = TreeItem>) -> SelfSet the flat item list (order + depth define the tree).
.id_source(id: impl Hash) -> SelfKey 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.

MethodEffect
TreeItem::new(label: impl Into<String>) -> SelfNew leaf at depth = 0, no icon, not expandable.
.depth(depth: usize) -> SelfIndentation level (defines nesting).
.icon(glyph: &'static str) -> SelfLeading glyph.
.expanded(open: bool) -> SelfMark 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> where depth encodes nesting; a collapsed expandable node hides every following item with greater depth (the collapse_until threshold) until depth returns to its level.
  • State ownership — selection is the consumer’s &mut usize (persist it yourself). Expand/collapse is a HashSet<usize> in egui memory keyed by id_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 ScrollArea if 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

TierModulesRule
paintviewport, grid, edge, handle, resizertouch the painter, but only via tokens
composenode, controls, minimap, toolbar, searchreuse 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

  1. GraphView::new(id).grid(true).controls(true).minimap(true) — configure.
  2. .show(ui, |ctx| { … }) allocates the canvas, runs egui::Scene (pan/zoom), paints the grid, reserves an under-node edge layer, and runs your closure in scene (world) coordinates.
  3. 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.
  4. show returns GraphResponse with 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

PageCovers
identityNodeId, PortId, NodeKindId, PortSide, Port, Connection — the caller-defined vocabulary.
canvasGraphView (entry point), GraphCtx (per-frame emit surface), GraphResponse (intents).
stateGraphViewState + the in-flight drag structs.
tokensGraphTokens — the single resolve point for everything the layer paints.
nodeNodeFrame/NodeResult/NodeStatus + ctx.node(...). Compose-tier.
edgeEdgeStyle/EdgeResult + ctx.edge(...). Paint-tier bezier wires.
handleHandleSpec/HandleVariant — ports, declared on NodeFrame. Paint-tier.
searchNodeSearch — the node-creation palette. Compose-tier.
viewportViewport — a standalone world↔screen transform helper (the live canvas uses egui::Scene, not this).
extrasInternal 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. show runs once per frame: resolve GraphTokens → allocate rect + paint surface/border → load GraphViewState from egui memory (keyed by the builder id) → Scene::show against &mut state.scene_rect → paint grid (culled when on-screen dot spacing < grid::MIN_DOT_SPACING) → reserve an under-node edge layer via Painter::add(Shape::Noop) → build the GraphCtx and 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 → return GraphResponse.
  • Coordinate convention. A node emitted at world pos lands there; Scene scales it. GraphCtx::scale is the live scene→screen factor; to_global is the TSTransform.
  • Zoom range. Canvas Scene zoom_range is MIN_ZOOM = 0.2 .. MAX_ZOOM = 4.0 (private consts). This is the live range — note it is not the Viewport helper’s 0.25..2.5; the canvas does not use Viewport.
  • Pan binding. Scene pan is bound to DragPanButtons::MIDDLE | SECONDARY only; primary drag is reserved for node move / marquee / connect so it never double-moves against Scene’s background pan.

GraphResponse fields (every field)

FieldTypeMeaning
responseegui::ResponseScene background interaction (pan response). Use for focus / context-menu hooks.
connectionOption<Connection>A connect-drag completed onto a valid target port. Always oriented Out → In.
delete_edgeOption<(Port, Port)>Selected edge + Delete/Backspace. Deleted before nodes.
delete_nodesVec<NodeId>Selected nodes + Delete/Backspace (only when no edge was selected).
edge_clickedOption<(Port, Port)>An edge was clicked this frame.
node_movedVec<(NodeId, Vec2)>World-space move deltas to apply (caller owns positions).
node_resizedVec<(NodeId, Vec2)>World-space size deltas from the node resizer.
create_requestOption<(NodeKindId, Pos2)>“Create a node of this kind at this world position” (from node search).
selectionHashSet<NodeId>Current selection, mirrored out (e.g. to drive per-node toolbars next frame).
fit_requestedboolThe 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)

MethodSignatureEffect
newfn new(id_source: impl Hash) -> SelfConstruct with a stable id; the view-state is keyed by it.
sizefn size(self, size: Vec2) -> SelfExplicit canvas size. Default: full available width × 420.0.
gridfn grid(self, on: bool) -> SelfToggle the dot grid (default true).
controlsfn controls(self, on: bool) -> SelfToggle the floating zoom/fit overlay (default false).
minimapfn minimap(self, on: bool) -> SelfToggle the minimap overlay (default false).
showfn show(self, ui: &mut egui::Ui, build: impl FnOnce(&mut GraphCtx)) -> GraphResponseRun 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.

MethodSignatureEffect
scalefn scale(&self) -> f32The scene→screen scale (current zoom factor).
visible_rectfn visible_rect(&self) -> RectThe visible region in scene (world) coordinates.
tokensfn tokens(&self) -> GraphTokensThe resolved graph paint tokens.
screen_delta_to_worldfn screen_delta_to_world(&self, delta: Vec2) -> Vec2Convert a screen-space delta (e.g. a Response::drag_delta) to a world delta.
screen_to_worldfn screen_to_world(&self, screen: Pos2) -> Pos2Convert a global screen point to a scene (world) point.

Emit methods (documented under their own pages):

MethodSignature
nodefn node(&mut self, id: NodeId, world_pos: Pos2, frame: NodeFrame, body: impl FnOnce(&mut egui::Ui)) -> NodeResult
edgefn edge(&mut self, from: Port, to: Port, style: EdgeStyle) -> EdgeResult
node_toolbarfn 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 — edge anchors on handle positions recorded by node, and returns a default (no-op) EdgeResult if 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 GraphView id 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 in GraphResponse, 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 oriented Out → 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 from GraphView flags. node / edge / node_toolbar are 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 OutIn (see identity).
  • Anatomy — four control points [a, c1, c2, b] where c1 = a + side_dir(a) * reach, c2 = b + side_dir(b) * reach, and reach = max(|a.x − b.x| * 0.5, MIN_REACH=40.0) so even short edges bow. The curve is flattened into SAMPLES + 1 = 25 points for both drawing and hit-testing. Midpoint decorations (button/label) anchor at t = 0.5.
  • Anchoring — endpoints are resolved via handle_pos(port) against positions recorded by node. If either port has no recorded handle, edge returns EdgeResult::default() and paints nothing — emit nodes first.
  • Widthedge_width token normally; core::EDGE_WIDTH + 0.5 when hovered or selected.

Variants / states (EdgeStyle)

VariantBehavior
EdgeStyle::Default (default)A plain wire.
EdgeStyle::AnimatedA dot travels along the wire (i.time-driven, t = time % 1.0), drawn on top; requests repaint each frame.
EdgeStyle::WithButtonA small ghost icon-button (phosphor X, an “x” delete affordance) at the midpoint; click surfaces via EdgeResult::button_clicked.
EdgeStyle::WithLabelA 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)

FieldTypeMeaning
hoveredboolCursor is within the grab radius of the wire this frame.
selectedboolThis (from, to) is the current edge_selection.
clickedboolThe wire was clicked this frame (selects it).
button_clickedboolThe 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 into edge_shapes; they do not allocate egui widgets except the optional midpoint Button / Badge for the decorated variants.
  • Drawn under nodes. The canvas flushes edge_shapes into 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 uses dist_to_polyline(samples, cursor) <= edge_hit_radius / zoom; click on hover sets selection. To delete a wire, watch EdgeResult::button_clicked (WithButton) or GraphResponse::edge_clicked and remove it from caller data — the library never deletes connections itself (it only reports delete_edge / edge_clicked intents).
  • 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_rectzoom_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 radius handle_radius, with a core::BORDER_THIN border ring (handle_border). On hover or while it is the active connect source, an extra core::BORDER_FOCUS ring in edge_selected is drawn. The Labeled variant adds a muted caption Text (width 72, 18 high) just inside the node beside the dot — left-aligned for inputs, right-aligned for outputs.
  • Anchoringanchor(rect, side, index, count) places handle index of count at y = rect.top() + height * (index+1)/(count+1) (even vertical distribution) and x = rect.left() for In / rect.right() for Out. Inputs and outputs are counted and indexed independently. The computed scene-space position is recorded into handle_positions keyed by Port{node, port, side}, which is what edge reads back to anchor wires.
  • Connect-drag — a connectable handle gets an interaction rect of 2 * handle_radius square. drag_started opens a ConnectDrag { from, from_world, cursor_world }; while dragging, cursor_world tracks the pointer in world space (a preview wire follows it on top); drag_stopped records the world release point. The canvas resolves the release against all known handle positions (handle_hit_radius) into a GraphResponse::connection (OutIn). A fixed() (non-connectable) handle is painted but skips all interaction.

Variants / states (HandleVariant)

VariantBehavior
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

FieldTypeMeaning
idPortId (u32 newtype)Port id, unique within the node’s side.
sidePortSideIn (left edge) or Out (right edge).
connectableboolWhether the port accepts a connect-drag.
variantHandleVariantVisual style (Base / Labeled).

HandleSpec is Clone + Copy + Debug.

HandleSpec builder

MethodEffect
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 Labeled borrows the Text atom for its caption — still no hand-rolled values (all geometry/colors via tokens / core), keeping the no_raw_values guard green.
  • Feeds the wire system. Every handle records its world position into handle_positions; edge anchors on those, and connect-drag releases hit-test against them (handle_hit_radius) to produce a connection. 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 OutIn by 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

TypeDefinitionMeaning
NodeIdpub struct NodeId(pub u64)Stable id of a node, assigned by the caller.
PortIdpub struct PortId(pub u32)Id of a port within a node’s port list, caller-assigned.
NodeKindIdpub 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 runs Out → 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 status Badge (dot, sm), followed by a horizontal Divider.
    • Body: the caller’s body(&mut egui::Ui) closure.
    • Appendix (optional): horizontal Divider + muted caption Text.
    • Handles: drawn on the left (In) / right (Out) edges by draw_handles (see handle.md).
    • Resizer grip: painted only when the node has an explicit size(..) and is selected.
  • Surface modeplaceholder() nodes use Surface::fill_none().border_strong() (muted empty slot, no shadow/body chrome); all others use Surface::elevated(). Selection ring is driven by Surface::selected(..).
  • Sizing — without size(..), the body hugs its content up to a max width of NODE_MAX_W = 240.0 world units. With size(..), the node takes a fixed world-space size and gains the resizer grip when selected.

Variants / states (NodeStatus)

VariantBadge variantHeader label
NodeStatus::OkSuccessok
NodeStatus::WarningWarningwarn
NodeStatus::ErrorDestructiveerror
NodeStatus::RunningInforunning

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

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

FieldTypeMeaning
clickedboolThe node body was clicked this frame.
draggedOption<Vec2>World-space move delta applied this frame (caller commits it), None if not dragged.
rectRectThe 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) and resizer.
  • Emit nodes before edges. node(..) records each handle’s world position into handle_positions; edge looks those up to anchor wires, so all nodes a wire touches must be emitted first in the same show closure.
  • Intents, not mutation. Drags push (NodeId, Vec2) into node_moved; resizes push into node_resized; clicks feed the selection. The caller reads these from GraphResponse after show returns 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 a trigger: &Response (typically a Button).
  • Inside: an [Input] with placeholder "Search nodes…", whose query is persisted in ui.data under ui.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;

ItemSignatureNotes
fieldkinds: Vec<(NodeKindId, String)>private; populated via .kind()
NodeSearch::newfn new() -> Selfempty palette (also #[derive(Default)])
.kindfn kind(self, id: NodeKindId, label: impl Into<String>) -> Selfappend one selectable kind; chainable
.showfn 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
}
}

NodeSearch itself does not populate GraphResponse.create_request — it is a standalone palette. The canvas field is filled by the canvas’s own internal create path; NodeSearch is the recommended UI for producing the NodeKindId half 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 one Ui to 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 with insert_temp at the end. The caller almost never touches it directly — it observes effects via GraphResponse instead.
  • scene_rect. The [egui::Scene] view window expressed in world coordinates. GraphView::show hands a &mut to it into Scene::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 by core::SPACE_8; the minimap recenters it.
  • Rect::ZERO sentinel. The Default scene_rect is Rect::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 an Option<(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). connect and edge_selection round-trip through the Scene closure (read in, resolved, written out); drag / marquee / hovered_node are maintained across frames.

GraphViewState fields

FieldTypeRole
scene_rectRectScene window in world coords. Rect::ZERO ⇒ auto-fit first frame.
selectionHashSet<NodeId>Currently selected nodes.
edge_selectionOption<(Port, Port)>Currently selected edge (the two endpoint ports).
hovered_nodeOption<NodeId>Node under the cursor.
dragOption<NodeDrag>Active node-move drag.
connectOption<ConnectDrag>Active connect (wire) drag.
marqueeOption<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)]

FieldTypeRole
nodeNodeIdWhich node grabbed the drag.
accum_worldVec2Accumulated world-space delta. Recomputed from origin each frame (accumulator pattern) to avoid drift on slow drags.

ConnectDrag#[derive(Clone, Copy, Debug)]

FieldTypeRole
fromPortPort the wire was dragged out from.
from_worldPos2World anchor of from’s handle.
cursor_worldPos2Current cursor position (world); the wire trails to it.

MarqueeDrag#[derive(Clone, Copy, Debug)]

FieldTypeRole
start_worldPos2Drag origin in world coords (so the box tracks under pan/zoom).
cursor_worldPos2Current cursor (world).
additiveboolShift 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 GraphViewState per GraphView id; clobbering it (e.g. inserting a fresh default()) resets the camera to the auto-fit sentinel and clears selection.
  • scene_rect is world, not screen. A smaller scene_rect means more zoomed in (the window covers less world). This is the opposite mental model from a screen rect.
  • Not the same as Viewport. Viewport is a standalone pan+zoom transform helper that the live canvas does not use; the canvas drives scene_rect through egui::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::show and thread the value through the paint helpers. Read it inside the emit closure via GraphCtx::tokens(). Never read Theme/core directly in graph paint code — go through GraphTokens.
  • 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 Theme token; every size/radius/width is a core::* constant. The one synthesized value is marquee_fill, the focus ring re-tinted translucent via core::tint(theme.ring, core::MARQUEE_ALPHA).

Field → source map

Background grid

FieldTypeSource
grid_dotColor32theme.border
grid_dot_radiusf32core::GRID_DOT_RADIUS
grid_spacingf32core::GRID_SPACING

Edges (wires)

FieldTypeSource
edgeColor32theme.muted_foreground
edge_hoverColor32theme.primary
edge_selectedColor32theme.ring
edge_widthf32core::EDGE_WIDTH
edge_hit_radiusf32core::EDGE_HIT_RADIUS

Handles (ports)

FieldTypeSource
handle_fillColor32theme.primary
handle_borderColor32theme.border_strong
handle_radiusf32core::HANDLE_RADIUS
handle_hit_radiusf32core::HANDLE_RADIUS * 2.0

Node selection

FieldTypeSource
node_selected_ringColor32theme.ring

Box-select marquee

FieldTypeSource
marquee_fillColor32core::tint(theme.ring, core::MARQUEE_ALPHA) (translucent ring)
marquee_borderColor32theme.ring

Minimap

FieldTypeSource
minimap_nodeColor32theme.muted_foreground
minimap_viewColor32theme.ring

API

MethodSignatureEffect
resolvefn resolve(theme: &Theme) -> SelfMap a Theme (+ core geometry) onto all paint values.
getfn get(ui: &egui::Ui) -> SelfConvenience: 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) and core::* (sizes). This is the layer invariant: graph is the one place outside atoms that paints, but every value still flows through a token.
  • Hit radii vs draw radii. handle_hit_radius is the draw radius; edge_hit_radius is 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 its core/Theme source, 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. GraphView drives its camera through egui::Scene directly (zoom range 0.2..4.0, stored as scene_rect in GraphViewState). Viewport is 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 is Scene/scene_rect, not this.
  • Conventions. world is the graph’s own coordinate space (node positions live here); screen is egui pixels. pan is the screen-space offset of the world origin relative to the canvas top-left; zoom is screen-px per world-unit. canvas_origin (the canvas rect’s left_top()) is supplied per call.
  • State. Two public fields only: pan: Vec2, zoom: f32. Default is pan 0, zoom 1. Derives Clone, Copy, Debug, PartialEq.
  • Clamps. MIN_ZOOM = 0.25, MAX_ZOOM = 2.5 (associated consts). Applied by zoom_around and fit. (Distinct from the canvas’s 0.2..4.0.)

API

Associated constants

ConstValueMeaning
Viewport::MIN_ZOOM0.25Lower zoom clamp — nodes never shrink to dust.
Viewport::MAX_ZOOM2.5Upper zoom clamp — nodes never balloon past usefulness.

Fields

FieldTypeMeaning
panVec2Screen-space offset of the world origin relative to the canvas top-left.
zoomf32Screen px per world unit.

Methods

MethodSignatureEffect
defaultfn default() -> Selfpan 0, zoom 1.
world_to_screenfn world_to_screen(&self, canvas_origin: Pos2, world: Pos2) -> Pos2canvas_origin + pan + world.to_vec2() * zoom.
screen_to_worldfn screen_to_world(&self, canvas_origin: Pos2, screen: Pos2) -> Pos2Inverse of world_to_screen.
scalefn scale(&self, world_len: f32) -> f32Scale a world length to on-screen length (world_len * zoom).
pan_byfn pan_by(&mut self, delta_screen: Vec2)Pan by a screen-space delta (e.g. a drag delta).
zoom_aroundfn 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.
fitfn 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. Viewport sits in the paint tier alongside grid/edge/handle — a value-level transform with no Ui dependency. It does not own a canvas rect, selection, or any drag state (that is GraphViewState).
  • Not the canvas camera (again). Because the live canvas uses egui::Scene, changing Viewport’s clamps or math has no effect on GraphView. If you need to alter the live zoom range, edit the MIN_ZOOM/MAX_ZOOM consts in canvas.rs (0.2/4.0), not here.
  • Unit tests. viewport.rs carries a #[cfg(test)] module that locks the contract:
    • world_screen_round_tripsscreen_to_world(world_to_screen(w)) == w across sample points with non-trivial pan/zoom.
    • zoom_keeps_point_under_cursor — the world point under the anchor is invariant across zoom_around, and zoom lands on the requested factor.
    • zoom_clamps — repeated zoom-in/out saturates exactly at MAX_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.