themes: add bundled theme registry and picker
This commit is contained in:
@@ -10,6 +10,9 @@ anyhow.workspace = true
|
||||
rust-embed.workspace = true
|
||||
gpui-kit.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
# On wasm there is no filesystem to read from, so rust-embed's debug-mode
|
||||
# disk loading returns nothing; embed the assets even in debug builds.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
|
||||
@@ -8,6 +8,7 @@ use rust_embed::RustEmbed;
|
||||
#[include = "fonts/**/*"]
|
||||
#[include = "brand/**/*"]
|
||||
#[include = "icons/**/*"]
|
||||
#[include = "themes/**/*"]
|
||||
#[exclude = "*.DS_Store"]
|
||||
pub struct Assets;
|
||||
|
||||
@@ -74,3 +75,29 @@ impl AssetSource for Assets {
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gpui_kit::component::ThemeSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Every bundled theme file must parse against gpui-kit's schema.
|
||||
#[test]
|
||||
fn bundled_themes_parse() {
|
||||
let mut count = 0;
|
||||
|
||||
for entry in Assets::iter().filter(|p| p.starts_with("themes/")) {
|
||||
let file = Assets::get(&entry).expect("theme file exists");
|
||||
let content = std::str::from_utf8(&file.data).expect("theme is UTF-8");
|
||||
let set: ThemeSet = serde_json::from_str(content).expect("theme parses");
|
||||
assert!(!set.themes.is_empty(), "{entry} has no themes");
|
||||
count += set.themes.len();
|
||||
}
|
||||
|
||||
assert!(
|
||||
count >= 36,
|
||||
"expected the bundled gpui-kit themes, got {count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+298
-128
@@ -1,9 +1,11 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use common::config_dir;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task, px};
|
||||
use gpui_kit::component::{Theme, ThemeConfig, ThemeMode, ThemeRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -12,6 +14,20 @@ use smallvec::{SmallVec, smallvec};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const SETTINGS_KEY: &str = "coop.settings";
|
||||
|
||||
/// The default base font size for the interface
|
||||
pub const DEFAULT_FONT_SIZE: f32 = 16.0;
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
/// Minimum days between automatic encryption key rotations
|
||||
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
|
||||
/// Maximum days between automatic encryption key rotations
|
||||
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
|
||||
/// Days between automatic encryption key rotations when enabled
|
||||
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
/// The user's appearance preference, persisted with the rest of the settings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum AppearanceMode {
|
||||
@@ -36,8 +52,20 @@ impl AppearanceMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// The default base font size for the interface
|
||||
pub const DEFAULT_FONT_SIZE: f32 = 16.0;
|
||||
/// Signer kind
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerKind {
|
||||
Auto,
|
||||
Encryption,
|
||||
#[default]
|
||||
User,
|
||||
}
|
||||
|
||||
impl SignerKind {
|
||||
pub fn encryption(&self) -> bool {
|
||||
matches!(self, SignerKind::Encryption)
|
||||
}
|
||||
}
|
||||
|
||||
/// User tuning applied on top of the native OS theme
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
@@ -63,123 +91,6 @@ impl Default for ThemeSettings {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
}
|
||||
|
||||
/// Read the persisted appearance mode synchronously.
|
||||
fn persisted_settings() -> Option<Settings> {
|
||||
let content = persisted_content();
|
||||
content.and_then(|content| serde_json::from_str(&content).ok())
|
||||
}
|
||||
|
||||
/// The persisted settings JSON, from the config file or browser storage
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn persisted_content() -> Option<String> {
|
||||
let path = config_dir().join(".settings");
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn persisted_content() -> Option<String> {
|
||||
common::web_store::get(SETTINGS_KEY).ok().flatten()
|
||||
}
|
||||
|
||||
pub fn persisted_appearance_mode() -> AppearanceMode {
|
||||
persisted_settings()
|
||||
.map(|settings| settings.appearance_mode)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the persisted theme tuning synchronously.
|
||||
pub fn persisted_theme() -> ThemeSettings {
|
||||
persisted_settings()
|
||||
.map(|settings| settings.theme)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
const LEGACY_FILE_SERVER: &str = "blossom.band";
|
||||
/// Minimum days between automatic encryption key rotations
|
||||
pub const NIP4E_ROTATION_MIN_DAYS: u32 = 1;
|
||||
/// Maximum days between automatic encryption key rotations
|
||||
pub const NIP4E_ROTATION_MAX_DAYS: u32 = 90;
|
||||
/// Days between automatic encryption key rotations when enabled
|
||||
pub const NIP4E_ROTATION_DEFAULT_DAYS: u32 = 30;
|
||||
/// Built-in image shown behind chat and group panels
|
||||
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
|
||||
|
||||
macro_rules! setting_accessors {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
|
||||
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.inner.update(cx, |inner, cx| {
|
||||
inner.$field = value;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! setting_getters {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setting_accessors! {
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub wot_auto_trust: bool,
|
||||
pub nip4e: bool,
|
||||
pub nip4e_rotation_days: Option<u32>,
|
||||
pub hide_group_notice: bool,
|
||||
pub file_server: Url,
|
||||
pub panel_background: bool,
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
setting_getters! {
|
||||
pub theme: ThemeSettings,
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
/// Signer kind
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerKind {
|
||||
Auto,
|
||||
Encryption,
|
||||
#[default]
|
||||
User,
|
||||
}
|
||||
|
||||
impl SignerKind {
|
||||
pub fn encryption(&self) -> bool {
|
||||
matches!(self, SignerKind::Encryption)
|
||||
}
|
||||
}
|
||||
|
||||
/// Room configuration
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct RoomConfig {
|
||||
@@ -237,6 +148,12 @@ pub struct Settings {
|
||||
/// Tuning applied on top of the native OS theme
|
||||
#[serde(default)]
|
||||
pub theme: ThemeSettings,
|
||||
/// Bundled theme applied in light appearance; None uses the default light theme
|
||||
#[serde(default)]
|
||||
pub theme_light: Option<String>,
|
||||
/// Bundled theme applied in dark appearance; None uses the default dark theme
|
||||
#[serde(default)]
|
||||
pub theme_dark: Option<String>,
|
||||
/// Show an image behind chat and group panels
|
||||
#[serde(default)]
|
||||
pub panel_background: bool,
|
||||
@@ -266,6 +183,8 @@ impl Default for Settings {
|
||||
nip4e_rotation_days: None,
|
||||
hide_group_notice: false,
|
||||
theme: ThemeSettings::default(),
|
||||
theme_light: None,
|
||||
theme_dark: None,
|
||||
panel_background: true,
|
||||
panel_background_path: None,
|
||||
trusted_relays: vec![],
|
||||
@@ -288,6 +207,64 @@ pub struct AppSettings {
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
macro_rules! setting_accessors {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
|
||||
pub fn [<update_ $field>](value: $type, cx: &mut App) {
|
||||
Self::global(cx).update(cx, |this, cx| {
|
||||
this.inner.update(cx, |inner, cx| {
|
||||
inner.$field = value;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! setting_getters {
|
||||
($(pub $field:ident: $type:ty),* $(,)?) => {
|
||||
impl AppSettings {
|
||||
$(
|
||||
paste::paste! {
|
||||
pub fn [<get_ $field>](cx: &App) -> $type {
|
||||
Self::global(cx).read(cx).inner.read(cx).$field.clone()
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setting_accessors! {
|
||||
pub appearance_mode: AppearanceMode,
|
||||
pub hide_avatar: bool,
|
||||
pub screening: bool,
|
||||
pub wot_auto_trust: bool,
|
||||
pub nip4e: bool,
|
||||
pub nip4e_rotation_days: Option<u32>,
|
||||
pub hide_group_notice: bool,
|
||||
pub file_server: Url,
|
||||
pub panel_background: bool,
|
||||
pub panel_background_path: Option<PathBuf>,
|
||||
pub theme_light: Option<String>,
|
||||
pub theme_dark: Option<String>,
|
||||
}
|
||||
|
||||
setting_getters! {
|
||||
pub theme: ThemeSettings,
|
||||
pub pinned_rooms: Vec<u64>,
|
||||
pub pinned_groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl AppSettings {
|
||||
/// Retrieve the global settings instance
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
@@ -345,8 +322,6 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
fn apply_theme_tuning(tuning: ThemeSettings, cx: &mut App) {
|
||||
use gpui_kit::component::Theme;
|
||||
|
||||
{
|
||||
let styled = Theme::global_mut(cx);
|
||||
styled.font_size = px(tuning.font_size);
|
||||
@@ -434,20 +409,81 @@ impl AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the persisted appearance mode to the app
|
||||
/// Apply the persisted appearance mode and theme selection to the app
|
||||
pub fn apply_theme(&mut self, cx: &mut Context<Self>) {
|
||||
use gpui_kit::component::Theme;
|
||||
|
||||
let mode = self.inner.read(cx).appearance_mode;
|
||||
match mode {
|
||||
let appearance = self.inner.read(cx).appearance_mode;
|
||||
match appearance {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(gpui_kit::component::ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
|
||||
let (light, dark) = {
|
||||
let inner = self.inner.read(cx);
|
||||
(inner.theme_light.clone(), inner.theme_dark.clone())
|
||||
};
|
||||
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
|
||||
|
||||
let tuning = self.inner.read(cx).theme;
|
||||
Self::apply_theme_tuning(tuning, cx);
|
||||
}
|
||||
|
||||
/// Select a registered theme by name.
|
||||
pub fn select_theme(name: &str, cx: &mut App) {
|
||||
let Some(config) = registered_theme(name, cx) else {
|
||||
log::warn!("theme is not registered: {name}");
|
||||
return;
|
||||
};
|
||||
|
||||
let dark = config.mode.is_dark();
|
||||
|
||||
if dark {
|
||||
Self::update_theme_dark(Some(name.to_string()), cx);
|
||||
Self::update_appearance_mode(AppearanceMode::Dark, cx);
|
||||
} else {
|
||||
Self::update_theme_light(Some(name.to_string()), cx);
|
||||
Self::update_appearance_mode(AppearanceMode::Light, cx);
|
||||
}
|
||||
|
||||
Theme::change(
|
||||
if dark {
|
||||
ThemeMode::Dark
|
||||
} else {
|
||||
ThemeMode::Light
|
||||
},
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
Self::reapply_theme(cx);
|
||||
}
|
||||
|
||||
/// Select the theme applied in light appearance; `None` restores the default
|
||||
pub fn set_theme_light(name: Option<String>, cx: &mut App) {
|
||||
Self::update_theme_light(name, cx);
|
||||
Self::reapply_theme(cx);
|
||||
}
|
||||
|
||||
/// Select the theme applied in dark appearance; `None` restores the default
|
||||
pub fn set_theme_dark(name: Option<String>, cx: &mut App) {
|
||||
Self::update_theme_dark(name, cx);
|
||||
Self::reapply_theme(cx);
|
||||
}
|
||||
|
||||
/// Re-apply the selected themes and tuning from the current settings
|
||||
fn reapply_theme(cx: &mut App) {
|
||||
let (light, dark, tuning) = AppSettings::global(cx).read_with(cx, |this, cx| {
|
||||
let inner = this.inner.read(cx);
|
||||
(
|
||||
inner.theme_light.clone(),
|
||||
inner.theme_dark.clone(),
|
||||
inner.theme,
|
||||
)
|
||||
});
|
||||
|
||||
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
|
||||
Self::apply_theme_tuning(tuning, cx);
|
||||
}
|
||||
|
||||
/// Check if decoupling encryption key is enabled
|
||||
pub fn is_nip4e_enabled(&self, cx: &App) -> bool {
|
||||
self.inner.read(cx).nip4e
|
||||
@@ -503,6 +539,116 @@ impl AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
}
|
||||
|
||||
/// Register the bundled gpui-kit themes with the theme registry
|
||||
pub fn init_themes(cx: &mut App) {
|
||||
let assets = cx.asset_source().clone();
|
||||
|
||||
for path in assets.list("themes").unwrap_or_default() {
|
||||
let Some(content) = assets.load(&path).unwrap_or_default() else {
|
||||
log::warn!("bundled theme {path} is listed but missing");
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(content) = std::str::from_utf8(&content) else {
|
||||
log::warn!("bundled theme {path} is not valid UTF-8");
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Err(error) = ThemeRegistry::global_mut(cx).load_themes_from_str(content) {
|
||||
log::warn!("failed to load bundled theme {path} ({error})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the persisted appearance mode synchronously.
|
||||
fn persisted_settings() -> Option<Settings> {
|
||||
let content = persisted_content();
|
||||
content.and_then(|content| serde_json::from_str(&content).ok())
|
||||
}
|
||||
|
||||
/// The persisted settings JSON, from the config file or browser storage
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn persisted_content() -> Option<String> {
|
||||
let path = config_dir().join(".settings");
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn persisted_content() -> Option<String> {
|
||||
common::web_store::get(SETTINGS_KEY).ok().flatten()
|
||||
}
|
||||
|
||||
pub fn persisted_appearance_mode() -> AppearanceMode {
|
||||
persisted_settings()
|
||||
.map(|settings| settings.appearance_mode)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the persisted theme tuning synchronously.
|
||||
pub fn persisted_theme() -> ThemeSettings {
|
||||
persisted_settings()
|
||||
.map(|settings| settings.theme)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read the persisted theme selection for light appearance.
|
||||
pub fn persisted_theme_light() -> Option<String> {
|
||||
persisted_settings().and_then(|settings| settings.theme_light)
|
||||
}
|
||||
|
||||
/// Read the persisted theme selection for dark appearance.
|
||||
pub fn persisted_theme_dark() -> Option<String> {
|
||||
persisted_settings().and_then(|settings| settings.theme_dark)
|
||||
}
|
||||
|
||||
/// The registered theme for a persisted theme name, if any
|
||||
fn registered_theme(name: &str, cx: &App) -> Option<Rc<ThemeConfig>> {
|
||||
ThemeRegistry::global(cx).themes().get(name).cloned()
|
||||
}
|
||||
|
||||
/// Swap the selected themes into the light/dark slots and reload the active mode.
|
||||
/// An unset or unknown theme falls back to the registry's default theme.
|
||||
fn apply_theme_selection(light: Option<&str>, dark: Option<&str>, cx: &mut App) {
|
||||
let light_config = light
|
||||
.and_then(|name| registered_theme(name, cx))
|
||||
.or_else(|| Some(ThemeRegistry::global(cx).default_light_theme().clone()));
|
||||
let dark_config = dark
|
||||
.and_then(|name| registered_theme(name, cx))
|
||||
.or_else(|| Some(ThemeRegistry::global(cx).default_dark_theme().clone()));
|
||||
|
||||
{
|
||||
let theme = Theme::global_mut(cx);
|
||||
if let Some(config) = light_config {
|
||||
theme.light_theme = config;
|
||||
}
|
||||
if let Some(config) = dark_config {
|
||||
theme.dark_theme = config;
|
||||
}
|
||||
}
|
||||
|
||||
let mode = Theme::global(cx).mode;
|
||||
Theme::change(mode, None, cx);
|
||||
}
|
||||
|
||||
/// Apply the persisted appearance, theme selection and tuning synchronously.
|
||||
pub fn apply_persisted_theme(cx: &mut App) {
|
||||
match persisted_appearance_mode() {
|
||||
AppearanceMode::System => Theme::sync_system_appearance(None, cx),
|
||||
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
|
||||
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
|
||||
}
|
||||
|
||||
let light = persisted_theme_light();
|
||||
let dark = persisted_theme_dark();
|
||||
apply_theme_selection(light.as_deref(), dark.as_deref(), cx);
|
||||
|
||||
AppSettings::apply_theme_tuning(persisted_theme(), cx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -524,5 +670,29 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.theme, ThemeSettings::default());
|
||||
assert_eq!(settings.theme_light, None);
|
||||
assert_eq!(settings.theme_dark, None);
|
||||
}
|
||||
|
||||
/// The theme selection round-trips through serde.
|
||||
#[test]
|
||||
fn theme_selection_persists() {
|
||||
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/",
|
||||
"theme_light": "Solarized Light",
|
||||
"theme_dark": "Tokyo Night"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(settings.theme_light.as_deref(), Some("Solarized Light"));
|
||||
assert_eq!(settings.theme_dark.as_deref(), Some("Tokyo Night"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::progress::Progress;
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::switch::Switch;
|
||||
use gpui_kit::component::{Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use gpui_kit::component::{Disableable, Sizable, ThemeRegistry, WindowExt, h_flex, v_flex};
|
||||
use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use speech::model::{self as speech_model};
|
||||
@@ -250,6 +250,69 @@ impl Preferences {
|
||||
});
|
||||
}
|
||||
|
||||
/// Dropdown to pick the bundled theme applied in an appearance.
|
||||
fn render_theme_choice(
|
||||
&self,
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
description: &'static str,
|
||||
dark: bool,
|
||||
cx: &Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
const DEFAULT: &str = "Default";
|
||||
|
||||
let selected = if dark {
|
||||
AppSettings::get_theme_dark(cx)
|
||||
} else {
|
||||
AppSettings::get_theme_light(cx)
|
||||
};
|
||||
|
||||
let themes: Vec<String> = ThemeRegistry::global(cx)
|
||||
.sorted_themes()
|
||||
.into_iter()
|
||||
.filter(|theme| theme.mode.is_dark() == dark)
|
||||
.map(|theme| theme.name.to_string())
|
||||
.collect();
|
||||
|
||||
let current = selected
|
||||
.filter(|name| themes.contains(name))
|
||||
.unwrap_or_else(|| DEFAULT.into());
|
||||
|
||||
settings_row(
|
||||
label,
|
||||
description,
|
||||
Button::new(id)
|
||||
.label(current)
|
||||
.ghost()
|
||||
.small()
|
||||
.dropdown_menu(move |menu, _window, _cx| {
|
||||
let reset = move |name: Option<String>, cx: &mut App| {
|
||||
if dark {
|
||||
AppSettings::set_theme_dark(name, cx);
|
||||
} else {
|
||||
AppSettings::set_theme_light(name, cx);
|
||||
}
|
||||
};
|
||||
|
||||
let menu =
|
||||
menu.item(PopupMenuItem::new(DEFAULT).on_click(move |_, _window, cx| {
|
||||
reset(None, cx);
|
||||
}));
|
||||
|
||||
themes.iter().fold(menu, |menu, name| {
|
||||
let name = name.clone();
|
||||
|
||||
menu.item(PopupMenuItem::new(name.clone()).on_click(
|
||||
move |_, _window, cx| {
|
||||
reset(Some(name.clone()), cx);
|
||||
},
|
||||
))
|
||||
})
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask the user for an image to show behind chat and group panels
|
||||
fn choose_background(
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))] window: &mut Window,
|
||||
@@ -388,6 +451,8 @@ impl Render for Preferences {
|
||||
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
|
||||
const APPEARANCE: &str =
|
||||
"Choose whether the app follows the system theme or uses a light/dark theme.";
|
||||
const LIGHT_THEME: &str = "Bundled theme applied in light appearance.";
|
||||
const DARK_THEME: &str = "Bundled theme applied in dark appearance.";
|
||||
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 =
|
||||
@@ -471,6 +536,8 @@ impl Render for Preferences {
|
||||
}),
|
||||
cx,
|
||||
))
|
||||
.child(self.render_theme_choice("theme-light", "Light Theme", LIGHT_THEME, false, cx))
|
||||
.child(self.render_theme_choice("theme-dark", "Dark Theme", DARK_THEME, true, cx))
|
||||
.child(Separator::horizontal())
|
||||
.child(
|
||||
v_flex()
|
||||
|
||||
+108
-3
@@ -1,3 +1,5 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::{AppSettings, AppearanceMode};
|
||||
@@ -8,13 +10,18 @@ use chat::{ChatEvent, ChatRegistry};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use common::download_dir;
|
||||
use gpui::{
|
||||
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
Action, App, AppContext, Context, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
ParentElement, Pixels, Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use gpui_kit::component::button::Button;
|
||||
use gpui_kit::component::command::{
|
||||
Command as CommandPalette, CommandGroup, CommandItem, CommandState,
|
||||
};
|
||||
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
use gpui_kit::component::notification::{Notification, NotificationType};
|
||||
use gpui_kit::component::{ActiveTheme, Theme, WindowExt, v_flex};
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme, IndexPath, Theme, ThemeConfig, ThemeRegistry, WindowExt, v_flex,
|
||||
};
|
||||
use nip4e::{DeviceEvent, DeviceRegistry};
|
||||
use nip29::{GroupId, GroupKey};
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -57,6 +64,7 @@ enum Command {
|
||||
ShowRelayList,
|
||||
ShowProfile,
|
||||
ShowSettings,
|
||||
ToggleTheme,
|
||||
ShowBackup,
|
||||
ShowContactList,
|
||||
ShowInbox,
|
||||
@@ -310,6 +318,9 @@ impl Workspace {
|
||||
.child(view.clone())
|
||||
});
|
||||
}
|
||||
Command::ToggleTheme => {
|
||||
self.theme_selector(window, cx);
|
||||
}
|
||||
Command::ShowProfile => {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
@@ -440,6 +451,100 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse and apply the bundled themes.
|
||||
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let state = cx.new(|cx| CommandState::new(window, cx));
|
||||
let focus_on_mount = Rc::new(Cell::new(true));
|
||||
|
||||
window.open_dialog(cx, move |dialog, _, _| {
|
||||
let state = state.clone();
|
||||
let focus_on_mount = focus_on_mount.clone();
|
||||
|
||||
dialog
|
||||
.close_button(false)
|
||||
.p_0()
|
||||
.width(px(420.))
|
||||
.content(move |content, window, cx| {
|
||||
if focus_on_mount.replace(false) {
|
||||
let state = state.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
state.read(cx).focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
let active = Theme::global(cx).theme_name().clone();
|
||||
let (lights, darks) = Self::theme_entries(cx);
|
||||
|
||||
content.child(
|
||||
CommandPalette::new(&state)
|
||||
.bordered(false)
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.placeholder("Search themes...")
|
||||
.max_h(px(420.))
|
||||
.on_confirm(move |index, _, cx| {
|
||||
if let Some(name) = Self::theme_name_at(index, cx) {
|
||||
AppSettings::select_theme(&name, cx);
|
||||
}
|
||||
})
|
||||
.group(
|
||||
CommandGroup::new()
|
||||
.label("Light Themes")
|
||||
.items(Self::theme_items(&lights, &active)),
|
||||
)
|
||||
.group(
|
||||
CommandGroup::new()
|
||||
.label("Dark Themes")
|
||||
.items(Self::theme_items(&darks, &active)),
|
||||
),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Sorted bundled themes partitioned into light and dark groups
|
||||
fn theme_entries(cx: &App) -> (Vec<Rc<ThemeConfig>>, Vec<Rc<ThemeConfig>>) {
|
||||
let (mut lights, mut darks) = (vec![], vec![]);
|
||||
|
||||
for theme in ThemeRegistry::global(cx).sorted_themes() {
|
||||
if theme.mode.is_dark() {
|
||||
darks.push(theme.clone());
|
||||
} else {
|
||||
lights.push(theme.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(lights, darks)
|
||||
}
|
||||
|
||||
/// One palette row per theme.
|
||||
fn theme_items(themes: &[Rc<ThemeConfig>], active: &SharedString) -> Vec<CommandItem> {
|
||||
themes
|
||||
.iter()
|
||||
.map(|theme| {
|
||||
CommandItem::new()
|
||||
.label(theme.name.clone())
|
||||
.checked(&theme.name == active)
|
||||
.keywords([if theme.mode.is_dark() {
|
||||
"dark"
|
||||
} else {
|
||||
"light"
|
||||
}])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The theme registered at the palette's original group position.
|
||||
fn theme_name_at(index: IndexPath, cx: &App) -> Option<SharedString> {
|
||||
let (lights, darks) = Self::theme_entries(cx);
|
||||
|
||||
match index.section {
|
||||
0 => lights.get(index.row),
|
||||
1 => darks.get(index.row),
|
||||
_ => None,
|
||||
}
|
||||
.map(|theme| theme.name.clone())
|
||||
}
|
||||
|
||||
/// Open the channel creation dialog under the action's group.
|
||||
fn on_create_channel(
|
||||
&mut self,
|
||||
|
||||
@@ -61,6 +61,7 @@ impl Sidebar {
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
this.menu("Profile", Box::new(Command::ShowProfile))
|
||||
.menu("Relays", Box::new(Command::ShowRelayList))
|
||||
.menu("Themes", Box::new(Command::ToggleTheme))
|
||||
.separator()
|
||||
.label(format!("{CLIENT_NAME} {}", env!("CARGO_PKG_VERSION")))
|
||||
.item(
|
||||
|
||||
Reference in New Issue
Block a user