feat: add user-configurable theme settings
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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})");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user