diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index a3c853b5..2015d461 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1828,7 +1828,7 @@ impl Render for ChatPanel { .gap_2() .border_b_1() .border_color(cx.theme().border) - .child(Input::new(&self.subject_input).small()) + .child(Input::new(&self.subject_input)) .child( Button::new("change") .icon(IconName::CheckCircle) diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index b5e3e30f..51f56b37 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use anyhow::{Error, anyhow}; use common::config_dir; -use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use smallvec::{SmallVec, smallvec}; @@ -31,6 +31,33 @@ impl AppearanceMode { } } +/// The default base font size for the interface +pub const DEFAULT_FONT_SIZE: f32 = 16.0; + +/// User tuning applied on top of the native OS theme +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct ThemeSettings { + /// The base UI font size in pixels + pub font_size: f32, + /// Draw a ring around focused controls + pub focus_ring: bool, + /// Corner radius for general elements; follows the OS theme when unset + pub radius: Option, + /// Corner radius for large elements like dialogs; follows the OS theme when unset + pub radius_lg: Option, +} + +impl Default for ThemeSettings { + fn default() -> Self { + Self { + font_size: DEFAULT_FONT_SIZE, + focus_ring: false, + radius: None, + radius_lg: None, + } + } +} + pub fn init(cx: &mut App) { AppSettings::set_global(cx.new(AppSettings::new), cx) } @@ -40,17 +67,34 @@ pub fn init(cx: &mut App) { /// The async load fills the global settings later; the native theme must be /// applied before the first window opens, so boot reads the mode straight /// from the settings file instead. -pub fn persisted_appearance_mode() -> AppearanceMode { +fn persisted_settings() -> Option { #[cfg(not(target_arch = "wasm32"))] { let path = config_dir().join(".settings"); if let Ok(content) = std::fs::read_to_string(path) && let Ok(settings) = serde_json::from_str::(&content) { - return settings.appearance_mode; + return Some(settings); } } - AppearanceMode::default() + None +} + +pub fn persisted_appearance_mode() -> AppearanceMode { + persisted_settings() + .map(|settings| settings.appearance_mode) + .unwrap_or_default() +} + +/// Read the persisted theme tuning synchronously. +/// +/// Like [`persisted_appearance_mode`], the native theme is applied before the +/// async settings load completes, so boot reads the tuning straight from the +/// settings file instead. +pub fn persisted_theme() -> ThemeSettings { + persisted_settings() + .map(|settings| settings.theme) + .unwrap_or_default() } const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; @@ -109,6 +153,7 @@ setting_accessors! { } setting_getters! { + pub theme: ThemeSettings, pub pinned_rooms: Vec, pub pinned_groups: Vec, } @@ -179,6 +224,9 @@ pub struct Settings { /// Hide the notice that messages in public groups are not encrypted #[serde(default)] pub hide_group_notice: bool, + /// Tuning applied on top of the native OS theme + #[serde(default)] + pub theme: ThemeSettings, /// Show an image behind chat and group panels #[serde(default)] pub panel_background: bool, @@ -206,6 +254,7 @@ impl Default for Settings { wot_auto_trust: true, nip4e: false, hide_group_notice: false, + theme: ThemeSettings::default(), panel_background: true, panel_background_path: None, trusted_relays: vec![], @@ -270,6 +319,34 @@ impl AppSettings { }); } + /// Edit the theme tuning + pub fn update_theme(edit: impl FnOnce(&mut ThemeSettings), cx: &mut App) { + Self::global(cx).update(cx, |this, cx| { + this.inner.update(cx, |inner, cx| { + edit(&mut inner.theme); + cx.notify(); + }); + }); + } + + /// Apply the persisted theme tuning on top of the active theme + pub fn apply_theme_style(cx: &mut App) { + use gpui_kit::component::Theme; + + let theme = Self::get_theme(cx); + let styled = Theme::global_mut(cx); + styled.font_size = px(theme.font_size); + styled.focus_ring = theme.focus_ring; + if let Some(radius) = theme.radius { + styled.radius = px(radius); + } + if let Some(radius_lg) = theme.radius_lg { + styled.radius_lg = px(radius_lg); + } + Theme::sync_base(cx); + cx.refresh_windows(); + } + /// Load settings fn load(&mut self, cx: &mut Context) { let task: Task> = cx.background_spawn(async move { @@ -382,3 +459,27 @@ impl AppSettings { }); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Settings written before theme tuning existed must keep loading. + #[test] + fn settings_without_theme_keep_defaults() { + let settings: Settings = serde_json::from_str( + r#"{ + "appearance_mode": "Dark", + "hide_avatar": false, + "screening": true, + "wot_auto_trust": true, + "nip4e": false, + "trusted_relays": [], + "file_server": "https://nostr.download/" + }"#, + ) + .unwrap(); + + assert_eq!(settings.theme, ThemeSettings::default()); + } +} diff --git a/crates/ui/src/form.rs b/crates/ui/src/form.rs index 89ac383a..5d7499b2 100644 --- a/crates/ui/src/form.rs +++ b/crates/ui/src/form.rs @@ -1,4 +1,5 @@ use gpui::{App, Div, IntoElement, ParentElement, SharedString, Styled, div}; +use gpui_kit::base::StyledExt as _; use gpui_kit::component::{ActiveTheme, h_flex, v_flex}; /// A settings row: the label and description on the left, the control on the right. @@ -9,14 +10,15 @@ pub fn settings_row( cx: &App, ) -> Div { h_flex() - .gap_3() + .w_full() + .gap_4() .items_center() .justify_between() .child( v_flex() .flex_1() .min_w_0() - .child(div().text_sm().child(label.into())) + .child(div().text_sm().font_semibold().child(label.into())) .child( div() .text_xs() @@ -24,5 +26,37 @@ pub fn settings_row( .child(description.into()), ), ) + .child( + h_flex() + .w_40() + .flex_shrink_0() + .justify_end() + .items_center() + .child(control), + ) +} + +/// A full-width settings block with the label and description in one header. +pub fn settings_block( + label: impl Into, + description: impl Into, + control: impl IntoElement, + cx: &App, +) -> impl IntoElement { + v_flex() + .w_full() + .gap_3() + .child( + v_flex() + .w_full() + .child(div().text_sm().font_semibold().child(label.into())) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(description.into()), + ), + ) .child(control) } diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index e4b1c8cd..00eb388a 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -424,19 +424,19 @@ impl Render for ProfileDialog { v_flex() .gap_1p5() .child(label("Name", cx)) - .child(Input::new(&self.name_input).small()), + .child(Input::new(&self.name_input)), ) .child( v_flex() .gap_1p5() .child(label("About", cx)) - .child(Textarea::new(&self.bio_input).small()), + .child(Textarea::new(&self.bio_input)), ) .child( v_flex() .gap_1p5() .child(label("Website", cx)) - .child(Input::new(&self.website_input).small()), + .child(Input::new(&self.website_input)), ) }) .when(!self.editing, |this| { diff --git a/crates/workspace/src/dialogs/relays.rs b/crates/workspace/src/dialogs/relays.rs index a74c07f5..4ec18ec2 100644 --- a/crates/workspace/src/dialogs/relays.rs +++ b/crates/workspace/src/dialogs/relays.rs @@ -1,11 +1,3 @@ -use gpui_kit::assets::IconName; -use gpui_kit::base::StyledExt as _; -use gpui_kit::component::button::{Button, ButtonVariants}; -use gpui_kit::component::input::{Input, InputEvent, InputState}; -use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; -use gpui_kit::component::scroll::ScrollableElement; -use gpui_kit::component::tab::{Tab, TabBar}; -use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use std::collections::HashSet; use anyhow::{Error, anyhow}; @@ -15,6 +7,14 @@ use gpui::{ ParentElement, Render, SharedString, Stateful, Styled, Subscription, Task, TextAlign, WeakEntity, Window, div, rems, }; +use gpui_kit::assets::IconName; +use gpui_kit::base::StyledExt as _; +use gpui_kit::component::button::{Button, ButtonVariants}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; +use gpui_kit::component::scroll::ScrollableElement; +use gpui_kit::component::tab::{Tab, TabBar}; +use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use instant::Duration; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; @@ -119,7 +119,7 @@ impl RelayManager { h_flex() .gap_1() .w_full() - .child(Input::new(&self.gossip.input).small().cleanable(true)) + .child(Input::new(&self.gossip.input).cleanable(true)) .child( Button::new("metadata") .map(|this| { @@ -208,7 +208,7 @@ impl RelayManager { h_flex() .gap_1() .w_full() - .child(Input::new(&self.inbox.input).small().cleanable(true)) + .child(Input::new(&self.inbox.input).cleanable(true)) .child( Button::new("add-inbox") .icon(IconName::Plus) diff --git a/crates/workspace/src/dialogs/settings.rs b/crates/workspace/src/dialogs/settings.rs index c5578263..a9f47ecf 100644 --- a/crates/workspace/src/dialogs/settings.rs +++ b/crates/workspace/src/dialogs/settings.rs @@ -1,19 +1,22 @@ use gpui::http_client::Url; use gpui::{ App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render, - SharedString, Styled, Window, div, + Styled, Subscription, Window, div, }; use gpui_kit::assets::IconName; -use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; -use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants}; -use gpui_kit::component::input::{Input, InputState}; +use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput}; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; use gpui_kit::component::notification::Notification; +use gpui_kit::component::separator::Separator; use gpui_kit::component::switch::Switch; -use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; +use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex}; use settings::{AppSettings, AppearanceMode}; -use ui::settings_row; +use ui::{settings_block, settings_row}; + +/// Bounds for the theme number inputs +const FONT_SIZE_RANGE: (f32, f32) = (10., 32.); +const RADIUS_RANGE: (f32, f32) = (0., 24.); pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Preferences::new(window, cx)) @@ -21,6 +24,11 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct Preferences { file_input: Entity, + font_size_input: Entity, + radius_input: Entity, + radius_lg_input: Entity, + /// Keeps the control subscriptions alive while the dialog is open + _subscriptions: Vec, } impl Preferences { @@ -32,7 +40,91 @@ impl Preferences { .placeholder("https://myblossom.com") }); - Self { file_input } + let theme = AppSettings::get_theme(cx); + let font_size_input = cx.new(|cx| { + InputState::new(window, cx) + .default_value(theme.font_size.to_string()) + .step(1.) + .min(FONT_SIZE_RANGE.0 as f64) + .max(FONT_SIZE_RANGE.1 as f64) + }); + let radius_input = cx.new(|cx| { + InputState::new(window, cx) + .default_value( + theme + .radius + .map(|value| value.to_string()) + .unwrap_or_default(), + ) + .placeholder("System") + .step(1.) + .min(RADIUS_RANGE.0 as f64) + .max(RADIUS_RANGE.1 as f64) + }); + let radius_lg_input = cx.new(|cx| { + InputState::new(window, cx) + .default_value( + theme + .radius_lg + .map(|value| value.to_string()) + .unwrap_or_default(), + ) + .placeholder("System") + .step(1.) + .min(RADIUS_RANGE.0 as f64) + .max(RADIUS_RANGE.1 as f64) + }); + + let subscriptions = vec![ + Self::wire_number_input(&font_size_input, FONT_SIZE_RANGE, cx, |value, cx| { + if let Some(font_size) = value { + AppSettings::update_theme(|theme| theme.font_size = font_size, cx); + AppSettings::apply_theme_style(cx); + } + }), + Self::wire_number_input(&radius_input, RADIUS_RANGE, cx, |value, cx| { + AppSettings::update_theme(|theme| theme.radius = value, cx); + AppSettings::apply_theme_style(cx); + }), + Self::wire_number_input(&radius_lg_input, RADIUS_RANGE, cx, |value, cx| { + AppSettings::update_theme(|theme| theme.radius_lg = value, cx); + AppSettings::apply_theme_style(cx); + }), + ]; + + Self { + file_input, + font_size_input, + radius_input, + radius_lg_input, + _subscriptions: subscriptions, + } + } + + /// Persist a theme number as it changes; an empty value stores `None` + fn wire_number_input( + state: &Entity, + range: (f32, f32), + cx: &mut Context, + persist: impl Fn(Option, &mut App) + 'static, + ) -> Subscription { + let (min, max) = range; + let state = state.clone(); + + cx.subscribe(&state, move |_, state, event: &InputEvent, cx| { + if !matches!(event, InputEvent::Change) { + return; + } + + let value = state + .read(cx) + .value() + .trim() + .parse::() + .ok() + .map(|value| value.clamp(min, max)); + persist(value, cx); + }) } /// Update the file server (blossom) URL @@ -89,15 +181,23 @@ impl Render for Preferences { const SCREENING: &str = "Show an screening dialog to verify the unknown sender."; const WOT: &str = "Treat people followed by your contacts as known senders."; const AVATAR: &str = "Hide all avatar pictures to improve performance."; - const MODE: &str = "Follow the OS, or always use light or dark."; + const APPEARANCE: &str = + "Choose whether the app follows the system theme or uses a light/dark theme."; + const FONT_SIZE: &str = "The base font size for the interface."; + const RADIUS: &str = "Corner radius for UI elements. Empty follows the OS theme."; + const RADIUS_LG: &str = + "Corner radius for large elements like dialogs. Empty follows the OS theme."; + const FOCUS_RING: &str = "Draw a ring around focused controls."; const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages."; const BACKGROUND: &str = "Show an image behind chat and group panels."; + const MEDIA: &str = "Server for media attachments. Only blossom services are supported."; let screening = AppSettings::get_screening(cx); let wot_auto_trust = AppSettings::get_wot_auto_trust(cx); let hide_avatar = AppSettings::get_hide_avatar(cx); let nip4e = AppSettings::get_nip4e(cx); let appearance_mode = AppSettings::get_appearance_mode(cx); + let theme = AppSettings::get_theme(cx); let panel_background = AppSettings::get_panel_background(cx); let panel_background_path = AppSettings::get_panel_background_path(cx); @@ -107,12 +207,13 @@ impl Render for Preferences { }; v_flex() + .mt_2() .gap_4() + .w_full() .child( - GroupBox::new() - .id("general") - .title("General") - .fill() + v_flex() + .w_full() + .gap_3() .child(settings_row( "Screening", SCREENING, @@ -144,47 +245,68 @@ impl Render for Preferences { cx, )), ) + .child(Separator::horizontal()) + .child(settings_row( + "Appearance", + APPEARANCE, + Button::new("appearance-mode") + .label(appearance_mode.label()) + .ghost() + .small() + .dropdown_menu(|this, _window, _cx| { + AppearanceMode::ALL.iter().fold(this, |menu, mode| { + menu.item(PopupMenuItem::new(mode.label()).on_click( + move |_, _window, cx| { + Self::set_appearance_mode(*mode, cx); + }, + )) + }) + }), + cx, + )) + .child(Separator::horizontal()) .child( - GroupBox::new() - .id("appearance") - .title("Appearance") - .fill() - .child( - h_flex() - .gap_3() - .justify_between() - .child( - v_flex() - .child(div().text_xs().child(SharedString::from("Mode"))) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(MODE)), - ), - ) - .child( - Button::new("appearance-mode") - .label(appearance_mode.label()) - .ghost() - .small() - .dropdown_menu(|this, _window, _cx| { - AppearanceMode::ALL.iter().fold(this, |menu, mode| { - menu.item(PopupMenuItem::new(mode.label()).on_click( - move |_, _window, cx| { - Self::set_appearance_mode(*mode, cx); - }, - )) - }) - }), - ), - ), + v_flex() + .w_full() + .gap_3() + .child(settings_row( + "Font Size", + FONT_SIZE, + NumberInput::new(&self.font_size_input).w_24(), + cx, + )) + .child(settings_row( + "Corner Radius", + RADIUS, + NumberInput::new(&self.radius_input).w_24(), + cx, + )) + .child(settings_row( + "Large Corner Radius", + RADIUS_LG, + NumberInput::new(&self.radius_lg_input).w_24(), + cx, + )) + .child(settings_row( + "Focus Ring", + FOCUS_RING, + Switch::new("focus-ring") + .checked(theme.focus_ring) + .on_click(move |_, _window, cx| { + AppSettings::update_theme( + |theme| theme.focus_ring = !theme.focus_ring, + cx, + ); + AppSettings::apply_theme_style(cx); + }), + cx, + )), ) + .child(Separator::horizontal()) .child( - GroupBox::new() - .id("background") - .title("Background") - .fill() + v_flex() + .w_full() + .gap_3() .child(settings_row( "Panel background", BACKGROUND, @@ -196,101 +318,64 @@ impl Render for Preferences { }), cx, )) - .child( + .child(settings_block( + "Image", + background_label, h_flex() - .gap_3() - .justify_between() + .justify_end() + .gap_1() .child( - v_flex() - .flex_1() - .min_w_0() - .child(div().text_xs().child(SharedString::from("Image"))) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(SharedString::from(background_label)), - ), + Button::new("choose-background") + .label("Choose image") + .ghost() + .small() + .disabled(!panel_background) + .on_click(|_, _window, cx| { + Self::choose_background(cx); + }), ) .child( - h_flex() - .flex_shrink_0() - .gap_1() - .child( - Button::new("choose-background") - .label("Choose image") - .ghost() - .small() - .disabled(!panel_background) - .on_click(|_, _window, cx| { - Self::choose_background(cx); - }), - ) - .child( - Button::new("reset-background") - .label("Reset") - .ghost() - .small() - .disabled( - !panel_background - || panel_background_path.is_none(), - ) - .on_click(|_, _window, cx| { - AppSettings::update_panel_background_path(None, cx); - cx.refresh_windows(); - }), - ), + Button::new("reset-background") + .label("Reset") + .ghost() + .small() + .disabled(!panel_background || panel_background_path.is_none()) + .on_click(|_, _window, cx| { + AppSettings::update_panel_background_path(None, cx); + cx.refresh_windows(); + }), ), - ), - ) - .child( - GroupBox::new() - .id("experiments") - .title("Experiments") - .fill() - .child(settings_row( - "Decoupling Encryption Key", - NIP4E, - Switch::new("nip4e") - .checked(nip4e) - .on_click(move |_, _window, cx| { - AppSettings::update_nip4e(!nip4e, cx); - }), cx, )), ) - .child( - GroupBox::new() - .id("media") - .title("Media Upload Service") - .fill() + .child(Separator::horizontal()) + .child(settings_row( + "Decoupling Encryption Key", + NIP4E, + Switch::new("nip4e") + .checked(nip4e) + .on_click(move |_, _window, cx| { + AppSettings::update_nip4e(!nip4e, cx); + }), + cx, + )) + .child(Separator::horizontal()) + .child(settings_block( + "Media Upload Service", + MEDIA, + h_flex() + .gap_1() + .child(div().flex_1().child(Input::new(&self.file_input))) .child( - v_flex() - .gap_0p5() - .child( - h_flex() - .gap_1() - .child(Input::new(&self.file_input).small()) - .child( - Button::new("update-file-server") - .icon(IconName::Check) - .ghost() - .size_8() - .on_click(cx.listener(move |this, _ev, window, cx| { - this.update_file_server(window, cx) - })), - ), - ) - .child( - div() - .text_xs() - .font_medium() - .italic() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from("Only support blossom service")), - ), + Button::new("update-file-server") + .icon(IconName::Check) + .ghost() + .size_8() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.update_file_server(window, cx) + })), ), - ) + cx, + )) } } diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 8b7fd248..a0482b24 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -275,7 +275,7 @@ impl Workspace { let view = settings::init(window, cx); window.open_dialog(cx, move |this, _window, _cx| { - this.width(px(520.)) + this.width(px(650.)) .close_button(true) .pb_2() .title("Preferences") diff --git a/crates/workspace/src/panels/backup.rs b/crates/workspace/src/panels/backup.rs index dbe82688..8a0bbcc5 100644 --- a/crates/workspace/src/panels/backup.rs +++ b/crates/workspace/src/panels/backup.rs @@ -158,7 +158,7 @@ impl Render for BackupPanel { .text_color(cx.theme().muted_foreground) .child(SharedString::from("Public Key:")), ) - .child(Input::new(&self.npub_input).small().disabled(true)), + .child(Input::new(&self.npub_input).disabled(true)), ) .child( v_flex() @@ -171,7 +171,7 @@ impl Render for BackupPanel { .text_color(cx.theme().muted_foreground) .child(SharedString::from("Secret Key:")), ) - .child(Input::new(&self.nsec_input).small().disabled(true)), + .child(Input::new(&self.nsec_input).disabled(true)), ) .child( Button::new("copy") diff --git a/crates/workspace/src/panels/contact_list.rs b/crates/workspace/src/panels/contact_list.rs index 571a0726..059eb876 100644 --- a/crates/workspace/src/panels/contact_list.rs +++ b/crates/workspace/src/panels/contact_list.rs @@ -337,7 +337,7 @@ impl Render for ContactListPanel { h_flex() .gap_1() .w_full() - .child(Input::new(&self.input).small().cleanable(true)) + .child(Input::new(&self.input).cleanable(true)) .child( Button::new("add") .icon(IconName::Plus) diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 7c37c5b8..a4c34176 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -16,8 +16,6 @@ use workspace::TAB_BAR_HEIGHT; actions!(coop, [Quit]); -const BASE_FONT_SIZE: f32 = 16.0; - fn main() { // Initialize logging tracing_subscriber::fmt::init(); @@ -66,11 +64,13 @@ fn main() { settings::init(cx); let appearance = settings::persisted_appearance_mode(); - match apply_native_theme(appearance, cx) { + let font_size = settings::persisted_theme().font_size; + match apply_native_theme(appearance, font_size, cx) { Ok(()) => start_theme_watcher(appearance, cx), Err(error) => { log::warn!("native theme unavailable ({error})"); apply_appearance(appearance, cx); + AppSettings::apply_theme_style(cx); } } @@ -171,31 +171,36 @@ fn quit(_ev: &Quit, cx: &mut App) { cx.quit(); } -/// Apply the native theme to the app, and then apply the persisted appearance mode -fn apply_native_theme(appearance: AppearanceMode, cx: &mut App) -> native_theme::Result<()> { +/// Apply the native theme to the app, scaled to the user's font size, +/// then layer the persisted theme tuning on top +fn apply_native_theme( + appearance: AppearanceMode, + font_size: f32, + cx: &mut App, +) -> native_theme::Result<()> { let mut system = native_theme_gpui::SystemTheme::from_system()?; let native = system.light.defaults.font.size; if native > 0.0 { - system.accessibility.text_scaling_factor *= BASE_FONT_SIZE / native; + system.accessibility.text_scaling_factor *= font_size / native; } apply_system_theme(&system, cx); apply_appearance(appearance, cx); + // Applying the native theme replaces the whole theme, so the user's + // tuning must be layered on afterwards. + AppSettings::apply_theme_style(cx); Ok(()) } -/// Honor the appearance mode against the installed (native) palettes. +/// Honor the appearance mode against the installed (native) palettes fn apply_appearance(appearance: AppearanceMode, cx: &mut App) { match 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), } - - // Like signed, inputs show only their tinted border when focused. - Theme::global_mut(cx).focus_ring = false; } /// Holds the watcher subscription so it lives as long as the app. @@ -225,10 +230,11 @@ fn start_theme_watcher(initial_appearance: AppearanceMode, cx: &mut App) { log::info!("OS theme changed; re-applying"); cx.update(|cx| { let appearance = AppSettings::get_appearance_mode(cx); + let font_size = AppSettings::get_theme(cx).font_size; if let Some(watcher) = cx.try_global::() { watcher.appearance.set(appearance); } - if let Err(error) = apply_native_theme(appearance, cx) { + if let Err(error) = apply_native_theme(appearance, font_size, cx) { log::warn!("failed to apply updated OS theme ({error})"); } });