Desktop (GPUI)
Native Rust desktop UI on raw GPUI — celestia-ui wraps the gpui-component library under the Celestia tokens (a compile-time theme.json) plus celestia-only builds, with no facade crate in between
celestia-ui is the desktop counterpart of @celestia-project/ui: a Rust component library for
native desktop apps built on GPUI (gpui-pre 0.3.6) directly — the two
widget layers it wraps, gpui-base and gpui-component 0.6.6, are named as themselves, so every
import states the crate it comes from. The shape mirrors the web package: packages/ui wraps Base
UI primitives with celestia tokens; celestia-ui wraps the gpui-component library the same way, so
most web files map 1:1 onto a desktop module (drawer.tsx → sheet, toast.tsx → notification,
file-tree.tsx → tree).
This page is the package overview — the crates, the architecture and the component-family map. For the run instructions, requirements, token swatches and the app bootstrap, see the dedicated Desktop (GPUI) surface.
packages/desktop is a self-contained Cargo workspace. pnpm/turbo tasks don't
see it — run cargo directly inside packages/desktop/, or use the root
pnpm desktop script, which shells into cargo. Nothing under this package
participates in turbo typecheck or the web build.
Where it lives
| Path | What it is |
|---|---|
packages/desktop | Cargo workspace root (cargo run -p celestia-desktop from here) |
packages/desktop/crates/ui | The reusable library — theme, palette, components |
packages/desktop/crates/showcase | The showcase binary (component gallery window) |
packages/desktop/crates/starter | Minimal boilerplate app — copy this crate to bootstrap your own window |
reference/GetCat/ | The design reference the stack and idioms were modeled on |
reference/gpui-kit/ | The upstream gpui-kit repo checkout (v0.7.0 crates/story examples — usage inspiration only; verify signatures against the installed 0.6.x source) |
reference/zeron/ | The Zeron app checkout — source of the ported motion kit, loaders, notice chip and context badges (see below) |
reference/zui/ | Zeron's gpui fork checkout (zeronsh/zui at the rev zeron pins) — documents the fork-only render primitives that can not be ported |
Running it
Rust ≥ 1.97 (edition 2024); no environment variables — the theme JSON is compile-time embedded. macOS needs no extra toolchain, Linux needs Vulkan + Wayland/X11/fontconfig headers, Windows needs MSVC.
pnpm desktop # from the repo root — build & open the gallery windowpnpm desktop:starter # minimal boilerplate window (copy crates/starter to start an app)cargo run -p celestia-desktop # or directly from packages/desktop/ (⌘D / Ctrl+D toggles theme)cargo run -p celestia-desktop-startercargo test --workspace # unit + gpui TestAppContext tests (theme pins live here)The gallery sidebar lists one entry per component (Button is a dedicated page),
grouped by category, with a search field above the nav to filter by name or package
path. The upper-right Package card copies the selected component’s
primitive/… / composite/… path; use Visual / Docs for props, a copyable Rust
usage snippet, and wiring notes.
Architecture
One file per component, split into two category modules mirroring
packages/ui/src/components/{primitive,composite}:
- Celestia wrappers — components with a shadcn-shaped prop surface that
render
gpui-componentprimitives under Celestia theming:button.rs(Button+ButtonVariant— primary/secondary/destructive/outline/ghost/quiet/link, plus desktop-onlysuccess/warning/infostatus fills — withButtonSize's five height steps and four icon steps,leading_icon/trailing_iconslots, theButton::iconconstructor, and aloadingstate that swaps the leading slot for aSpinnerorButtonLoadingStyle::Diamond),badge.rs(Badge+BadgeVariant— composed from a plaindivrather thangpui-component'sTag, because the web status variants are a 10% tint of the accent under accent-coloured text and the Tag paints solid fills plus a hover dim the web does not have),avatar.rs(Avatar+AvatarSize, the web's 24/32/40px scale),card.rs,section_heading.rs,text_editor.rsandcode_editor.rs. - Per-component re-exports — the remaining components each get a file
(
switch.rs,popover.rs,table.rs, …) re-exporting the matching gpui-component API, with a doc comment recording the web-file → desktop mapping (the table below). Wrapper types are also flattened atcelestia_ui::components::*(e.g.components::Button); module-only components are reached through their category-qualified path (components::primitive::table::…,components::composite::sidebar::…). - Raw-GPUI components — written directly on
gpui(gpui-pre) and importing nothing fromgpui_component:primitive/alert.rs,primitive/progress.rs,primitive/skeleton.rs,primitive/spinner.rs,primitive/loader.rs,primitive/shimmer.rs,primitive/icon.rs,primitive/badge.rs,primitive/label.rs,primitive/rating.rs,composite/loaders.rs,composite/notice.rs,composite/context_badge.rs. They render withgpui::div/gpui::svg/gpui::canvasand read colors fromcx.theme().
Dependency layers
There is no facade crate. celestia-ui depends on the framework and its two
widget layers directly, so every import names the crate it actually comes from:
| Layer | Crate | Used for |
|---|---|---|
| Framework | gpui (gpui-pre) | everything a component draws |
| Base | gpui-base | the inner component library (Checkbox, Popover, Tree, Resizable, Slider, Tabs, Table, the motion stack, scroll) plus the free style helpers (h_flex / v_flex, StyledExt, RoleOverride, box_shadow) |
| Widgets | gpui-component | a thin styling layer over gpui-base; the styled controls a file still re-exports |
Components move from (2) → (3) one family at a time, and
crates/ui/src/components/mod.rs tracks what is done. Rewritten so far:
- Feedback —
primitive/:alert,progress,skeleton,spinner,shimmer,icon;composite/:loaders,notice,context_badge. - Structural —
primitive/:separator,kbd,link,breadcrumb;composite/:group_box,status_bar,title_bar,empty. - Display —
primitive/:badge,label,rating. - Layout —
primitive/:card;composite/:section_heading,sidebar_layout, and the SwiftUI vocabularyh_stack,v_stack,z_stack,spacer,scroll_view,v_grid,frame,alignment. - Conversation —
primitive/:bubble;composite/:message,marker. (message_scrolleris split out and still a shim — it is the one part of the conversation stack sitting ongpui-basemotion and scroll.)
Pick the next family by transitive cost, not file size, measured on two axes.
First, what the target's own imports drag in: the question is not "how big is it"
but "does the closure reach a gpui-base state machine". StyledExt,
h_flex / v_flex, RoleOverride and the sizing traits are style helpers and
cost nothing to inline; Checkbox, Slider, Popover, Tree, ResizableState
and the motion stack are state machines and have to be reimplemented. Second,
who else imports it: bubble and message import each other (MessageContent::bubble
takes the crate's own Bubble), so they move together, and menu is imported by
ten upstream modules.
34 shims remain, and exactly one of them is still free. attachment (1017
upstream lines) references gpui_base not once — it draws from gpui plus
the free style helpers, and its only component dependency, shimmer, is already
migrated. title_bar was the other one; it is done. Everything else names at
least one gpui-base module directly, and the large ones close over ~45k
upstream lines each — for those the base layer is the next milestone, not another
component batch. (resizable is the one the analyzer cannot rank: it reports a
zero because gpui_component::resizable is an inline pub mod over gpui-base
rather than a file. A name that resolves to nothing is a red flag, not a cheap
target.)
Two things make that measurement reproducible rather than remembered.
scripts/ui-audit/gpui-migration-cost.py resolves each shim's target module and
reports a gb1 column — the gpui-base modules the target's own files
import, at depth 1. Read gb1, not the closure columns: a closure total
saturates at ~45k lines for nearly every shim and over-reaches (stepper's
closure claims markdown_ext, number_input and virtual_list, which a stepper
plainly does not use). Depth 1 cannot be inflated transitively, so gb1 == 0
means a one-file port and nothing else does.
And a correction that reframes the whole remaining list: gpui-base is not a
helper crate, it is the inner component library. gpui-component is a thin
styling layer over it — upstream checkbox.rs is a struct holding
base: gpui_base::Checkbox, and popover.rs does
use gpui_base::Popover as BasePopover. Only the pure style helpers re-exported
through it (h_flex / v_flex, StyledExt, RoleOverride, FocusableExt,
box_shadow, AxisExt, LengthExt, Edges, Placement, Side, Measure)
are free to inline, which is why title_bar — zero of them — was cheap.
The substitutions that recur, and the two places the old facade was load-bearing:
Was (gpui-kit facade) | Now |
|---|---|
use gpui_kit::{div, px, …} | use gpui::{div, px, …} — same items, so mostly mechanical |
use gpui_kit::component::ActiveTheme | use crate::theme::ActiveTheme as _ |
gpui_kit::component::Icon / assets::IconName | crate::components::primitive::icon::{Phosphor, PhosphorIcon} |
IconName::StarFill | Phosphor::new(PhosphorIcon::Star).weight(PhosphorWeight::Fill) |
.refine_style(&style) | el.style().refine(&style) |
h_flex() / v_flex() | div().flex().flex_row() / div().flex().flex_col(), plus the cross-axis rule the helper supplied — h_flex() is .items_center(), so a row that relied on it keeps an explicit .items_center() |
Tooltip::new(..).build(..) | .tooltip(|_, cx| cx.new(|_| MyHint).into()) |
Sizable / Size | a crate-local metric bucket, e.g. RatingSize, GpuiBadgeSize |
refine_style and the h_flex/v_flex helpers are gpui-base, not core
gpui, so a migrated file spells out style().refine(..) and the flex
direction. Two further traps: ParentElement must be imported by name
(not as _) if the file implements it, and Styled / InteractiveElement /
Refineable must be in scope or h_4 / child / id / refine silently
fail to resolve. A third: a facade enum that derives Clone may not survive the
port — widening one arm to hold an AnyElement (not Clone) means dropping the
derive, not boxing around it. The full recipe — and how to pick the next family
by transitive cost rather than file size — is the module doc of
crates/ui/src/components/mod.rs. The theme service (cx.theme() →
gpui-component's Theme/ThemeColor) is the last piece scheduled to move; it
is re-exported from crate::theme so component files never name
gpui_component directly.
One rule runs through everything: raw hex lives only in theme.json and palette.rs. Call
sites take colors by role through cx.theme() (semantic roles: background, foreground, primary,
border, sidebar…) or palette(cx) (brand + charts).
Component map
Every component lives in crates/ui/src/components/, under one of two
categories that mirror packages/ui/src/components/{primitive,composite}:
primitive/— one control, or one visual atom, per file.composite/— surfaces assembled from several primitives, plus application-level assemblies (chrome, shells, editors).
The split is by composition, not size: a file that renders two or more primitives, or that owns a layout region rather than a control, is a composite.
| File (desktop) | Web counterpart |
|---|---|
primitive/button.rs (wrapper: ButtonVariant, ButtonSize) | button.tsx |
primitive/badge.rs (wrapper: BadgeVariant incl. Brand; GpuiBadge for count/dot) | badge.tsx |
primitive/input.rs, primitive/textarea.rs, primitive/input_otp.rs, primitive/select.rs, composite/combobox.rs | input.tsx, textarea.tsx, input-otp.tsx, select.tsx, combobox composite |
primitive/switch.rs, primitive/checkbox.rs, primitive/radio.rs, primitive/slider.rs, primitive/rating.rs, primitive/toggle.rs (Toggle + ToggleGroup), composite/stepper.rs, composite/toolbar_menu.rs | switch.tsx, checkbox.tsx, radio-group.tsx, slider.tsx, —, toggle.tsx (+ toggle-group), —, toolbar-menu.tsx |
primitive/calendar.rs, composite/date_picker.rs, composite/color_picker.rs, primitive/label.rs | calendar.tsx, —, —, label.tsx |
primitive/alert.rs, primitive/toast.rs (toasts + WindowExt), primitive/progress.rs, primitive/spinner.rs, primitive/loader.rs, primitive/skeleton.rs, primitive/shimmer.rs | alert.tsx, toast.tsx / sonner.tsx, progress.tsx, spinner.tsx, (desktop-only pixel loaders), skeleton.tsx, ai/shimmer.tsx |
primitive/popover.rs, primitive/dialog.rs, composite/modal.rs (Modal / ModalHeader / ModalTitle / ModalDescription / ModalBody / ModalFooter), primitive/sheet.rs, composite/menu.rs, primitive/menubar.rs, primitive/navigation_menu.rs, primitive/hover_card.rs, primitive/tooltip.rs, primitive/command.rs | popover.tsx, dialog.tsx, drawer.tsx / sheet.tsx, dropdown/context, menubar.tsx, navigation-menu.tsx, hover-card.tsx, tooltip.tsx, command.tsx |
primitive/avatar.rs (wrapper: AvatarSize — web 24/32/40px), primitive/breadcrumb.rs, primitive/item.rs, composite/list.rs, composite/file_tree.rs (FileTree / FileTreeFolder / FileTreeFile), composite/tree.rs (gpui-component host), composite/pagination.rs, composite/toolbar_menu.rs, composite/marker.rs, composite/empty.rs, composite/virtual_list.rs | avatar.tsx, breadcrumb.tsx, item.tsx, list delegate, ai/file-tree.tsx, —, pagination.tsx, toolbar-menu.tsx, marker.tsx, empty.tsx, — |
primitive/table.rs — pure GPUI composition table (Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption) + declarative DataTable (from_key_value, striped rows). Columns share width via flex_basis(relative) so cells align across rows | table.tsx (primitives), the data-table composite; GetCat kv_table.rs inspired the Key/Value constructor |
primitive/separator.rs, primitive/tabs.rs, primitive/accordion.rs, primitive/collapsible.rs, primitive/carousel.rs, primitive/resizable.rs (ResizablePanelGroup / resizable_panel + Celestia handle chrome), composite/resizable_split.rs (two-pane drag twin of SplitPane), primitive/scroll_area.rs, primitive/aspect_ratio.rs, composite/group_box.rs | separator.tsx, tabs.tsx, accordion.tsx, collapsible.tsx, carousel.tsx, resizable.tsx, scroll-area.tsx, aspect-ratio.tsx, — |
composite/sidebar.rs, composite/sidebar_layout.rs (shell: SidebarLayout + header/nav/item/footer), composite/dock.rs, composite/title_bar.rs, composite/status_bar.rs | sidebar.tsx (composite); the layout shell, dock and window chrome are desktop-only |
primitive/bubble.rs, composite/message.rs, composite/message_scroller.rs, composite/marker.rs, composite/attachment.rs | the web ai/ family's bubble, chat-message + message-scroller, attachment |
composite/charts.rs (chart, plot — generic API, series colors from palette(cx).chart(i)) | chart.tsx + the chart-*.tsx composites |
composite/h_stack.rs, v_stack.rs, z_stack.rs, spacer.rs, scroll_view.rs, v_grid.rs, frame.rs, alignment.rs (SwiftUI-style layout: HStack/VStack/ZStack with alignment + spacing, Spacer, axis ScrollView, VGrid + GridItem, Frame) | SwiftUI's layout vocabulary — no web counterpart; gallery has a dedicated section |
primitive/icon.rs (Phosphor icons: PhosphorIcon catalog + Phosphor weight wrapper) | lucide-react on web; desktop uses Phosphor Core (no web counterpart) |
primitive/card.rs, composite/section_heading.rs, composite/text_editor.rs, composite/code_editor.rs (wrappers / celestia-only) | web Card / SectionHeading idiom; text-editor.tsx / block-text-editor |
primitive/chip.rs, status_dot.rs, caption.rs, image_icon.rs (ImageIcon — image/icon mark ± label), meter.rs | Chip / presence / caption / image+label / capacity meter (desktop-first) |
composite/app_header.rs, toolbar.rs, action_bar.rs, start_bar.rs, system_tray.rs, start_menu.rs, button_group.rs, input_group.rs, panel.rs, media_object.rs, activity_feed.rs, code_block.rs, chat_composer.rs, command_palette.rs, breadcrumb_bar.rs, tab_bar.rs, split_pane.rs, inspector_panel.rs, form_field.rs, settings_page.rs, search_field.rs, filter_bar.rs, keybinding_recorder.rs, data_table_toolbar.rs, file_browser.rs, file_tree.rs, kanban_board.rs, timeline.rs, stat_card.rs, confirm_dialog.rs, toast_stack.rs, banner.rs, onboarding_tip.rs, drop_zone.rs, dnd.rs, sortable_list.rs | App-level composites; each has its own searchable gallery page under Composites |
primitive/drag_handle.rs, drop_indicator.rs | DnD grip + insert line (pair with composite/dnd / sortable_list) |
The desktop kit ships no AI surfaces: the ai module (prompt input, reasoning, tools, plans,
citations, terminal, …) was removed, and only the conversation pieces — bubble, message,
message_scroller — were kept. Their web counterparts still live in packages/ui/src/components/ai/.
App composites
Presentational shells for common desktop app surfaces. The host owns
Entity state (inputs, open/close, selection); builders take callbacks and
slots. Import via celestia_ui::components::… (most are flat-reexported) or
components::composite::tab_bar::TabBar (document tabs — the name collides with
primitive::tabs::TabBar, so it stays module-path only). Full builder tables and
the new primitives (Toggle, Chip, Meter, Menubar, NavigationMenu, …)
live on Desktop Components.
use celestia_ui::components::composite::command_palette::{CommandPalette, CommandPaletteItem};use celestia_ui::components::composite::form_field::FormField;use celestia_ui::components::composite::confirm_dialog::ConfirmDialog;use celestia_ui::components::composite::modal::{Modal, ModalSize};use celestia_ui::components::primitive::icon::PhosphorIcon;
CommandPalette::new("palette") .item(CommandPaletteItem::new("new", "New file").icon(PhosphorIcon::File));
FormField::new("name") .label("Display name") .required(true) .control(/* Input::new(&state) */);
Modal::new("edit-profile") .title("Edit profile") .size(ModalSize::Medium) .child(/* body */);
ConfirmDialog::new("delete", "Delete project?") .body("This cannot be undone.") .destructive();Button
Button is the one component that goes past its web counterpart, so its extra surface is worth
spelling out.
Sizes are web's height ladder plus its icon ladder, in one place:
| Step | Web | Height | Padding | Gap | Text | Icon |
|---|---|---|---|---|---|---|
XSmall | xs | 20px | 8px | 4px | 10px | 10px |
Small | sm | 24px | 10px | 4px | 12px | 12px |
Medium (default) | default | 26px | 12px | 6px | 12px | 14px |
Md | md | 28px | 10px | 4px | 12px | 12px |
Large | lg | 36px | 14px | 8px | 14px | 16px |
Icon | icon | 26px square | — | — | 12px | 14px |
IconXs / IconSm / IconLg | icon-xs / icon-sm / icon-lg | 16 / 20 / 32px square | — | — | 10 / 12 / 14px | 10 / 12 / 16px |
Md and Medium are different steps — web's default and md are not the same thing. The
icon column is deliberately non-monotonic: Md is sm with one more step of height and nothing
else changed, so the taller button keeps the smaller icon. Large is the one deliberate
deviation — web's lg is 36px but keeps text-xs and gap-1.5; the desktop widens both.
Variants are the web set (primary / secondary / destructive / outline / ghost /
quiet / link) plus three desktop-only status fills — Success, Warning, Info — which
complete the set alert.rs and badge.rs already carry. All four status variants share one
recipe: the accent is the surface and the border, the paired *_foreground token is the text,
and the 3D edge is the accent darkened toward black.
Icon slots and loading are typed rather than assembled by hand:
use celestia_ui::components::primitive::button::{ Button, ButtonLoadingStyle, ButtonSize, ButtonVariant,};use celestia_ui::components::primitive::icon::PhosphorIcon;
// Leading / trailing slots — a bare PhosphorIcon renders at the size step's// icon size and inherits the button's text colour, so it follows the hover// fade and the disabled dimming for free.Button::new("export") .variant(ButtonVariant::Outline) .leading_icon(PhosphorIcon::DownloadSimple) .trailing_icon(PhosphorIcon::CaretDown) .label("Export");
// Icon-only: the `icon` constructor defaults to `ButtonSize::Icon`.// Always set `accessibility_label` (and usually `tooltip`) so AT and// keyboard users get a name — Tab stops + focus rings are built in.Button::icon("settings", PhosphorIcon::Gear) .accessibility_label("Settings") .tooltip("Settings");
// Loading: the mark takes the *leading slot* rather than being pushed in// front of it, so the label and the trailing icon keep their positions and the// button does not reflow. Interaction is blocked, but the fill, the elevation// and the opacity are kept — a loading button is busy, not forbidden.// Default mark is Spinner; pass `.loading_style(ButtonLoadingStyle::Diamond)`// for the eight-pixel diamond chase.Button::new("save") .variant(ButtonVariant::Primary) .loading(true) .loading_style(ButtonLoadingStyle::Diamond) .label("Saving");Two GPUI-specific notes on the icon ladder. Web expresses it with a descendant selector
([&_svg:not([class*='size-'])]:size-3.5); GPUI has no descendant styling, so the slot is an
explicit wrapper whose text_size a bare PhosphorIcon inherits — an icon that carries its own
explicit size still wins, which is what web's :not([class*='size-']) guard buys. And
Button::icon_size is the escape hatch for a glyph whose proportions need another step.
CursorStyle has no wait/progress variant, so a loading button keeps the plain arrow cursor;
OperationNotAllowed is the disabled signal and would read as a broken control.
Select
Select is upstream's widget plus one addition: options can carry an icon. The widget is
data-driven — the dropdown row and the trigger's committed value are drawn by the item type, so
the glyph lives on SelectOption rather than on a prop:
use celestia_ui::components::primitive::icon::PhosphorIcon;use celestia_ui::components::primitive::select::{ Select, SelectItems, SelectOption, SelectState,};
let status = cx.new(|cx| { SelectState::new( SelectItems::new(vec![ SelectOption::new("draft", "Draft").icon(PhosphorIcon::PencilSimpleLine), SelectOption::new("review", "In review").icon(PhosphorIcon::Eye), SelectOption::new("blocked", "Blocked") .icon(PhosphorIcon::WarningCircle) .icon_color(cx.theme().warning), SelectOption::new("shipped", "Shipped") .icon(PhosphorIcon::CheckCircle) .icon_color(cx.theme().success), SelectOption::new("archived", "Archived") .icon(PhosphorIcon::Archive) .disabled(true), ]), None, window, cx, ) .searchable(true)});
Select::new(&status).placeholder("Status").cleanable(true)| Builder | Effect |
|---|---|
SelectOption::new(value, label) | value + the text drawn in the row and in the trigger |
.icon(PhosphorIcon) | leading glyph, in the row and the trigger |
.icon_color(color) | fixed colour instead of the surrounding text colour — status semantics only |
.disabled(bool) | greyed-out, unclickable row |
Three things are worth knowing:
title()stays text. The accessible value is the plain label, so a screen reader never announces a glyph —display_title()is presentation only, per upstream's contract.- The icon supersedes
Select::title_prefix. Upstream draws one or the other, never both, so an icon option drops the prefix. Fold the qualifier into the label instead. SelectItemsis only needed for search. A plainVec<SelectOption<_>>is a delegate too;SelectItems(aSearchableVec) is what makessearchable(true)actually filter.
Icons inherit the surrounding text style, so they dim with a disabled trigger and take the row's
foreground on hover with no extra wiring — .icon_color is the escape hatch for a colour that
must not follow the text.
TitleBar
The window chrome is desktop-only, so TitleBar is a port of gpui-component's rather than
of a web component. It is the cheapest kind of port — upstream's file reaches zero
gpui-base components, so nothing had to be reimplemented on its behalf.
Use it as the first child of the window's root view, and start the window from
TitleBar::window_options():
use celestia_ui::components::composite::title_bar::TitleBar;use celestia_ui::gpui::WindowOptions;
let options = WindowOptions { window_min_size: None, ..TitleBar::window_options() // app_owns_titlebar_drag: true};window_options() sets app_owns_titlebar_drag: true deliberately. The bar moves the window
itself via start_window_move, so AppKit must not also treat it as a system window-move
region — otherwise macOS handles double clicks on its own in addition to ours and delays
every title bar click while it disambiguates.
Four deviations from the upstream file, all marked DEV: in the source:
- Caption glyphs. Upstream's
IconName::WindowMinimize/WindowRestore/WindowMaximize/WindowCloseare not in the Phosphor catalog, so they map to the closest Phosphor shapes:Minus,CopySimple,Square,X. Vecin place ofSmallVec<[AnyElement; 1]>— one inline slot is not worth a new direct dependency.- No
Renderimpl on the drag state. Upstream carries an empty one with a "remove this when GPUI has released v0.2.3" TODO;Window::use_state<S: 'static>places no such bound. - The real
ClickEventis passed to the close callback instead of a synthesizedClickEvent::default().
Two upstream helpers were inlined rather than ported: on_double_click is on_click plus a
click_count() == 2 guard, and Icon::new(..).small() resolves to a 14px glyph, which is
Phosphor::new(icon).size(px(14.0)) here.
The caption cluster is platform-conditional and worth knowing about: it renders nothing on
macOS (the traffic lights are the OS's) or in the browser, and on Linux it stands down entirely
when window.window_decorations() is not Decorations::Client — under server-side decorations
the window manager already draws min/max/close, so ours would stack a duplicate pair on top,
most visibly two close buttons.
Zeron-port primitives
A first family of primitives is ported from the Zeron reference (reference/zeron, a GPUI chat
app). These are celestia-native APIs — colors resolve from cx.theme() / palette(cx), never
from zeron's theme — and all motion runs through one shared module:
celestia_ui::motion— the animation kit: CSS-exactCubicBeziercurves, aMotionSpeccatalog (FADE_IN,MENU_IN,DIALOG_IN,PULSE,GRADIENT_SPIN, …) with entrance helpers (fade_in,menu_in,dialog_in), premultiplied-sRGBmix, and the pulse clock: a shared ~30fps driver that keeps every mounted loader phase-locked and parks entirely once the last one unmounts (a window with no spinner schedules nothing). Reduced motion (gpui'sreduce_motionflag) snaps loaders to a static rest brightness and zeroes the wave phases.components/primitive/loader.rs— icon-sized (default 16px) pixel loaders that drop into the same slot asSpinner. Chase:diamond(webspin-pixel),orbit,box_dots,snake,zigzag,blocks. Processing (Cursor-style):wave,bounce,cascade,breath,spark, plusdots/bars. Boxy 3×3 (two-tone theme primary):grid_zigzag,grid_shift(scrambled phases),grid_spin— override with.color/.secondary. Useswith_animation(noEntityId); reduced motion parks every cell at a static rest brightness.components/loaders.rs— the cell loaders:pulse_loader(5-cell opacity/scale wave),gradient_spinner(3×3 grid on thebrand → brand_deepramp, the pulse travels bottom-edge → top-centre),mini_spinner/mini_mono_spinner(2×3 radial chase in a self-contained view), andprogress_ring(stroked-polyline radial percent, works over dimmed imagery with a white tint).components/notice.rs—notice_chip: the tinted failure card (accent wash + border, alert triangle, copy-to-clipboard button, wrapping message — never truncating stderr),Plain(inline) andTile(block) icon treatments, danger/warning palettes.components/context_badge.rs—MessageBadgepills withBadgeDetailrows: a 24px chip that lifts a hover card of location/tag/body rows after a 280ms hover delay. The three slots are generic — the module never learns what the context cites.
The gallery's Feedback section renders all of them live.
Zeron does not run stock gpui — it builds on its fork zeronsh/zui (checked
out at reference/zui), which adds renderer-level primitives: BackdropBlur
(frosted glass), per-pixel EdgeFade / Window::with_edge_fade (scroll-edge
fades), transparent-window compositing and image corner-radius masks.
the pinned gpui (the gpui-pre series) has none of these, so zeron's
frost.rs, glass.rs and edge_fade.rs stay unported — adopting them would
mean replacing the whole pinned stack with the fork. Everything built
on the shared core API (div/Styled, Animation, canvas, element state)
ports cleanly, which is the family above.
Motion
celestia_ui::motion has two layers, and it is worth knowing which one a given
component is on.
1. The design-system token layer. The same curves and durations the web
primitives use, ported from @theme in packages/ui/src/styles/globals.css:
| Token | Value | Used for |
|---|---|---|
EASE_OUT_TOKEN | cubic-bezier(0.23, 1, 0.32, 1) | the signature curve — hovers, indicator, entrances |
EASE_IN_OUT_TOKEN | cubic-bezier(0.77, 0, 0.175, 1) | deliberate moves |
EASE_DRAWER_TOKEN | cubic-bezier(0.32, 0.72, 0, 1) | sheets, drawers, the overlay scrim |
DURATION_INSTANT | 80ms | press feedback (active:*) |
DURATION_FAST | 150ms | hover colour and background fades |
DURATION_NORMAL | 220ms | the tabs indicator, panel entrances |
DURATION_SLOW | 320ms | sheets, navigation menus, toasts |
DURATION_SLOWER | 500ms | caret blink, long reveals |
They are exposed three ways. Named MotionSpecs (HOVER, SLIDE, PRESS,
POPUP_IN, OVERLAY) let a component name the effect it is reproducing;
spec_transition(spec) (and the hover_transition / press_transition /
slide_transition shorthands) turn one into a gpui_base value-transition
policy for transition-colors-style fades; and motion_tokens() packages the
whole scale as gpui-component's MotionTokens.
// `transition-colors duration-fast ease-out` — a hover fade between two states.let t = transition(("btn-hover", self.id.clone()), hovered as u8 as f32, motion::hover_transition(), window, cx);let bg = motion::mix(rest, hover, t);theme.json cannot carry this scale — gpui-component's Theme::motion is
#[serde(skip)] — so theme::install assigns it directly. That one assignment
is what re-times every re-exported gpui-component component (switch, checkbox, slider,
accordion, collapsible, progress, carousel, TabBar) off gpui-component's own
0/120/180/280ms ladder and onto the design system's 80/150/220/320ms. Because
apply_config never touches the field, it survives Theme::change and
system-appearance switches.
Colour blending goes through motion::mix, which interpolates component-wise in
sRGB with premultiplied alpha — the way a browser transitions a colour — so a
wash fading in from transparent brightens rather than passing through grey.
2. The Zeron catalogue. The entrances and the loader pulse clock described
under Zeron-port primitives below are a separate,
older layer: Zeron's own curves (EASE_OUT_EXPO, EASE, …) and periods
(PULSE, GRADIENT_SPIN). They have no web-primitive counterpart to match and
were deliberately left alone.
Focus rings
Every focus ring is 2px at full strength — focus-visible:ring-2 focus-visible:ring-ring, enforced on the web by
scripts/ui-audit/focus-rings.mjs. gpui-component's own focus_ring_style draws 3px
at 50% opacity, which that rule forbids, so celestia_ui::focus_ring::focus_ring
is an in-crate replacement: it reads the element's own border widths and corner
radii, adds 2px, and attaches an absolutely-positioned bordered child at
-inset coloured cx.theme().ring with no alpha.
Shared interactive primitives (Button, Switch, TabsTrigger,
SidebarNavItem, Fab) track focus with tab_stop(true), announce a
Role, and paint that house ring when focused. Icon-only controls need an
explicit accessibility_label (falling back to tooltip / visible label
on Button).
What GPUI cannot animate
GPUI has no element transform — only images and SVGs take a
TransformationMatrix, so there is nothing to scale a subtree with. Two web
effects therefore cannot be reproduced literally:
active:scale-[0.97]on a tab trigger. The desktop keeps the web's colour behaviour (no colour change beyond hover) and drops only the geometry.zoom-in-[0.99]on the tabs panel entrance. The 1% zoom is approximated by a 2px rise, which reads the same on a panel edge.
gpui-component documents the same constraint for the dropdown entrance in its
popover.rs. Anything built on the shared core API — div/Styled,
Animation, canvas, element state — ports cleanly; only the renderer-level
extras do not.
The component-by-component audit that produced this layer, including the
per-wrapper parity tables and the deliberate non-goals, lives at
packages/desktop/AUDIT-component-motion.md.
Layout stacks — SwiftUI vocabulary
components/composite/ carries SwiftUI's layout vocabulary — one file per
construct, no web counterpart. Each is a thin RenderOnce wrapper over a gpui
flex row / column / layer, so they compose with every other component in the
library:
| Component | Maps to | Notes |
|---|---|---|
HStack | HStack(alignment:spacing:) | flex row; spacing becomes the flex gap (default 8px); alignment is the vertical cross axis (Top / Center / Bottom) |
VStack | VStack(alignment:spacing:) | flex column; defaults Leading / 8px; alignment is the horizontal cross axis (Leading / Center / Trailing) |
Spacer | Spacer(minLength:) | expands along the parent stack's main axis; min_length floors BOTH axes (min-width and min-height) |
ZStack | ZStack(alignment:) | every child becomes an absolutely-positioned full-bleed layer; the single alignment pins all layers; fills its parent, so give it a bounded frame |
VGrid | LazyVGrid(columns:spacing:) | children chunk into rows sized by GridItem::Fixed(px(...)) / GridItem::Flexible rules; rows wrap in child order; spacing applies between rows and columns |
use celestia_ui::components::composite::alignment::{ HorizontalAlignment, VerticalAlignment, ZAlignment,};use celestia_ui::components::composite::h_stack::HStack;use celestia_ui::components::composite::spacer::Spacer;use celestia_ui::components::composite::v_grid::{GridItem, VGrid};use celestia_ui::components::composite::v_stack::VStack;use celestia_ui::components::composite::z_stack::ZStack;
// Toolbar idiom: content leading, actions trailing, Spacer in between.HStack::new(VerticalAlignment::Center).spacing(px(8.)) .child(title) .child(Spacer::new()) .child(Button::new("act").label("Action"))
// One alignment pins every ZStack layer; compose a layer (an inner// HStack) to place content on independent edges.ZStack::new(ZAlignment::BottomTrailing) .child(div().size_full().bg(photo)) .child( div().w_full().p_3().child( HStack::new(VerticalAlignment::Bottom) .child(caption) .child(Spacer::new()) .child(badge), ), )
// Column rules: Fixed pins a width, Flexible shares what's left.VGrid::new([GridItem::Fixed(px(88.)), GridItem::Flexible, GridItem::Flexible]) .spacing(px(8.)) .children(cards)Three gotchas worth internalizing:
- The stacks are layout-only — they implement
ParentElementbut notStyled, so there is noHStack::new(..).p_3(). Wrap them in a plaindiv()for width, padding, or background. - ZStack is always full-bleed. Its root is
relative size_fulland each child is wrapped in anabsolute inset-0layer, so a ZStack inside an unsized container collapses. Put a real frame (h(px(..))/size_full) under it. - VGrid grows vertically — wrap long content in a
ScrollView(the axisScrollViewlives beside it incomposite/).
The gallery's Layout section (crates/showcase/src/sections/layout.rs)
renders every alignment variant of all three stacks live, states the defaults
and gotchas in each card's footer, and ships an interactive playground: pick
the axis (row / column / depth), alignment, spacing, and child count, and the
preview plus the exact constructor snippet update together.
Icons — Phosphor
crates/ui/src/components/primitive/icon.rs ships the Phosphor icon set (Phosphor Core 2.1.1, MIT —
license vendored at assets/phosphor/LICENSE-PHOSPHOR). The upstream
gpui-phosphor crate can't be used directly — it targets the
gpui 0.2 lineage and can't link against the pinned gpui-pre types — so the approach is ported
onto this stack instead:
- SVGs — 1,512 glyphs × 3 weights (
regular,bold,fill) vendored undercrates/ui/assets/phosphor/<weight>/<name>.svg. Duotone is skipped: GPUI's SVG renderer paints every path with one color, so duotone would render as solid. PhosphorIcon— a typed catalog enum generated bycrates/ui/build.rsat compile time (PhosphorIcon::Heart,PhosphorIcon::PaperPlaneTilt, …, plusALL/COUNT/name()). It renders through a plaingpui::svg— no third-party icon component sits in between — and bare.child(PhosphorIcon::Heart)renders at the surrounding text style, regular weight.Phosphor— the weight-aware wrapper (the counterpart of gpui-phosphor'sIconbuilder):Phosphor::new(PhosphorIcon::Heart).weight(PhosphorWeight::Fill).size(px(20.)).color(…). Size and color default to the surrounding text style. The weight is just the asset-path prefix (phosphor/<weight>/<name>.svg), so every enum variant exists at every embedded weight.
cargo test --workspace # asset-source tests: every glyph resolves at every weightAsset source registration (required)
gpui takes a single asset source at startup. Register the composite from
celestia_ui::assets in main — it serves the vendored Phosphor SVGs and
reports every other path as a clean miss:
use celestia_ui::assets::CelestiaAssets;
gpui_platform::application() .with_assets(CelestiaAssets) // phosphor/… .run(|cx| { /* … */ });Without a registered source gpui's fallback serves nothing and every icon renders blank
silently — CelestiaAssets fixes it. Phosphor is the only catalog: the crate used to fall
through to a bundled Lucide set, which was dropped once the three Lucide glyphs still in use
(a tab's BookOpen, the showcase Visual/Docs toggle's Eye and BookOpen) proved to have direct Phosphor
equivalents. The gallery's Phosphor Icons section
(crates/showcase/src/sections/icons.rs) renders the full catalog with live search, a
Regular/Bold/Fill weight switcher, and click-to-copy PhosphorIcon::… names.
An application can layer its own media on top the same way: embed a folder with
rust_embed and wrap CelestiaAssets as the fallback. The showcase does exactly this —
crates/showcase/src/assets.rs embeds its demo avatars (assets/avatars/**, used by the
chat section's image Avatars) and registers a ShowcaseAssets that checks the demo
media first, then falls through to CelestiaAssets.
Theming
crates/ui/src/theme.json is the sRGB-hex port of
packages/ui/src/styles/globals.css — the web CSS file stays the single source of truth, and the
JSON is re-derived from it when tokens change (grays via OKLab → linear sRGB = L³, chromatic
tokens through full OKLab → sRGB with gamut clamping, alpha tokens as 8-digit hex). init()
installs Celestia's own Theme global from that JSON and mirrors the light/dark pair onto
gpui-component's widget theme, so one mode switch keeps the whole window consistent.
App authors never edit theme.json by hand. They call celestia_ui::init, then either flip
light/dark or overlay tokens with AppTheme.
How end users use the theme config
| When | Call |
|---|---|
| Startup | celestia_ui::init(cx), then optional AppTheme { … }.apply(cx) |
| Settings “Dark mode” | Theme::toggle(cx) or Theme::change(…) |
| Brand / radius / font prefs | mutate AppTheme → .apply(cx) |
| Multiple skins | .register("Name", cx) + select("Name", cx) |
| Reset to defaults | AppTheme::default().apply(cx) |
Always import ActiveTheme / Theme / ThemeMode from celestia_ui::theme (or
celestia_ui), never from gpui_component — the two crates each have a Theme global, and
mixing them produces a split shell (light sidebar on a dark content pane).
Theme::change / Theme::toggle also swap the syntax highlighter used by
CodeEditor (tree-sitter). Celestia’s theme.json is colors-only; the bridge
loads gpui-component’s default light/dark highlight themes so the editor pane
and token colors follow the active mode.
Live demo: the gallery’s Theming & Config section
(crates/showcase/src/sections/theming.rs) switches families, registers a Nocturne custom
theme, and swaps radius presets, font faces, and accent palettes.
1. Bootstrap once
celestia_ui::init(cx); // installs Celestia light/dark2. Light and dark
use celestia_ui::theme::{ActiveTheme as _, Theme, ThemeMode};
Theme::toggle(cx); // flip light ↔ dark (⌘D in the gallery)Theme::change(ThemeMode::Dark, cx); // or set explicitlyTheme::sync_system_appearance(Some(window), cx);
let dark = cx.theme().is_dark();let bg = cx.theme().background; // every component already reads these3. Theme config (AppTheme) — the main knob
theme.json is the compiled-in default; the runtime knob over it is
celestia_ui::theme::AppTheme (crates/ui/src/theme/config.rs) — the desktop counterpart of
the shadcn :root / .dark blocks in globals.css. One config controls the whole library's
colors, radius, and font. Every Scheme field is optional: None keeps the Celestia default.
Every AppFont field is optional too: None keeps Geist and the platform monospace face.
Colors are written as hex(0xrrggbb).
use celestia_ui::theme::{hex, AppFont, AppTheme, Scheme};use gpui::px;
AppTheme { radius: px(10.), font: AppFont { family: Some("Georgia".into()), // None = Geist (embedded) size: Some(px(18.)), // None = 16px, also the rem size mono_family: Some("Menlo".into()), ..Default::default() }, light: Scheme { primary: Some(hex(0x2563eb)), ..Default::default() }, dark: Scheme { primary: Some(hex(0x6aa5ff)), background: Some(hex(0x0b1120)), ..Default::default() },}.apply(cx); // whole window re-themes; survives later Theme::toggleReset:
AppTheme::default().apply(cx);Useful Scheme fields (per mode): background, foreground, surface, popover, primary,
secondary, muted, accent, the status accents (destructive / success / warning /
info, each with a foreground), border, input, ring, and the sidebar* family.
Semantics:
- Overlay, not replacement.
AppTheme::default().apply(cx)is a re-assert oftheme.json, not a wipe. - One token drives every key it owns. Setting
primaryalso re-colors hover/active steps,button.primary.*,ring,caret,link.*, slider/progress fills,drag.border, list/table active borders, the 22%selectionwash, and primary-tinted surfaces (list/tab/table.active,drop_target, at 10% over the background). - One radius.
radiusmaps to shadcn’s--radius; dialogs/notifications useradius_lg = radius + 4px. - One font.
fontis anAppFont.family/sizeare the UI face and the window rem size (unset is Geist at 16px — the faces inassets/fonts/, SIL Open Font License, registered bycelestia_ui::init).mono_family/mono_sizeare the code face (unset is Menlo, Consolas, or DejaVu Sans Mono at 13px).applywrites both onto Celestia’sThemeand ontogpui-component’s theme, which the window root uses for the cascading family andset_rem_size. Code, terminals, andTypographycode readcx.theme().mono_font_family. Name a family that is installed, or one embedded withadd_fonts..SystemUIFontis the platform face when an app wants to leave Geist. - Mode-safe.
applyoverlays the active family and re-applies the current mode, so laterTheme::toggle/sync_system_appearancekeep the configured palettes and the font.
4. Named theme families (optional)
Light, dark, and custom themes share the same token vocabulary. A family is a named
light + dark pair. install() seeds Celestia from theme.json;
crates/ui/src/theme/book.rs keeps the registry.
use celestia_ui::theme::{active, families, hex, select, AppTheme, Scheme};use gpui::px;
AppTheme { radius: px(8.), dark: Scheme { background: Some(hex(0x0b1120)), primary: Some(hex(0x38bdf8)), ..Default::default() }, ..Default::default()}.register("Nocturne", cx); // register + activate
select("Celestia", cx); // switch back — light/dark mode is preservedfamilies(cx); // ["Celestia", "Nocturne"]active(cx); // "Celestia"registerderives the pair from the Celestia base (same fan-out asapply), names the configs"<Family> Light/Dark", and activates them. Re-registering a name replaces that family.applyis family-aware. It overlays whichever family is active and stores the result on it, so token tweaks survive switching away and back.- Mode toggles stay inside the family.
Theme::toggle/Theme::change/sync_system_appearanceoperate on the active pair; ⌘D in the showcase flips light/dark within it.
The mechanics live on Celestia’s Theme / ThemeColor / ActiveTheme (also re-exported at
celestia_ui::{Theme, ThemeMode, ActiveTheme, …}). Custom families stay out of
gpui-component’s ThemeRegistry; mode flips still sync that registry’s Celestia light/dark
pair so re-exported widgets match.
Pins and product colors
Key values are pinned by cargo test --workspace: light primary #d40c1a, dark #ff4d46
(out-of-gamut oklch(0.68 0.22 27) clipped), the dark border as white-alpha, selection always
primary at 22%, and a guard that fails if primary regresses to the retired brand blue.
config.rs / book.rs tests cover default apply, radius derivation, per-mode overrides, font
apply (both theme globals, surviving a mode toggle, reset), and register / select / apply
interplay.
Mode-independent product colors (brand #dc2626, brand_deep #b51230, brand_foreground,
the chart ramp) live in palette(cx) — like the web :root, they are defined once and never
re-themed.
State management
crates/ui/src/state/ is the desktop counterpart of the web app's zustand stores and React
contexts, built on GPUI's entity model (an entity re-renders when it calls cx.notify()). Two
layers:
Store/StoreHandle— the zustand layer.StoreHandle::new(state, cx)creates a store entity;read(cx)isgetState(),set(cx, …)/update(cx, …)aresetState()and notify every observer,replace(cx, state)swaps the whole value, andset_if_changedskips the notification when the state compares equal afterwards. Actions are plain&mut selfmethods on the state type, passed as the closure:store.set(cx, AppState::close_tab).StoreContext<T>— the React-context layer.StoreContext::<T>::provide(state, cx)registers an ambient, store-backed value once at bootstrap; any code later callsStoreContext::<T>::require(cx)(orconsume) and gets the full read/write handle with no prop drilling. There is one ambient value per state type per app — for multiple live instances of the same type, skip the ambient layer and hand outStoreHandles explicitly.
The re-render gate is observe_slice: bind an entity to a selector over the state, and its callback
(where you call cx.notify()) only runs when that slice's value actually changes — the
useStore(selector) equivalent. subscribe is the unfiltered variant for side effects
(persistence, logging). Like GPUI's own observers, callbacks run once per update batch with that
batch's final state, not once per set call.
The showcase's State & Stores section (crates/showcase/src/sections/state.rs) renders this
live: a GalleryState store with per-slice notification counters, and a Session ambient context
provided in main.rs. The layer is unit-tested with #[gpui::test] tests inside
crates/ui/src/state/.
Full usage guide — defining stores, actions, gating, contexts, testing, and the live demo — at Desktop State.
Building a desktop app on it
Scaffold a standalone app with the create CLI:
npx @celestia-project/create my-desktop --template desktopcd my-desktop && cargo run -p my-desktopOr run the in-repo skeleton with pnpm desktop:starter
(packages/desktop/crates/starter). Both wire assets, celestia_ui::init,
TitleBar, SidebarLayout, theme toggle, and a two-page home screen.
Depend on the crate (celestia-ui = { path = "…/crates/ui" }, plus gpui,
gpui-component and gpui-platform), register
the asset source with .with_assets(celestia_ui::assets::CelestiaAssets) (see
Icons — Phosphor — without it icons render blank), call
celestia_ui::init(cx) inside application().run(...) before opening any window, and wrap
your root view in a gpui-component Root. Dialog / sheet / toast overlays are
mounted by the root plugin that celestia_ui::init registers (gpui-component
0.7) — do not re-mount Root::render_*_layer in your view.
Bootstrap order (from crates/starter/src/main.rs):
gpui_platform::application() .with_assets(CelestiaAssets) .run(|cx| { celestia_ui::init(cx); // keybindings, StoreContext::provide, … cx.open_window(options, |window, cx| { let view = cx.new(|cx| MyApp::new(window, cx)); cx.new(|cx| Root::new(view, window, cx)) }) .expect("open window"); cx.activate(true); });The showcase (crates/showcase) remains the full component reference; the
starter is the app skeleton.
Related
- Desktop Components — Toggle, Chip, Meter, Menubar, and app composites (header, toolbar, panel, feed, …)
- Desktop State —
StoreHandle, ambient context, selector-gated subscriptions - Desktop Bridge — HTTP client from GPUI to
apps/api