diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0d81a4211d..31c0cae77c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -539,6 +539,7 @@ pub fn run(cli_args: CliArgs) { shortcut::change_audio_feedback_setting, shortcut::change_audio_feedback_volume_setting, shortcut::change_sound_theme_setting, + shortcut::change_theme_setting, shortcut::change_start_hidden_setting, shortcut::change_autostart_setting, shortcut::change_translate_to_english_setting, @@ -816,6 +817,13 @@ pub fn run(cli_args: CliArgs) { let mut settings = get_settings(app.handle()); + // Apply the persisted appearance theme to the Windows title bar before + // the window is shown, so it matches the in-app palette without a flash + // of the wrong theme. On macOS/Linux, Tauri themes are app-wide and + // would also affect windows that intentionally keep the system theme. + #[cfg(target_os = "windows")] + shortcut::apply_window_theme(app.handle(), settings.theme); + // CLI --debug flag overrides debug_mode and log level (runtime-only, not persisted) if cli_args.debug { settings.debug_mode = true; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 0ad4356294..5fa6279f0e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -258,6 +258,16 @@ impl SoundTheme { } } +/// UI appearance mode. `System` follows the OS `prefers-color-scheme`; `Light` +/// and `Dark` force one of the two palettes Handy already ships. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)] +#[serde(rename_all = "snake_case")] +pub enum Theme { + System, + Light, + Dark, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type, Default)] #[serde(rename_all = "snake_case")] pub enum TypingTool { @@ -419,6 +429,8 @@ pub struct AppSettings { pub append_trailing_space: bool, #[serde(default = "default_app_language")] pub app_language: String, + #[serde(default = "default_theme")] + pub theme: Theme, #[serde(default)] pub experimental_enabled: bool, #[serde(default)] @@ -559,6 +571,10 @@ fn default_sound_theme() -> SoundTheme { SoundTheme::Marimba } +fn default_theme() -> Theme { + Theme::System +} + fn default_post_process_enabled() -> bool { false } @@ -862,6 +878,7 @@ pub fn get_default_settings() -> AppSettings { mute_while_recording: false, append_trailing_space: false, app_language: default_app_language(), + theme: default_theme(), experimental_enabled: false, lazy_stream_close: false, keyboard_implementation: KeyboardImplementation::default(), diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index 18a615fc54..e20edfc26f 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -23,7 +23,7 @@ use tauri_plugin_autostart::ManagerExt; use crate::settings::APPLE_INTELLIGENCE_DEFAULT_MODEL_ID; use crate::settings::{ self, get_settings, AutoSubmitKey, ClipboardHandling, KeyboardImplementation, LLMPrompt, - OverlayPosition, OverlayStyle, PasteMethod, ShortcutBinding, SoundTheme, TypingTool, + OverlayPosition, OverlayStyle, PasteMethod, ShortcutBinding, SoundTheme, Theme, TypingTool, APPLE_INTELLIGENCE_PROVIDER_ID, }; use crate::tray; @@ -514,6 +514,44 @@ pub fn change_sound_theme_setting(app: AppHandle, theme: String) -> Result<(), S Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn change_theme_setting(app: AppHandle, theme: String) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + let parsed = match theme.as_str() { + "system" => Theme::System, + "light" => Theme::Light, + "dark" => Theme::Dark, + other => { + warn!("Invalid theme '{}', defaulting to system", other); + Theme::System + } + }; + settings.theme = parsed; + settings::write_settings(&app, settings); + #[cfg(target_os = "windows")] + apply_window_theme(&app, parsed); + Ok(()) +} + +/// Applies the appearance setting to the Windows title bar, which CSS +/// `data-theme` cannot reach. `System` clears the override so the window follows +/// Windows. Call this on startup and whenever the setting changes to keep the +/// title bar in sync with the in-app palette. +#[cfg(target_os = "windows")] +pub fn apply_window_theme(app: &AppHandle, theme: Theme) { + let window_theme = match theme { + Theme::System => None, + Theme::Light => Some(tauri::Theme::Light), + Theme::Dark => Some(tauri::Theme::Dark), + }; + if let Some(window) = app.get_webview_window("main") { + if let Err(e) = window.set_theme(window_theme) { + warn!("Failed to apply window theme: {}", e); + } + } +} + #[tauri::command] #[specta::specta] pub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Result<(), String> { diff --git a/src/App.css b/src/App.css index f0d791532b..650b809bd9 100644 --- a/src/App.css +++ b/src/App.css @@ -59,6 +59,27 @@ } } +/* + * Explicit theme override, driven by the `theme` setting via + * `document.documentElement.dataset.theme`. When set, the higher-specificity + * attribute selectors win over the `prefers-color-scheme` media query above, + * so a user can keep Handy light or dark regardless of the OS setting. + * `system` (or no attribute) leaves the OS in charge. + */ +:root[data-theme="light"] { + --color-text: var(--light-color-text); + --color-background: var(--light-color-background); + --color-logo-primary: var(--light-color-logo-primary); + --color-logo-stroke: var(--light-color-logo-stroke); +} + +:root[data-theme="dark"] { + --color-text: var(--dark-color-text); + --color-background: var(--dark-color-background); + --color-logo-primary: var(--dark-color-logo-primary); + --color-logo-stroke: var(--dark-color-logo-stroke); +} + /* macOS - tint native overlay scrollbar thumb */ :root[data-platform="macos"] { scrollbar-color: var(--scrollbar-thumb) transparent; diff --git a/src/bindings.ts b/src/bindings.ts index 0f78c3ebf9..f31c730a3b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -53,6 +53,14 @@ async changeSoundThemeSetting(theme: string) : Promise> { else return { status: "error", error: e as any }; } }, +async changeThemeSetting(theme: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("change_theme_setting", { theme }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async changeStartHiddenSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_start_hidden_setting", { enabled }) }; @@ -897,7 +905,7 @@ bindings?: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk?: boolean * upgrading from before this key existed are blanked by the migration so they * see the current release's notes — see `apply_settings_migrations`. */ -whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; typing_tool?: TypingTool; external_script_path?: string | null; custom_filler_words?: string[] | null; transcribe_accelerator?: TranscribeAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; transcribe_gpu_device?: number; extra_recording_buffer_ms?: number; vad_enabled?: boolean; +whats_new_last_seen_version?: string; selected_model?: string; onboarding_completed?: boolean; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; theme?: Theme; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; paste_delay_after_ms?: number; typing_tool?: TypingTool; external_script_path?: string | null; custom_filler_words?: string[] | null; transcribe_accelerator?: TranscribeAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; transcribe_gpu_device?: number; extra_recording_buffer_ms?: number; vad_enabled?: boolean; /** * Which recording overlay to show: None / Minimal / Live. Streaming mode is * not gated on this — that follows model capability. Migrated from the old @@ -1007,6 +1015,11 @@ export type StreamTextEvent = { committed: string; tentative: string } * Semantic kind of "working" phase, used to localize the spinner label. */ export type StreamWorkKind = "transcribing" | "polishing" +/** + * UI appearance mode. `System` follows the OS `prefers-color-scheme`; `Light` + * and `Dark` force one of the two palettes Handy already ships. + */ +export type Theme = "system" | "light" | "dark" export type TranscribeAcceleratorSetting = "auto" | "cpu" | "gpu" export type TypingTool = "auto" | "wtype" | "kwtype" | "dotool" | "ydotool" | "xdotool" export type WindowsMicrophonePermissionStatus = { supported: boolean; overall_access: PermissionAccess; device_access: PermissionAccess; app_access: PermissionAccess; desktop_app_access: PermissionAccess } diff --git a/src/components/settings/ThemeSelector.tsx b/src/components/settings/ThemeSelector.tsx new file mode 100644 index 0000000000..3757fc5b7a --- /dev/null +++ b/src/components/settings/ThemeSelector.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Dropdown } from "../ui/Dropdown"; +import { SettingContainer } from "../ui/SettingContainer"; +import { useSettings } from "@/hooks/useSettings"; +import { applyTheme, THEME_OPTIONS } from "@/lib/utils/theme"; +import type { Theme } from "@/bindings"; + +interface ThemeSelectorProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +export const ThemeSelector: React.FC = React.memo( + ({ descriptionMode = "tooltip", grouped = false }) => { + const { t } = useTranslation(); + const { settings, updateSetting } = useSettings(); + + const currentTheme: Theme = settings?.theme ?? "system"; + + const themeOptions = THEME_OPTIONS.map((value) => ({ + value, + label: t(`theme.options.${value}`), + })); + + const handleThemeChange = (value: string) => { + const theme = value as Theme; + applyTheme(theme); + updateSetting("theme", theme); + }; + + return ( + + + + ); + }, +); + +ThemeSelector.displayName = "ThemeSelector"; diff --git a/src/components/settings/about/AboutSettings.tsx b/src/components/settings/about/AboutSettings.tsx index 8d31d33a6a..fdeb4df4a3 100644 --- a/src/components/settings/about/AboutSettings.tsx +++ b/src/components/settings/about/AboutSettings.tsx @@ -8,6 +8,7 @@ import { Button } from "../../ui/Button"; import { AppDataDirectory } from "../AppDataDirectory"; import { AppLanguageSelector } from "../AppLanguageSelector"; import { ShowWhatsNewOnUpdate } from "../ShowWhatsNewOnUpdate"; +import { ThemeSelector } from "../ThemeSelector"; import { LogDirectory } from "../debug"; export const AboutSettings: React.FC = () => { @@ -40,6 +41,7 @@ export const AboutSettings: React.FC = () => {
+ + value === "system" || value === "light" || value === "dark"; + +/** Apply a theme to the document root and remember it for the next launch. */ +export const applyTheme = (theme: Theme): void => { + const root = document.documentElement; + if (theme === "system") { + delete root.dataset.theme; + } else { + root.dataset.theme = theme; + } + try { + localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch { + // localStorage may be unavailable (e.g. private mode); the setting still + // persists in AppSettings, so this only costs a one-frame flash on boot. + } +}; + +/** Read the last-applied theme for synchronous boot-time application. */ +export const getStoredTheme = (): Theme => { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if (isTheme(stored)) return stored; + } catch { + // ignore + } + return "system"; +}; + +/** Apply the persisted theme from AppSettings (the source of truth). */ +export const syncThemeFromSettings = async (): Promise => { + try { + const result = await commands.getAppSettings(); + if (result.status === "ok") { + applyTheme(result.data.theme ?? "system"); + } + } catch (e) { + console.warn("Failed to sync theme from settings:", e); + } +}; diff --git a/src/main.tsx b/src/main.tsx index 1e83ae7417..3cd2ed60a5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,10 +2,20 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { platform } from "@tauri-apps/plugin-os"; import App from "./App"; +import { + applyTheme, + getStoredTheme, + syncThemeFromSettings, +} from "./lib/utils/theme"; // Set platform before render so CSS can scope per-platform (e.g. scrollbar styles) document.documentElement.dataset.platform = platform(); +// Apply the last-known theme synchronously before render to avoid a flash of +// the wrong palette, then reconcile with the persisted setting once it loads. +applyTheme(getStoredTheme()); +syncThemeFromSettings(); + // Initialize i18n import "./i18n"; diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 744f9daf8a..b4e042829d 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -145,6 +145,7 @@ const settingUpdaters: { commands.changeAppendTrailingSpaceSetting(value as boolean), log_level: (value) => commands.setLogLevel(value as any), app_language: (value) => commands.changeAppLanguageSetting(value as string), + theme: (value) => commands.changeThemeSetting(value as string), experimental_enabled: (value) => commands.changeExperimentalEnabledSetting(value as boolean), lazy_stream_close: (value) => diff --git a/src/styles/theme.css b/src/styles/theme.css index 401e00db29..2494b5b024 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -1,20 +1,37 @@ /* Handy Colors */ :root { - --color-text: #0f0f0f; - --color-background: #fbfbfb; + /* Palette source of truth. Each themed color is defined once as a + light/dark pair; the active tokens below and the `data-theme` overrides + in App.css reference these, so a hex value lives in exactly one place. */ + --light-color-text: #0f0f0f; + --light-color-background: #fbfbfb; + --light-color-logo-primary: #faa2ca; + --light-color-logo-stroke: #382731; + + --dark-color-text: #fbfbfb; + --dark-color-background: #2c2b29; + --dark-color-logo-primary: #f28cbb; + --dark-color-logo-stroke: #fad1ed; + + /* Tokens that do not change between light and dark */ --color-background-ui: #da5893; - --color-logo-primary: #faa2ca; - --color-logo-stroke: #382731; --color-text-stroke: #f6f6f6; --color-mid-gray: #808080; + + /* Active palette — defaults to light, follows the OS via the media query + below, or is forced by `data-theme` (see App.css). */ + --color-text: var(--light-color-text); + --color-background: var(--light-color-background); + --color-logo-primary: var(--light-color-logo-primary); + --color-logo-stroke: var(--light-color-logo-stroke); } @media (prefers-color-scheme: dark) { :root { - --color-text: #fbfbfb; - --color-background: #2c2b29; - --color-logo-primary: #f28cbb; - --color-logo-stroke: #fad1ed; + --color-text: var(--dark-color-text); + --color-background: var(--dark-color-background); + --color-logo-primary: var(--dark-color-logo-primary); + --color-logo-stroke: var(--dark-color-logo-stroke); } }