mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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!({
|
||||
|
||||
+7
-1
@@ -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 && (
|
||||
<WindowControls side={isSidebarOnRight ? 'right' : 'left'} />
|
||||
<WindowControls
|
||||
side={isSidebarOnRight ? 'right' : 'left'}
|
||||
controlStyle={windowControlStyle}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
|
||||
|
||||
@@ -8,5 +8,6 @@ import type { SchedulerSettings } from "./SchedulerSettings";
|
||||
import type { SettingsTab } from "./SettingsTab";
|
||||
import type { SiteLogin } from "./SiteLogin";
|
||||
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, };
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
</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>
|
||||
<div className="mac-settings-group">
|
||||
<div className="mac-settings-row settings-choice-row">
|
||||
|
||||
@@ -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<HTMLButtonElement>) => {
|
||||
|
||||
interface WindowControlsProps {
|
||||
side: 'left' | 'right';
|
||||
controlStyle: ResolvedWindowControlStyle;
|
||||
}
|
||||
|
||||
export function WindowControls({ side }: WindowControlsProps) {
|
||||
export function WindowControls({ side, controlStyle }: WindowControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`window-controls window-controls--${side}`}
|
||||
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
|
||||
aria-label={t($ => $.window.controls)}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -586,6 +586,14 @@ const common = {
|
||||
sidebarPositionAutomatic: 'Automatic (recommended)',
|
||||
sidebarPositionLeft: 'Left',
|
||||
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',
|
||||
languageEnglish: 'English',
|
||||
languageChinese: 'Simplified Chinese',
|
||||
|
||||
@@ -586,6 +586,14 @@ const fa = {
|
||||
sidebarPositionAutomatic: 'خودکار (پیشنهادشده)',
|
||||
sidebarPositionLeft: 'چپ',
|
||||
sidebarPositionRight: 'راست',
|
||||
windowControls: 'کنترلهای پنجره',
|
||||
windowControlStyle: 'سبک کنترلها',
|
||||
windowControlsDescription: 'نحوه نمایش دکمههای سفارشی پنجره Firelink را انتخاب کنید. حالت خودکار از سیستمعامل شما پیروی میکند.',
|
||||
windowControlStyleAutomatic: 'خودکار (پیشنهادشده)',
|
||||
windowControlStyleMacos: 'چراغهای macOS',
|
||||
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||
windowControlStyleMinimal: 'ساده',
|
||||
languageSystem: 'زبان سیستم',
|
||||
languageEnglish: 'انگلیسی',
|
||||
languageChinese: 'چینی سادهشده',
|
||||
|
||||
@@ -586,6 +586,14 @@ const he = {
|
||||
sidebarPositionAutomatic: 'אוטומטי (מומלץ)',
|
||||
sidebarPositionLeft: 'שמאל',
|
||||
sidebarPositionRight: 'ימין',
|
||||
windowControls: 'פקדי חלון',
|
||||
windowControlStyle: 'סגנון פקדים',
|
||||
windowControlsDescription: 'בחר כיצד Firelink מצייר את כפתורי החלון המותאמים אישית. מצב אוטומטי עוקב אחר מערכת ההפעלה שלך.',
|
||||
windowControlStyleAutomatic: 'אוטומטי (מומלץ)',
|
||||
windowControlStyleMacos: 'רמזורי macOS',
|
||||
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||
windowControlStyleMinimal: 'מינימלי',
|
||||
languageSystem: 'שפת המערכת',
|
||||
languageEnglish: 'אנגלית',
|
||||
languageChinese: 'סינית מפושטת',
|
||||
|
||||
@@ -586,6 +586,14 @@ const ru = {
|
||||
sidebarPositionAutomatic: 'Автоматически (рекомендуется)',
|
||||
sidebarPositionLeft: 'Слева',
|
||||
sidebarPositionRight: 'Справа',
|
||||
windowControls: 'Элементы управления окном',
|
||||
windowControlStyle: 'Стиль элементов управления',
|
||||
windowControlsDescription: 'Выберите, как Firelink отображает пользовательские кнопки окна. Автоматический режим учитывает вашу ОС.',
|
||||
windowControlStyleAutomatic: 'Автоматически (рекомендуется)',
|
||||
windowControlStyleMacos: 'Светофоры macOS',
|
||||
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||
windowControlStyleMinimal: 'Минималистичный',
|
||||
languageSystem: 'Системный по умолчанию',
|
||||
languageEnglish: 'Английский',
|
||||
languageChinese: 'Упрощённый китайский',
|
||||
|
||||
@@ -586,6 +586,14 @@ const uk = {
|
||||
sidebarPositionAutomatic: 'Автоматично (рекомендовано)',
|
||||
sidebarPositionLeft: 'Ліворуч',
|
||||
sidebarPositionRight: 'Праворуч',
|
||||
windowControls: 'Керування вікном',
|
||||
windowControlStyle: 'Стиль елементів керування',
|
||||
windowControlsDescription: 'Виберіть, як Firelink відображає власні кнопки вікна. Автоматичний режим враховує вашу ОС.',
|
||||
windowControlStyleAutomatic: 'Автоматично (рекомендовано)',
|
||||
windowControlStyleMacos: 'Світлофори macOS',
|
||||
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||
windowControlStyleMinimal: 'Мінімальний',
|
||||
languageSystem: 'Мова системи',
|
||||
languageEnglish: 'Англійська',
|
||||
languageChinese: 'Спрощена китайська',
|
||||
|
||||
@@ -586,6 +586,14 @@ const zhCN = {
|
||||
sidebarPositionAutomatic: '自动(推荐)',
|
||||
sidebarPositionLeft: '左侧',
|
||||
sidebarPositionRight: '右侧',
|
||||
windowControls: '窗口控件',
|
||||
windowControlStyle: '控件样式',
|
||||
windowControlsDescription: '选择 Firelink 绘制自定义窗口按钮的方式。“自动”会跟随操作系统。',
|
||||
windowControlStyleAutomatic: '自动(推荐)',
|
||||
windowControlStyleMacos: 'macOS 交通灯',
|
||||
windowControlStyleWindows: 'Windows 11 Fluent',
|
||||
windowControlStyleGnome: 'GNOME / Adwaita',
|
||||
windowControlStyleMinimal: '极简',
|
||||
languageSystem: '系统默认',
|
||||
languageEnglish: '英语',
|
||||
languageChinese: '简体中文',
|
||||
|
||||
@@ -94,6 +94,8 @@ describe('translation catalogs', () => {
|
||||
'settings.integrations.chromiumZip',
|
||||
'settings.lookAndFeel.dracula',
|
||||
'settings.lookAndFeel.nord',
|
||||
'settings.lookAndFeel.windowControlStyleWindows',
|
||||
'settings.lookAndFeel.windowControlStyleGnome',
|
||||
'settings.network.chromeWindows',
|
||||
'settings.network.chromeMacos',
|
||||
'settings.network.edgeWindows',
|
||||
|
||||
+100
@@ -1783,6 +1783,106 @@ html[data-list-density="relaxed"] {
|
||||
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 {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { SchedulerSettings } from '../bindings/SchedulerSettings';
|
||||
import type { SettingsTab } from '../bindings/SettingsTab';
|
||||
import type { SiteLogin } from '../bindings/SiteLogin';
|
||||
import type { Theme } from '../bindings/Theme';
|
||||
import type { WindowControlStyle } from '../bindings/WindowControlStyle';
|
||||
import {
|
||||
DEFAULT_CATEGORY_SUBFOLDERS,
|
||||
normalizeDownloadLocationSettings
|
||||
@@ -73,6 +74,7 @@ const notifySettingsPersistenceError = () => {
|
||||
};
|
||||
|
||||
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 LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const;
|
||||
const SIDEBAR_POSITION_VALUES = ['auto', 'left', 'right'] as const;
|
||||
@@ -168,13 +170,15 @@ export type {
|
||||
SchedulerSettings,
|
||||
SettingsTab,
|
||||
SiteLogin,
|
||||
Theme
|
||||
Theme,
|
||||
WindowControlStyle
|
||||
};
|
||||
|
||||
export type SidebarPosition = 'auto' | 'left' | 'right';
|
||||
|
||||
export interface SettingsState {
|
||||
theme: Theme;
|
||||
windowControlStyle: WindowControlStyle;
|
||||
calendarPreference: CalendarPreference;
|
||||
language: AppLocalePreference;
|
||||
baseDownloadFolder: string;
|
||||
@@ -231,6 +235,7 @@ export interface SettingsState {
|
||||
showKeychainModal: boolean;
|
||||
|
||||
setTheme: (theme: Theme) => void;
|
||||
setWindowControlStyle: (style: WindowControlStyle) => void;
|
||||
setCalendarPreference: (calendarPreference: CalendarPreference) => void;
|
||||
setLanguage: (language: AppLocalePreference) => void;
|
||||
setBaseDownloadFolder: (path: string) => void;
|
||||
@@ -288,6 +293,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: 'system',
|
||||
windowControlStyle: 'auto',
|
||||
calendarPreference: DEFAULT_CALENDAR_PREFERENCE,
|
||||
language: 'system',
|
||||
baseDownloadFolder: '~/Downloads',
|
||||
@@ -351,6 +357,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
showKeychainModal: false,
|
||||
|
||||
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
||||
setWindowControlStyle: (windowControlStyle) => {
|
||||
info('Settings updated: windowControlStyle');
|
||||
set({ windowControlStyle });
|
||||
},
|
||||
setCalendarPreference: (calendarPreference) => {
|
||||
info('Settings updated: calendarPreference');
|
||||
set({ calendarPreference });
|
||||
@@ -561,6 +571,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
partialize: (state): PersistedSettingsSnapshot => ({
|
||||
theme: state.theme,
|
||||
windowControlStyle: state.windowControlStyle,
|
||||
calendarPreference: state.calendarPreference,
|
||||
language: state.language,
|
||||
baseDownloadFolder: state.baseDownloadFolder,
|
||||
@@ -622,6 +633,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
||||
? persisted.theme
|
||||
: currentState.theme,
|
||||
windowControlStyle: isAllowedSetting(WINDOW_CONTROL_STYLE_VALUES, persisted.windowControlStyle)
|
||||
? persisted.windowControlStyle
|
||||
: currentState.windowControlStyle,
|
||||
calendarPreference: isCalendarPreference(persisted.calendarPreference)
|
||||
? persisted.calendarPreference
|
||||
: currentState.calendarPreference,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
};
|
||||
Reference in New Issue
Block a user