feat: add user-configurable theme settings

This commit is contained in:
2026-10-24 09:15:12 +07:00
parent 30da985ac1
commit 8f35ae92b7
10 changed files with 397 additions and 171 deletions
+1 -1
View File
@@ -1828,7 +1828,7 @@ impl Render for ChatPanel {
.gap_2() .gap_2()
.border_b_1() .border_b_1()
.border_color(cx.theme().border) .border_color(cx.theme().border)
.child(Input::new(&self.subject_input).small()) .child(Input::new(&self.subject_input))
.child( .child(
Button::new("change") Button::new("change")
.icon(IconName::CheckCircle) .icon(IconName::CheckCircle)
+105 -4
View File
@@ -2,7 +2,7 @@ use std::path::PathBuf;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use common::config_dir; 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 nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec}; 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<f32>,
/// Corner radius for large elements like dialogs; follows the OS theme when unset
pub radius_lg: Option<f32>,
}
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) { pub fn init(cx: &mut App) {
AppSettings::set_global(cx.new(AppSettings::new), cx) 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 /// 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 /// applied before the first window opens, so boot reads the mode straight
/// from the settings file instead. /// from the settings file instead.
pub fn persisted_appearance_mode() -> AppearanceMode { fn persisted_settings() -> Option<Settings> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let path = config_dir().join(".settings"); let path = config_dir().join(".settings");
if let Ok(content) = std::fs::read_to_string(path) if let Ok(content) = std::fs::read_to_string(path)
&& let Ok(settings) = serde_json::from_str::<Settings>(&content) && let Ok(settings) = serde_json::from_str::<Settings>(&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/"; const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
@@ -109,6 +153,7 @@ setting_accessors! {
} }
setting_getters! { setting_getters! {
pub theme: ThemeSettings,
pub pinned_rooms: Vec<u64>, pub pinned_rooms: Vec<u64>,
pub pinned_groups: Vec<String>, pub pinned_groups: Vec<String>,
} }
@@ -179,6 +224,9 @@ pub struct Settings {
/// Hide the notice that messages in public groups are not encrypted /// Hide the notice that messages in public groups are not encrypted
#[serde(default)] #[serde(default)]
pub hide_group_notice: bool, 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 /// Show an image behind chat and group panels
#[serde(default)] #[serde(default)]
pub panel_background: bool, pub panel_background: bool,
@@ -206,6 +254,7 @@ impl Default for Settings {
wot_auto_trust: true, wot_auto_trust: true,
nip4e: false, nip4e: false,
hide_group_notice: false, hide_group_notice: false,
theme: ThemeSettings::default(),
panel_background: true, panel_background: true,
panel_background_path: None, panel_background_path: None,
trusted_relays: vec![], 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 /// Load settings
fn load(&mut self, cx: &mut Context<Self>) { fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move { let task: Task<Result<Settings, Error>> = 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());
}
}
+36 -2
View File
@@ -1,4 +1,5 @@
use gpui::{App, Div, IntoElement, ParentElement, SharedString, Styled, div}; use gpui::{App, Div, IntoElement, ParentElement, SharedString, Styled, div};
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::{ActiveTheme, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, h_flex, v_flex};
/// A settings row: the label and description on the left, the control on the right. /// 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, cx: &App,
) -> Div { ) -> Div {
h_flex() h_flex()
.gap_3() .w_full()
.gap_4()
.items_center() .items_center()
.justify_between() .justify_between()
.child( .child(
v_flex() v_flex()
.flex_1() .flex_1()
.min_w_0() .min_w_0()
.child(div().text_sm().child(label.into())) .child(div().text_sm().font_semibold().child(label.into()))
.child( .child(
div() div()
.text_xs() .text_xs()
@@ -24,5 +26,37 @@ pub fn settings_row(
.child(description.into()), .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<SharedString>,
description: impl Into<SharedString>,
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) .child(control)
} }
+3 -3
View File
@@ -424,19 +424,19 @@ impl Render for ProfileDialog {
v_flex() v_flex()
.gap_1p5() .gap_1p5()
.child(label("Name", cx)) .child(label("Name", cx))
.child(Input::new(&self.name_input).small()), .child(Input::new(&self.name_input)),
) )
.child( .child(
v_flex() v_flex()
.gap_1p5() .gap_1p5()
.child(label("About", cx)) .child(label("About", cx))
.child(Textarea::new(&self.bio_input).small()), .child(Textarea::new(&self.bio_input)),
) )
.child( .child(
v_flex() v_flex()
.gap_1p5() .gap_1p5()
.child(label("Website", cx)) .child(label("Website", cx))
.child(Input::new(&self.website_input).small()), .child(Input::new(&self.website_input)),
) )
}) })
.when(!self.editing, |this| { .when(!self.editing, |this| {
+10 -10
View File
@@ -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 std::collections::HashSet;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
@@ -15,6 +7,14 @@ use gpui::{
ParentElement, Render, SharedString, Stateful, Styled, Subscription, Task, TextAlign, ParentElement, Render, SharedString, Stateful, Styled, Subscription, Task, TextAlign,
WeakEntity, Window, div, rems, 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 instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
@@ -119,7 +119,7 @@ impl RelayManager {
h_flex() h_flex()
.gap_1() .gap_1()
.w_full() .w_full()
.child(Input::new(&self.gossip.input).small().cleanable(true)) .child(Input::new(&self.gossip.input).cleanable(true))
.child( .child(
Button::new("metadata") Button::new("metadata")
.map(|this| { .map(|this| {
@@ -208,7 +208,7 @@ impl RelayManager {
h_flex() h_flex()
.gap_1() .gap_1()
.w_full() .w_full()
.child(Input::new(&self.inbox.input).small().cleanable(true)) .child(Input::new(&self.inbox.input).cleanable(true))
.child( .child(
Button::new("add-inbox") Button::new("add-inbox")
.icon(IconName::Plus) .icon(IconName::Plus)
+177 -92
View File
@@ -1,19 +1,22 @@
use gpui::http_client::Url; use gpui::http_client::Url;
use gpui::{ use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render, App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render,
SharedString, Styled, Window, div, Styled, Subscription, Window, div,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::group_box::{GroupBox, GroupBoxVariants}; use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem}; use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
use gpui_kit::component::separator::Separator;
use gpui_kit::component::switch::Switch; 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 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<Preferences> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
cx.new(|cx| Preferences::new(window, cx)) cx.new(|cx| Preferences::new(window, cx))
@@ -21,6 +24,11 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
pub struct Preferences { pub struct Preferences {
file_input: Entity<InputState>, file_input: Entity<InputState>,
font_size_input: Entity<InputState>,
radius_input: Entity<InputState>,
radius_lg_input: Entity<InputState>,
/// Keeps the control subscriptions alive while the dialog is open
_subscriptions: Vec<Subscription>,
} }
impl Preferences { impl Preferences {
@@ -32,7 +40,91 @@ impl Preferences {
.placeholder("https://myblossom.com") .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<InputState>,
range: (f32, f32),
cx: &mut Context<Self>,
persist: impl Fn(Option<f32>, &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::<f32>()
.ok()
.map(|value| value.clamp(min, max));
persist(value, cx);
})
} }
/// Update the file server (blossom) URL /// 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 SCREENING: &str = "Show an screening dialog to verify the unknown sender.";
const WOT: &str = "Treat people followed by your contacts as known senders."; const WOT: &str = "Treat people followed by your contacts as known senders.";
const AVATAR: &str = "Hide all avatar pictures to improve performance."; 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 NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const BACKGROUND: &str = "Show an image behind chat and group panels."; 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 screening = AppSettings::get_screening(cx);
let wot_auto_trust = AppSettings::get_wot_auto_trust(cx); let wot_auto_trust = AppSettings::get_wot_auto_trust(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx); let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx); let nip4e = AppSettings::get_nip4e(cx);
let appearance_mode = AppSettings::get_appearance_mode(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 = AppSettings::get_panel_background(cx);
let panel_background_path = AppSettings::get_panel_background_path(cx); let panel_background_path = AppSettings::get_panel_background_path(cx);
@@ -107,12 +207,13 @@ impl Render for Preferences {
}; };
v_flex() v_flex()
.mt_2()
.gap_4() .gap_4()
.w_full()
.child( .child(
GroupBox::new() v_flex()
.id("general") .w_full()
.title("General") .gap_3()
.fill()
.child(settings_row( .child(settings_row(
"Screening", "Screening",
SCREENING, SCREENING,
@@ -144,26 +245,10 @@ impl Render for Preferences {
cx, cx,
)), )),
) )
.child( .child(Separator::horizontal())
GroupBox::new() .child(settings_row(
.id("appearance") "Appearance",
.title("Appearance") 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") Button::new("appearance-mode")
.label(appearance_mode.label()) .label(appearance_mode.label())
.ghost() .ghost()
@@ -177,14 +262,51 @@ impl Render for Preferences {
)) ))
}) })
}), }),
), cx,
), ))
) .child(Separator::horizontal())
.child( .child(
GroupBox::new() v_flex()
.id("background") .w_full()
.title("Background") .gap_3()
.fill() .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(
v_flex()
.w_full()
.gap_3()
.child(settings_row( .child(settings_row(
"Panel background", "Panel background",
BACKGROUND, BACKGROUND,
@@ -196,26 +318,11 @@ impl Render for Preferences {
}), }),
cx, cx,
)) ))
.child( .child(settings_block(
"Image",
background_label,
h_flex() h_flex()
.gap_3() .justify_end()
.justify_between()
.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)),
),
)
.child(
h_flex()
.flex_shrink_0()
.gap_1() .gap_1()
.child( .child(
Button::new("choose-background") Button::new("choose-background")
@@ -232,23 +339,16 @@ impl Render for Preferences {
.label("Reset") .label("Reset")
.ghost() .ghost()
.small() .small()
.disabled( .disabled(!panel_background || panel_background_path.is_none())
!panel_background
|| panel_background_path.is_none(),
)
.on_click(|_, _window, cx| { .on_click(|_, _window, cx| {
AppSettings::update_panel_background_path(None, cx); AppSettings::update_panel_background_path(None, cx);
cx.refresh_windows(); cx.refresh_windows();
}), }),
), ),
), cx,
), )),
) )
.child( .child(Separator::horizontal())
GroupBox::new()
.id("experiments")
.title("Experiments")
.fill()
.child(settings_row( .child(settings_row(
"Decoupling Encryption Key", "Decoupling Encryption Key",
NIP4E, NIP4E,
@@ -258,20 +358,14 @@ impl Render for Preferences {
AppSettings::update_nip4e(!nip4e, cx); AppSettings::update_nip4e(!nip4e, cx);
}), }),
cx, cx,
)), ))
) .child(Separator::horizontal())
.child( .child(settings_block(
GroupBox::new() "Media Upload Service",
.id("media") MEDIA,
.title("Media Upload Service")
.fill()
.child(
v_flex()
.gap_0p5()
.child(
h_flex() h_flex()
.gap_1() .gap_1()
.child(Input::new(&self.file_input).small()) .child(div().flex_1().child(Input::new(&self.file_input)))
.child( .child(
Button::new("update-file-server") Button::new("update-file-server")
.icon(IconName::Check) .icon(IconName::Check)
@@ -281,16 +375,7 @@ impl Render for Preferences {
this.update_file_server(window, cx) this.update_file_server(window, cx)
})), })),
), ),
) cx,
.child( ))
div()
.text_xs()
.font_medium()
.italic()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("Only support blossom service")),
),
),
)
} }
} }
+1 -1
View File
@@ -275,7 +275,7 @@ impl Workspace {
let view = settings::init(window, cx); let view = settings::init(window, cx);
window.open_dialog(cx, move |this, _window, _cx| { window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(520.)) this.width(px(650.))
.close_button(true) .close_button(true)
.pb_2() .pb_2()
.title("Preferences") .title("Preferences")
+2 -2
View File
@@ -158,7 +158,7 @@ impl Render for BackupPanel {
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(SharedString::from("Public Key:")), .child(SharedString::from("Public Key:")),
) )
.child(Input::new(&self.npub_input).small().disabled(true)), .child(Input::new(&self.npub_input).disabled(true)),
) )
.child( .child(
v_flex() v_flex()
@@ -171,7 +171,7 @@ impl Render for BackupPanel {
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child(SharedString::from("Secret Key:")), .child(SharedString::from("Secret Key:")),
) )
.child(Input::new(&self.nsec_input).small().disabled(true)), .child(Input::new(&self.nsec_input).disabled(true)),
) )
.child( .child(
Button::new("copy") Button::new("copy")
+1 -1
View File
@@ -337,7 +337,7 @@ impl Render for ContactListPanel {
h_flex() h_flex()
.gap_1() .gap_1()
.w_full() .w_full()
.child(Input::new(&self.input).small().cleanable(true)) .child(Input::new(&self.input).cleanable(true))
.child( .child(
Button::new("add") Button::new("add")
.icon(IconName::Plus) .icon(IconName::Plus)
+17 -11
View File
@@ -16,8 +16,6 @@ use workspace::TAB_BAR_HEIGHT;
actions!(coop, [Quit]); actions!(coop, [Quit]);
const BASE_FONT_SIZE: f32 = 16.0;
fn main() { fn main() {
// Initialize logging // Initialize logging
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
@@ -66,11 +64,13 @@ fn main() {
settings::init(cx); settings::init(cx);
let appearance = settings::persisted_appearance_mode(); 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), Ok(()) => start_theme_watcher(appearance, cx),
Err(error) => { Err(error) => {
log::warn!("native theme unavailable ({error})"); log::warn!("native theme unavailable ({error})");
apply_appearance(appearance, cx); apply_appearance(appearance, cx);
AppSettings::apply_theme_style(cx);
} }
} }
@@ -171,31 +171,36 @@ fn quit(_ev: &Quit, cx: &mut App) {
cx.quit(); cx.quit();
} }
/// Apply the native theme to the app, and then apply the persisted appearance mode /// Apply the native theme to the app, scaled to the user's font size,
fn apply_native_theme(appearance: AppearanceMode, cx: &mut App) -> native_theme::Result<()> { /// 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 mut system = native_theme_gpui::SystemTheme::from_system()?;
let native = system.light.defaults.font.size; let native = system.light.defaults.font.size;
if native > 0.0 { 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_system_theme(&system, cx);
apply_appearance(appearance, 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(()) 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) { fn apply_appearance(appearance: AppearanceMode, cx: &mut App) {
match appearance { match appearance {
AppearanceMode::System => Theme::sync_system_appearance(None, cx), AppearanceMode::System => Theme::sync_system_appearance(None, cx),
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx), AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, 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. /// 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"); log::info!("OS theme changed; re-applying");
cx.update(|cx| { cx.update(|cx| {
let appearance = AppSettings::get_appearance_mode(cx); let appearance = AppSettings::get_appearance_mode(cx);
let font_size = AppSettings::get_theme(cx).font_size;
if let Some(watcher) = cx.try_global::<ThemeWatcher>() { if let Some(watcher) = cx.try_global::<ThemeWatcher>() {
watcher.appearance.set(appearance); 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})"); log::warn!("failed to apply updated OS theme ({error})");
} }
}); });