14 KiB
Native theme migration plan
Integrate native-theme (via the
native-theme-gpui connector) so the app follows the OS native theme on
macOS, Windows and Linux (KDE/GNOME), instead of only the bundled
signed.json themes.
Principles for this migration: plug the connector in, migrate what it replaces, do not wrap it, and do not adopt its optional subsystems (icons, theme-drawn widgets, presets UI) in this pass.
1. Current state
| Piece | Location | Notes |
|---|---|---|
| Startup theme setup | desktop/src/main.rs |
gpui_component::init + theme::init, loads signed.json into ThemeRegistry, sets Theme::global_mut fields (radius, radius_lg, focus_ring, shadow, font_size, mono_font_size, light_theme, dark_theme), then Theme::change / Theme::sync_system_appearance |
| Runtime theme application | crates/workspace/src/views/sidebar/settings_dialog.rs |
apply_theme() re-reads settings into the global Theme; apply_appearance() maps AppearanceMode to Theme::change / sync_system_appearance |
| System appearance follow | crates/workspace/src/workspace.rs |
cx.observe_window_appearance → Theme::sync_system_appearance when appearance is System |
| Theme settings | crates/settings/src/settings.rs (ThemeSettings) |
light_theme, dark_theme, font_size, mono_font_size, radius, radius_lg, focus_ring, shadow |
| Bundled themes | crates/assets/assets/themes/signed.json |
Signed Light / Signed Dark colors + a highlight section (editor/syntax colors) |
| Theme consumers | dock, signed_ui, workspace |
~50 cx.theme() call sites via gpui_component::ActiveTheme |
Dependencies today (branch feat/native-theme, commit eb4fbb0):
gpui-pre 0.3.9, gpui-pre-platform 0.3.9, gpui-component 0.8,
gpui-base 0.8, toolchain 1.96.
2. What the connector does
native-theme-gpui turns a native_theme::ResolvedTheme (read from the OS,
or from a bundled preset) into a fully configured gpui_component::Theme and
installs it:
- all 139
ThemeColorfields, fonts,radius,shadow,focus_ring, scrollbar and base-layer values; - a
ThemeConfigper mode, so the app's existingTheme::change(mode, None, cx)/Theme::sync_system_appearance(..)switch between the native light and dark palettes unchanged; - an internal observer that restores the native values gpui-component overwrites on each rebuild;
- accessibility: the OS text-scaling factor scales fonts (through the window rem), reduce-motion and reduce-transparency are forwarded.
Because the connector produces a regular gpui-component Theme, every
existing cx.theme() call site keeps working without modification. That is
the core reason this integration is small.
Key APIs: SystemTheme::from_system(), apply_system_theme(&system, cx),
apply(theme, &resolved, &prefs, cx), from_preset(name, is_dark, &prefs),
AccessibilityPreferences::from_system().
3. Gate 0 — version compatibility (blocking)
Verified against crates.io and the upstream repo on 2026-10-21:
| Fact | Value |
|---|---|
| Latest connector | native-theme-gpui 0.6.1 (2026-10-09); upstream main is the same |
| Connector requires | gpui-component ^0.7.1, gpui-base ^0.7.1, gpui-pre ^0.3.8, Rust ≥ 1.95 |
gpui-component/gpui-base 0.7.1 pin |
gpui-pre =0.3.8 |
gpui-component/gpui-base 0.7.3 pin |
gpui-pre =0.3.9 |
gpui-component 0.8.0 pins |
gpui-pre =0.3.9 (0.8.0 published 2026-10-19) |
| This branch | moved 0.7.1 / 0.3.8 → 0.8 / 0.3.9 in eb4fbb0 ("chore: update deps") |
The pins are mutually exclusive: with the app on gpui-component 0.8, cargo
cannot unify the graph — the connector would pull a second gpui-pre 0.3.8
and a second gpui-component 0.7.x, and apply(.., cx) would not even
type-check against the app's App. The connector cannot be added on top of
gpui-component 0.8 today.
Decision
Revert the GPUI-side dependency bump on this branch and align with the connector's supported closure (recommended):
# [workspace.dependencies]
gpui = { package = "gpui-pre", version = "0.3.8" }
gpui_platform = { package = "gpui-pre-platform", version = "0.3.8", features = ["font-kit", "x11", "wayland"] }
reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
gpui-component = { version = "=0.7.1", features = ["tree-sitter-languages"] }
gpui-base = "=0.7.1"
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
- This is the state of
masterbeforeeb4fbb0, the version set the connector was verified against (2026-10-08 stamp). The exact=0.7.1pins are required, not a style choice: a caret"0.7.1"resolves 0.7.3, whosegpui-pre =0.3.9pin drags the graph onto 0.3.9, outside the closure the connector is verified against. The threegpui-pre-*family crates are published in lockstep; all three move back to 0.3.8 together. - The connector re-exports the
native-themetypes that appear in its public signatures (SystemTheme,AccessibilityPreferences, …), so the app does not neednative-themeas a direct dependency. - The cost of the revert is the two one-line tweaks
eb4fbb0made for 0.8 (theDerefremoval incrates/dock/src/dock_area.rsis restored; the 0.7.1ResizeHandle::on_draghands the closure anRc<T>and requiresE: Render). The nostr changes in that commit are unrelated and stay. - When
native-theme-gpuireleases gpui-component 0.8 support (upstream has bumped within days of each gpui-component release so far), bump both sides together in one commit.
Alternative, rejected for now: staying on gpui-component 0.8 and waiting for upstream 0.8 support. Nothing in this migration depends on 0.8-only APIs, and 0.8.0 shipped two days before this plan, so the revert is the cheaper path.
Note: the connector's README quick-start shows the gpui-kit facade crate;
this project depends on gpui-component + gpui-base directly, which is
equivalent — no gpui-kit dependency is needed.
4. Target design
4.1 Theme source
The native OS theme becomes the primary source. The bundled signed.json
registry stays as the fallback when the OS theme cannot be read (unsupported
Linux desktop, headless, etc.). This keeps the existing startup code as the
fallback branch instead of deleting a working safety net.
4.2 Startup flow (desktop/src/main.rs)
Order is load-bearing:
gpui_component::init(cx)andtheme::init(cx)— must run beforeapply(connector requirement).SettingsStoreload — already before any theme work.ThemeRegistryload fromAssets.themes()— kept, feeds the fallback.- Apply the native theme:
// native_theme_gpui re-exports SystemTheme from native-theme.
match native_theme_gpui::SystemTheme::from_system() {
Ok(system) => native_theme_gpui::apply_system_theme(&system, cx),
Err(error) => {
tracing::warn!("native theme unavailable ({error}); using bundled themes");
// existing path: registry lookups + Theme::global_mut field setup
}
}
// shared: the existing `match settings.appearance { .. }` block stays,
// now driving native palettes per mode on the native path.
apply_system_theme installs both the light and dark variants plus their
configs, so the subsequent Theme::change / sync_system_appearance for the
user's AppearanceMode picks the right native palette.
Sizing note: gpui-component derives the window rem from theme.font_size, so
every rem-relative element scales with it. The OS-reported UI font (13 on
macOS) is smaller than the 16 this app is designed around, so the native path
multiplies system.accessibility.text_scaling_factor by
settings.theme.font_size / native_size before applying — the connector's
supported scaling knob, which also preserves any OS accessibility scaling.
The same factor scales mono_font_size, so code text follows the UI size
(the pre-migration app used 13 for mono while the UI was 16).
On the native path, do not set radius / font_size / light_theme /
dark_theme etc. on Theme::global_mut — the connector owns those fields,
and overwriting them fights its observer.
4.3 Appearance switching (no changes)
AppearanceMode::System | Light | Darkandapply_appearance()insettings_dialog.rswork as-is.workspace.rs'sobserve_window_appearance→sync_system_appearancekeeps following the OS while inSystemmode.Theme::change(mode, None, cx)reproduces the native palette for the mode because the connector installed aThemeConfigper variant.
Known gap (not addressed here): gpui-component's system-appearance follow is
driven by window-activation events. A live OS theme change while the window
is focused may not propagate on Linux until the window is re-focused;
native-theme's watch feature (a theme-change watcher) is a possible
follow-up, out of scope.
4.4 Settings dialog (settings_dialog.rs)
Keep:
- the Appearance row (
System/Light/Dark) and its handler.
Remove (the native theme now owns all of these):
- Light Theme / Dark Theme pickers and
theme_options()— colors are chosen by the OS. This is also the only way to avoid the connector's registry trap: selecting a registry theme would overwrite the connector's per-mode configs on the nextTheme::changeand silently drop native colors until restart. - Font size, Mono font size, Radius, Large radius, Focus ring, Shadows rows and their handlers — the resolved theme provides geometry, and the connector already scales fonts by the OS text size. Keeping manual overrides would require re-applying them after every upstream rebuild, a permanent fight with the connector.
apply_theme() and theme_options() are deleted outright: with the pickers
and number fields gone they have no remaining callers. The fallback logic
lives only in main.rs's Err branch; the Appearance handler keeps working
through apply_appearance() alone (Theme::change picks up whatever configs
are installed, bundled or native).
ThemeSettings keeps its fields and serde shape — the fallback branch still
reads them, existing settings files keep parsing, and the tests in
settings.rs stay valid. They become fallback-only tuning (editable via
settings.json). Deleting the fields is a possible follow-up cleanup if the
fallback ever moves to native-theme presets.
4.5 Syntax highlighting (known trade-off)
signed.json carries a highlight section (editor and syntax colors) used by
code and markdown rendering (TextView in the files view and elsewhere). The
connector's installed per-mode configs carry upstream's default highlighter
styles, so on the native path the active syntax palette is upstream's, not
Signed's.
Decision for this pass: accept upstream's defaults — zero code. If the Signed
syntax palette must be kept later, investigate whether gpui-component 0.7
exposes a syntax-theme setter that survives Theme::change; do not build an
override mechanism now.
4.6 Features
Use the connector with narrowed features; the app uses its own SVG icons and gpui-component widgets:
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
system-fonts keeps a theme font family the system lacks drawing in the
platform's substitute. widgets (theme-drawn checkbox/switch/etc.),
material-icons, lucide-icons, system-icons, svg-rasterize are all
unused here and each carries build weight (the icon bundles are ~2 MB).
Adopting any of them is a separate, deliberate follow-up.
5. Change list
| File | Change |
|---|---|
Cargo.toml |
Revert gpui-side pins to 0.3.8 / =0.7.1 (Gate 0); add the native-theme-gpui workspace dep |
desktop/Cargo.toml |
Add native-theme-gpui |
desktop/src/main.rs |
Native apply with bundled fallback (§4.2); move the settings-field setup into the fallback branch |
crates/workspace/src/views/sidebar/settings_dialog.rs |
Remove theme picker + font/radius/focus/shadow rows; shrink apply_theme to fallback-only (§4.4) |
CHANGELOG.md |
Added: native OS theme via native-theme; Removed: manual theme/font/radius settings |
Unchanged:
- every
cx.theme()consumer indock,signed_ui,workspace— including the dock/tab skins and window controls, which keep reading tokens; crates/workspace/src/workspace.rsappearance observer;crates/settings(fields stay),crates/assets(signed.jsonstays as the fallback),crates/dock.
6. Validation
cargo check,cargo test --workspace,cargo clippyacross the workspace (toolchain 1.96 satisfies the connector's 1.95 MSRV).- Manual matrix, per platform available (macOS first; KDE and GNOME Linux;
Windows):
- cold start matches the OS theme and accent; radius, fonts and controls read as native;
- switching Appearance
System ↔ Light ↔ Darkin settings switches native palettes; - with
System, changing the OS theme and re-focusing the window updates the app; - settings dialog, dock tabs, diffs, files view, markdown rendering look correct; the syntax palette difference from §4.5 is reviewed and accepted;
cargo runonce with native detection forced to fail (e.g. temporarily return early in the match) to prove the bundled fallback still works.
cargo tree -i gpui-preandcargo tree -i gpui-componentmust each show exactly one version — the whole point of Gate 0.
7. Explicitly out of scope
- native-theme icon sets /
CustomIconNamemapping — the app keeps its own SVG assets. - theme-drawn widgets (
widgetsfeature). - a preset-picker UI (
from_preset) in settings. - runtime OS-theme watching (
watchfeature). - per-field user overrides layered on the native theme.
- migrating
signed.jsoninto a native-themeThemedefinition.