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
This commit is contained in:
NimBold
2026-07-23 16:25:39 +03:30
parent 6adbccdf43
commit 4e3ceb21a5
18 changed files with 294 additions and 6 deletions
+19
View File
@@ -214,6 +214,23 @@ pub enum Theme {
Nord, 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)] #[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")] #[ts(export, export_to = "../../src/bindings/")]
@@ -295,6 +312,8 @@ pub struct SchedulerSettings {
pub struct PersistedSettings { pub struct PersistedSettings {
pub theme: Theme, pub theme: Theme,
#[serde(default)] #[serde(default)]
pub window_control_style: WindowControlStyle,
#[serde(default)]
pub calendar_preference: CalendarPreference, pub calendar_preference: CalendarPreference,
#[serde(default = "default_language_preference")] #[serde(default = "default_language_preference")]
pub language: String, pub language: String,
+23 -1
View File
@@ -1,6 +1,6 @@
use crate::ipc::{ use crate::ipc::{
AppFontSize, CalendarPreference, ListRowDensity, MediaCookieSource, PersistedSettings, AppFontSize, CalendarPreference, ListRowDensity, MediaCookieSource, PersistedSettings,
PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme, PostQueueAction, ProxyMode, SchedulerSettings, SettingsTab, Theme, WindowControlStyle,
}; };
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::collections::HashMap; use std::collections::HashMap;
@@ -192,6 +192,11 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
"theme", "theme",
&["system", "light", "dark", "dracula", "nord"], &["system", "light", "dark", "dracula", "nord"],
); );
sanitize_allowed_string(
state,
"windowControlStyle",
&["auto", "macos", "windows", "gnome", "minimal"],
);
sanitize_allowed_string( sanitize_allowed_string(
state, state,
"language", "language",
@@ -417,6 +422,7 @@ fn derived_location_path(base: &str, subfolder: &str) -> String {
fn default_settings() -> PersistedSettings { fn default_settings() -> PersistedSettings {
PersistedSettings { PersistedSettings {
theme: Theme::System, theme: Theme::System,
window_control_style: WindowControlStyle::Auto,
calendar_preference: CalendarPreference::Gregorian, calendar_preference: CalendarPreference::Gregorian,
language: "system".to_string(), language: "system".to_string(),
base_download_folder: "~/Downloads".to_string(), base_download_folder: "~/Downloads".to_string(),
@@ -472,6 +478,7 @@ fn default_settings() -> PersistedSettings {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::ipc::WindowControlStyle;
use super::{ use super::{
decode_stored_settings, default_settings, preserve_portable_pairing_token, decode_stored_settings, default_settings, preserve_portable_pairing_token,
preserve_scheduler_runtime_keys, preserve_scheduler_runtime_keys,
@@ -678,6 +685,21 @@ mod tests {
assert_eq!(settings.sidebar_position, "auto"); 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] #[test]
fn clamps_out_of_range_download_settings() { fn clamps_out_of_range_download_settings() {
let stored = json!({ let stored = json!({
+7 -1
View File
@@ -20,6 +20,7 @@ import { setLogStreamActive } from './utils/logger';
import { updateDockBadge } from './utils/dockBadge'; import { updateDockBadge } from './utils/dockBadge';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform'; import { getPlatformInfo, shouldUseCustomWindowControls, usePlatformInfo } from './utils/platform';
import { resolveWindowControlStyle } from './utils/windowControlStyle';
import { import {
getKeychainAccessReady, getKeychainAccessReady,
getKeychainConsentVersion, getKeychainConsentVersion,
@@ -187,6 +188,7 @@ function App() {
}); });
const theme = useSettingsStore(state => state.theme); const theme = useSettingsStore(state => state.theme);
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
const languagePreference = useSettingsStore(state => state.language); const languagePreference = useSettingsStore(state => state.language);
const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible); const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible);
const sidebarPosition = useSettingsStore(state => state.sidebarPosition); const sidebarPosition = useSettingsStore(state => state.sidebarPosition);
@@ -246,6 +248,7 @@ function App() {
const { addToast, removeToast } = useToast(); const { addToast, removeToast } = useToast();
const isMacUserAgent = navigator.userAgent.includes('Mac'); const isMacUserAgent = navigator.userAgent.includes('Mac');
const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent); const usesCustomWindowControls = shouldUseCustomWindowControls(platform.os, navigator.userAgent);
const windowControlStyle = resolveWindowControlStyle(windowControlStylePreference, platform.os, navigator.userAgent);
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl'; const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl); const isSidebarOnRight = sidebarPosition === 'right' || (sidebarPosition === 'auto' && isRtl);
// Keep dialogs out of the titlebar area while platform detection is still // Keep dialogs out of the titlebar area while platform detection is still
@@ -1021,7 +1024,10 @@ function App() {
hasWindowChrome ? 'app-shell--window-chrome' : '' hasWindowChrome ? 'app-shell--window-chrome' : ''
}`}> }`}>
{usesCustomWindowControls && ( {usesCustomWindowControls && (
<WindowControls side={isSidebarOnRight ? 'right' : 'left'} /> <WindowControls
side={isSidebarOnRight ? 'right' : 'left'}
controlStyle={windowControlStyle}
/>
)} )}
<div <div
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${ className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
+2 -1
View File
@@ -8,5 +8,6 @@ import type { SchedulerSettings } from "./SchedulerSettings";
import type { SettingsTab } from "./SettingsTab"; import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin"; import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme"; import type { Theme } from "./Theme";
import type { WindowControlStyle } from "./WindowControlStyle";
export type PersistedSettings = { theme: Theme, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, 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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+3
View File
@@ -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";
+27
View File
@@ -3,6 +3,7 @@ import {
type AppFontSize, type AppFontSize,
type ListRowDensity, type ListRowDensity,
type SidebarPosition, type SidebarPosition,
type WindowControlStyle,
type SettingsState, type SettingsState,
SettingsTab, SettingsTab,
runSettingsPersistenceTransaction, runSettingsPersistenceTransaction,
@@ -698,6 +699,13 @@ runEngineChecks(false);
{ value: 'persian', label: t($ => $.settings.lookAndFeel.calendarPersian) }, { value: 'persian', label: t($ => $.settings.lookAndFeel.calendarPersian) },
{ value: 'hebrew', label: t($ => $.settings.lookAndFeel.calendarHebrew) }, { value: 'hebrew', label: t($ => $.settings.lookAndFeel.calendarHebrew) },
] as const; ] 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 TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => {
const active = activeTab === type; const active = activeTab === type;
@@ -907,6 +915,25 @@ runEngineChecks(false);
</div> </div>
</div> </div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.windowControls)}</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>{t($ => $.settings.lookAndFeel.windowControlStyle)}</span>
<small>{t($ => $.settings.lookAndFeel.windowControlsDescription)}</small>
</div>
<select
value={settings.windowControlStyle}
onChange={(event) => settings.setWindowControlStyle(event.target.value as WindowControlStyle)}
className="app-control w-48"
>
{windowControlStyleOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.appTheme)}</h2> <h2 className="settings-section-title">{t($ => $.settings.lookAndFeel.appTheme)}</h2>
<div className="mac-settings-group"> <div className="mac-settings-group">
<div className="mac-settings-row settings-choice-row"> <div className="mac-settings-row settings-choice-row">
+4 -2
View File
@@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { Maximize2, Minus, X } from 'lucide-react'; import { Maximize2, Minus, X } from 'lucide-react';
import type { PointerEvent } from 'react'; import type { PointerEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { ResolvedWindowControlStyle } from '../utils/windowControlStyle';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
@@ -11,14 +12,15 @@ const stopTitlebarDrag = (event: PointerEvent<HTMLButtonElement>) => {
interface WindowControlsProps { interface WindowControlsProps {
side: 'left' | 'right'; side: 'left' | 'right';
controlStyle: ResolvedWindowControlStyle;
} }
export function WindowControls({ side }: WindowControlsProps) { export function WindowControls({ side, controlStyle }: WindowControlsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div <div
className={`window-controls window-controls--${side}`} className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
aria-label={t($ => $.window.controls)} aria-label={t($ => $.window.controls)}
> >
<button <button
+8
View File
@@ -586,6 +586,14 @@ const common = {
sidebarPositionAutomatic: 'Automatic (recommended)', sidebarPositionAutomatic: 'Automatic (recommended)',
sidebarPositionLeft: 'Left', sidebarPositionLeft: 'Left',
sidebarPositionRight: 'Right', sidebarPositionRight: 'Right',
windowControls: 'Window controls',
windowControlStyle: 'Control style',
windowControlsDescription: 'Choose how Firelink draws its custom window buttons. Automatic follows your operating system.',
windowControlStyleAutomatic: 'Automatic (recommended)',
windowControlStyleMacos: 'macOS Traffic Lights',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'Minimal',
languageSystem: 'System default', languageSystem: 'System default',
languageEnglish: 'English', languageEnglish: 'English',
languageChinese: 'Simplified Chinese', languageChinese: 'Simplified Chinese',
+8
View File
@@ -586,6 +586,14 @@ const fa = {
sidebarPositionAutomatic: 'خودکار (پیشنهادشده)', sidebarPositionAutomatic: 'خودکار (پیشنهادشده)',
sidebarPositionLeft: 'چپ', sidebarPositionLeft: 'چپ',
sidebarPositionRight: 'راست', sidebarPositionRight: 'راست',
windowControls: 'کنترل‌های پنجره',
windowControlStyle: 'سبک کنترل‌ها',
windowControlsDescription: 'نحوه نمایش دکمه‌های سفارشی پنجره Firelink را انتخاب کنید. حالت خودکار از سیستم‌عامل شما پیروی می‌کند.',
windowControlStyleAutomatic: 'خودکار (پیشنهادشده)',
windowControlStyleMacos: 'چراغ‌های macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'ساده',
languageSystem: 'زبان سیستم', languageSystem: 'زبان سیستم',
languageEnglish: 'انگلیسی', languageEnglish: 'انگلیسی',
languageChinese: 'چینی ساده‌شده', languageChinese: 'چینی ساده‌شده',
+8
View File
@@ -586,6 +586,14 @@ const he = {
sidebarPositionAutomatic: 'אוטומטי (מומלץ)', sidebarPositionAutomatic: 'אוטומטי (מומלץ)',
sidebarPositionLeft: 'שמאל', sidebarPositionLeft: 'שמאל',
sidebarPositionRight: 'ימין', sidebarPositionRight: 'ימין',
windowControls: 'פקדי חלון',
windowControlStyle: 'סגנון פקדים',
windowControlsDescription: 'בחר כיצד Firelink מצייר את כפתורי החלון המותאמים אישית. מצב אוטומטי עוקב אחר מערכת ההפעלה שלך.',
windowControlStyleAutomatic: 'אוטומטי (מומלץ)',
windowControlStyleMacos: 'רמזורי macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'מינימלי',
languageSystem: 'שפת המערכת', languageSystem: 'שפת המערכת',
languageEnglish: 'אנגלית', languageEnglish: 'אנגלית',
languageChinese: 'סינית מפושטת', languageChinese: 'סינית מפושטת',
+8
View File
@@ -586,6 +586,14 @@ const ru = {
sidebarPositionAutomatic: 'Автоматически (рекомендуется)', sidebarPositionAutomatic: 'Автоматически (рекомендуется)',
sidebarPositionLeft: 'Слева', sidebarPositionLeft: 'Слева',
sidebarPositionRight: 'Справа', sidebarPositionRight: 'Справа',
windowControls: 'Элементы управления окном',
windowControlStyle: 'Стиль элементов управления',
windowControlsDescription: 'Выберите, как Firelink отображает пользовательские кнопки окна. Автоматический режим учитывает вашу ОС.',
windowControlStyleAutomatic: 'Автоматически (рекомендуется)',
windowControlStyleMacos: 'Светофоры macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'Минималистичный',
languageSystem: 'Системный по умолчанию', languageSystem: 'Системный по умолчанию',
languageEnglish: 'Английский', languageEnglish: 'Английский',
languageChinese: 'Упрощённый китайский', languageChinese: 'Упрощённый китайский',
+8
View File
@@ -586,6 +586,14 @@ const uk = {
sidebarPositionAutomatic: 'Автоматично (рекомендовано)', sidebarPositionAutomatic: 'Автоматично (рекомендовано)',
sidebarPositionLeft: 'Ліворуч', sidebarPositionLeft: 'Ліворуч',
sidebarPositionRight: 'Праворуч', sidebarPositionRight: 'Праворуч',
windowControls: 'Керування вікном',
windowControlStyle: 'Стиль елементів керування',
windowControlsDescription: 'Виберіть, як Firelink відображає власні кнопки вікна. Автоматичний режим враховує вашу ОС.',
windowControlStyleAutomatic: 'Автоматично (рекомендовано)',
windowControlStyleMacos: 'Світлофори macOS',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: 'Мінімальний',
languageSystem: 'Мова системи', languageSystem: 'Мова системи',
languageEnglish: 'Англійська', languageEnglish: 'Англійська',
languageChinese: 'Спрощена китайська', languageChinese: 'Спрощена китайська',
+8
View File
@@ -586,6 +586,14 @@ const zhCN = {
sidebarPositionAutomatic: '自动(推荐)', sidebarPositionAutomatic: '自动(推荐)',
sidebarPositionLeft: '左侧', sidebarPositionLeft: '左侧',
sidebarPositionRight: '右侧', sidebarPositionRight: '右侧',
windowControls: '窗口控件',
windowControlStyle: '控件样式',
windowControlsDescription: '选择 Firelink 绘制自定义窗口按钮的方式。“自动”会跟随操作系统。',
windowControlStyleAutomatic: '自动(推荐)',
windowControlStyleMacos: 'macOS 交通灯',
windowControlStyleWindows: 'Windows 11 Fluent',
windowControlStyleGnome: 'GNOME / Adwaita',
windowControlStyleMinimal: '极简',
languageSystem: '系统默认', languageSystem: '系统默认',
languageEnglish: '英语', languageEnglish: '英语',
languageChinese: '简体中文', languageChinese: '简体中文',
+2
View File
@@ -94,6 +94,8 @@ describe('translation catalogs', () => {
'settings.integrations.chromiumZip', 'settings.integrations.chromiumZip',
'settings.lookAndFeel.dracula', 'settings.lookAndFeel.dracula',
'settings.lookAndFeel.nord', 'settings.lookAndFeel.nord',
'settings.lookAndFeel.windowControlStyleWindows',
'settings.lookAndFeel.windowControlStyleGnome',
'settings.network.chromeWindows', 'settings.network.chromeWindows',
'settings.network.chromeMacos', 'settings.network.chromeMacos',
'settings.network.edgeWindows', 'settings.network.edgeWindows',
+100
View File
@@ -1783,6 +1783,106 @@ html[data-list-density="relaxed"] {
filter: brightness(0.9); filter: brightness(0.9);
} }
/* Windows 11 caption-button treatment: full-width hit targets, neutral
glyphs, and the conventional minimize/maximize/close order. */
.window-controls--style-windows {
top: 10px;
gap: 0;
flex-direction: row;
}
.window-controls--style-windows .window-control {
width: 46px;
height: 32px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
color: hsl(var(--text-primary) / 0.84);
order: initial;
}
.window-controls--style-windows .window-control.minimize {
order: 1;
}
.window-controls--style-windows .window-control.maximize {
order: 2;
}
.window-controls--style-windows .window-control.close {
order: 3;
}
.window-controls--style-windows .window-control:hover {
color: hsl(var(--text-primary));
background: hsl(var(--item-hover));
filter: none;
}
.window-controls--style-windows .window-control.close:hover {
color: white;
background: #c42b1c;
}
.window-controls--style-windows .window-control:active {
transform: none;
filter: brightness(0.9);
}
/* GNOME/Adwaita header-bar treatment: compact, borderless icon buttons. */
.window-controls--style-gnome {
top: 10px;
gap: 4px;
}
.window-controls--style-gnome .window-control,
.window-controls--style-minimal .window-control {
border: 0;
background: transparent;
box-shadow: none;
color: hsl(var(--text-primary) / 0.84);
}
.window-controls--style-gnome .window-control {
width: 32px;
height: 32px;
border-radius: 6px;
}
.window-controls--style-gnome .window-control:hover,
.window-controls--style-minimal .window-control:hover {
color: hsl(var(--text-primary));
background: hsl(var(--item-hover));
filter: none;
}
.window-controls--style-gnome .window-control.close,
.window-controls--style-gnome .window-control.minimize,
.window-controls--style-gnome .window-control.maximize,
.window-controls--style-minimal .window-control.close,
.window-controls--style-minimal .window-control.minimize,
.window-controls--style-minimal .window-control.maximize {
background: transparent;
}
/* Minimal keeps the compact footprint while removing colored circles. */
.window-controls--style-minimal {
top: 15px;
gap: 4px;
}
.window-controls--style-minimal .window-control {
width: 22px;
height: 22px;
border-radius: 6px;
}
.window-controls--style-minimal .window-control:active,
.window-controls--style-gnome .window-control:active {
filter: none;
}
.main-titlebar { .main-titlebar {
height: 52px; height: 52px;
display: flex; display: flex;
+15 -1
View File
@@ -14,6 +14,7 @@ import type { SchedulerSettings } from '../bindings/SchedulerSettings';
import type { SettingsTab } from '../bindings/SettingsTab'; import type { SettingsTab } from '../bindings/SettingsTab';
import type { SiteLogin } from '../bindings/SiteLogin'; import type { SiteLogin } from '../bindings/SiteLogin';
import type { Theme } from '../bindings/Theme'; import type { Theme } from '../bindings/Theme';
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
import { import {
DEFAULT_CATEGORY_SUBFOLDERS, DEFAULT_CATEGORY_SUBFOLDERS,
normalizeDownloadLocationSettings normalizeDownloadLocationSettings
@@ -73,6 +74,7 @@ const notifySettingsPersistenceError = () => {
}; };
const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const; const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const;
const WINDOW_CONTROL_STYLE_VALUES = ['auto', 'macos', 'windows', 'gnome', 'minimal'] as const;
const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const; const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const;
const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const; const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const;
const SIDEBAR_POSITION_VALUES = ['auto', 'left', 'right'] as const; const SIDEBAR_POSITION_VALUES = ['auto', 'left', 'right'] as const;
@@ -168,13 +170,15 @@ export type {
SchedulerSettings, SchedulerSettings,
SettingsTab, SettingsTab,
SiteLogin, SiteLogin,
Theme Theme,
WindowControlStyle
}; };
export type SidebarPosition = 'auto' | 'left' | 'right'; export type SidebarPosition = 'auto' | 'left' | 'right';
export interface SettingsState { export interface SettingsState {
theme: Theme; theme: Theme;
windowControlStyle: WindowControlStyle;
calendarPreference: CalendarPreference; calendarPreference: CalendarPreference;
language: AppLocalePreference; language: AppLocalePreference;
baseDownloadFolder: string; baseDownloadFolder: string;
@@ -231,6 +235,7 @@ export interface SettingsState {
showKeychainModal: boolean; showKeychainModal: boolean;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
setWindowControlStyle: (style: WindowControlStyle) => void;
setCalendarPreference: (calendarPreference: CalendarPreference) => void; setCalendarPreference: (calendarPreference: CalendarPreference) => void;
setLanguage: (language: AppLocalePreference) => void; setLanguage: (language: AppLocalePreference) => void;
setBaseDownloadFolder: (path: string) => void; setBaseDownloadFolder: (path: string) => void;
@@ -288,6 +293,7 @@ export const useSettingsStore = create<SettingsState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
theme: 'system', theme: 'system',
windowControlStyle: 'auto',
calendarPreference: DEFAULT_CALENDAR_PREFERENCE, calendarPreference: DEFAULT_CALENDAR_PREFERENCE,
language: 'system', language: 'system',
baseDownloadFolder: '~/Downloads', baseDownloadFolder: '~/Downloads',
@@ -351,6 +357,10 @@ export const useSettingsStore = create<SettingsState>()(
showKeychainModal: false, showKeychainModal: false,
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); }, setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
setWindowControlStyle: (windowControlStyle) => {
info('Settings updated: windowControlStyle');
set({ windowControlStyle });
},
setCalendarPreference: (calendarPreference) => { setCalendarPreference: (calendarPreference) => {
info('Settings updated: calendarPreference'); info('Settings updated: calendarPreference');
set({ calendarPreference }); set({ calendarPreference });
@@ -561,6 +571,7 @@ export const useSettingsStore = create<SettingsState>()(
}, },
partialize: (state): PersistedSettingsSnapshot => ({ partialize: (state): PersistedSettingsSnapshot => ({
theme: state.theme, theme: state.theme,
windowControlStyle: state.windowControlStyle,
calendarPreference: state.calendarPreference, calendarPreference: state.calendarPreference,
language: state.language, language: state.language,
baseDownloadFolder: state.baseDownloadFolder, baseDownloadFolder: state.baseDownloadFolder,
@@ -622,6 +633,9 @@ export const useSettingsStore = create<SettingsState>()(
theme: isAllowedSetting(THEME_VALUES, persisted.theme) theme: isAllowedSetting(THEME_VALUES, persisted.theme)
? persisted.theme ? persisted.theme
: currentState.theme, : currentState.theme,
windowControlStyle: isAllowedSetting(WINDOW_CONTROL_STYLE_VALUES, persisted.windowControlStyle)
? persisted.windowControlStyle
: currentState.windowControlStyle,
calendarPreference: isCalendarPreference(persisted.calendarPreference) calendarPreference: isCalendarPreference(persisted.calendarPreference)
? persisted.calendarPreference ? persisted.calendarPreference
: currentState.calendarPreference, : currentState.calendarPreference,
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { resolveWindowControlStyle } from './windowControlStyle';
describe('resolveWindowControlStyle', () => {
it('uses the platform convention for automatic style', () => {
expect(resolveWindowControlStyle('auto', 'macos')).toBe('macos');
expect(resolveWindowControlStyle('auto', 'windows')).toBe('windows');
expect(resolveWindowControlStyle('auto', 'linux')).toBe('gnome');
});
it('falls back to macOS styling while an unsupported platform is unresolved', () => {
expect(resolveWindowControlStyle('auto', 'unknown')).toBe('macos');
expect(resolveWindowControlStyle('auto', 'android')).toBe('macos');
});
it('uses the desktop user agent while native platform detection is unresolved', () => {
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (X11; Linux x86_64)')).toBe('gnome');
expect(resolveWindowControlStyle('auto', 'unknown', 'Mozilla/5.0 (Linux; Android 14)')).toBe('macos');
});
it('preserves an explicit style across platforms', () => {
expect(resolveWindowControlStyle('macos', 'windows')).toBe('macos');
expect(resolveWindowControlStyle('windows', 'linux')).toBe('windows');
expect(resolveWindowControlStyle('gnome', 'macos')).toBe('gnome');
expect(resolveWindowControlStyle('minimal', 'windows')).toBe('minimal');
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
export type ResolvedWindowControlStyle = Exclude<WindowControlStyle, 'auto'>;
export const resolveWindowControlStyle = (
style: WindowControlStyle,
os: string,
userAgent = ''
): ResolvedWindowControlStyle => {
if (style !== 'auto') return style;
if (os === 'windows') return 'windows';
if (os === 'linux') return 'gnome';
if (os === 'unknown' && /Windows/i.test(userAgent)) return 'windows';
if (os === 'unknown' && /Linux/i.test(userAgent) && !/Android/i.test(userAgent)) return 'gnome';
return 'macos';
};