From 4e3ceb21a530a67f480a215966f3c248e7fec2b1 Mon Sep 17 00:00:00 2001 From: NimBold Date: Thu, 23 Jul 2026 16:25:39 +0330 Subject: [PATCH] feat(ui): add cross-platform window control styles Persist automatic and explicit macOS, Windows, GNOME, and minimal control presets with localized settings and platform-aware fallback. Refs #31 --- src-tauri/src/ipc.rs | 19 +++++ src-tauri/src/settings.rs | 24 ++++++- src/App.tsx | 8 ++- src/bindings/PersistedSettings.ts | 3 +- src/bindings/WindowControlStyle.ts | 3 + src/components/SettingsView.tsx | 27 ++++++++ src/components/WindowControls.tsx | 6 +- src/i18n/catalogs/en.ts | 8 +++ src/i18n/catalogs/fa.ts | 8 +++ src/i18n/catalogs/he.ts | 8 +++ src/i18n/catalogs/ru.ts | 8 +++ src/i18n/catalogs/uk.ts | 8 +++ src/i18n/catalogs/zh-CN.ts | 8 +++ src/i18n/resources.test.ts | 2 + src/index.css | 100 +++++++++++++++++++++++++++ src/store/useSettingsStore.ts | 16 ++++- src/utils/windowControlStyle.test.ts | 28 ++++++++ src/utils/windowControlStyle.ts | 16 +++++ 18 files changed, 294 insertions(+), 6 deletions(-) create mode 100644 src/bindings/WindowControlStyle.ts create mode 100644 src/utils/windowControlStyle.test.ts create mode 100644 src/utils/windowControlStyle.ts diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index c03a0e7..bc9ebb5 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -214,6 +214,23 @@ pub enum Theme { Nord, } +#[derive(Clone, Debug, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = "../../src/bindings/")] +pub enum WindowControlStyle { + Auto, + Macos, + Windows, + Gnome, + Minimal, +} + +impl Default for WindowControlStyle { + fn default() -> Self { + Self::Auto + } +} + #[derive(Clone, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = "../../src/bindings/")] @@ -295,6 +312,8 @@ pub struct SchedulerSettings { pub struct PersistedSettings { pub theme: Theme, #[serde(default)] + pub window_control_style: WindowControlStyle, + #[serde(default)] pub calendar_preference: CalendarPreference, #[serde(default = "default_language_preference")] pub language: String, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index f83d4d9..a9630fd 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,6 +1,6 @@ use crate::ipc::{ AppFontSize, CalendarPreference, ListRowDensity, MediaCookieSource, PersistedSettings, - PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme, + PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme, WindowControlStyle, }; use serde_json::{Map, Value}; use std::collections::HashMap; @@ -192,6 +192,11 @@ fn sanitize_persisted_setting_values(state: &mut Value) { "theme", &["system", "light", "dark", "dracula", "nord"], ); + sanitize_allowed_string( + state, + "windowControlStyle", + &["auto", "macos", "windows", "gnome", "minimal"], + ); sanitize_allowed_string( state, "language", @@ -417,6 +422,7 @@ fn derived_location_path(base: &str, subfolder: &str) -> String { fn default_settings() -> PersistedSettings { PersistedSettings { theme: Theme::System, + window_control_style: WindowControlStyle::Auto, calendar_preference: CalendarPreference::Gregorian, language: "system".to_string(), base_download_folder: "~/Downloads".to_string(), @@ -472,6 +478,7 @@ fn default_settings() -> PersistedSettings { #[cfg(test)] mod tests { + use crate::ipc::WindowControlStyle; use super::{ decode_stored_settings, default_settings, preserve_portable_pairing_token, preserve_scheduler_runtime_keys, @@ -678,6 +685,21 @@ mod tests { assert_eq!(settings.sidebar_position, "auto"); } + #[test] + fn invalid_window_control_style_uses_automatic_layout() { + let stored = json!({ + "state": {"windowControlStyle": "neon"}, + "version": 5 + }); + + let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap(); + + assert!(matches!( + settings.window_control_style, + WindowControlStyle::Auto + )); + } + #[test] fn clamps_out_of_range_download_settings() { let stored = json!({ diff --git a/src/App.tsx b/src/App.tsx index 17c5567..d11a419 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { setLogStreamActive } from './utils/logger'; import { updateDockBadge } from './utils/dockBadge'; import { openUrl } from '@tauri-apps/plugin-opener'; import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform'; +import { resolveWindowControlStyle } from './utils/windowControlStyle'; import { getKeychainAccessReady, getKeychainConsentVersion, @@ -187,6 +188,7 @@ function App() { }); const theme = useSettingsStore(state => state.theme); + const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle); const languagePreference = useSettingsStore(state => state.language); const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible); const sidebarPosition = useSettingsStore(state => state.sidebarPosition); @@ -246,6 +248,7 @@ function App() { const { addToast, removeToast } = useToast(); const isMacUserAgent = navigator.userAgent.includes('Mac'); const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent); + const windowControlStyle = resolveWindowControlStyle(windowControlStylePreference, platform.os, navigator.userAgent); const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl'; const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl); // Keep dialogs out of the titlebar area while platform detection is still @@ -1021,7 +1024,10 @@ function App() { hasWindowChrome ? 'app-shell--window-chrome' : '' }`}> {usesCustomWindowControls && ( - + )}
, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; +export type PersistedSettings = { theme: Theme, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/bindings/WindowControlStyle.ts b/src/bindings/WindowControlStyle.ts new file mode 100644 index 0000000..8fd7bb9 --- /dev/null +++ b/src/bindings/WindowControlStyle.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WindowControlStyle = "auto" | "macos" | "windows" | "gnome" | "minimal"; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 74902d6..ce719bb 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -3,6 +3,7 @@ import { type AppFontSize, type ListRowDensity, type SidebarPosition, + type WindowControlStyle, type SettingsState, SettingsTab, runSettingsPersistenceTransaction, @@ -698,6 +699,13 @@ runEngineChecks(false); { value: 'persian', label: t($ => $.settings.lookAndFeel.calendarPersian) }, { value: 'hebrew', label: t($ => $.settings.lookAndFeel.calendarHebrew) }, ] as const; + const windowControlStyleOptions: Array<{ value: WindowControlStyle; label: string }> = [ + { value: 'auto', label: t($ => $.settings.lookAndFeel.windowControlStyleAutomatic) }, + { value: 'macos', label: t($ => $.settings.lookAndFeel.windowControlStyleMacos) }, + { value: 'windows', label: t($ => $.settings.lookAndFeel.windowControlStyleWindows) }, + { value: 'gnome', label: t($ => $.settings.lookAndFeel.windowControlStyleGnome) }, + { value: 'minimal', label: t($ => $.settings.lookAndFeel.windowControlStyleMinimal) }, + ]; const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => { const active = activeTab === type; @@ -907,6 +915,25 @@ runEngineChecks(false);
+

{t($ => $.settings.lookAndFeel.windowControls)}

+
+
+
+ {t($ => $.settings.lookAndFeel.windowControlStyle)} + {t($ => $.settings.lookAndFeel.windowControlsDescription)} +
+ +
+
+

{t($ => $.settings.lookAndFeel.appTheme)}

diff --git a/src/components/WindowControls.tsx b/src/components/WindowControls.tsx index 5e25fdb..6a0dda4 100644 --- a/src/components/WindowControls.tsx +++ b/src/components/WindowControls.tsx @@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { Maximize2, Minus, X } from 'lucide-react'; import type { PointerEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import type { ResolvedWindowControlStyle } from '../utils/windowControlStyle'; const appWindow = getCurrentWindow(); @@ -11,14 +12,15 @@ const stopTitlebarDrag = (event: PointerEvent) => { interface WindowControlsProps { side: 'left' | 'right'; + controlStyle: ResolvedWindowControlStyle; } -export function WindowControls({ side }: WindowControlsProps) { +export function WindowControls({ side, controlStyle }: WindowControlsProps) { const { t } = useTranslation(); return (
$.window.controls)} >