Desktop
Native Rust desktop components built directly on gpui (gpui-pre) — the desktop counterpart of @celestia-project/ui, drawing the same Celestia tokens as a compile-time theme. This surface shows the shape of the package; the reference docs carry the architecture, theming rules and the per-primitive component map.
Get started
What this package is
Two crates, one rule: colors come from roles, never from hex literals at a call site.
packages/ui wraps Base UI primitives with celestia tokens; celestia-ui wraps the gpui-component library the same way — one file per primitive, so most web files map 1:1 onto a desktop file (drawer.tsx → sheet.rs, toast.tsx → toast.rs, file-tree.tsx → tree.rs). It is not a port — the web components assume a DOM; these draw straight into a GPU window. What they share is the taxonomy and the design tokens, plus shadcn-shaped wrappers (Button and Badge carry real Variant props).
cargo directly inside packages/desktop, or use the root pnpm desktop script, which shells into cargo.theme.json is the sRGB-hex port of globals.css — the web file stays authoritative. Raw hex lives only in theme.json and palette.rs; tests pin the values so a stale re-port fails CI. At runtime, AppTheme overlays the same tokens — see the theming sections below.Get started
Run the gallery
The showcase binary renders one Card section per family through the same re-export layer an app would use — buttons and badges, inputs and selection, alerts and toasts, a live popover/dialog/sheet, an accordion, date/color/number pickers, both text editors (markdown toolbar + tree-sitter code mode), and a live theming section: radius presets, font faces, accent swaps and custom theme families. ⌘D / Ctrl+D toggles light/dark.
pnpm desktopcargo run -p celestia-desktop| Platform | Toolchain |
|---|---|
| macOS | None beyond Rust ≥ 1.97 (edition 2024) |
| Linux | Vulkan + Wayland/X11/fontconfig headers |
| Windows | MSVC |
cargo test --workspace # unit + gpui TestAppContext tests (theme pins)
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warningsComponents
One file per primitive
Every primitive gets its own file mirroring packages/ui's primitive/*.tsx — Celestia wrappers (button.rs, badge.rs, card.rs, the editors) plus per-primitive re-exports of gpui-component, each with the web → desktop mapping in its doc comment.
actionsbutton.rs (wrapper) · kbd.rs · link.rs · command.rs · menu.rs
web: button.tsx, kbd.tsx, link.tsx, command.tsx, dropdown/context/menubar
inputsinput.rs · textarea.rs · input_otp.rs · select.rs · combobox.rs · switch.rs · checkbox.rs · radio.rs · slider.rs · rating.rs · stepper.rs · label.rs · calendar.rs · date_picker.rs · color_picker.rs
web: input.tsx, textarea.tsx, input-otp.tsx, select.tsx, switch.tsx, checkbox.tsx, radio-group.tsx, slider.tsx, calendar.tsx, label.tsx
feedbackalert.rs · toast.rs · progress.rs · spinner.rs · skeleton.rs · shimmer.rs · tooltip.rs
web: alert.tsx, toast.tsx / sonner.tsx, progress.tsx, spinner.tsx, skeleton.tsx, tooltip.tsx
motion (zeron port)motion.rs · loaders.rs · notice.rs · context_badge.rs
web: — desktop-only; ported from the reference/zeron GPUI app (see the usage guide)
overlayspopover.rs · dialog.rs · modal.rs · sheet.rs · hover_card.rs
web: popover.tsx, dialog.tsx, drawer.tsx / sheet.tsx, hover-card.tsx
data displaybadge.rs (wrapper) · avatar.rs · breadcrumb.rs · table.rs · list.rs · tree.rs · pagination.rs · marker.rs · empty.rs
web: badge.tsx, avatar.tsx, breadcrumb.tsx, table.tsx, item.tsx, ai/file-tree.tsx, pagination.tsx, marker.tsx, empty.tsx
layoutseparator.rs · tabs.rs · accordion.rs · collapsible.rs · carousel.rs · resizable.rs · scroll_area.rs · group_box.rs · sidebar.rs · dock.rs · title_bar.rs · status_bar.rs
web: separator.tsx, tabs.tsx, accordion.tsx / collapsible.tsx, carousel.tsx, resizable.tsx, scroll-area.tsx, sidebar.tsx — dock & window chrome are desktop-only
chatbubble.rs · message.rs · attachment.rs
web: the ai/ family's bubble, chat-message, message-scroller, attachment
editorstext_editor.rs · code_editor.rs
web: text-editor.tsx / block-text-editor (markdown toolbar + tree-sitter code mode)
chartscharts.rs (chart · plot)
web: chart.tsx + the chart-*.tsx composites — series colors from palette(cx).chart(i)
card.rs · section_heading.rsCelestia-only builds — components gpui-component lacks, written against the token layer. SectionHeading ports the agency idiom (eyebrow + title + description). The wrappers button.rs / badge.rs add shadcn-shaped Variant props on top of gpui primitives.
Guide
Using the components in a project
From an empty Rust binary to a themed window rendering Celestia components. Every snippet below compiles against the current crates — the library lives at packages/desktop/crates/ui, the package name is celestia-ui.
1Depend on the crate
celestia-ui depends on the framework and its two widget layers directly — gpui, gpui-base and gpui-component — with no facade crate in between. Every import names the layer it actually comes from.
# a crate inside packages/desktop — inherit the workspace entry
[dependencies]
celestia-ui = { workspace = true }
# an app outside the workspace — point at the library crate
[dependencies]
celestia-ui = { path = "../packages/desktop/crates/ui" }2Initialize once, before any window
celestia_ui::init installs the gpui-component runtime and the Celestia light/dark palettes; Theme::change and system-appearance sync keep using them afterwards. The full window-opening bootstrap is in the last section of this page.
use gpui::App;
fn main() {
gpui_platform::application().run(|cx: &mut App| {
celestia_ui::init(cx); // runtime + Celestia light/dark themes
// open windows — see "Bootstrap" below
});
}3Compose views from the components
Everything hangs off celestia_ui::components, with module paths mirroring the web files (components::badge ↔ badge.tsx). Stateless primitives are plain values; stateful ones are entities you create in the view constructor and render by reference.
use celestia_ui::components::badge::{Badge, BadgeVariant};
use celestia_ui::components::button::{Button, ButtonVariant};
use celestia_ui::components::input::{Input, InputState};
use celestia_ui::components::Card;
use gpui::{Entity, Window};
use gpui_component::{h_flex, v_flex};
use gpui::prelude::*;
struct InviteView {
email: Entity<InputState>,
}
impl InviteView {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
// Stateful primitives are entities: create them in the
// constructor, render them by reference below.
email: cx.new(|cx| {
InputState::new(window, cx).placeholder("[email protected]")
}),
}
}
}
impl Render for InviteView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex().gap_3().child(
Card::new()
.title("Invite your team")
.description("Teammates get a magic link by email.")
.child(Input::new(&self.email).cleanable(true))
.child(
h_flex()
.gap_2()
.items_center()
.child(Badge::new("Beta").variant(BadgeVariant::Brand))
.child(
Button::new("send")
.label("Send invite")
.variant(ButtonVariant::Primary)
.on_click(|_, _, _| {
// handle it — event/window/cx come in here
}),
)
.child(
Button::new("later")
.label("Later")
.variant(ButtonVariant::Ghost),
),
),
)
}
}4Take colors from roles, never hex
cx.theme() resolves the semantic roles (background, muted, border, danger…) that flip with light/dark; palette(cx) carries the mode-independent product colors (brand, brand_deep, chart). The one rule of the package: no hex literals at a call site.
use celestia_ui::theme::ActiveTheme;
use gpui::{div, px};
let theme = cx.theme();
let row = div()
.flex()
.gap_2()
.rounded(px(8.0))
.bg(theme.muted) // semantic role — flips in dark mode
.border_1()
.border_color(theme.border)
.text_color(theme.muted_foreground);
let wash = celestia_ui::palette(cx).brand().opacity(0.08); // product color, mode-independent5Animate with the motion kit
The zeron-port family adds a motion catalog, cell loaders, the notice chip and context badges (desktop-only — no web counterpart). All loaders share one pulse clock: mount as many as you like, and a window with none scheduled draws nothing.
use celestia_ui::components::context_badge::{BadgeDetail, MessageBadge, context_badge};
use celestia_ui::components::primitive::icon::PhosphorIcon;
use celestia_ui::components::loaders::{gradient_spinner, progress_ring, pulse_loader};
use celestia_ui::components::notice::{NoticeChipIcon, notice_chip};
use celestia_ui::motion;
// entrances — gpui Animations, snapped automatically under reduced motion
motion::fade_in("invite-enter", my_card)
// loaders — pass your view's id so the pulse clock can invalidate it
pulse_loader(8.0, cx.entity_id(), cx)
gradient_spinner(6.0, cx.entity_id(), cx)
progress_ring(65, 40.0, cx.theme().foreground)
// failure notice — tinted card, copy button, wrapping message
notice_chip(false, "Build failed", "cargo build exited with 101", NoticeChipIcon::Tile, cx)
// context pill — hovers into a card of location/tag/body rows
context_badge(
"ctx-comments",
&MessageBadge {
icon: PhosphorIcon::FileCode,
label: "2 comments".into(),
details: vec![BadgeDetail {
location: "src/main.rs:42".into(),
tag: Some("R".into()),
body: "early-return here".into(),
}],
},
cx,
)6Build your own primitive
Custom components are RenderOnce structs with #[derive(IntoElement)] — resolve theme colors inside render, not in the constructor, so light/dark switches apply on the next frame.
use celestia_ui::theme::ActiveTheme;
use gpui::{App, IntoElement, RenderOnce, SharedString, Window, div, px};
#[derive(IntoElement)]
pub struct Pill {
label: SharedString,
}
impl RenderOnce for Pill {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
div()
.px(px(6.0))
.rounded(px(4.0))
.bg(theme.muted)
.border_1()
.border_color(theme.border)
.text_color(theme.muted_foreground)
.child(self.label)
}
}cx.theme() for semantic roles, palette(cx) for brand/charts. Stateful primitives are entities — construct the state in the view constructor, pass &state at render. Mount the dialog/sheet/notification layers in your root render or push_notification and open_dialog do nothing. Re-theme at runtime through AppTheme — one config, apply(cx), named families via register — never per-component style forks. And keep the gallery open: crates/showcase renders every primitive through the same re-export layer your app uses.Theming
The same tokens, compiled in
celestia_ui::init installs the light/dark palettes and replaces gpui-component's defaults — Theme::change and system-appearance sync keep using them. The swatches below are the live web tokens; the native gallery renders the same roles from the ported theme. The runtime layer over it is the next section.
Beyond the semantic roles, palette(cx) carries the mode-independent product colors gpui's Theme has no slot for: brand (#dc2626), brand_deep (#b51230), brand_foreground and the grayscale chart(0…4) ramp — mirroring the web where these live once on :root and never change in dark mode.
Theming
One config drives the theme at runtime
AppTheme is the desktop counterpart of the shadcn :root / .dark blocks in globals.css — semantic tokens, one radius, and one font, with overlay semantics over the compiled-in theme. Nothing per-component changes: every primitive already reads cx.theme(), so applying a config re-themes the whole window on the next frame.
use celestia_ui::theme::{hex, AppFont, AppTheme, Scheme};
use gpui::px;
let config = AppTheme {
radius: px(10.), // one knob — radius_lg derives +4px
font: AppFont {
family: Some("Georgia".into()), // None = Geist (embedded)
size: Some(px(18.)), // None = 16px, also the rem size
..Default::default()
},
light: Scheme {
primary: Some(hex(0x2563eb)), // None = keep the theme.json value
..Default::default()
},
dark: Scheme {
primary: Some(hex(0x6aa5ff)),
..Default::default()
},
};
// Re-themes every window on the next frame — and survives later
// Theme::change / system-appearance switches.
config.apply(cx);Overlay, not replacement
Every Scheme field is optional — None keeps the theme.json value, so AppTheme::default().apply(cx) is a re-assert of the compiled-in theme, not a change. Colors are hex(0xrrggbb) values, the config's unit, like a CSS variable value.
One token drives its whole fan-out
Setting primary re-colors the fills, their derived hover/active steps, ring, caret, links, the 22% selection wash and the primary-tinted surfaces — the same fan-out theme.json encodes by hand. The full shadcn set is covered per mode, plus a surface token for the chrome family.
One radius knob
radius maps to shadcn's --radius and lands on theme.radius everywhere; the large-element radius (dialogs, notifications) derives as radius + 4px — the shadcn --radius-xl step.
One font
AppFont sets the UI family and rem size (unset is the embedded Geist face at 16px) and the monospace family and size (unset is the platform mono face at 13px). apply writes both theme globals, so the window root and every code surface follow.
Light, dark and custom families
register adds a named light+dark pair built from tokens and activates it; select switches between families with the mode preserved; apply overlays whichever family is active and stores the tweak on it, so it survives switching away and back.
use celestia_ui::theme::{active, families, select, AppTheme, Scheme, hex};
// Register a named light+dark family from tokens — and activate it:
AppTheme {
dark: Scheme {
background: Some(hex(0x0b1120)),
primary: Some(hex(0x38bdf8)),
..Default::default()
},
..Default::default()
}
.register("Nocturne", cx);
select("Celestia", cx); // switch back — light/dark mode is preserved
families(cx); // ["Celestia", "Nocturne"]
active(cx); // "Celestia"The gpui-component theming surface is re-exported at celestia_ui::theme — Theme, ThemeColor and ActiveTheme — so apps reach the whole story from one import. Brand and the chart ramp stay mode-independent in palette(cx); the showcase’s Theming & Config section drives all of this live.
Apps
App bootstrap in three lines
Install the runtime + theme, open a window, wrap the root view in a gpui-component Root — then mount the overlay layers yourself, because Root::render does not draw them.
gpui_platform::application().run(|cx: &mut App| {
celestia_ui::init(cx); // runtime + Celestia light/dark themes
cx.open_window(WindowOptions { ..TitleBar::window_options() }, |window, cx| {
let view = cx.new(|cx| MyApp::new(window, cx));
cx.new(|cx| Root::new(view, window, cx))
})?;
cx.activate(true);
});// inside MyApp::render — dialogs, sheets and toasts are invisible until
// the root view mounts their layers:
let dialog_layer = Root::render_dialog_layer(window, cx);
let sheet_layer = Root::render_sheet_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx);
div().child(self.content(cx))
.children(dialog_layer)
.children(sheet_layer)
.children(notification_layer)impl RenderOnce and #[derive(IntoElement)]; cx.theme().radius is already a Pixels value; the root element should hold focus with an id and a role; and Root::render never draws the Dialog/Sheet/Notification layers — unmounted layers swallow push_notification silently.