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
33 changes: 33 additions & 0 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ pub struct PluginConfig {
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
pub theme: Option<String>,
/// When true, the app follows the OS light/dark appearance using
/// `light_theme_id` / `dark_theme_id`. None/false ⇒ static `theme`.
pub follow_system_theme: Option<bool>,
/// Theme applied while the OS is in light mode and follow-system is on.
pub light_theme_id: Option<String>,
/// Theme applied while the OS is in dark mode and follow-system is on.
pub dark_theme_id: Option<String>,
pub language: Option<String>,
pub result_page_size: Option<u32>,
pub font_family: Option<String>,
Expand Down Expand Up @@ -263,6 +270,15 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> {
if config.theme.is_some() {
existing_config.theme = config.theme;
}
if config.follow_system_theme.is_some() {
existing_config.follow_system_theme = config.follow_system_theme;
}
if config.light_theme_id.is_some() {
existing_config.light_theme_id = config.light_theme_id;
}
if config.dark_theme_id.is_some() {
existing_config.dark_theme_id = config.dark_theme_id;
}
if config.language.is_some() {
existing_config.language = config.language;
}
Expand Down Expand Up @@ -853,6 +869,23 @@ pub fn save_config_json(app: AppHandle, json: String) -> Result<(), String> {
mod tests {
use super::*;

#[test]
fn app_config_deserializes_system_theme_fields() {
let json = r#"{"theme":"tabularis-dark","followSystemTheme":true,"lightThemeId":"tabularis-light","darkThemeId":"dracula"}"#;
let config: AppConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.follow_system_theme, Some(true));
assert_eq!(config.light_theme_id.as_deref(), Some("tabularis-light"));
assert_eq!(config.dark_theme_id.as_deref(), Some("dracula"));
}

#[test]
fn app_config_defaults_system_theme_fields_to_none() {
let config: AppConfig = serde_json::from_str("{}").unwrap();
assert!(config.follow_system_theme.is_none());
assert!(config.light_theme_id.is_none());
assert!(config.dark_theme_id.is_none());
}

#[test]
fn selected_schemas_default_is_none() {
let config = AppConfig::default();
Expand Down
66 changes: 59 additions & 7 deletions src/components/settings/AppearanceTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@ import {
import { FontPicker } from "./FontPicker";
import { ThemePicker } from "./ThemePicker";
import { ResultColorsSection } from "./ResultColorsSection";
import { themeRegistry } from "../../themes/themeRegistry";

export function AppearanceTab() {
const { t } = useTranslation();
const { settings, updateSetting } = useSettings();
const { currentTheme, allThemes, setTheme } = useTheme();
const {
currentTheme,
allThemes,
setTheme,
settings: themeSettings,
updateSettings,
} = useTheme();
const [subTab, setSubTab] = useState<"general" | "editor">("general");

return (
Expand Down Expand Up @@ -57,13 +64,58 @@ export function AppearanceTab() {
{subTab === "general" && (
<>
<SettingSection title={t("settings.themeSelection")}>
<div className="py-3">
<ThemePicker
value={currentTheme.id}
onChange={setTheme}
themes={allThemes}
<SettingRow
label={t("settings.themeMode")}
description={t("settings.themeModeDesc")}
>
<SettingButtonGroup
value={themeSettings.followSystemTheme ? "system" : "static"}
onChange={(mode) =>
updateSettings({ followSystemTheme: mode === "system" })
}
options={[
{ value: "static", label: t("settings.themeModeStatic") },
{ value: "system", label: t("settings.themeModeSystem") },
]}
/>
</div>
</SettingRow>

{themeSettings.followSystemTheme ? (
<>
<div className="py-3">
<p className="text-sm text-muted mb-2">
{t("settings.lightTheme")}
</p>
<ThemePicker
value={themeSettings.lightThemeId}
onChange={(id) => updateSettings({ lightThemeId: id })}
themes={allThemes.filter((theme) =>
themeRegistry.isLightTheme(theme),
)}
/>
</div>
<div className="py-3">
<p className="text-sm text-muted mb-2">
{t("settings.darkTheme")}
</p>
<ThemePicker
value={themeSettings.darkThemeId}
onChange={(id) => updateSettings({ darkThemeId: id })}
themes={allThemes.filter((theme) =>
themeRegistry.isDarkTheme(theme),
)}
/>
</div>
</>
) : (
<div className="py-3">
<ThemePicker
value={currentTheme.id}
onChange={setTheme}
themes={allThemes}
/>
</div>
)}
</SettingSection>

<SettingSection title={t("settings.fontFamily")}>
Expand Down
145 changes: 131 additions & 14 deletions src/contexts/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
type ReactNode,
} from "react";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { ThemeContext } from "./ThemeContext";
import { themeRegistry } from "../themes/themeRegistry";
import { applyThemeToCSS } from "../themes/themeUtils";
import { getSystemThemeId, resolveActiveThemeId } from "../utils/themeManagement";
import type { Theme, ThemeSettings } from "../types/theme";

const DEFAULT_THEME_SETTINGS: ThemeSettings = {
Expand All @@ -21,6 +23,9 @@ const DEFAULT_THEME_SETTINGS: ThemeSettings = {

interface AppConfig {
theme?: string;
followSystemTheme?: boolean;
lightThemeId?: string;
darkThemeId?: string;
language?: string;
resultPageSize?: number;
fontFamily?: string;
Expand Down Expand Up @@ -97,19 +102,51 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => {
).catch(() => [] as Theme[]);
setCustomThemes(loadedCustomThemes.filter((t) => !t.isPreset));

// Set initial theme
// Hydrate follow-system settings (absent ⇒ static mode)
const followSystemTheme = config.followSystemTheme ?? false;
const lightThemeId = config.lightThemeId ?? "tabularis-light";
const darkThemeId = config.darkThemeId ?? "tabularis-dark";

// Resolve active theme: follow-system overrides config.theme
let systemIsDark: boolean | undefined;
if (followSystemTheme) {
systemIsDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
activeThemeId = resolveActiveThemeId(
{
...DEFAULT_THEME_SETTINGS,
activeThemeId: activeThemeId ?? "tabularis-dark",
followSystemTheme,
lightThemeId,
darkThemeId,
},
systemIsDark,
);
}

// Set initial theme; follow-system falls back to the preset matching
// the OS mode, static mode keeps the registry default
const allAvailableThemes = [
...themeRegistry.getAllPresets(),
...loadedCustomThemes,
];
const initialTheme =
allAvailableThemes.find((t) => t.id === activeThemeId) ||
(followSystemTheme
? themeRegistry.getPreset(
systemIsDark ? "tabularis-dark" : "tabularis-light",
)
: undefined) ||
themeRegistry.getDefault();

setCurrentTheme(initialTheme);
setSettings({
...DEFAULT_THEME_SETTINGS,
activeThemeId: initialTheme.id,
followSystemTheme,
lightThemeId,
darkThemeId,
});
} catch (error) {
console.error("Failed to load themes:", error);
Expand All @@ -125,6 +162,14 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => {
if (currentTheme) {
applyThemeToCSS(currentTheme);
const windowTheme = themeRegistry.isDarkTheme(currentTheme)
? "dark"
: "light";
getCurrentWindow()
.setTheme(windowTheme)
.catch((error) =>
console.error("Failed to set window theme:", error),
);
}
}, [currentTheme]);

Expand All @@ -145,17 +190,21 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => {

const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => {
const newThemeId = e.matches ? "tabularis-dark" : "tabularis-light";
const newTheme = allThemes.find((t) => t.id === newThemeId);
const newThemeId = getSystemThemeId(e.matches, settings);
const newTheme =
allThemes.find((t) => t.id === newThemeId) ||
themeRegistry.getPreset(
e.matches ? "tabularis-dark" : "tabularis-light",
);
if (newTheme) {
setCurrentTheme(newTheme);
setSettings((prev) => ({ ...prev, activeThemeId: newThemeId }));
setSettings((prev) => ({ ...prev, activeThemeId: newTheme.id }));
}
};

mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [settings.followSystemTheme, allThemes]);
}, [settings, allThemes]);

const setTheme = useCallback(
async (themeId: string) => {
Expand Down Expand Up @@ -240,14 +289,57 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => {
customThemes: prev.customThemes.filter((id) => id !== themeId),
}));

// If the deleted theme was active, switch to default
// Reset per-mode picks that referenced the deleted theme.
// Compute from current state and persist outside the updater so
// StrictMode double-invocation cannot fire save_config twice.
const lightThemeId =
settings.lightThemeId === themeId
? "tabularis-light"
: settings.lightThemeId;
const darkThemeId =
settings.darkThemeId === themeId
? "tabularis-dark"
: settings.darkThemeId;
if (
lightThemeId !== settings.lightThemeId ||
darkThemeId !== settings.darkThemeId
) {
invoke("save_config", {
config: { lightThemeId, darkThemeId },
}).catch((error) =>
console.error("Failed to reset per-mode theme picks:", error),
);
setSettings((prev) => ({ ...prev, lightThemeId, darkThemeId }));
}

// Active branch: in follow-system mode, resolve through the current OS
// mode and the just-reset per-mode picks (same fallback chain as the
// listener and load path). Static mode keeps the registry default.
if (currentTheme.id === themeId) {
const defaultTheme = themeRegistry.getDefault();
setCurrentTheme(defaultTheme);
setSettings((prev) => ({ ...prev, activeThemeId: defaultTheme.id }));
let replacement: Theme;
if (settings.followSystemTheme) {
const systemIsDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
const targetId = getSystemThemeId(systemIsDark, {
...settings,
lightThemeId,
darkThemeId,
});
replacement =
allThemes.find((t) => t.id === targetId) ||
themeRegistry.getPreset(
systemIsDark ? "tabularis-dark" : "tabularis-light",
) ||
themeRegistry.getDefault();
} else {
replacement = themeRegistry.getDefault();
}
setCurrentTheme(replacement);
setSettings((prev) => ({ ...prev, activeThemeId: replacement.id }));
}
},
[allThemes, currentTheme.id],
[allThemes, currentTheme.id, settings.lightThemeId, settings.darkThemeId, settings.followSystemTheme],
);

const duplicateTheme = useCallback(
Expand Down Expand Up @@ -323,12 +415,37 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => {

const updateSettings = useCallback(
async (newSettings: Partial<ThemeSettings>) => {
setSettings((prev) => ({ ...prev, ...newSettings }));
const merged = { ...settings, ...newSettings };
setSettings(merged);

await invoke("save_config", {
config: {
followSystemTheme: merged.followSystemTheme,
lightThemeId: merged.lightThemeId,
darkThemeId: merged.darkThemeId,
},
}).catch((error) => {
console.error("Failed to save theme settings:", error);
});

// If followSystemTheme is enabled, we would need to set up a media query listener
// This is handled separately in a dedicated effect
// Follow-system on: immediately apply the theme for the current OS mode
if (merged.followSystemTheme) {
const systemIsDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
const targetId = getSystemThemeId(systemIsDark, merged);
const target =
allThemes.find((t) => t.id === targetId) ||
themeRegistry.getPreset(
systemIsDark ? "tabularis-dark" : "tabularis-light",
);
if (target && target.id !== currentTheme.id) {
setCurrentTheme(target);
setSettings((prev) => ({ ...prev, activeThemeId: target.id }));
}
}
},
[],
[settings, allThemes, currentTheme.id],
);

const value = useMemo(
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,12 @@
"appearance": "Darstellung",
"localization": "Lokalisierung",
"themeSelection": "Themaauswahl",
"themeMode": "Themenmodus",
"themeModeDesc": "Wähle ein festes Thema oder folge dem Hell-/Dunkelmodus des Systems.",
"themeModeStatic": "Statisch",
"themeModeSystem": "System folgen",
"lightTheme": "Helles Thema",
"darkTheme": "Dunkles Thema",
"fontFamily": "Schriftfamilie",
"fonts": {
"system": "Systemstandard",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,12 @@
"appearance": "Appearance",
"localization": "Localization",
"themeSelection": "Theme Selection",
"themeMode": "Theme Mode",
"themeModeDesc": "Choose a fixed theme, or follow your system's light/dark appearance.",
"themeModeStatic": "Static",
"themeModeSystem": "Follow System",
"lightTheme": "Light Theme",
"darkTheme": "Dark Theme",
"fontFamily": "Font Family",
"fonts": {
"system": "System Default",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,12 @@
"appearance": "Apariencia",
"localization": "Localización",
"themeSelection": "Selección de Tema",
"themeMode": "Modo de tema",
"themeModeDesc": "Elige un tema fijo o sigue la apariencia clara/oscura del sistema.",
"themeModeStatic": "Estático",
"themeModeSystem": "Seguir sistema",
"lightTheme": "Tema claro",
"darkTheme": "Tema oscuro",
"fontFamily": "Familia de Fuente",
"fonts": {
"system": "Sistema",
Expand Down
Loading
Loading