Files
signed/docs/NATIVE-THEME-MIGRATION.md
T
reya 36e5a18982
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Scale text by font size ratio when applying system theme
2026-10-21 14:08:35 +07:00

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 ThemeColor fields, fonts, radius, shadow, focus_ring, scrollbar and base-layer values;
  • a ThemeConfig per mode, so the app's existing Theme::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 master before eb4fbb0, the version set the connector was verified against (2026-10-08 stamp). The exact =0.7.1 pins are required, not a style choice: a caret "0.7.1" resolves 0.7.3, whose gpui-pre =0.3.9 pin drags the graph onto 0.3.9, outside the closure the connector is verified against. The three gpui-pre-* family crates are published in lockstep; all three move back to 0.3.8 together.
  • The connector re-exports the native-theme types that appear in its public signatures (SystemTheme, AccessibilityPreferences, …), so the app does not need native-theme as a direct dependency.
  • The cost of the revert is the two one-line tweaks eb4fbb0 made for 0.8 (the Deref removal in crates/dock/src/dock_area.rs is restored; the 0.7.1 ResizeHandle::on_drag hands the closure an Rc<T> and requires E: Render). The nostr changes in that commit are unrelated and stay.
  • When native-theme-gpui releases 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:

  1. gpui_component::init(cx) and theme::init(cx) — must run before apply (connector requirement).
  2. SettingsStore load — already before any theme work.
  3. ThemeRegistry load from Assets.themes() — kept, feeds the fallback.
  4. 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 | Dark and apply_appearance() in settings_dialog.rs work as-is.
  • workspace.rs's observe_window_appearance → sync_system_appearance keeps following the OS while in System mode.
  • Theme::change(mode, None, cx) reproduces the native palette for the mode because the connector installed a ThemeConfig per 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 next Theme::change and 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 in dock, signed_ui, workspace — including the dock/tab skins and window controls, which keep reading tokens;
  • crates/workspace/src/workspace.rs appearance observer;
  • crates/settings (fields stay), crates/assets (signed.json stays as the fallback), crates/dock.

6. Validation

  1. cargo check, cargo test --workspace, cargo clippy across the workspace (toolchain 1.96 satisfies the connector's 1.95 MSRV).
  2. 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 ↔ Dark in 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 run once with native detection forced to fail (e.g. temporarily return early in the match) to prove the bundled fallback still works.
  3. cargo tree -i gpui-pre and cargo tree -i gpui-component must each show exactly one version — the whole point of Gate 0.

7. Explicitly out of scope

  • native-theme icon sets / CustomIconName mapping — the app keeps its own SVG assets.
  • theme-drawn widgets (widgets feature).
  • a preset-picker UI (from_preset) in settings.
  • runtime OS-theme watching (watch feature).
  • per-field user overrides layered on the native theme.
  • migrating signed.json into a native-theme Theme definition.