migrate theming to gpui-native-kit

This commit is contained in:
2026-11-09 21:33:20 +07:00
parent 688b1ce418
commit 8e8ceaf2db
11 changed files with 278 additions and 456 deletions
-1
View File
@@ -5,7 +5,6 @@ edition.workspace = true
publish.workspace = true
[dependencies]
gpui-kit.workspace = true
common = { path = "../common" }
nostr-sdk.workspace = true
+5 -104
View File
@@ -3,7 +3,7 @@ use std::path::PathBuf;
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::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec};
@@ -36,33 +36,6 @@ 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) {
AppSettings::set_global(cx.new(AppSettings::new), cx)
}
@@ -91,13 +64,6 @@ pub fn persisted_appearance_mode() -> AppearanceMode {
.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
@@ -160,7 +126,6 @@ setting_accessors! {
}
setting_getters! {
pub theme: ThemeSettings,
pub pinned_rooms: Vec<u64>,
pub pinned_groups: Vec<String>,
}
@@ -234,9 +199,6 @@ 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,
@@ -265,7 +227,6 @@ impl Default for Settings {
nip4e: false,
nip4e_rotation_days: None,
hide_group_notice: false,
theme: ThemeSettings::default(),
panel_background: true,
panel_background_path: None,
trusted_relays: vec![],
@@ -330,52 +291,6 @@ 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();
});
});
}
pub fn apply_theme_style(cx: &mut App) {
Self::apply_theme_tuning(Self::get_theme(cx), cx);
}
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);
styled.focus_ring = tuning.focus_ring;
if let Some(radius) = tuning.radius {
styled.radius = px(radius);
}
if let Some(radius_lg) = tuning.radius_lg {
styled.radius_lg = px(radius_lg);
}
}
Theme::update(cx, |styled| {
let background = styled.background;
let foreground = styled.foreground;
let (hover_alpha, active_alpha) = if styled.is_dark() {
(0.08, 0.12)
} else {
(0.04, 0.06)
};
if styled.list_hover == background {
styled.list_hover = foreground.alpha(hover_alpha);
}
if styled.tab_active == background {
styled.tab_active = foreground.alpha(active_alpha);
}
});
}
/// Load settings
fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
@@ -408,7 +323,6 @@ impl AppSettings {
// Update settings
this.update(cx, |this, cx| {
this.set_settings(settings, cx);
this.apply_theme(cx);
cx.refresh_windows();
})
.ok();
@@ -434,20 +348,6 @@ impl AppSettings {
}
}
/// Apply the persisted appearance mode 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 {
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),
}
let tuning = self.inner.read(cx).theme;
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
@@ -507,9 +407,9 @@ impl AppSettings {
mod tests {
use super::*;
/// Settings written before theme tuning existed must keep loading.
/// Settings written by older versions, including theme tuning, must keep loading.
#[test]
fn settings_without_theme_keep_defaults() {
fn legacy_settings_keep_loading() {
let settings: Settings = serde_json::from_str(
r#"{
"appearance_mode": "Dark",
@@ -517,12 +417,13 @@ mod tests {
"screening": true,
"wot_auto_trust": true,
"nip4e": false,
"theme": { "font_size": 18.0, "focus_ring": true },
"trusted_relays": [],
"file_server": "https://nostr.download/"
}"#,
)
.unwrap();
assert_eq!(settings.theme, ThemeSettings::default());
assert_eq!(settings.appearance_mode, AppearanceMode::Dark);
}
}
+5
View File
@@ -41,6 +41,11 @@ pub fn recognizer() -> impl SpeechRecognizer {
parakeet::ParakeetRecognizer
}
/// Initializes the old component kit that the retained speech widgets still use.
pub fn init_compat(cx: &mut App) {
gpui_kit::init(cx);
}
/// The recognizer backing the composer's microphone button.
#[cfg(target_arch = "wasm32")]
pub fn recognizer() -> impl SpeechRecognizer {
+1
View File
@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
ui = { path = "../ui" }
gpui-kit.workspace = true
gpui-native-kit.workspace = true
common = { path = "../common" }
state = { path = "../state" }
nip29 = { path = "../nip29" }
+3 -137
View File
@@ -13,7 +13,7 @@ use gpui::{
};
use gpui_kit::assets::IconName;
use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::input::{Input, InputState};
use gpui_kit::component::kbd::Kbd;
use gpui_kit::component::menu::{DropdownMenu, PopupMenuItem};
use gpui_kit::component::notification::Notification;
@@ -27,19 +27,12 @@ use settings::{AppSettings, AppearanceMode, NIP4E_ROTATION_DEFAULT_DAYS};
use speech::model::{self as speech_model};
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> {
cx.new(|cx| Preferences::new(window, cx))
}
pub struct Preferences {
file_input: Entity<InputState>,
font_size_input: Entity<InputState>,
radius_input: Entity<InputState>,
radius_lg_input: Entity<InputState>,
#[cfg(not(target_arch = "wasm32"))]
mint_input: Entity<InputState>,
#[cfg(not(target_arch = "wasm32"))]
@@ -57,61 +50,9 @@ impl Preferences {
.placeholder("https://myblossom.com")
});
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)
});
#[cfg(not(target_arch = "wasm32"))]
let mint_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://mint…"));
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);
}),
];
#[cfg(not(target_arch = "wasm32"))]
let _speech_poll = cx.spawn_in(window, async move |this, cx| {
let timer = Duration::from_millis(500);
@@ -141,43 +82,14 @@ impl Preferences {
Self {
file_input,
font_size_input,
radius_input,
radius_lg_input,
#[cfg(not(target_arch = "wasm32"))]
mint_input,
#[cfg(not(target_arch = "wasm32"))]
_speech_poll,
_subscriptions: subscriptions,
_subscriptions: Vec::new(),
}
}
/// 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
fn update_file_server(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.file_input.read(cx).value();
@@ -245,9 +157,7 @@ impl Preferences {
/// Set the appearance mode (system, light or dark)
fn set_appearance_mode(mode: AppearanceMode, cx: &mut App) {
AppSettings::update_appearance_mode(mode, cx);
AppSettings::global(cx).update(cx, |settings, cx| {
settings.apply_theme(cx);
});
crate::apply_appearance_mode(mode, cx);
}
/// Ask the user for an image to show behind chat and group panels
@@ -388,11 +298,6 @@ 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 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_ROTATION: &str = "Automatically rotate the encryption key every 30 days, keeping the previous key for messages still sealed to it.";
const BACKGROUND: &str = "Show an image behind chat and group panels.";
@@ -404,7 +309,6 @@ impl Render for Preferences {
let nip4e = AppSettings::get_nip4e(cx);
let nip4e_rotation_days = AppSettings::get_nip4e_rotation_days(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);
@@ -472,44 +376,6 @@ impl Render for Preferences {
cx,
))
.child(Separator::horizontal())
.child(
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(
v_flex()
.w_full()
+30 -8
View File
@@ -1,6 +1,6 @@
use std::sync::Arc;
use ::settings::{AppSettings, AppearanceMode};
use ::settings::AppearanceMode;
use anyhow::Error;
#[cfg(not(target_arch = "wasm32"))]
use auto_update::AutoUpdater;
@@ -14,7 +14,7 @@ use gpui::{
use gpui_kit::component::button::Button;
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, WindowExt, v_flex};
use nip4e::{DeviceEvent, DeviceRegistry};
use nip29::{GroupId, GroupKey};
use nostr_sdk::prelude::*;
@@ -42,6 +42,31 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
cx.new(|cx| Workspace::new(window, cx))
}
/// Apply the appearance mode to the kit and mirror it into the old theme
/// that the speech components still use.
pub fn apply_appearance_mode(mode: AppearanceMode, cx: &mut App) {
match mode {
AppearanceMode::System => {
gpui_native_kit::theme::follow_system(cx);
gpui_kit::component::Theme::sync_system_appearance(None, cx);
}
AppearanceMode::Light => {
gpui_native_kit::theme::set_mode_override(
Some(gpui_native_kit::theme::ThemeVariant::Light),
cx,
);
gpui_kit::component::Theme::change(gpui_kit::component::ThemeMode::Light, None, cx);
}
AppearanceMode::Dark => {
gpui_native_kit::theme::set_mode_override(
Some(gpui_native_kit::theme::ThemeVariant::Dark),
cx,
);
gpui_kit::component::Theme::change(gpui_kit::component::ThemeMode::Dark, None, cx);
}
}
}
struct DeviceNotifcation;
struct MsgRelayNotification;
@@ -93,10 +118,7 @@ impl Workspace {
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| {
if AppSettings::get_appearance_mode(cx) == AppearanceMode::System {
Theme::sync_system_appearance(Some(window), cx);
AppSettings::apply_theme_style(cx);
}
gpui_native_kit::theme::Theme::sync_from_window(window, cx);
}));
subscriptions.push(
@@ -536,8 +558,8 @@ impl Workspace {
impl Render for Workspace {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Dialog, notification and tooltip layers are owned by the framework
// Root that gpui_kit::open_window installed for this window.
// Dialog, notification and tooltip layers previously came from the
// old kit's window root; they move to common dialogs and toasts.
div()
.id("workspace")
.on_action(cx.listener(Self::on_command))