16 KiB
Migrate the UI to GPUI-Kit, then native-theme
Plan to move coop's custom UI elements onto GPUI-Kit
(gpui-kit 0.7.1), delete the custom theme implementation (crates/theme),
and then integrate native-theme
through its official native-theme-gpui connector.
Revision. This supersedes the earlier approach in this file (depend on the
native-themecore crate and hand-write aResolvedTheme→ coop token mapping). Going through GPUI-Kit first makes that mapping unnecessary: the connector already projects native themes onto gpui-component'sTheme, and every call site readscx.theme()from gpui-component directly.
1. What the git history shows
516eb0e— move gpui-components to ui crate: the UI crate was the upstream component library, vendored intocrates/ui. Call sites used library components and its theme vocabulary (cx.theme().elevated_surface_background,surface_background,border_variant, …).0c1c23f— migrate to gpui-base (#51): the vendored library was deleted and replaced by thegpui-basecrate plus hand-written wrappers (Button, Switch, Dialog, Input, Dock, Menu, Notification, …) and the customcrates/theme.
"Migrate like before" therefore means: go back to the upstream component
library — this time as a dependency (gpui-kit), not vendored — and let it
own the theme.
2. Strategy
graph LR
A[Phase A: GPUI-Kit] --> B[Phase B: native-theme-gpui]
A1[A1 Bootstrap<br/>init + open_window + Root] --> A2[A2 Component swap<br/>~20 custom modules]
A2 --> A3[A3 Theme removal<br/>cx.theme = gpui-component]
A3 --> B1[B1 Connector<br/>apply_system_theme]
B1 --> B2[B2 Appearance + presets<br/>settings wiring]
Phase A replaces the custom elements with gpui_kit::component
components and moves all color reads onto gpui-component's Theme
(ThemeColor, 139 semantic fields). The app runs on gpui-component's
built-in light/dark palettes at the end of Phase A; crates/theme is gone.
Phase B adds native-theme-gpui, which installs native OS themes (or any
of 16 bundled presets) into that same Theme. No call site changes — the
connector is verified against exactly the versions Phase A pins.
Target dependency set (exact versions)
| Crate | Version | Note |
|---|---|---|
gpui-kit |
0.7.1 | facade: gpui_kit::* = GPUI (gpui-pre), gpui_kit::component (gpui-component 0.7.1), gpui_kit::base (gpui-base 0.7.1), gpui_kit::assets |
gpui-pre |
=0.3.8 | already pinned; gpui-kit 0.7.1 requires it |
native-theme-gpui |
0.6 | Phase B; verified against gpui-kit 0.7.1 / gpui-pre 0.3.8; needs Rust ≥ 1.95 (toolchain is 1.98) |
Do not jump to gpui-kit 0.8: native-theme-gpui 0.6.1 pins 0.7.1, and a
gpui-component patch release has broken the connector before.
3. Current custom UI inventory
crates/ui is ~8,700 lines across 34 files. Consumers import it at ~138
sites (workspace 14 files, chat_ui, nip29_ui, nip4e, settings,
desktop, web); theme tokens are read at ~340 sites across the same
crates.
| Custom module | Lines | GPUI-Kit replacement |
|---|---|---|
menu/ (popup, dropdown, item) |
983 | menu::{PopupMenu, PopupMenuItem, DropdownMenu, ContextMenu} |
dock/ (mod, panel) |
1,032 | dock (entity-backed DockArea / panels) |
motion.rs |
668 | keep — app-level animation, no theme dependency |
notification.rs |
594 | notification::Notification + WindowExt::push_notification; placement via Theme.notification |
button.rs |
581 | button::{Button, ButtonGroup, Toggle, DropdownButton} |
message.rs |
553 | evaluate message::{Message, Bubble, Attachment, MessageScroller}; keep as app composite where it fits better |
avatar.rs |
445 | avatar::Avatar |
root.rs |
430 | framework Root via gpui_kit::open_window (owns dialogs, notifications, tooltip/menu layers, focus traps) |
dialog.rs |
394 | WindowExt::open_dialog / AlertDialog; custom footer buttons → Dialog's footer API |
input/ |
~290 | input::{Input, InputState, Textarea, NumberInput}; delete the custom styling (its input_bg/selection/caret reads die with it) |
icon.rs |
274 | Icon + IconName (Lucide); keep ui::icon as a thin mapping shim (see §4.3) |
kbd.rs |
270 | Kbd |
tab/ |
385 | tab::{Tab, TabBar, Tabs} |
title_bar.rs |
218 | TitleBar (keep the client-side-decoration constants) |
popover.rs |
170 | popover::Popover |
styled.rs |
161 | h_flex/v_flex/StyledExt from the kit; drop coop-specific helpers as their users migrate |
switch.rs |
157 | switch::Switch |
markdown.rs |
146 | text::TextView::markdown (code styling via Theme.highlight_theme) |
nav.rs + nav_item.rs |
268 | sidebar::{Sidebar, SidebarMenu, …}; if the coop layout doesn't fit, keep as an app composite on top of kit primitives — decide during the slice |
group_box.rs |
126 | group_box::GroupBox |
scroll/ |
~124 | scroll::{Scrollbar, Scrollable} |
resizable/ |
31 | resizable::{h_resizable, v_resizable, resizable_panel} |
tooltip.rs |
34 | .tooltip() |
indicator.rs |
59 | Marker/Badge, or keep if neither fits |
window_ext.rs |
90 | gpui_kit::component::WindowExt |
4. Phase A — GPUI-Kit migration
4.1 Bootstrap
desktop/main.rsandweb/lib.rs:gpui_kit::init(cx)first, then open the window withgpui_kit::open_window(opts, cx, |window, cx| …), which installs the frameworkRoot(dialog, sheet, notification and tooltip layers). Deleteui::Rootand its layers;workspace::initstays as the window's content view.TitlebarOptions/ client-side decoration setup stays coop's (CLIENT_SIDE_DECORATION_*,TABBAR_HEIGHTmove toui).- Keep
bind_keysbeforeset_menus(guide rule); coop already does.
4.2 Component slices (each PR compiles)
Replace one family at a time; consumers update in the same PR:
- Deps + bootstrap (above). Custom components still work alongside the framework Root.
- Icons — introduce the
ui::iconshim so later slices see kit icons through the old call sites (see §4.3). - Small controls — Button, Switch, Kbd, GroupBox, Avatar, Tooltip, Indicator, Popover.
- Menu — PopupMenu/DropdownMenu/ContextMenu (+ the popup-menu actions
already imported from
gpui_base::actions). - Input — Input/InputState/Textarea; port the chat composer, dialog fields, relay editor; drop the clear-button and input-styling modules.
- Dialog + Notification —
open_dialog/AlertDialog/push_notification; port the settings, relays, profile, new-chat, import… dialogs; the custom footers move to the Dialog footer API. - Scroll + Resizable + Tab — panels and the workspace tab strip.
- Sidebar/Nav — Sidebar family (or keep composites, per the table).
- Title bar — kit
TitleBar+ traffic-light handling. - Dock — the biggest single item (805 + 205 lines,
dock::addregistry,DockPlacement). Port panels ontoDockArea; do it last so the panels it hosts are already kit-based. - Message + Markdown — TextView::markdown; Message/Bubble where they
fit, otherwise keep
ui::messageas an app composite (it is domain layout, allowed by the guides' app-component layer). - Cleanup — delete the replaced modules; decide what
crates/uistill is (see §4.4).
4.3 Icons
coop's IconName has 128 variants backed by assets/icons/*.svg; the kit's
IconName (Lucide) has 106. Keep ui::icon as a mapping module: a
coop-IconName enum (same variant names) that resolves to the kit IconName
where a Lucide equivalent exists, and to a custom asset path (coop's bundled
SVGs) where it doesn't (brand marks, a few specials). Most Icon::new(...)
call sites then keep compiling with import changes only. Delete
assets/icons entries that get Lucide equivalents; keep assets/brand.
4.4 Theme removal
- Call sites:
theme::ActiveTheme→gpui_kit::component'sActiveTheme(cx.theme() -> &Theme). Rewrite the ~340 token reads with the mapping below (done per consumer crate, one slice each:ui,workspace,chat_ui,nip29_ui,settings+nip4e).
coop token (cx.theme().x) |
gpui-component ThemeColor |
|---|---|
bg |
background |
surface |
sidebar |
surface_raised |
secondary |
surface_card |
muted |
surface_dialog, surface_overlay |
popover |
element_hover |
accent (gpui-component's accent is the item highlight) |
element_active |
list_active |
border |
border |
border_faint, border_strong |
border (one border token; faintness loss accepted) |
ring |
ring |
text |
foreground |
text_muted, text_faint, text_dim |
muted_foreground |
cursor, caret |
caret |
selection |
selection |
solid / on_solid |
primary / primary_foreground |
accent, accent_strong |
primary (emphasis ink) |
danger / danger_muted |
danger |
danger_strong |
button_danger |
warning / warning_muted |
warning / warning.alpha(≈0.15) at the call site |
success |
success |
input_bg, selection, caret inside the custom input |
die with the custom Input (§3) |
code_text, code_wash |
die with ui::markdown → TextView |
drop_line / drop_target |
drag_border / drop_target |
- Non-color fields move to
Themedirectly:font_sans/font_mono→theme.font_family/mono_font_family;shadow→theme.shadow;scrollbar_mode→theme.scrollbar_mode; notification placement →theme.notification(gpui-component ships the sameNotificationSettingscoop's was modeled on);is_dark()→theme.mode. crates/themedisposition:- delete:
lib.rs(Theme global,AppExt,sync_base,PaletteBuilder),colors.rs,palette.rs,brand.rs,appearance.rs,paint.rs,color.rs,syntax.rs,notification.rs,scrollbar_mode.rs,platform_kind.rs; - move to
crates/uiunchanged:typography.rs(TextStyle/Typeset, ~200 call sites — retained deliberately as coop's type ladder),layout.rs(spacing constants, radius accessors),geometry.rs(Edges/Side/AxisExt), and the threeCLIENT_SIDE_DECORATION_*constants; AppearanceMode(System/Light/Dark, serde-persisted) moves intocrates/settings.
- delete:
- The gpui-base projection coop did in
sync_baseis now the framework's job (Theme::update/Theme::changekeep colors, tokens and the base layer in step per the coding guides).
4.5 Appearance handling (end of Phase A)
settings::apply_theme→Theme::change(mode, None, cx)for a pinned Light/Dark,Theme::sync_system_appearance(None, cx)for System.workspace'sTheme::sync_system_appearance(Some(window), cx)call stays, now against the kit'sTheme; window-appearance observation comes with the framework Root/theme (verify; if not, keep the existingobserve_window_appearancewiring).
5. Phase B — native-theme-gpui
-
Add
native-theme-gpui = "0.6"(pullsnative-theme; keep default features or trim icon features coop doesn't use). -
Boot, after
gpui_kit::initand before opening the window:match native_theme::SystemTheme::from_system() { Ok(sys) => native_theme_gpui::apply_system_theme(&sys, cx), Err(error) => { log::warn!("system theme unavailable: {error}"); // per-platform fallback preset: macos-sonoma / kde-breeze / windows-11 let prefs = native_theme_gpui::AccessibilityPreferences::default(); if let Ok((theme, resolved)) = native_theme_gpui::from_preset("macos-sonoma", true, &prefs) { native_theme_gpui::apply(theme, &resolved, &prefs, cx); } } }apply_system_themestores both variants and aThemeConfigper mode; afterwards the Phase A controls keep working unchanged:Theme::sync_system_appearance/Theme::changeswitch between the native palettes. -
Optional preset picker (the same one proposed in the superseded plan):
settings.theme_preset: Option<String>, a dropdown in Preferences → Appearance; selecting one callsfrom_preset(name, is_dark, &prefs)+applyfor both variants. -
Fonts, radius, scrollbar geometry and the base layer are written by the connector (it also re-asserts them after each upstream rebuild via its observer). Coop writes nothing theme-related itself.
wasm: SystemTheme::from_system is unavailable on wasm32 — cfg-gate step 2
to native targets; on web, apply the fallback preset (or extend
native-theme with a web story later; presets need no OS reader).
6. Testing and QA
- Per slice:
cargo checkworkspace,cargo clippy,cargo fmt --check, plus the affected crate's tests; UI behavior verified by hand in the desktop app. - Early spike (before slice 2):
cargo check -p web --target wasm32-unknown-unknownwith thegpui-kitdependency added. The vendored library was wasm-capable; confirm the published crates are too before committing to the migration. If they are not, stop and reassess — the web target is a launch blocker. - End of Phase A:
- all dialogs open/close, focus trap and restore works; nested popover-in-dialog works (framework Root owns the layers);
- notifications stack and auto-hide; settings' appearance dropdown switches Light/Dark/System with a full repaint;
- dock panels move, split and zoom; sidebar resizes; scrollbars behave in Hover/Always/Scrolling modes;
- markdown code blocks render through
TextViewwith syntax colors fromTheme.highlight_theme.
- End of Phase B:
- macOS: OS light/dark follows; pinned modes; accent changes picked up at
next launch (documented limitation — no
watchfeature); - Linux (KDE + GNOME/portal) and Windows: theme reads work or fall back to the platform preset with a logged warning;
- preset switch renders in both modes; inputs/menus/dialogs/tooltips/ scrollbars all paint from the native palette;
- startup shows no wrong-palette flash (apply before the first window).
- macOS: OS light/dark follows; pinned modes; accent changes picked up at
next launch (documented limitation — no
7. Risks
| Risk | Mitigation |
|---|---|
Scale: ~8.7k lines of custom UI, ~140 ui:: imports, ~340 token reads |
Slices in §4.2 each compile; crates/ui acts as the facade during the migration so consumer churn is incremental |
| Dock port (largest, most stateful) | Scheduled last; panels are kit-based by then; DockArea owns layout state coop currently hand-rolls |
| gpui-component on wasm | Spike in §6 before committing; fallback would be staying on 0.7.1 desktop-only and parking the web target |
| Visual identity changes (shadcn-style stock palette in Phase A) | Accepted — requirement says the current palette need not survive; Phase B replaces it with native themes anyway |
| Icon gaps (coop 128 vs Lucide 106) | Mapping shim keeps coop names; unmatched variants fall back to bundled SVGs |
API drift between coop's wrappers and the kit (e.g. Sizable, Disableable, callback shapes) |
Per the coding guides: never invent APIs; verify each component page / source signature during its slice |
TextStyle ladder is a deliberate holdout from rem-based text |
Documented exception; sizes are absolute points and don't claim to zoom. Revisit only if the kit's text scale is adopted later |
| native-theme-gpui version coupling | Pin gpui-kit = "=0.7.1"-era set (0.7.1, gpui-pre =0.3.8) matching the connector's verified table; bump all together |
8. Out of scope
- Migrating
motion.rsor the panel-background image feature. - Accessibility text scaling / reduce-motion plumbing (connector supports it; adopt after Phase B if wanted).
- Replacing coop's composited panels (chat, screening, backup…) with kit
Sheet/Settings— revisit after the migration settles.