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,
|
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,
|
||||||
|
|||||||
@@ -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
@@ -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)] ${
|
||||||
|
|||||||
@@ -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, };
|
||||||
|
|||||||
@@ -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 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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: 'چینی سادهشده',
|
||||||
|
|||||||
@@ -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: 'סינית מפושטת',
|
||||||
|
|||||||
@@ -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: 'Упрощённый китайский',
|
||||||
|
|||||||
@@ -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: 'Спрощена китайська',
|
||||||
|
|||||||
@@ -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: '简体中文',
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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