Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -559,6 +571,10 @@ fn default_sound_theme() -> SoundTheme {
SoundTheme::Marimba
}

fn default_theme() -> Theme {
Theme::System
}

fn default_post_process_enabled() -> bool {
false
}
Expand Down Expand Up @@ -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(),
Expand Down
40 changes: 39 additions & 1 deletion src-tauri/src/shortcut/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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> {
Expand Down
21 changes: 21 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ async changeSoundThemeSetting(theme: string) : Promise<Result<null, string>> {
else return { status: "error", error: e as any };
}
},
async changeThemeSetting(theme: string) : Promise<Result<null, string>> {
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<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_start_hidden_setting", { enabled }) };
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
49 changes: 49 additions & 0 deletions src/components/settings/ThemeSelector.tsx
Original file line number Diff line number Diff line change
@@ -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<ThemeSelectorProps> = 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 (
<SettingContainer
title={t("theme.title")}
description={t("theme.description")}
descriptionMode={descriptionMode}
grouped={grouped}
>
<Dropdown
options={themeOptions}
selectedValue={currentTheme}
onSelect={handleThemeChange}
/>
</SettingContainer>
);
},
);

ThemeSelector.displayName = "ThemeSelector";
2 changes: 2 additions & 0 deletions src/components/settings/about/AboutSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -40,6 +41,7 @@ export const AboutSettings: React.FC = () => {
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title={t("settings.about.title")}>
<AppLanguageSelector descriptionMode="tooltip" grouped={true} />
<ThemeSelector descriptionMode="tooltip" grouped={true} />
<SettingContainer
title={t("settings.about.version.title")}
description={t("settings.about.version.description")}
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/ar/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,15 @@
"title": "لغة التطبيق",
"description": "تغيير لغة واجهة Handy"
},
"theme": {
"title": "سمة التطبيق",
"description": "اختر ما إذا كان Handy يتبع مظهر النظام أو يبقى فاتحًا أو داكنًا",
"options": {
"system": "النظام",
"light": "فاتح",
"dark": "داكن"
}
},
"overlay": {
"transcribing": "...جاري التفريغ",
"processing": "...جاري المعالجة"
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/bg/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,15 @@
"title": "Език на приложението",
"description": "Промяна на езика на интерфейса на Handy"
},
"theme": {
"title": "Тема на приложението",
"description": "Изберете дали Handy да следва системната тема, или да остане светла или тъмна",
"options": {
"system": "Системна",
"light": "Светла",
"dark": "Тъмна"
}
},
"overlay": {
"transcribing": "Транскрибиране...",
"processing": "Обработка..."
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/cs/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,15 @@
"title": "Jazyk aplikace",
"description": "Změňte jazyk rozhraní Handy"
},
"theme": {
"title": "Motiv aplikace",
"description": "Zvolte, zda má Handy sledovat motiv systému, nebo zůstat světlý či tmavý",
"options": {
"system": "Systém",
"light": "Světlý",
"dark": "Tmavý"
}
},
"overlay": {
"transcribing": "Přepisuji...",
"processing": "Zpracovávám..."
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,15 @@
"title": "Anwendungssprache",
"description": "Sprache der Handy-Oberfläche ändern"
},
"theme": {
"title": "Anwendungsdesign",
"description": "Wählen Sie, ob Handy dem Systemthema folgt oder hell bzw. dunkel bleibt",
"options": {
"system": "System",
"light": "Hell",
"dark": "Dunkel"
}
},
"overlay": {
"transcribing": "Transkribiere...",
"processing": "Verarbeite..."
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,15 @@
"title": "Application Language",
"description": "Change the language of the Handy interface"
},
"theme": {
"title": "Application Theme",
"description": "Choose whether Handy follows your system theme or stays light or dark",
"options": {
"system": "System",
"light": "Light",
"dark": "Dark"
}
},
"overlay": {
"transcribing": "Transcribing...",
"processing": "Processing..."
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,15 @@
"title": "Idioma de la aplicación",
"description": "Cambia el idioma de la interfaz de Handy"
},
"theme": {
"title": "Tema de la aplicación",
"description": "Elige si Handy sigue el tema del sistema o permanece claro u oscuro",
"options": {
"system": "Sistema",
"light": "Claro",
"dark": "Oscuro"
}
},
"overlay": {
"transcribing": "Transcribiendo...",
"processing": "Procesando..."
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,15 @@
"title": "Langue de l'application",
"description": "Changer la langue de l'interface de Handy"
},
"theme": {
"title": "Thème de l'application",
"description": "Choisissez si Handy suit le thème du système ou reste clair ou sombre",
"options": {
"system": "Système",
"light": "Clair",
"dark": "Sombre"
}
},
"overlay": {
"transcribing": "Transcription...",
"processing": "Traitement..."
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/he/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,15 @@
"title": "שפת האפליקציה",
"description": "שנה את שפת הממשק של Handy"
},
"theme": {
"title": "ערכת נושא של האפליקציה",
"description": "בחר אם Handy יעקוב אחר ערכת הנושא של המערכת או יישאר בהיר או כהה",
"options": {
"system": "מערכת",
"light": "בהיר",
"dark": "כהה"
}
},
"overlay": {
"transcribing": "מתמלל...",
"processing": "מעבד..."
Expand Down
9 changes: 9 additions & 0 deletions src/i18n/locales/it/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,15 @@
"title": "Lingua Applicazione",
"description": "Cambia la lingua dell'interfaccia di Handy"
},
"theme": {
"title": "Tema dell'applicazione",
"description": "Scegli se Handy segue il tema di sistema o resta chiaro o scuro",
"options": {
"system": "Sistema",
"light": "Chiaro",
"dark": "Scuro"
}
},
"overlay": {
"transcribing": "Trascrizione...",
"processing": "Elaborazione..."
Expand Down
Loading