4 Commits
Author SHA1 Message Date
reya 36e5a18982 Scale text by font size ratio when applying system theme
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
2026-10-21 14:08:35 +07:00
reya 6122474f9e Follow the OS native theme on macOS, Windows and Linux 2026-10-21 13:57:01 +07:00
reya f7865780d9 add plan 2026-10-21 10:56:14 +07:00
reya eb4fbb0a33 chore: update deps 2026-10-20 09:58:26 +07:00
7 changed files with 344 additions and 114 deletions
+6 -3
View File
@@ -1,10 +1,12 @@
# Changelog # Changelog
## v0.2.0 - 2026/10/21 ## Unreleased
### Breaking changes
### Added ### Added
- Follow the operating system's native theme on macOS, Windows and Linux (KDE, GNOME), applying the OS colors, fonts, radii and accessibility preferences. - Follow the operating system's native theme on macOS, Windows and Linux (KDE, GNOME) via `native-theme`, applying the OS colors, fonts, radii and accessibility preferences; the bundled themes remain as the fallback when the OS theme cannot be read
- Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI - Add an identity import dialog, importing an existing identity by pasting its `nsec` secret key (encrypted with a new passphrase), NIP-49 `ncryptsec` (unlocked with its passphrase), or `bunker://` NIP-46 URI
- Add a dropdown menu on the signed-in user button in the sidebar - Add a dropdown menu on the signed-in user button in the sidebar
- Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential - Add sign-out feature, confirmed by an alert dialog before deleting the stored session credential
@@ -16,7 +18,6 @@
### Changed ### Changed
- Use the built-in light/dark syntax highlighting palette instead of the Signed palette bundled in `signed.json` when the native theme is active
- Increase the default theme corner radii from 2 to 4 and large radii from 6 to 8 - Increase the default theme corner radii from 2 to 4 and large radii from 6 to 8
- Surface backend errors as error notifications in the workspace instead of silently dropping them - Surface backend errors as error notifications in the workspace instead of silently dropping them
- Break ties in repository activity lists by event id, so same-second events order deterministically - Break ties in repository activity lists by event id, so same-second events order deterministically
@@ -44,6 +45,8 @@
- Remove the light/dark theme pickers and the font size, corner radius, focus ring and shadow settings from the settings dialog; colors and geometry now come from the native theme - Remove the light/dark theme pickers and the font size, corner radius, focus ring and shadow settings from the settings dialog; colors and geometry now come from the native theme
- Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths - Remove dead code: the unused `login`/`logout` family, the unwired `SyncProgress` pipeline, `merge_pull_request`, inbox mark-read/archive APIs and the wasm32-only code paths
### Deprecated
## v0.2.0-alpha - 2026/09/27 ## v0.2.0-alpha - 2026/09/27
### Added ### Added
Generated
+13 -16
View File
@@ -285,7 +285,7 @@ dependencies = [
[[package]] [[package]]
name = "assets" name = "assets"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui-component", "gpui-component",
@@ -1863,7 +1863,7 @@ dependencies = [
[[package]] [[package]]
name = "dock" name = "dock"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"gpui-base", "gpui-base",
"gpui-component", "gpui-component",
@@ -5562,7 +5562,7 @@ dependencies = [
[[package]] [[package]]
name = "metadata" name = "metadata"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"flume 0.11.1", "flume 0.11.1",
@@ -5699,7 +5699,6 @@ dependencies = [
"fontdb", "fontdb",
"inventory", "inventory",
"native-theme-derive", "native-theme-derive",
"notify",
"objc2 0.6.5", "objc2 0.6.5",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"objc2-core-foundation", "objc2-core-foundation",
@@ -6654,7 +6653,7 @@ dependencies = [
[[package]] [[package]]
name = "paths" name = "paths"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"dirs 6.0.0", "dirs 6.0.0",
] ]
@@ -8237,7 +8236,7 @@ dependencies = [
[[package]] [[package]]
name = "settings" name = "settings"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui-pre", "gpui-pre",
@@ -8334,17 +8333,15 @@ dependencies = [
[[package]] [[package]]
name = "signed" name = "signed"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"assets", "assets",
"dock", "dock",
"flume 0.11.1",
"gpui-component", "gpui-component",
"gpui-pre", "gpui-pre",
"gpui-pre-platform", "gpui-pre-platform",
"gpui-pre-reqwest-client", "gpui-pre-reqwest-client",
"metadata", "metadata",
"native-theme",
"native-theme-gpui", "native-theme-gpui",
"paths", "paths",
"settings", "settings",
@@ -8356,7 +8353,7 @@ dependencies = [
[[package]] [[package]]
name = "signed_core" name = "signed_core"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"nostr", "nostr",
"serde", "serde",
@@ -8365,7 +8362,7 @@ dependencies = [
[[package]] [[package]]
name = "signed_git" name = "signed_git"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"diffy", "diffy",
@@ -8383,7 +8380,7 @@ dependencies = [
[[package]] [[package]]
name = "signed_nostr" name = "signed_nostr"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"nostr-connect", "nostr-connect",
@@ -8395,7 +8392,7 @@ dependencies = [
[[package]] [[package]]
name = "signed_state" name = "signed_state"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitcoin_hashes 1.2.0", "bitcoin_hashes 1.2.0",
@@ -8419,7 +8416,7 @@ dependencies = [
[[package]] [[package]]
name = "signed_ui" name = "signed_ui"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"assets", "assets",
"futures", "futures",
@@ -10018,7 +10015,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "utils" name = "utils"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"nostr", "nostr",
] ]
@@ -11267,7 +11264,7 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "workspace" name = "workspace"
version = "0.2.0" version = "0.2.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assets", "assets",
+1 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop"]
default-members = ["desktop"] default-members = ["desktop"]
[workspace.package] [workspace.package]
version = "0.2.0" version = "0.2.0-alpha"
edition = "2024" edition = "2024"
publish = false publish = false
@@ -18,7 +18,6 @@ reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" }
gpui-component = { version = "=0.7.1", features = ["tree-sitter-languages"] } gpui-component = { version = "=0.7.1", features = ["tree-sitter-languages"] }
gpui-base = "=0.7.1" gpui-base = "=0.7.1"
native-theme = { version = "0.6.1", features = ["watch"] }
native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] } native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
dock = { path = "crates/dock" } dock = { path = "crates/dock" }
+19 -14
View File
@@ -514,13 +514,10 @@ impl SidebarPanel {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
let name = announcement.name().map(SharedString::from); let name = announcement.name().map(SharedString::from);
let owner = self.directory.read(cx).profile(&announcement.owner, cx); // The sidebar rows are compact, so the avatar matches the 16px nav icons.
let avatar = Avatar::new(announcement.owner) let avatar = Avatar::new(format!("{}:{}", announcement.owner, announcement.id)).xsmall();
.name(owner.name())
.picture(owner.picture())
.xsmall();
let announcement = announcement.clone(); let announcement = announcement.clone();
let addr = announcement.addr(); let addr = announcement.addr();
let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0); let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
let unsynced = self.sync_directory.read(cx).unsynced(&addr, cx); let unsynced = self.sync_directory.read(cx).unsynced(&addr, cx);
@@ -776,24 +773,32 @@ pub(super) fn server_host(relay: &RelayUrl) -> SharedString {
.unwrap_or_else(|| SharedString::from(relay.to_string())) .unwrap_or_else(|| SharedString::from(relay.to_string()))
} }
/// The bound owner's avatar, falling back to the repo's pixel avatar when unowned. /// The repository's pixel avatar, with the bound owner's avatar at its bottom right.
fn local_avatar( fn local_avatar(
entry: &ResolvedLocalRepo, entry: &ResolvedLocalRepo,
directory: &Entity<ProfileDirectory>, directory: &Entity<ProfileDirectory>,
cx: &App, cx: &App,
) -> AnyElement { ) -> AnyElement {
// Matches the other sidebar rows, and the badge below sits at its corner.
let avatar = Avatar::new(entry.path.to_string_lossy()).xsmall();
let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else { let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else {
return Avatar::new(entry.path.to_string_lossy()) return avatar.into_any_element();
.xsmall()
.into_any_element();
}; };
let profile = directory.read(cx).profile(&owner, cx); let profile = directory.read(cx).profile(&owner, cx);
Avatar::new(owner) div()
.name(profile.name()) .relative()
.picture(profile.picture()) .child(avatar)
.xsmall() .child(
div().absolute().bottom_neg_0p5().right_neg_0p5().child(
Avatar::new(owner)
.name(profile.name())
.picture(profile.picture())
.size(px(14.)),
),
)
.into_any_element() .into_any_element()
} }
+1 -3
View File
@@ -14,7 +14,7 @@ product-name = "Signed"
description = "Nostr Git client for browsing repositories and collaborating" description = "Nostr Git client for browsing repositories and collaborating"
identifier = "info.reya.signed" identifier = "info.reya.signed"
category = "DeveloperTool" category = "DeveloperTool"
version = "0.2.0" version = "0.2.0-alpha"
out-dir = "../dist" out-dir = "../dist"
before-packaging-command = "cargo build --release" before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"] resources = ["Cargo.toml", "src"]
@@ -38,9 +38,7 @@ gpui.workspace = true
gpui_platform.workspace = true gpui_platform.workspace = true
dock = { workspace = true } dock = { workspace = true }
gpui-component.workspace = true gpui-component.workspace = true
native-theme.workspace = true
native-theme-gpui.workspace = true native-theme-gpui.workspace = true
flume.workspace = true
reqwest_client.workspace = true reqwest_client.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
+21 -76
View File
@@ -5,9 +5,8 @@ use dock::TAB_BAR_HEIGHT;
use gpui::*; use gpui::*;
use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme}; use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme};
use gpui_platform::application; use gpui_platform::application;
use native_theme::watch::ThemeSubscription;
use native_theme_gpui::{SystemTheme, apply_system_theme}; use native_theme_gpui::{SystemTheme, apply_system_theme};
use settings::{AppearanceMode, Settings, SettingsStore}; use settings::{AppearanceMode, SettingsStore};
actions!(signed, [Quit]); actions!(signed, [Quit]);
@@ -29,21 +28,26 @@ fn main() {
SettingsStore::set_global(store.clone(), cx); SettingsStore::set_global(store.clone(), cx);
let settings = store.read(cx).settings().clone(); let settings = store.read(cx).settings().clone();
{ let registry = ThemeRegistry::global_mut(cx);
let registry = ThemeRegistry::global_mut(cx);
for (name, content) in Assets.themes() { for (name, content) in Assets.themes() {
if let Err(err) = registry.load_themes_from_str(&content) { if let Err(err) = registry.load_themes_from_str(&content) {
tracing::error!("Failed to load theme {name}: {err}"); tracing::error!("Failed to load theme {name}: {err}");
}
} }
} }
match apply_native_theme(&settings, cx) { match SystemTheme::from_system() {
Ok(()) => start_theme_watcher(cx), Ok(mut system) => {
let native = system.light.defaults.font.size;
if native > 0.0 {
let scaling = settings.theme.font_size / native;
system.accessibility.text_scaling_factor *= scaling;
}
apply_system_theme(&system, cx);
}
Err(error) => { Err(error) => {
tracing::warn!("native theme unavailable ({error})"); tracing::warn!("native theme unavailable ({error})");
let registry = ThemeRegistry::global_mut(cx);
let light_theme = registry let light_theme = registry
.themes() .themes()
.get(settings.theme.light_theme.as_str()) .get(settings.theme.light_theme.as_str())
@@ -73,15 +77,15 @@ fn main() {
} else { } else {
tracing::warn!("{} theme is missing", settings.theme.dark_theme); tracing::warn!("{} theme is missing", settings.theme.dark_theme);
} }
match settings.appearance {
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
}
} }
} }
match settings.appearance {
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
}
std::fs::create_dir_all(paths::nostr_dir()).ok(); std::fs::create_dir_all(paths::nostr_dir()).ok();
std::fs::create_dir_all(paths::repos_dir()).ok(); std::fs::create_dir_all(paths::repos_dir()).ok();
@@ -124,62 +128,3 @@ fn main() {
cx.activate(true); cx.activate(true);
}); });
} }
/// Apply the OS theme to the app, scaling the font size to match the system font size.
fn apply_native_theme(settings: &Settings, cx: &mut App) -> native_theme::Result<()> {
let mut system = SystemTheme::from_system()?;
let native = system.light.defaults.font.size;
if native > 0.0 {
let scaling = settings.theme.font_size / native;
system.accessibility.text_scaling_factor *= scaling;
}
apply_system_theme(&system, cx);
match settings.appearance {
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
}
Ok(())
}
/// Holds the watcher subscription so it lives as long as the app.
struct ThemeWatcher {
_subscription: ThemeSubscription,
}
impl Global for ThemeWatcher {}
/// Re-apply the OS theme whenever it changes at runtime.
fn start_theme_watcher(cx: &mut App) {
let (sender, receiver) = flume::unbounded();
match native_theme::watch::on_theme_change(move |event| {
if let Err(error) = sender.send(event) {
tracing::warn!("theme change dropped ({error})");
}
}) {
Ok(subscription) => {
cx.set_global(ThemeWatcher {
_subscription: subscription,
});
cx.spawn(async move |cx| {
while receiver.recv_async().await.is_ok() {
tracing::info!("OS theme changed; re-applying");
cx.update(|cx| {
let store = SettingsStore::global(cx);
let settings = store.read(cx).settings().clone();
if let Err(error) = apply_native_theme(&settings, cx) {
tracing::warn!("failed to apply updated OS theme ({error})");
}
});
}
})
.detach();
}
Err(error) => {
tracing::warn!("OS theme watching unavailable ({error})")
}
}
}
+283
View File
@@ -0,0 +1,283 @@
# Native theme migration plan
Integrate [`native-theme`](https://github.com/tiborgats/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):
```toml
# [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:
```rust
// 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:
```toml
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.