From 3a8516334a448ec863c678b54c1fc5902568d4b1 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 4 May 2026 02:31:25 +0800 Subject: [PATCH 01/29] Add Windows system chrome patchers (Harmony) Introduce support for toggling the system chrome on Windows using Harmony patchers. Adds Lib.Harmony.Thin to package props and project, new patcher infrastructure (ChromePatchState, PatcherEntrance) and two Harmony patches that disable FluentAvalonia's Windows chrome when configured. Program.cs now loads the chrome setting and installs patchers conditionally on Windows/x86-x64. Settings viewmodel and view updated: expose IsWindowsOs, require restart on appearance changes, migrate SettingsWindow to FAAppWindow and adapt titlebar/layout (include Windows caption placeholder and footer menu items). Also add a .gitkeep and a build log file. --- .cursor/skills/.gitkeep | 1 + Directory.Packages.props | 1 + LanMountainDesktop/LanMountainDesktop.csproj | 1 + .../Platform/Windows/ChromePatchState.cs | 6 + .../Platform/Windows/PatcherEntrance.cs | 12 ++ .../AppWindowInitializeAppWindowPatcher.cs | 25 +++ .../Win32WindowManagerConstructorPatcher.cs | 21 +++ LanMountainDesktop/Program.cs | 45 +++++ .../ViewModels/SettingsViewModels.cs | 8 +- LanMountainDesktop/Views/SettingsWindow.axaml | 171 ++++++++++-------- .../Views/SettingsWindow.axaml.cs | 74 ++++---- _b.txt | 13 ++ 12 files changed, 266 insertions(+), 112 deletions(-) create mode 100644 .cursor/skills/.gitkeep create mode 100644 LanMountainDesktop/Platform/Windows/ChromePatchState.cs create mode 100644 LanMountainDesktop/Platform/Windows/PatcherEntrance.cs create mode 100644 LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs create mode 100644 LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs create mode 100644 _b.txt diff --git a/.cursor/skills/.gitkeep b/.cursor/skills/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.cursor/skills/.gitkeep @@ -0,0 +1 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index aa56451..f43665b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,6 +16,7 @@ + diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj index 4af574e..464bcdd 100644 --- a/LanMountainDesktop/LanMountainDesktop.csproj +++ b/LanMountainDesktop/LanMountainDesktop.csproj @@ -56,6 +56,7 @@ + diff --git a/LanMountainDesktop/Platform/Windows/ChromePatchState.cs b/LanMountainDesktop/Platform/Windows/ChromePatchState.cs new file mode 100644 index 0000000..7ee09a6 --- /dev/null +++ b/LanMountainDesktop/Platform/Windows/ChromePatchState.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.Platform.Windows; + +internal static class ChromePatchState +{ + public static bool UseSystemChrome { get; set; } +} diff --git a/LanMountainDesktop/Platform/Windows/PatcherEntrance.cs b/LanMountainDesktop/Platform/Windows/PatcherEntrance.cs new file mode 100644 index 0000000..6cecf3d --- /dev/null +++ b/LanMountainDesktop/Platform/Windows/PatcherEntrance.cs @@ -0,0 +1,12 @@ +using HarmonyLib; + +namespace LanMountainDesktop.Platform.Windows; + +internal static class PatcherEntrance +{ + public static void InstallPatchers() + { + var harmony = new Harmony("dev.lanmountain.desktop.patchers"); + harmony.PatchAll(typeof(PatcherEntrance).Assembly); + } +} diff --git a/LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs b/LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs new file mode 100644 index 0000000..0941f4e --- /dev/null +++ b/LanMountainDesktop/Platform/Windows/Patches/AppWindowInitializeAppWindowPatcher.cs @@ -0,0 +1,25 @@ +using System.Runtime.CompilerServices; +using Avalonia; +using Avalonia.Controls; +using FluentAvalonia.UI.Windowing; +using LanMountainDesktop.Platform.Windows; +using HarmonyLib; + +namespace LanMountainDesktop.Platform.Windows.Patches; + +[HarmonyPatch(typeof(FAAppWindow), "InitializeAppWindow")] +internal class AppWindowInitializeAppWindowPatcher +{ + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "get_PseudoClasses")] + private static extern IPseudoClasses GetPseudoClasses(StyledElement window); + + [UnsafeAccessor(UnsafeAccessorKind.Method, Name = "set_IsWindows")] + private static extern void SetIsWindowsProperty(FAAppWindow window, bool v); + + static void Postfix(FAAppWindow __instance) + { + if (!ChromePatchState.UseSystemChrome) return; + GetPseudoClasses(__instance).Remove(":windows"); + SetIsWindowsProperty(__instance, false); + } +} diff --git a/LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs b/LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs new file mode 100644 index 0000000..e67b3f8 --- /dev/null +++ b/LanMountainDesktop/Platform/Windows/Patches/Win32WindowManagerConstructorPatcher.cs @@ -0,0 +1,21 @@ +using FluentAvalonia.UI.Windowing; +using HarmonyLib; +using LanMountainDesktop.Platform.Windows; + +namespace LanMountainDesktop.Platform.Windows.Patches; + +[HarmonyPatch] +internal class Win32WindowManagerConstructorPatcher +{ + [HarmonyTargetMethod] + static System.Reflection.MethodBase TargetMethod() + { + var type = AccessTools.TypeByName("FluentAvalonia.UI.Windowing.Win32WindowManager"); + return AccessTools.Constructor(type!, [typeof(FAAppWindow)]); + } + + static bool Prefix(FAAppWindow window) + { + return !ChromePatchState.UseSystemChrome; + } +} diff --git a/LanMountainDesktop/Program.cs b/LanMountainDesktop/Program.cs index 1e1a50d..f911de9 100644 --- a/LanMountainDesktop/Program.cs +++ b/LanMountainDesktop/Program.cs @@ -87,6 +87,8 @@ public sealed class Program AppLogger.Info("SingleInstance", "Activation acknowledged before Avalonia App was ready."); }); + LoadChromePatchState(); + InstallChromePatchersIfNeeded(); BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args); AppLogger.Info("Startup", "Application exited normally."); } @@ -198,6 +200,49 @@ public sealed class Program } } + private static void LoadChromePatchState() + { + try + { + var snapshot = HostSettingsFacadeProvider.GetOrCreate() + .Settings + .LoadSnapshot(LanMountainDesktop.PluginSdk.SettingsScope.App); + if (OperatingSystem.IsWindows()) + { + LanMountainDesktop.Platform.Windows.ChromePatchState.UseSystemChrome = snapshot.UseSystemChrome; + } + } + catch (Exception ex) + { + AppLogger.Warn("Startup", "Failed to load chrome patch state. Falling back to FA chrome.", ex); + } + } + + private static void InstallChromePatchersIfNeeded() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + var arch = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture; + if (arch != System.Runtime.InteropServices.Architecture.X64 && + arch != System.Runtime.InteropServices.Architecture.X86) + { + return; + } + + try + { + LanMountainDesktop.Platform.Windows.PatcherEntrance.InstallPatchers(); + AppLogger.Info("Startup", $"Chrome patchers installed. UseSystemChrome={LanMountainDesktop.Platform.Windows.ChromePatchState.UseSystemChrome}."); + } + catch (Exception ex) + { + AppLogger.Warn("Startup", "Failed to install chrome patchers.", ex); + } + } + private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc) { try diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index e09021f..ff8255a 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -31,12 +31,14 @@ public sealed partial class SettingsWindowViewModel : ViewModelBase { _localizationService = new(); _languageCode = "zh-CN"; + IsWindowsOs = OperatingSystem.IsWindows(); } public SettingsWindowViewModel(LocalizationService localizationService, string languageCode) { _localizationService = localizationService; _languageCode = languageCode; + IsWindowsOs = OperatingSystem.IsWindows(); } private string L(string key) => _localizationService.GetString(_languageCode, key, key); @@ -86,6 +88,10 @@ public sealed partial class SettingsWindowViewModel : ViewModelBase [ObservableProperty] private bool _isDrawerOpen; + /// 用于标题栏右侧系统按钮占位(与 SecRandom / ClassIsland 一致,仅 Windows 显示)。 + [ObservableProperty] + private bool _isWindowsOs; + public SettingsWindowViewModel Initialize() { RefreshLanguage(_languageCode); @@ -855,7 +861,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase return; } - PersistCurrentState(restartRequired: false); + PersistCurrentState(restartRequired: true); } partial void OnSelectedCornerRadiusStyleChanged(SelectionOption? value) diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml b/LanMountainDesktop/Views/SettingsWindow.axaml index d95e0b2..9ca9764 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml +++ b/LanMountainDesktop/Views/SettingsWindow.axaml @@ -1,26 +1,26 @@ - + - + 960 - + - + - + + - - - - - - + + + + + + - + PointerPressed="OnTitleBarDragZonePointerPressed" /> - + + + + + + + + + @@ -182,4 +195,4 @@ - + diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs index 0608187..83f1e66 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs @@ -5,9 +5,10 @@ using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Input; -using Avalonia.Platform; +using Avalonia.Media; using Avalonia.Threading; using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Windowing; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; @@ -16,7 +17,7 @@ using Symbol = FluentIcons.Common.Symbol; namespace LanMountainDesktop.Views; -public partial class SettingsWindow : Window, ISettingsPageHostContext +public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext { private const double BaseSettingsContainerWidth = 960d; private const double MinSettingsContentWidth = 320d; @@ -56,7 +57,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext _hostApplicationLifecycle = hostApplicationLifecycle; DataContext = ViewModel; InitializeComponent(); - Icon = _appLogoService.CreateWindowIcon(); + SetValue(Window.IconProperty, _appLogoService.CreateWindowIcon()); ApplyChromeMode(useSystemChrome); if (RootNavigationView is not null) @@ -75,6 +76,14 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext private void OnLoaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { + TitleBar.Height = 48; + TitleBar.ExtendsContentIntoTitleBar = true; + + // SecRandom MainWindow:标题栏按钮悬停/按下/非活动色,与系统 caption 更一致 + TitleBar.ButtonHoverBackgroundColor = Color.FromArgb(23, 0, 0, 0); + TitleBar.ButtonPressedBackgroundColor = Color.FromArgb(52, 0, 0, 0); + TitleBar.ButtonInactiveForegroundColor = Colors.Gray; + SyncPendingRestartState(); SyncTitleText(); UpdateChromeMetrics(); @@ -160,11 +169,11 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext { _useSystemChrome = useSystemChrome || OperatingSystem.IsMacOS(); + ExtendClientAreaToDecorationsHint = true; + WindowDecorations = WindowDecorations.Full; + if (_useSystemChrome) { - ExtendClientAreaToDecorationsHint = true; - WindowDecorations = WindowDecorations.Full; - if (WindowTitleBarHost is { }) { WindowTitleBarHost.IsVisible = false; @@ -172,9 +181,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext return; } - WindowDecorations = WindowDecorations.BorderOnly; - ExtendClientAreaToDecorationsHint = true; - if (WindowTitleBarHost is { }) { WindowTitleBarHost.IsVisible = true; @@ -195,21 +201,32 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext } RootNavigationView.MenuItems.Clear(); + RootNavigationView.FooterMenuItems.Clear(); + SettingsPageCategory? previousCategory = null; foreach (var page in ViewModel.Pages) { + var item = new FANavigationViewItem + { + Content = page.Title, + Tag = page.PageId, + IconSource = CreateSettingsIconSource(MapIcon(page.IconKey)) + }; + + if (page.Category == SettingsPageCategory.About || + page.Category == SettingsPageCategory.Dev) + { + RootNavigationView.FooterMenuItems.Add(item); + continue; + } + if (previousCategory is not null && previousCategory != page.Category) { RootNavigationView.MenuItems.Add(new FANavigationViewItemSeparator()); } - RootNavigationView.MenuItems.Add(new FANavigationViewItem - { - Content = page.Title, - Tag = page.PageId, - IconSource = CreateSettingsIconSource(MapIcon(page.IconKey)) - }); + RootNavigationView.MenuItems.Add(item); previousCategory = page.Category; } @@ -293,7 +310,10 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext return; } - foreach (var item in RootNavigationView.MenuItems.OfType()) + var allItems = RootNavigationView.MenuItems.OfType() + .Concat(RootNavigationView.FooterMenuItems.OfType()); + + foreach (var item in allItems) { if (string.Equals(item.Tag as string, pageId, StringComparison.OrdinalIgnoreCase)) { @@ -494,7 +514,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext TelemetryServices.Usage?.TrackSettingsWindowClosed("SettingsWindow.OnClosed", ViewModel.CurrentPageId); } - private void OnWindowTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) + private void OnTitleBarDragZonePointerPressed(object? sender, PointerPressedEventArgs e) { _ = sender; if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) @@ -518,13 +538,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext RequestResponsiveLayoutRefresh(); } - private void OnCloseWindowClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - _ = sender; - _ = e; - Close(); - } - private void OnRootNavigationViewPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { _ = sender; @@ -573,6 +586,10 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext { return; } + + TogglePaneButtonIcon.Icon = RootNavigationView.IsPaneOpen + ? FluentIcons.Common.Icon.LineHorizontal3 + : FluentIcons.Common.Icon.Navigation; } private void UpdateChromeMetrics() @@ -594,8 +611,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext RestartNowButton is null || RestartButtonIcon is null || RestartButtonTextBlock is null || - CloseWindowButton is null || - CloseWindowButtonIcon is null || DrawerTitleTextBlock is null || RootNavigationView is null) { @@ -606,7 +621,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext var height = Bounds.Height > 1 ? Bounds.Height : Math.Max(Height, MinHeight); var layoutScale = Math.Clamp(Math.Min(width / 1120d, height / 760d), 0.90, 1.18); - var titleBarHeight = Math.Clamp(48d * layoutScale, 44d, 58d); + const double titleBarHeight = 48d; var titleBarButtonWidth = Math.Clamp(40d * layoutScale, 36d, 48d); var titleBarButtonHeight = Math.Clamp(32d * layoutScale, 30d, 38d); var titleFontSize = Math.Clamp(12d * layoutScale, 11d, 14d); @@ -618,7 +633,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext ExtendClientAreaTitleBarHeightHint = titleBarHeight; WindowTitleBarHost.Height = titleBarHeight; - WindowTitleBarHost.Padding = new Thickness(chromePadding, 0, chromePadding, 0); TogglePaneButton.Width = titleBarButtonWidth; TogglePaneButton.Height = titleBarButtonHeight; @@ -636,10 +650,6 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext RestartButtonIcon.FontSize = titleBarIconSize; RestartButtonTextBlock.FontSize = titleFontSize; - CloseWindowButton.Width = titleBarButtonWidth; - CloseWindowButton.Height = titleBarButtonHeight; - CloseWindowButtonIcon.FontSize = titleBarIconSize; - DrawerTitleTextBlock.FontSize = drawerTitleFontSize; } diff --git a/_b.txt b/_b.txt new file mode 100644 index 0000000..9ecc1c2 --- /dev/null +++ b/_b.txt @@ -0,0 +1,13 @@ + 正在确定要还原的项目… + 已还原 D:\github\LanMountainDesktop\LanMountainDesktop.PluginIsolation.Contracts\LanMountainDesktop.PluginIsolation.Contracts.csproj (用时 265 毫秒)。 + 已还原 D:\github\LanMountainDesktop\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj (用时 597 毫秒)。 + 已还原 D:\github\LanMountainDesktop\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj (用时 264 毫秒)。 +C:\Program Files\dotnet\sdk\10.0.201\NuGet.targets(196,5): error : 磁盘空间不足。 [D:\github\LanMountainDesktop\LanMountainDesktop\LanMountainDesktop.csproj] + +生成失败。 + +C:\Program Files\dotnet\sdk\10.0.201\NuGet.targets(196,5): error : 磁盘空间不足。 [D:\github\LanMountainDesktop\LanMountainDesktop\LanMountainDesktop.csproj] + 0 个警告 + 1 个错误 + +已用时间 00:00:07.94 From 6a30bc6fce1acee7a6045296551d3c28cd9b9c50 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 4 May 2026 03:19:25 +0800 Subject: [PATCH 02/29] Refactor settings window UI and theming Improve theming and layout for the Settings window and related services. - MaterialSurfaceService: add special material parameters for SettingsWindowBackground (lower alpha, no blur) and avoid hot-switching real backdrops for non-settings windows. - GlassEffectService: add AdaptiveSettingsWindowTintBrush + ResolveSettingsWindowTintAlpha to provide optional content tinting tied to system material mode. - SettingsWindowService: refactor theme application into ApplyThemeVariantAndResources, ensure settings window material is applied at show/activate times, and tidy theme/resource application flow. - SettingsWindow.axaml / .axaml.cs: restructure title bar (separate Grid.Row=0 border) and FANavigationView host, add pane-footer toggle button for :minimal layout, use dynamic corner radius resource, and update toggle/visibility/icon logic and responsive layout code. - SettingsPages: remove some IconText usages and adjust margins; use DesignCornerRadiusLg for update card corner radius. - Add NuGet.Config to set local globalPackagesFolder and ignore .nuget/packages in .gitignore. These changes aim to improve visuals, avoid backdrop overdraw, and make the settings window behavior consistent across themes and layouts. --- .gitignore | 3 + .../Services/AppearanceThemeService.cs | 23 +- .../Services/GlassEffectService.cs | 21 ++ .../Settings/SettingsWindowService.cs | 21 +- .../SettingsPages/LauncherSettingsPage.axaml | 10 +- .../SettingsPages/UpdateSettingsPage.axaml | 2 +- LanMountainDesktop/Views/SettingsWindow.axaml | 197 +++++++++--------- .../Views/SettingsWindow.axaml.cs | 46 ++-- NuGet.Config | 7 + 9 files changed, 205 insertions(+), 125 deletions(-) create mode 100644 NuGet.Config diff --git a/.gitignore b/.gitignore index a2037bb..56e5892 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ # dotenv files .env +# Local NuGet global packages (NuGet.Config globalPackagesFolder) +.nuget/packages/ + # User-specific files *.rsuser *.suo diff --git a/LanMountainDesktop/Services/AppearanceThemeService.cs b/LanMountainDesktop/Services/AppearanceThemeService.cs index 805c365..7139342 100644 --- a/LanMountainDesktop/Services/AppearanceThemeService.cs +++ b/LanMountainDesktop/Services/AppearanceThemeService.cs @@ -353,6 +353,26 @@ internal sealed class MaterialSurfaceService : IMaterialSurfaceService MaterialSurfaceRole role, bool isNightMode) { + // Settings 根层(如 RootGrid)叠在 Transparent + Mica/Acrylic 上:过高 alpha 会完全盖住系统 backdrop。 + // 保持非 None 下较低 alpha;None 仍用不透明白底等价。BlurRadius=0(由 DWM 提供模糊)。 + if (role == MaterialSurfaceRole.SettingsWindowBackground) + { + return materialMode switch + { + ThemeAppearanceValues.MaterialAcrylic => ( + 0.20, + 0.14, + isNightMode ? (byte)0x8E : (byte)0x96, + 0), + ThemeAppearanceValues.MaterialMica => ( + 0.14, + 0.08, + isNightMode ? (byte)0x9E : (byte)0xA6, + 0), + _ => (0.08, 0.05, (byte)0xFF, 0) + }; + } + var isOverlay = role is MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel; return materialMode switch { @@ -491,7 +511,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa // Avoid hot-switching real backdrops on already-visible windows. This has been // a stability hotspot when users flip theme source/material at runtime. - if (window.IsVisible) + // SettingsWindowBackground 是唯一需要材质与资源同步热切换的宿主角色;其它窗口仍保持「仅创建时」应用以降低风险。 + if (window.IsVisible && role != MaterialSurfaceRole.SettingsWindowBackground) { return; } diff --git a/LanMountainDesktop/Services/GlassEffectService.cs b/LanMountainDesktop/Services/GlassEffectService.cs index 2f071b7..9c0b2fd 100644 --- a/LanMountainDesktop/Services/GlassEffectService.cs +++ b/LanMountainDesktop/Services/GlassEffectService.cs @@ -69,6 +69,15 @@ public static class GlassEffectService resources["AdaptiveWindowBackgroundBrush"] = new SolidColorBrush(windowSurface.BackgroundColor); resources["AdaptiveWindowBorderBrush"] = new SolidColorBrush(windowSurface.BorderColor); resources["AdaptiveSettingsWindowBackgroundBrush"] = new SolidColorBrush(settingsWindowSurface.BackgroundColor); + // 可选:叠在内容区上的可读性 tint(半透明);不改变 AdaptiveSettingsWindowBackgroundBrush 的语义权重,供 P1 绑定内容层。 + var settingsTintBase = settingsWindowSurface.BackgroundColor; + var settingsTintAlpha = ResolveSettingsWindowTintAlpha(context); + resources["AdaptiveSettingsWindowTintBrush"] = new SolidColorBrush( + Color.FromArgb( + settingsTintAlpha, + settingsTintBase.R, + settingsTintBase.G, + settingsTintBase.B)); resources["AdaptiveSettingsWindowBorderBrush"] = new SolidColorBrush(settingsWindowSurface.BorderColor); resources["AdaptiveDockBackgroundBrush"] = new SolidColorBrush(dockSurface.BackgroundColor); resources["AdaptiveDockBorderBrush"] = new SolidColorBrush(dockSurface.BorderColor); @@ -100,4 +109,16 @@ public static class GlassEffectService resources["AdaptiveDesktopComponentHostOpacity"] = desktopComponentSurface.Opacity; resources["AdaptiveStatusBarComponentHostOpacity"] = statusBarComponentSurface.Opacity; } + + /// 可选内容叠层 alpha,与设置窗表面色相一致;None 为 0 避免重复染色。 + private static byte ResolveSettingsWindowTintAlpha(ThemeColorContext context) + { + var mode = ThemeAppearanceValues.NormalizeSystemMaterialMode(context.SystemMaterialMode); + return mode switch + { + ThemeAppearanceValues.MaterialAcrylic => context.IsNightMode ? (byte)0x58 : (byte)0x4C, + ThemeAppearanceValues.MaterialMica => context.IsNightMode ? (byte)0x50 : (byte)0x44, + _ => (byte)0x00 + }; + } } diff --git a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs index edc0ad1..147e4d6 100644 --- a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs +++ b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs @@ -71,7 +71,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService _window ??= CreateWindow(); var appearanceSnapshot = _appearanceThemeService.GetCurrent(); _window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome); - ApplyTheme(_window); + ApplyThemeVariantAndResources(_window); var targetPageId = request.PageId ?? _window.ViewModel.CurrentPageId; _window.ReloadPages(targetPageId); @@ -79,6 +79,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService if (!_window.IsVisible) { CenterWindow(_window, request); + _appearanceThemeService.ApplyWindowMaterial(_window, MaterialSurfaceRole.SettingsWindowBackground); _window.Show(); NotifyStateChanged(); CenterWindowLater(_window, request); @@ -90,6 +91,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService _window.WindowState = WindowState.Normal; } + _appearanceThemeService.ApplyWindowMaterial(_window, MaterialSurfaceRole.SettingsWindowBackground); _window.Activate(); } @@ -113,7 +115,6 @@ internal sealed class SettingsWindowService : ISettingsWindowService _pageRegistry, _hostApplicationLifecycle, useSystemChrome); - ApplyTheme(window); window.ShowInTaskbar = true; window.Closed += (_, _) => { @@ -285,13 +286,23 @@ internal sealed class SettingsWindowService : ISettingsWindowService }, DispatcherPriority.Background); } - private void ApplyTheme(SettingsWindow window) + private static void ApplyThemeVariantAndResources(SettingsWindow window, IAppearanceThemeService appearanceThemeService) { - var appearanceSnapshot = _appearanceThemeService.GetCurrent(); + var appearanceSnapshot = appearanceThemeService.GetCurrent(); window.RequestedThemeVariant = appearanceSnapshot.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; - _appearanceThemeService.ApplyThemeResources(window.Resources); + appearanceThemeService.ApplyThemeResources(window.Resources); + } + + private void ApplyThemeVariantAndResources(SettingsWindow window) + { + ApplyThemeVariantAndResources(window, _appearanceThemeService); + } + + private void ApplyTheme(SettingsWindow window) + { + ApplyThemeVariantAndResources(window, _appearanceThemeService); _appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.SettingsWindowBackground); } diff --git a/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml index 8394886..c0a2455 100644 --- a/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/LauncherSettingsPage.axaml @@ -1,7 +1,6 @@  - - - - diff --git a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml index e2c540a..5af4903 100644 --- a/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/UpdateSettingsPage.axaml @@ -10,7 +10,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - @@ -81,6 +164,7 @@ FontWeight="SemiBold" Margin="8,0,0,0" VerticalAlignment="Center" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" IsHitTestVisible="False" Text="{Binding Title}" /> @@ -119,80 +203,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml.cs b/LanMountainDesktop/Views/SettingsWindow.axaml.cs index 83f1e66..845b0f6 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml.cs +++ b/LanMountainDesktop/Views/SettingsWindow.axaml.cs @@ -87,7 +87,8 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext SyncPendingRestartState(); SyncTitleText(); UpdateChromeMetrics(); - UpdatePaneToggleIcon(); + UpdatePaneFooterToggleVisibility(); + UpdatePaneFooterToggleIcon(); UpdateResponsiveLayout(); RequestResponsiveLayoutRefresh(); } @@ -104,6 +105,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext CloseDrawer(); RebuildNavigationItems(); NavigateTo(pageId ?? ViewModel.Pages.FirstOrDefault()?.PageId); + UpdatePaneFooterToggleVisibility(); } public void RebuildAndNavigateToDevPage() @@ -266,6 +268,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext ViewModel.IsPageTitleVisible = !descriptor.HidePageTitle; TrySelectNavigationItem(descriptor.PageId); SyncTitleText(); + UpdatePaneFooterToggleVisibility(); UpdateResponsiveLayout(); RequestResponsiveLayoutRefresh(); if (!string.Equals(previousPageId, descriptor.PageId, StringComparison.OrdinalIgnoreCase)) @@ -523,7 +526,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext } } - private void OnTogglePaneButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + private void OnPaneFooterToggleClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { _ = sender; _ = e; @@ -533,7 +536,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext } RootNavigationView.IsPaneOpen = !RootNavigationView.IsPaneOpen; - UpdatePaneToggleIcon(); + UpdatePaneFooterToggleIcon(); UpdateResponsiveLayout(); RequestResponsiveLayoutRefresh(); } @@ -544,13 +547,33 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext if (e.Property == FANavigationView.IsPaneOpenProperty || e.Property == FANavigationView.OpenPaneLengthProperty || - e.Property == FANavigationView.PaneDisplayModeProperty) + e.Property == FANavigationView.PaneDisplayModeProperty || + e.Property == FANavigationView.IsPaneToggleButtonVisibleProperty) { - UpdatePaneToggleIcon(); + if (e.Property == FANavigationView.IsPaneToggleButtonVisibleProperty) + { + UpdatePaneFooterToggleVisibility(); + } + + UpdatePaneFooterToggleIcon(); RequestResponsiveLayoutRefresh(); } } + /// + /// 仅在 :minimal 为 false)时显示侧栏底部备胎按钮。 + /// 根 DataContext 为 ViewModel 时,对 #RootNavigationView 的绑定易失效,故用代码同步可见性。 + /// + private void UpdatePaneFooterToggleVisibility() + { + if (PaneFooterToggleButton is null || RootNavigationView is null) + { + return; + } + + PaneFooterToggleButton.IsVisible = !RootNavigationView.IsPaneToggleButtonVisible; + } + private void RequestResponsiveLayoutRefresh() { if (_isResponsiveRefreshPending) @@ -580,14 +603,14 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext : compactPaneWidth; } - private void UpdatePaneToggleIcon() + private void UpdatePaneFooterToggleIcon() { - if (TogglePaneButtonIcon is null || RootNavigationView is null) + if (PaneFooterToggleButtonIcon is null || RootNavigationView is null) { return; } - TogglePaneButtonIcon.Icon = RootNavigationView.IsPaneOpen + PaneFooterToggleButtonIcon.Icon = RootNavigationView.IsPaneOpen ? FluentIcons.Common.Icon.LineHorizontal3 : FluentIcons.Common.Icon.Navigation; } @@ -604,8 +627,6 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext } if (WindowTitleBarHost is null || - TogglePaneButton is null || - TogglePaneButtonIcon is null || WindowBrandIcon is null || WindowTitleTextBlock is null || RestartNowButton is null || @@ -622,8 +643,6 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext var layoutScale = Math.Clamp(Math.Min(width / 1120d, height / 760d), 0.90, 1.18); const double titleBarHeight = 48d; - var titleBarButtonWidth = Math.Clamp(40d * layoutScale, 36d, 48d); - var titleBarButtonHeight = Math.Clamp(32d * layoutScale, 30d, 38d); var titleFontSize = Math.Clamp(12d * layoutScale, 11d, 14d); var titleBarIconSize = Math.Clamp(16d * layoutScale, 15d, 20d); var drawerTitleFontSize = Math.Clamp(16d * layoutScale, 14d, 20d); @@ -634,9 +653,6 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext WindowTitleBarHost.Height = titleBarHeight; - TogglePaneButton.Width = titleBarButtonWidth; - TogglePaneButton.Height = titleBarButtonHeight; - TogglePaneButtonIcon.FontSize = titleBarIconSize; WindowBrandIcon.FontSize = titleBarIconSize + 2; WindowTitleTextBlock.FontSize = titleFontSize; diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..696b4d2 --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + From 1d7df5a1058f0d75a7855181ef5ce263e601d947 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 4 May 2026 04:09:51 +0800 Subject: [PATCH 03/29] Add localization and localize settings pages Add many new localization keys (en-US and zh-CN) for notifications, developer tools, about page, status bar, and video wallpaper. Update Notification, Dev, About and StatusBar view models to use LocalizationService, expose localized ObservableProperties, and refresh localized text at construction. Localize selection options and test notification texts, and fix notification severity handling. Wire up XAML to the new localized properties (About/Dev/StatusBar pages) and update the settings page title for notifications. Also adjust copyright line generation and replace hardcoded placeholders with bound Watermark properties. --- LanMountainDesktop/Localization/en-US.json | 72 ++++++++ LanMountainDesktop/Localization/zh-CN.json | 79 +++++++++ .../NotificationSettingsPageViewModel.cs | 166 ++++++++++++------ .../ViewModels/SettingsViewModels.cs | 115 +++++++++++- .../StatusBarSettingsPageViewModel.cs | 4 + .../SettingsPages/AboutSettingsPage.axaml | 10 +- .../Views/SettingsPages/DevSettingsPage.axaml | 40 ++--- .../NotificationSettingsPage.axaml.cs | 2 +- .../SettingsPages/StatusBarSettingsPage.axaml | 2 +- 9 files changed, 408 insertions(+), 82 deletions(-) diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 6a79161..75b7736 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -535,6 +535,78 @@ "settings.about.codename_label": "Codename", "settings.about.render_backend_label": "Render Backend", "settings.about.render_backend_format": "Render Backend: {0}", + "settings.about.project_resources_header": "Project resources", + "settings.about.link_github": "GitHub Repository", + "settings.about.link_issues": "Issue Tracker", + "settings.about.copyright_format": "Copyright (c) 2024-{0} Lincube", + "settings.notifications.title": "Notifications", + "settings.notifications.description": "Configure global notifications, interaction behavior, limits, and send test toasts.", + "settings.notifications.section_header": "Notifications", + "settings.notifications.enable_header": "Enable notifications", + "settings.notifications.enable_desc": "Turn all notification toasts on or off.", + "settings.notifications.behavior_header": "Behavior", + "settings.notifications.hover_pause_header": "Pause on hover", + "settings.notifications.hover_pause_desc": "Pause the auto-dismiss timer while the pointer is over a notification.", + "settings.notifications.click_close_header": "Close on click", + "settings.notifications.click_close_desc": "Dismiss a notification when it is clicked.", + "settings.notifications.max_header": "Max per position", + "settings.notifications.max_desc": "Maximum notifications shown at once for each corner or edge.", + "settings.notifications.test_header": "Test", + "settings.notifications.test_notification_header": "Test notification", + "settings.notifications.test_notification_desc": "Pick a position and severity, then send a sample notification.", + "settings.notifications.default_position_header": "Default position", + "settings.notifications.default_position_desc": "Where notifications appear first.", + "settings.notifications.duration_header": "Visible duration", + "settings.notifications.duration_desc": "How long notifications stay on screen.", + "settings.notifications.position.top_left": "Top left", + "settings.notifications.position.top_right": "Top right", + "settings.notifications.position.top_center": "Top center", + "settings.notifications.position.bottom_left": "Bottom left", + "settings.notifications.position.bottom_right": "Bottom right", + "settings.notifications.position.bottom_center": "Bottom center", + "settings.notifications.position.center": "Center", + "settings.notifications.duration.2s": "2 seconds", + "settings.notifications.duration.4s": "4 seconds", + "settings.notifications.duration.6s": "6 seconds", + "settings.notifications.duration.8s": "8 seconds", + "settings.notifications.duration.10s": "10 seconds", + "settings.notifications.severity.info": "Info", + "settings.notifications.severity.success": "Success", + "settings.notifications.severity.warning": "Warning", + "settings.notifications.severity.error": "Error", + "settings.notifications.test.title_info": "Test notification", + "settings.notifications.test.message_info": "This is an informational test notification.", + "settings.notifications.test.title_success": "Succeeded", + "settings.notifications.test.message_success": "The task completed successfully.", + "settings.notifications.test.title_warning": "Warning", + "settings.notifications.test.message_warning": "Please review this notice.", + "settings.notifications.test.title_error": "Error", + "settings.notifications.test.message_error": "Something went wrong. Please try again.", + "settings.notifications.test.title_default": "Test notification", + "settings.notifications.test.message_default": "This is a test notification.", + "settings.dev.title": "Developer", + "settings.dev.description": "Debugging, diagnostics, and local plugin development options.", + "settings.dev.infobar.title": "Preview and developer features", + "settings.dev.infobar.message": "These options are intended for debugging, diagnostics, and local plugin development.", + "settings.dev.mode_header": "Developer mode", + "settings.dev.mode_description": "Enable developer-focused startup helpers and diagnostics.", + "settings.dev.three_finger_header": "Three-finger desktop swipe", + "settings.dev.three_finger_description": "Enable desktop page switching gestures when the current platform supports them.", + "settings.dev.fused_header": "Fused desktop experience", + "settings.dev.fused_description": "Enable the fused desktop shell and its related experimental entry points.", + "settings.dev.plugin_path_header": "Development plugin path", + "settings.dev.plugin_path_description": "Load a local plugin output directory for iterative debugging without packaging.", + "settings.dev.plugin_path_placeholder": "e.g. C:\\path\\to\\plugin\\bin\\Debug\\net10.0", + "settings.dev.startup_args_header": "Developer startup arguments", + "settings.dev.startup_args_description": "Use these launch arguments or environment variables to start the app in development scenarios.", + "settings.dev.cli_label": "Command-line arguments:", + "settings.dev.env_label": "Environment variables:", + "settings.dev.other_args_label": "Other arguments:", + "settings.dev.cli_example": "--dev-plugin or -dp ", + "settings.dev.env_example": "LMD_DEV_PLUGIN=", + "settings.dev.other_dev_mode": "--dev-mode / -dev Enable developer mode startup helpers.", + "settings.dev.other_hot_reload": "--hot-reload / -hr Enable hot reload for development builds.", + "settings.status_bar.text_capsule_placeholder": "Enter Markdown text…", "settings.restart_dialog.title": "Restart required", "settings.restart_dialog.render_mode_message": "Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?", "settings.restart_dialog.restart": "Restart now", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 49b28ad..2827e1f 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -69,6 +69,11 @@ "settings.wallpaper.storage_unavailable": "存储提供器不可用。", "settings.wallpaper.import_failed": "导入壁纸文件失败。", "settings.wallpaper.image_applied": "图片壁纸已应用。", + "settings.wallpaper.video_applied": "视频壁纸已应用。", + "settings.wallpaper.video_restored": "已从保存的设置恢复视频壁纸。", + "settings.wallpaper.video_not_found": "未找到视频壁纸文件。", + "settings.wallpaper.video_player_unavailable": "视频播放器不可用。", + "settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}", "settings.wallpaper.unsupported_file": "所选文件类型不受支持。", "settings.wallpaper.apply_failed_format": "应用壁纸失败:{0}", "settings.wallpaper.mode_format": "壁纸模式:{0}。", @@ -102,6 +107,7 @@ "settings.color.theme_ready_format": "主题色已就绪:{0}。", "settings.color.theme_applied_format": "{0}主题色已应用:{1}。", "settings.color.theme_updated_wallpaper": "壁纸已更新,莫奈色已刷新。", + "settings.color.theme_updated_video": "视频壁纸已更新,主题色已刷新。", "settings.color.theme_cleared_wallpaper": "壁纸已清除,莫奈色已刷新。", "settings.status_bar.title": "状态栏", "settings.status_bar.description": "选择顶部状态栏显示的组件。", @@ -541,6 +547,79 @@ "settings.footer": "LanMountainDesktop 设置", "filepicker.title": "选择壁纸", "filepicker.image_files": "图片文件", + "filepicker.video_files": "视频文件", + "settings.notifications.title": "通知", + "settings.notifications.description": "配置全局通知开关、交互行为与数量上限,并可发送测试通知。", + "settings.notifications.section_header": "通知", + "settings.notifications.enable_header": "启用通知", + "settings.notifications.enable_desc": "开启或关闭全局通知功能。", + "settings.notifications.behavior_header": "行为", + "settings.notifications.hover_pause_header": "悬停暂停计时", + "settings.notifications.hover_pause_desc": "鼠标悬停时暂停自动关闭倒计时。", + "settings.notifications.click_close_header": "点击关闭", + "settings.notifications.click_close_desc": "点击通知后立即关闭。", + "settings.notifications.max_header": "每区域最大数量", + "settings.notifications.max_desc": "同一位置最多同时显示多少条通知。", + "settings.notifications.test_header": "测试", + "settings.notifications.test_notification_header": "测试通知", + "settings.notifications.test_notification_desc": "选择位置与类型后发送一条测试通知。", + "settings.notifications.default_position_header": "默认位置", + "settings.notifications.default_position_desc": "通知首次出现的位置。", + "settings.notifications.duration_header": "显示时长", + "settings.notifications.duration_desc": "通知在屏幕上保持可见的时间。", + "settings.notifications.position.top_left": "左上角", + "settings.notifications.position.top_right": "右上角", + "settings.notifications.position.top_center": "正上方", + "settings.notifications.position.bottom_left": "左下角", + "settings.notifications.position.bottom_right": "右下角", + "settings.notifications.position.bottom_center": "正下方", + "settings.notifications.position.center": "正中央", + "settings.notifications.duration.2s": "2 秒", + "settings.notifications.duration.4s": "4 秒", + "settings.notifications.duration.6s": "6 秒", + "settings.notifications.duration.8s": "8 秒", + "settings.notifications.duration.10s": "10 秒", + "settings.notifications.severity.info": "信息", + "settings.notifications.severity.success": "成功", + "settings.notifications.severity.warning": "警告", + "settings.notifications.severity.error": "错误", + "settings.notifications.test.title_info": "测试通知", + "settings.notifications.test.message_info": "这是一条信息类型的测试通知。", + "settings.notifications.test.title_success": "操作成功", + "settings.notifications.test.message_success": "任务已完成。", + "settings.notifications.test.title_warning": "警告提示", + "settings.notifications.test.message_warning": "请注意检查。", + "settings.notifications.test.title_error": "错误报告", + "settings.notifications.test.message_error": "操作失败,请重试。", + "settings.notifications.test.title_default": "测试通知", + "settings.notifications.test.message_default": "这是一条测试通知。", + "settings.dev.title": "开发者", + "settings.dev.description": "调试、诊断与本地插件开发相关选项。", + "settings.dev.infobar.title": "预览与开发者功能", + "settings.dev.infobar.message": "以下选项适用于调试、诊断与本地插件开发场景。", + "settings.dev.mode_header": "开发者模式", + "settings.dev.mode_description": "启用面向开发者的启动辅助与诊断信息。", + "settings.dev.three_finger_header": "三指滑动切换桌面页", + "settings.dev.three_finger_description": "在当前平台支持时,启用手势在桌面分页间切换。", + "settings.dev.fused_header": "融合桌面体验", + "settings.dev.fused_description": "启用融合桌面壳及相关实验入口。", + "settings.dev.plugin_path_header": "开发插件路径", + "settings.dev.plugin_path_description": "加载本地插件输出目录以便免打包迭代调试。", + "settings.dev.plugin_path_placeholder": "例如:C:\\path\\to\\plugin\\bin\\Debug\\net10.0", + "settings.dev.startup_args_header": "开发者启动参数", + "settings.dev.startup_args_description": "可使用下列命令行参数或环境变量启动应用以进行开发。", + "settings.dev.cli_label": "命令行参数:", + "settings.dev.env_label": "环境变量:", + "settings.dev.other_args_label": "其它参数:", + "settings.dev.cli_example": "--dev-plugin 或 -dp ", + "settings.dev.env_example": "LMD_DEV_PLUGIN=", + "settings.dev.other_dev_mode": "--dev-mode / -dev 启用开发者模式启动辅助。", + "settings.dev.other_hot_reload": "--hot-reload / -hr 为开发构建启用热重载。", + "settings.about.project_resources_header": "项目资源", + "settings.about.link_github": "GitHub 仓库", + "settings.about.link_issues": "问题反馈", + "settings.about.copyright_format": "Copyright (c) 2024-{0} Lincube", + "settings.status_bar.text_capsule_placeholder": "请输入 Markdown 文本…", "common.day": "日间", "common.night": "夜间", "common.back": "返回", diff --git a/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs index 7a0464c..ab323fc 100644 --- a/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs +++ b/LanMountainDesktop/ViewModels/NotificationSettingsPageViewModel.cs @@ -13,23 +13,26 @@ namespace LanMountainDesktop.ViewModels; public sealed partial class NotificationSettingsPageViewModel : ViewModelBase { private readonly ISettingsFacadeService _settingsFacade; + private readonly LocalizationService _localizationService = new(); + private readonly string _languageCode; private bool _isInitializing; public NotificationSettingsPageViewModel(ISettingsFacadeService settingsFacade) { _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); + _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); Positions = CreatePositionOptions(); Durations = CreateDurationOptions(); TestPositions = CreatePositionOptions(); TestSeverities = CreateSeverityOptions(); + RefreshLocalizedText(); LoadSettings(); - // Initialize test selections - SelectedTestPosition = TestPositions[1]; // TopRight - SelectedTestSeverity = TestSeverities[0]; // Info - TestDurationSeconds = 4; // Default 4 seconds + SelectedTestPosition = TestPositions[1]; + SelectedTestSeverity = TestSeverities[0]; + TestDurationSeconds = 4; } private void LoadSettings() @@ -44,11 +47,11 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase MaxNotificationsPerPosition = snapshot.NotificationMaxPerPosition; SelectedPosition = Positions.FirstOrDefault(p => - string.Equals(p.Value, snapshot.NotificationDefaultPosition, StringComparison.OrdinalIgnoreCase)) + string.Equals(p.Value, snapshot.NotificationDefaultPosition, StringComparison.OrdinalIgnoreCase)) ?? Positions[1]; SelectedDuration = Durations.FirstOrDefault(d => - int.TryParse(d.Value, out var seconds) && seconds == snapshot.NotificationDurationSeconds) + int.TryParse(d.Value, out var seconds) && seconds == snapshot.NotificationDurationSeconds) ?? Durations[1]; _isInitializing = false; @@ -81,71 +84,117 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase ]); } - private static ObservableCollection CreatePositionOptions() + private ObservableCollection CreatePositionOptions() { return [ - new SelectionOption("TopLeft", "左上角"), - new SelectionOption("TopRight", "右上角"), - new SelectionOption("TopCenter", "正上方"), - new SelectionOption("BottomLeft", "左下角"), - new SelectionOption("BottomRight", "右下角"), - new SelectionOption("BottomCenter", "正下方"), - new SelectionOption("Center", "正中央") + new SelectionOption("TopLeft", L("settings.notifications.position.top_left", "Top left")), + new SelectionOption("TopRight", L("settings.notifications.position.top_right", "Top right")), + new SelectionOption("TopCenter", L("settings.notifications.position.top_center", "Top center")), + new SelectionOption("BottomLeft", L("settings.notifications.position.bottom_left", "Bottom left")), + new SelectionOption("BottomRight", L("settings.notifications.position.bottom_right", "Bottom right")), + new SelectionOption("BottomCenter", L("settings.notifications.position.bottom_center", "Bottom center")), + new SelectionOption("Center", L("settings.notifications.position.center", "Center")) ]; } - private static ObservableCollection CreateDurationOptions() + private ObservableCollection CreateDurationOptions() { return [ - new SelectionOption("2", "2 秒"), - new SelectionOption("4", "4 秒"), - new SelectionOption("6", "6 秒"), - new SelectionOption("8", "8 秒"), - new SelectionOption("10", "10 秒") + new SelectionOption("2", L("settings.notifications.duration.2s", "2 seconds")), + new SelectionOption("4", L("settings.notifications.duration.4s", "4 seconds")), + new SelectionOption("6", L("settings.notifications.duration.6s", "6 seconds")), + new SelectionOption("8", L("settings.notifications.duration.8s", "8 seconds")), + new SelectionOption("10", L("settings.notifications.duration.10s", "10 seconds")) ]; } - private static ObservableCollection CreateSeverityOptions() + private ObservableCollection CreateSeverityOptions() { return [ - new SelectionOption("Info", "信息"), - new SelectionOption("Success", "成功"), - new SelectionOption("Warning", "警告"), - new SelectionOption("Error", "错误") + new SelectionOption("Info", L("settings.notifications.severity.info", "Info")), + new SelectionOption("Success", L("settings.notifications.severity.success", "Success")), + new SelectionOption("Warning", L("settings.notifications.severity.warning", "Warning")), + new SelectionOption("Error", L("settings.notifications.severity.error", "Error")) ]; } - [ObservableProperty] private string _notificationHeader = "通知"; - [ObservableProperty] private string _enableNotificationHeader = "启用通知"; - [ObservableProperty] private string _enableNotificationDescription = "开启或关闭全局通知功能"; - [ObservableProperty] private string _defaultPositionHeader = "默认位置"; - [ObservableProperty] private string _defaultPositionDescription = "通知弹出的默认位置"; - [ObservableProperty] private string _durationHeader = "显示时长"; - [ObservableProperty] private string _durationDescription = "通知自动关闭的时间"; - [ObservableProperty] private string _behaviorHeader = "行为"; - [ObservableProperty] private string _hoverPauseHeader = "悬停暂停"; - [ObservableProperty] private string _hoverPauseDescription = "鼠标悬停时暂停自动关闭计时"; - [ObservableProperty] private string _clickCloseHeader = "点击关闭"; - [ObservableProperty] private string _clickCloseDescription = "点击通知后关闭"; - [ObservableProperty] private string _maxNotificationsHeader = "最大数量"; - [ObservableProperty] private string _maxNotificationsDescription = "每个位置最多显示的通知数量"; - [ObservableProperty] private string _testHeader = "测试"; - [ObservableProperty] private string _testNotificationHeader = "测试通知"; - [ObservableProperty] private string _testNotificationDescription = "选择位置和类型,发送测试通知"; - [ObservableProperty] private string _sendTestButtonText = "发送"; + private void RefreshLocalizedText() + { + NotificationHeader = L("settings.notifications.section_header", "Notifications"); + EnableNotificationHeader = L("settings.notifications.enable_header", "Enable notifications"); + EnableNotificationDescription = L("settings.notifications.enable_desc", "Turn all notification toasts on or off."); + BehaviorHeader = L("settings.notifications.behavior_header", "Behavior"); + HoverPauseHeader = L("settings.notifications.hover_pause_header", "Pause on hover"); + HoverPauseDescription = L("settings.notifications.hover_pause_desc", "Pause auto-dismiss while hovering."); + ClickCloseHeader = L("settings.notifications.click_close_header", "Close on click"); + ClickCloseDescription = L("settings.notifications.click_close_desc", "Dismiss when clicked."); + MaxNotificationsHeader = L("settings.notifications.max_header", "Max per position"); + MaxNotificationsDescription = L("settings.notifications.max_desc", "Maximum notifications per corner or edge."); + TestHeader = L("settings.notifications.test_header", "Test"); + TestNotificationHeader = L("settings.notifications.test_notification_header", "Test notification"); + TestNotificationDescription = L("settings.notifications.test_notification_desc", "Send a sample notification."); + DefaultPositionHeader = L("settings.notifications.default_position_header", "Default position"); + DefaultPositionDescription = L("settings.notifications.default_position_desc", "Where notifications appear first."); + DurationHeader = L("settings.notifications.duration_header", "Visible duration"); + DurationDescription = L("settings.notifications.duration_desc", "How long notifications stay on screen."); + } + + private string L(string key, string fallback) + => _localizationService.GetString(_languageCode, key, fallback); + + [ObservableProperty] private string _notificationHeader = string.Empty; + + [ObservableProperty] private string _enableNotificationHeader = string.Empty; + + [ObservableProperty] private string _enableNotificationDescription = string.Empty; + + [ObservableProperty] private string _defaultPositionHeader = string.Empty; + + [ObservableProperty] private string _defaultPositionDescription = string.Empty; + + [ObservableProperty] private string _durationHeader = string.Empty; + + [ObservableProperty] private string _durationDescription = string.Empty; + + [ObservableProperty] private string _behaviorHeader = string.Empty; + + [ObservableProperty] private string _hoverPauseHeader = string.Empty; + + [ObservableProperty] private string _hoverPauseDescription = string.Empty; + + [ObservableProperty] private string _clickCloseHeader = string.Empty; + + [ObservableProperty] private string _clickCloseDescription = string.Empty; + + [ObservableProperty] private string _maxNotificationsHeader = string.Empty; + + [ObservableProperty] private string _maxNotificationsDescription = string.Empty; + + [ObservableProperty] private string _testHeader = string.Empty; + + [ObservableProperty] private string _testNotificationHeader = string.Empty; + + [ObservableProperty] private string _testNotificationDescription = string.Empty; [ObservableProperty] private bool _isNotificationEnabled = true; + [ObservableProperty] private bool _isHoverPauseEnabled = true; + [ObservableProperty] private bool _isClickCloseEnabled = true; + [ObservableProperty] private int _maxNotificationsPerPosition = 5; [ObservableProperty] private SelectionOption? _selectedPosition; + [ObservableProperty] private SelectionOption? _selectedDuration; + [ObservableProperty] private SelectionOption? _selectedTestPosition; + [ObservableProperty] private SelectionOption? _selectedTestSeverity; + [ObservableProperty] private int _testDurationSeconds = 4; public ObservableCollection Positions { get; } @@ -154,10 +203,15 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase public ObservableCollection TestSeverities { get; } partial void OnIsNotificationEnabledChanged(bool value) => SaveSettings(); + partial void OnIsHoverPauseEnabledChanged(bool value) => SaveSettings(); + partial void OnIsClickCloseEnabledChanged(bool value) => SaveSettings(); + partial void OnMaxNotificationsPerPositionChanged(int value) => SaveSettings(); + partial void OnSelectedPositionChanged(SelectionOption? value) => SaveSettings(); + partial void OnSelectedDurationChanged(SelectionOption? value) => SaveSettings(); [RelayCommand] @@ -169,24 +223,32 @@ public sealed partial class NotificationSettingsPageViewModel : ViewModelBase var position = Enum.Parse(SelectedTestPosition.Value); var severity = SelectedTestSeverity.Value; - var (title, message) = severity! switch + var (title, message) = severity switch { - "Info" => ("测试通知", "这是一条信息类型的通知"), - "Success" => ("操作成功", "任务已完成"), - "Warning" => ("警告提示", "请注意检查"), - "Error" => ("错误报告", "操作失败,请重试"), - _ => ("测试通知", "这是一条测试通知") + "Info" => ( + L("settings.notifications.test.title_info", "Test notification"), + L("settings.notifications.test.message_info", "This is an informational test notification.")), + "Success" => ( + L("settings.notifications.test.title_success", "Succeeded"), + L("settings.notifications.test.message_success", "The task completed successfully.")), + "Warning" => ( + L("settings.notifications.test.title_warning", "Warning"), + L("settings.notifications.test.message_warning", "Please review this notice.")), + "Error" => ( + L("settings.notifications.test.title_error", "Error"), + L("settings.notifications.test.message_error", "Something went wrong. Please try again.")), + _ => ( + L("settings.notifications.test.title_default", "Test notification"), + L("settings.notifications.test.message_default", "This is a test notification.")) }; - // Create notification content with specified duration var content = new NotificationContent( Title: title, Message: message, - Severity: Enum.Parse(severity), + Severity: Enum.Parse(severity!), Position: position, Duration: TimeSpan.FromSeconds(TestDurationSeconds)); - // Use Show method which will automatically route to dialog or toast based on position App.CurrentNotificationService?.Show(content); } } diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index ff8255a..46829bc 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -1590,6 +1590,18 @@ public sealed partial class AboutSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _renderBackendLabel = string.Empty; + [ObservableProperty] + private string _projectResourcesHeader = string.Empty; + + [ObservableProperty] + private string _linkGitHubText = string.Empty; + + [ObservableProperty] + private string _linkIssuesText = string.Empty; + + [ObservableProperty] + private string _copyrightLine = string.Empty; + private void RefreshLocalizedText() { PageTitle = L("settings.about.title", "About"); @@ -1598,6 +1610,14 @@ public sealed partial class AboutSettingsPageViewModel : ViewModelBase VersionLabel = L("settings.about.version_label", "Version"); CodenameLabel = L("settings.about.codename_label", "Codename"); RenderBackendLabel = L("settings.about.render_backend_label", "Render Backend"); + ProjectResourcesHeader = L("settings.about.project_resources_header", "Project resources"); + LinkGitHubText = L("settings.about.link_github", "GitHub Repository"); + LinkIssuesText = L("settings.about.link_issues", "Issue Tracker"); + var year = Math.Max(2025, DateTime.UtcNow.Year); + CopyrightLine = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + L("settings.about.copyright_format", "Copyright (c) 2024-{0} Lincube"), + year); } private string L(string key, string fallback) @@ -3352,16 +3372,21 @@ public sealed class PluginGeneratedSettingsPageViewModel public sealed partial class DevSettingsPageViewModel : ViewModelBase { private readonly ISettingsFacadeService _settingsFacade; + private readonly LocalizationService _localizationService = new(); + private readonly string _languageCode; private bool _isInitializing; public DevSettingsPageViewModel(ISettingsFacadeService settingsFacade) { _settingsFacade = settingsFacade; + _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); + + RefreshLocalizedText(); + _isInitializing = true; LoadSettings(); _isInitializing = false; - // 监听设置变更,防止被意外重置 _settingsFacade.Settings.Changed += OnSettingsChanged; } @@ -3377,6 +3402,93 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase [ObservableProperty] private bool _enableFusedDesktop; + [ObservableProperty] + private string _infoBarTitle = string.Empty; + + [ObservableProperty] + private string _infoBarMessage = string.Empty; + + [ObservableProperty] + private string _devModeHeader = string.Empty; + + [ObservableProperty] + private string _devModeDescription = string.Empty; + + [ObservableProperty] + private string _threeFingerHeader = string.Empty; + + [ObservableProperty] + private string _threeFingerDescription = string.Empty; + + [ObservableProperty] + private string _fusedHeader = string.Empty; + + [ObservableProperty] + private string _fusedDescription = string.Empty; + + [ObservableProperty] + private string _pluginPathHeader = string.Empty; + + [ObservableProperty] + private string _pluginPathDescription = string.Empty; + + [ObservableProperty] + private string _pluginPathPlaceholder = string.Empty; + + [ObservableProperty] + private string _startupArgsHeader = string.Empty; + + [ObservableProperty] + private string _startupArgsDescription = string.Empty; + + [ObservableProperty] + private string _cliLabel = string.Empty; + + [ObservableProperty] + private string _envLabel = string.Empty; + + [ObservableProperty] + private string _otherArgsLabel = string.Empty; + + [ObservableProperty] + private string _cliExample = string.Empty; + + [ObservableProperty] + private string _envExample = string.Empty; + + [ObservableProperty] + private string _otherDevModeLine = string.Empty; + + [ObservableProperty] + private string _otherHotReloadLine = string.Empty; + + private void RefreshLocalizedText() + { + InfoBarTitle = L("settings.dev.infobar.title", "Preview and developer features"); + InfoBarMessage = L("settings.dev.infobar.message", "These options are intended for debugging and local plugin development."); + DevModeHeader = L("settings.dev.mode_header", "Developer mode"); + DevModeDescription = L("settings.dev.mode_description", "Enable developer-focused startup helpers and diagnostics."); + ThreeFingerHeader = L("settings.dev.three_finger_header", "Three-finger desktop swipe"); + ThreeFingerDescription = L("settings.dev.three_finger_description", "Enable desktop page switching gestures when supported."); + FusedHeader = L("settings.dev.fused_header", "Fused desktop experience"); + FusedDescription = L("settings.dev.fused_description", "Enable the fused desktop shell and experimental entry points."); + PluginPathHeader = L("settings.dev.plugin_path_header", "Development plugin path"); + PluginPathDescription = L("settings.dev.plugin_path_description", "Load a local plugin output directory without packaging."); + PluginPathPlaceholder = L("settings.dev.plugin_path_placeholder", "e.g. C:\\path\\to\\plugin\\bin\\Debug\\net10.0"); + StartupArgsHeader = L("settings.dev.startup_args_header", "Developer startup arguments"); + StartupArgsDescription = L("settings.dev.startup_args_description", "Command-line arguments and environment variables for development."); + CliLabel = L("settings.dev.cli_label", "Command-line arguments:"); + EnvLabel = L("settings.dev.env_label", "Environment variables:"); + OtherArgsLabel = L("settings.dev.other_args_label", "Other arguments:"); + CliExample = L("settings.dev.cli_example", "--dev-plugin or -dp "); + EnvExample = L("settings.dev.env_example", "LMD_DEV_PLUGIN="); + OtherDevModeLine = L("settings.dev.other_dev_mode", "--dev-mode / -dev Enable developer mode startup helpers."); + OtherHotReloadLine = L("settings.dev.other_hot_reload", "--hot-reload / -hr Enable hot reload for development builds."); + } + + private string L(string key, string fallback) + => _localizationService.GetString(_languageCode, key, fallback); + partial void OnIsDevModeEnabledChanged(bool value) { if (_isInitializing) return; @@ -3423,7 +3535,6 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase return; } - // 如果是其他设置变更,重新加载我们的设置 _isInitializing = true; try { diff --git a/LanMountainDesktop/ViewModels/StatusBarSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/StatusBarSettingsPageViewModel.cs index 73955d9..7a87b0c 100644 --- a/LanMountainDesktop/ViewModels/StatusBarSettingsPageViewModel.cs +++ b/LanMountainDesktop/ViewModels/StatusBarSettingsPageViewModel.cs @@ -130,6 +130,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _textCapsuleContentLabel = string.Empty; + [ObservableProperty] + private string _textCapsulePlaceholder = string.Empty; + [ObservableProperty] private string _textCapsuleTransparentBackgroundLabel = string.Empty; @@ -600,6 +603,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase TextCapsuleDescription = L("settings.status_bar.text_capsule_description", "Display custom text with Markdown support on the status bar."); TextCapsulePositionLabel = L("settings.status_bar.text_capsule_position_label", "Text capsule position"); TextCapsuleContentLabel = L("settings.status_bar.text_capsule_content_label", "Text content (Markdown supported)"); + TextCapsulePlaceholder = L("settings.status_bar.text_capsule_placeholder", "Enter Markdown text…"); TextCapsuleTransparentBackgroundLabel = L("settings.status_bar.text_capsule_transparent_background_label", "Transparent background"); NetworkSpeedHeader = L("settings.status_bar.network_speed_header", "Network Speed"); NetworkSpeedDescription = L("settings.status_bar.network_speed_description", "Display real-time network upload and download speed."); diff --git a/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml index beee6de..c7064f9 100644 --- a/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/AboutSettingsPage.axaml @@ -81,7 +81,7 @@ - @@ -96,16 +96,14 @@ - + - + - - Lincube - + diff --git a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml index 8c19024..302e1cd 100644 --- a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml @@ -10,16 +10,16 @@ - + @@ -28,8 +28,8 @@ - + @@ -38,8 +38,8 @@ - + @@ -50,14 +50,14 @@ - + @@ -65,26 +65,26 @@ - + - - - + Text="{Binding OtherDevModeLine}" /> + Text="{Binding OtherHotReloadLine}" /> diff --git a/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs b/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs index 7630367..a20c016 100644 --- a/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs +++ b/LanMountainDesktop/Views/SettingsPages/NotificationSettingsPage.axaml.cs @@ -6,7 +6,7 @@ namespace LanMountainDesktop.Views.SettingsPages; [SettingsPageInfo( "notifications", - "通知", + "Notifications", SettingsPageCategory.Components, IconKey = "Bell", SortOrder = 5, diff --git a/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml index 689e1a5..b6d3481 100644 --- a/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/StatusBarSettingsPage.axaml @@ -110,7 +110,7 @@ Height="100" IsEnabled="{Binding ShowTextCapsule}" Text="{Binding TextCapsuleContent}" - PlaceholderText="Enter Markdown text..." /> + Watermark="{Binding TextCapsulePlaceholder}" /> From 49bbae29af3db832b13b499dcdfe11ff84786436 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 4 May 2026 04:46:12 +0800 Subject: [PATCH 04/29] Redesign settings window with fluent shell & search Rebuild the settings window as a Fluent shell: adds a custom 48-DIP titlebar with Back, pane toggle, icon/title, search box, restart/more menu, and caption-button spacer; moves compact pane toggle into the titlebar and preserves FANavigationView as the primary navigation surface. Introduces a SettingsSearchService (with UI AutoComplete integration, search indexing, navigation-by-result, and search result highlighting) plus focused tests for search filtering and theme material normalization. Adds navigation history/back stack, updates SettingsViewModels for new bindings and localization keys, and updates General/Apearance pages to expose new strings and options. Implements an "auto" system material mode: default in AppSettingsSnapshot, new MaterialAuto constants and normalization/resolution logic in ThemeAppearanceValues, WindowMaterialService and MaterialSurfaceService adjustments to prefer Mica on Win11 and Acrylic on Win10 using TransparencyLevelHint. GlassEffectService and AppearanceThemeService updated to use effective material mode and to track live theme state changes. Adds localization entries (en-US, zh-CN), spec/tasks docs, and other UI/style tweaks to support the redesign. --- .../spec.md | 25 ++ .../tasks.md | 13 + .../SettingsSearchServiceTests.cs | 27 ++ .../ThemeAppearanceValuesTests.cs | 29 ++ LanMountainDesktop/Localization/en-US.json | 14 + LanMountainDesktop/Localization/zh-CN.json | 14 + .../Models/AppSettingsSnapshot.cs | 2 +- .../Services/AppearanceThemeService.cs | 71 +++- .../Services/GlassEffectService.cs | 2 +- .../Settings/SettingsWindowService.cs | 3 + .../Services/SettingsSearchService.cs | 258 ++++++++++++++ .../Services/ThemeAppearanceValues.cs | 37 +- LanMountainDesktop/Theme/ThemeColorContext.cs | 2 +- .../ViewModels/SettingsViewModels.cs | 76 ++++- .../SettingsPages/GeneralSettingsPage.axaml | 10 +- LanMountainDesktop/Views/SettingsWindow.axaml | 177 +++++++--- .../Views/SettingsWindow.axaml.cs | 317 ++++++++++++++++-- design.md | 2 + docs/ai/SETTINGS_WINDOW_DESIGN.md | 48 +++ 19 files changed, 1045 insertions(+), 82 deletions(-) create mode 100644 .trae/specs/settings-window-fluent-shell-redesign/spec.md create mode 100644 .trae/specs/settings-window-fluent-shell-redesign/tasks.md create mode 100644 LanMountainDesktop.Tests/SettingsSearchServiceTests.cs create mode 100644 LanMountainDesktop.Tests/ThemeAppearanceValuesTests.cs create mode 100644 LanMountainDesktop/Services/SettingsSearchService.cs create mode 100644 docs/ai/SETTINGS_WINDOW_DESIGN.md diff --git a/.trae/specs/settings-window-fluent-shell-redesign/spec.md b/.trae/specs/settings-window-fluent-shell-redesign/spec.md new file mode 100644 index 0000000..709207d --- /dev/null +++ b/.trae/specs/settings-window-fluent-shell-redesign/spec.md @@ -0,0 +1,25 @@ +# Settings Window Fluent Shell Redesign + +## Goal + +Rebuild the settings window as an independent Fluent shell with a custom titlebar, titlebar hamburger menu, persistent side navigation, search, and Avalonia-standard system material support. + +## Requirements + +- Keep the existing independent settings-window lifecycle: open-or-focus, no owner anchor, own taskbar entry. +- Use a 48 DIP titlebar with Back, pane toggle, icon/title, search, restart action, more menu, and caption-button spacer. +- Keep `FANavigationView` as the primary navigation surface with `OpenPaneLength` around 283 DIP. +- Move the compact/minimal pane toggle from the navigation footer into the titlebar. +- Add search over built-in settings pages and settings expanders; selecting a result navigates, expands, focuses, and highlights. +- Add `auto` system material mode and make it the default. +- Implement material with Avalonia `TransparencyLevelHint` only. +- Preserve settings page layout as direct `ScrollViewer -> StackPanel -> FASettingsExpander` content. +- Follow `docs/VISUAL_SPEC.md`, `docs/CORNER_RADIUS_SPEC.md`, and `docs/ai/SETTINGS_WINDOW_DESIGN.md`. + +## Acceptance + +- `dotnet build LanMountainDesktop.slnx -c Debug` succeeds. +- `dotnet test LanMountainDesktop.slnx -c Debug` succeeds or any unrelated failures are documented. +- The settings window can navigate by sidebar, titlebar Back, titlebar pane toggle, and search. +- Appearance settings expose Auto, None, Mica, and/or Acrylic according to system support. +- Existing dirty user changes are not reverted. diff --git a/.trae/specs/settings-window-fluent-shell-redesign/tasks.md b/.trae/specs/settings-window-fluent-shell-redesign/tasks.md new file mode 100644 index 0000000..480e365 --- /dev/null +++ b/.trae/specs/settings-window-fluent-shell-redesign/tasks.md @@ -0,0 +1,13 @@ +# Tasks + +- [x] Analyze current `SettingsWindow`, appearance theme service, and existing settings page layout. +- [x] Compare ClassIsland `SettingsWindowNew` and SecRandom v3 Avalonia `SettingsView`. +- [x] Replace footer fallback pane toggle with titlebar pane toggle. +- [x] Add titlebar Back, search, restart, and more-options controls. +- [x] Add settings navigation history. +- [x] Add settings search service and result highlight. +- [x] Add `auto` system material mode and Avalonia `TransparencyLevelHint` priority. +- [x] Update appearance settings options and localization. +- [x] Add focused tests for material normalization and search filtering. +- [x] Add design/spec documentation. +- [ ] Run full app manually on Windows 11 and Windows 10 to verify actual Mica/Acrylic backdrops. diff --git a/LanMountainDesktop.Tests/SettingsSearchServiceTests.cs b/LanMountainDesktop.Tests/SettingsSearchServiceTests.cs new file mode 100644 index 0000000..4e75d6f --- /dev/null +++ b/LanMountainDesktop.Tests/SettingsSearchServiceTests.cs @@ -0,0 +1,27 @@ +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class SettingsSearchServiceTests +{ + [Fact] + public void Filter_MatchesTitleAndPageMetadata() + { + var result = new SettingsSearchResult( + "appearance", + "Appearance", + "Theme and material settings", + "System material", + "Choose Mica or Acrylic", + "appearance:material", + targetControl: null, + isPageResult: false, + keywords: ["fluent"]); + + Assert.True(SettingsSearchService.Filter("material", result)); + Assert.True(SettingsSearchService.Filter("appearance", result)); + Assert.True(SettingsSearchService.Filter("fluent", result)); + Assert.False(SettingsSearchService.Filter("network", result)); + } +} diff --git a/LanMountainDesktop.Tests/ThemeAppearanceValuesTests.cs b/LanMountainDesktop.Tests/ThemeAppearanceValuesTests.cs new file mode 100644 index 0000000..0800682 --- /dev/null +++ b/LanMountainDesktop.Tests/ThemeAppearanceValuesTests.cs @@ -0,0 +1,29 @@ +using LanMountainDesktop.Services; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class ThemeAppearanceValuesTests +{ + [Theory] + [InlineData("auto", ThemeAppearanceValues.MaterialAuto)] + [InlineData("AUTO", ThemeAppearanceValues.MaterialAuto)] + [InlineData("mica", ThemeAppearanceValues.MaterialMica)] + [InlineData("acrylic", ThemeAppearanceValues.MaterialAcrylic)] + [InlineData("unknown", ThemeAppearanceValues.MaterialNone)] + [InlineData(null, ThemeAppearanceValues.MaterialNone)] + public void NormalizeSystemMaterialMode_ReturnsKnownValue(string? input, string expected) + { + Assert.Equal(expected, ThemeAppearanceValues.NormalizeSystemMaterialMode(input)); + } + + [Fact] + public void NormalizeAvailableMaterialModes_AddsAutoAndNone() + { + var result = ThemeAppearanceValues.NormalizeAvailableMaterialModes([ThemeAppearanceValues.MaterialMica]); + + Assert.Equal(ThemeAppearanceValues.MaterialAuto, result[0]); + Assert.Equal(ThemeAppearanceValues.MaterialNone, result[1]); + Assert.Contains(ThemeAppearanceValues.MaterialMica, result); + } +} diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 75b7736..602b869 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -346,6 +346,11 @@ "settings.general.preview_time_label": "Time", "settings.general.preview_date_label": "Date", "settings.general.render_mode_restart_message": "Rendering mode changes require restarting the app.", + "settings.general.fade_transition_header": "Fade startup transition", + "settings.general.slide_transition_header": "Slide startup transition", + "settings.general.slide_transition_desc": "Use a slide-in startup transition on supported Windows builds. This option disables fade transition.", + "settings.general.show_main_window_taskbar_header": "Show main desktop window in taskbar", + "settings.general.show_main_window_taskbar_desc": "Keep the main desktop host window visible in the taskbar. The independent settings window always has its own taskbar entry.", "settings.appearance.title": "Appearance", "settings.appearance.description": "Adjust theme source, system material, and window chrome.", "settings.appearance.theme_header": "Theme", @@ -369,11 +374,13 @@ "settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.", "component.color_scheme.follow_system": "Follow system color scheme", "component.color_scheme.native": "Use component custom color scheme", + "settings.appearance.system_material.auto": "Auto (recommended)", "settings.appearance.system_material.none": "None", "settings.appearance.system_material.mica": "Mica", "settings.appearance.system_material.acrylic": "Acrylic", "settings.appearance.system_material_desc.switchable": "Apply the selected material to windows, Dock, status bar, and component hosts.", "settings.appearance.system_material_desc.fixed": "Your current system only exposes the material modes listed here.", + "settings.appearance.system_material_desc.auto": "Auto prefers Mica on Windows 11, Acrylic on Windows 10, and falls back to no material when unavailable.", "settings.appearance.restart_message": "Theme source and system material changes require restarting the app.", "settings.appearance.preview.primary": "Primary", "settings.appearance.preview.secondary": "Secondary", @@ -740,6 +747,13 @@ "settings.update.source_plonds_desc": "Prefer PLONDS distribution endpoints, then automatically fallback to GitHub.", "settings.update.status_check_failed_plonds": "PLONDS update check failed, falling back to GitHub...", "settings.window.drawer_default": "Details", + "settings.search.placeholder": "Search settings", + "settings.search.no_results": "No matching settings", + "settings.search.page_hint": "Open settings page", + "settings.window.more_options": "More options", + "settings.window.restart_menu_item": "Restart app", + "settings.window.toggle_pane": "Toggle navigation", + "settings.window.back": "Back", "market.toolbar.search_placeholder": "Search plugins", "market.toolbar.refresh": "Refresh", "market.status.loading": "Loading the official plugin market...", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 2827e1f..c21d16c 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -347,6 +347,11 @@ "settings.general.preview_time_label": "时间", "settings.general.preview_date_label": "日期", "settings.general.render_mode_restart_message": "渲染模式变更需要重启应用。", + "settings.general.fade_transition_header": "淡入淡出启动过渡", + "settings.general.slide_transition_header": "滑入启动过渡", + "settings.general.slide_transition_desc": "在受支持的 Windows 版本上使用滑入启动过渡。启用后会关闭淡入淡出过渡。", + "settings.general.show_main_window_taskbar_header": "在任务栏显示主桌面窗口", + "settings.general.show_main_window_taskbar_desc": "让主桌面宿主窗口保持在任务栏中可见。独立设置窗口始终拥有自己的任务栏入口。", "settings.appearance.title": "外观", "settings.appearance.description": "调整主题来源、系统材质与窗口外观。", "settings.appearance.theme_header": "主题", @@ -370,11 +375,13 @@ "settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。", "component.color_scheme.follow_system": "跟随系统配色", "component.color_scheme.native": "使用组件自定义配色", + "settings.appearance.system_material.auto": "自动(推荐)", "settings.appearance.system_material.none": "无", "settings.appearance.system_material.mica": "Mica", "settings.appearance.system_material.acrylic": "Acrylic", "settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。", "settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。", + "settings.appearance.system_material_desc.auto": "自动模式会在 Windows 11 优先使用 Mica,在 Windows 10 优先使用 Acrylic,不可用时回退到无材质。", "settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。", "settings.appearance.preview.primary": "主色", "settings.appearance.preview.secondary": "次色", @@ -741,6 +748,13 @@ "settings.update.source_plonds_desc": "优先使用 PLONDS 分发端点,不可用时自动回退到 GitHub。", "settings.update.status_check_failed_plonds": "PLONDS 更新检查失败,正在回退到 GitHub...", "settings.window.drawer_default": "详情", + "settings.search.placeholder": "搜索设置", + "settings.search.no_results": "没有匹配的设置", + "settings.search.page_hint": "打开设置页面", + "settings.window.more_options": "更多选项", + "settings.window.restart_menu_item": "重启应用", + "settings.window.toggle_pane": "展开或收起导航", + "settings.window.back": "返回", "market.toolbar.search_placeholder": "搜索插件", "market.toolbar.refresh": "刷新", "market.status.loading": "正在加载官方插件目录...", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 7fedb9a..be07ab9 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -23,7 +23,7 @@ public sealed class AppSettingsSnapshot public string ThemeColorMode { get; set; } = "default_neutral"; - public string SystemMaterialMode { get; set; } = "none"; + public string SystemMaterialMode { get; set; } = "auto"; public string? SelectedWallpaperSeed { get; set; } diff --git a/LanMountainDesktop/Services/AppearanceThemeService.cs b/LanMountainDesktop/Services/AppearanceThemeService.cs index 7139342..aac58db 100644 --- a/LanMountainDesktop/Services/AppearanceThemeService.cs +++ b/LanMountainDesktop/Services/AppearanceThemeService.cs @@ -145,7 +145,7 @@ internal sealed class WindowMaterialService : IWindowMaterialService private const int Windows11Build = 22000; private const int Windows11_24H2Build = 26100; - public bool CanChangeMode => GetSupportProfile() == WindowMaterialSupportProfile.FullSwitching; + public bool CanChangeMode => GetAvailableModes().Count > 1; public IReadOnlyList GetAvailableModes() { @@ -153,22 +153,26 @@ internal sealed class WindowMaterialService : IWindowMaterialService { WindowMaterialSupportProfile.FullSwitching => [ + ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialNone, ThemeAppearanceValues.MaterialMica, ThemeAppearanceValues.MaterialAcrylic ], WindowMaterialSupportProfile.FixedMica => [ + ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialNone, ThemeAppearanceValues.MaterialMica ], WindowMaterialSupportProfile.FixedAcrylic => [ + ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialNone, ThemeAppearanceValues.MaterialAcrylic ], _ => [ + ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialNone ] }; @@ -179,8 +183,12 @@ internal sealed class WindowMaterialService : IWindowMaterialService ArgumentNullException.ThrowIfNull(window); var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode); + var supportProfile = GetSupportProfile(); + var effectiveMode = normalizedMode == ThemeAppearanceValues.MaterialAuto + ? ResolveAutoMaterialMode(supportProfile) + : normalizedMode; - if (normalizedMode == ThemeAppearanceValues.MaterialNone) + if (effectiveMode == ThemeAppearanceValues.MaterialNone) { window.Background = Brushes.White; window.TransparencyLevelHint = [WindowTransparencyLevel.None]; @@ -189,7 +197,7 @@ internal sealed class WindowMaterialService : IWindowMaterialService window.Background = Brushes.Transparent; - if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled()) + if (supportProfile == WindowMaterialSupportProfile.NoneOnly) { window.TransparencyLevelHint = [ @@ -198,7 +206,9 @@ internal sealed class WindowMaterialService : IWindowMaterialService return; } - window.TransparencyLevelHint = normalizedMode switch + window.TransparencyLevelHint = normalizedMode == ThemeAppearanceValues.MaterialAuto + ? ResolveAutoTransparencyLevels(supportProfile) + : effectiveMode switch { ThemeAppearanceValues.MaterialMica => [ @@ -219,6 +229,42 @@ internal sealed class WindowMaterialService : IWindowMaterialService }; } + private static string ResolveAutoMaterialMode(WindowMaterialSupportProfile supportProfile) + { + return supportProfile switch + { + WindowMaterialSupportProfile.FullSwitching or WindowMaterialSupportProfile.FixedMica => + ThemeAppearanceValues.MaterialMica, + WindowMaterialSupportProfile.FixedAcrylic => + ThemeAppearanceValues.MaterialAcrylic, + _ => ThemeAppearanceValues.MaterialNone + }; + } + + private static IReadOnlyList ResolveAutoTransparencyLevels(WindowMaterialSupportProfile supportProfile) + { + return supportProfile switch + { + WindowMaterialSupportProfile.FullSwitching or WindowMaterialSupportProfile.FixedMica => + [ + WindowTransparencyLevel.Mica, + WindowTransparencyLevel.AcrylicBlur, + WindowTransparencyLevel.Blur, + WindowTransparencyLevel.None + ], + WindowMaterialSupportProfile.FixedAcrylic => + [ + WindowTransparencyLevel.AcrylicBlur, + WindowTransparencyLevel.Blur, + WindowTransparencyLevel.None + ], + _ => + [ + WindowTransparencyLevel.None + ] + }; + } + private static bool IsTransparencyEnabled() { if (!OperatingSystem.IsWindows()) @@ -300,7 +346,7 @@ internal sealed class MaterialSurfaceService : IMaterialSurfaceService ?? (monetColors.Length > 4 ? monetColors[4] : ResolveLiftBase(context.IsNightMode, role)); - var materialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(context.SystemMaterialMode); + var materialMode = ThemeAppearanceValues.ResolveEffectiveSystemMaterialMode(context.SystemMaterialMode); var (tintStrength, liftStrength, alpha, blurRadius) = ResolveModeParameters(materialMode, role, context.IsNightMode); var neutralBase = ResolveNeutralBase(context.IsNightMode, role); @@ -428,9 +474,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa private readonly IWindowMaterialService _windowMaterialService; private readonly IMaterialSurfaceService _materialSurfaceService; private readonly MonetColorService _monetColorService = new(); - private readonly string _liveThemeColorMode; - private readonly string _liveSystemMaterialMode; - private readonly string? _liveSelectedWallpaperSeed; + private string _liveThemeColorMode; + private string _liveSystemMaterialMode; + private string? _liveSelectedWallpaperSeed; private readonly object _paletteGate = new(); private readonly Dictionary _wallpaperSeedCache = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _pendingWallpaperSeedKeys = new(StringComparer.OrdinalIgnoreCase); @@ -573,6 +619,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa !changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) && !changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) && !changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColorMode), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparer.OrdinalIgnoreCase) && !(respondsToThemeColor && changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) && !(respondsToWallpaper && @@ -583,6 +632,12 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa return; } + var latestThemeState = _settingsFacade.Theme.Get(); + _liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode( + latestThemeState.ThemeColorMode, + latestThemeState.ThemeColor); + _liveSystemMaterialMode = ResolveSupportedMaterialMode(latestThemeState.SystemMaterialMode); + _liveSelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed; RaiseChanged(queueWallpaperPaletteBuild: true); } diff --git a/LanMountainDesktop/Services/GlassEffectService.cs b/LanMountainDesktop/Services/GlassEffectService.cs index 9c0b2fd..0dd03d7 100644 --- a/LanMountainDesktop/Services/GlassEffectService.cs +++ b/LanMountainDesktop/Services/GlassEffectService.cs @@ -113,7 +113,7 @@ public static class GlassEffectService /// 可选内容叠层 alpha,与设置窗表面色相一致;None 为 0 避免重复染色。 private static byte ResolveSettingsWindowTintAlpha(ThemeColorContext context) { - var mode = ThemeAppearanceValues.NormalizeSystemMaterialMode(context.SystemMaterialMode); + var mode = ThemeAppearanceValues.ResolveEffectiveSystemMaterialMode(context.SystemMaterialMode); return mode switch { ThemeAppearanceValues.MaterialAcrylic => context.IsNightMode ? (byte)0x58 : (byte)0x4C, diff --git a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs index 147e4d6..94f3044 100644 --- a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs +++ b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs @@ -234,6 +234,9 @@ internal sealed class SettingsWindowService : ISettingsWindowService var themeChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColorMode), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) || (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) && changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) || (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && diff --git a/LanMountainDesktop/Services/SettingsSearchService.cs b/LanMountainDesktop/Services/SettingsSearchService.cs new file mode 100644 index 0000000..4c21a7f --- /dev/null +++ b/LanMountainDesktop/Services/SettingsSearchService.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.VisualTree; +using FluentAvalonia.UI.Controls; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.Services; + +public sealed class SettingsSearchResult +{ + public SettingsSearchResult( + string pageId, + string pageTitle, + string? pageDescription, + string displayTitle, + string? displayDescription, + string? targetId, + Control? targetControl, + bool isPageResult, + IEnumerable? keywords = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pageId); + ArgumentException.ThrowIfNullOrWhiteSpace(pageTitle); + ArgumentException.ThrowIfNullOrWhiteSpace(displayTitle); + + PageId = pageId.Trim(); + PageTitle = pageTitle.Trim(); + PageDescription = NormalizeText(pageDescription); + DisplayTitle = displayTitle.Trim(); + DisplayDescription = NormalizeText(displayDescription); + TargetId = NormalizeText(targetId); + TargetControl = targetControl; + IsPageResult = isPageResult; + Keywords = keywords? + .Select(NormalizeText) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + ?? []; + } + + public string PageId { get; } + + public string PageTitle { get; } + + public string? PageDescription { get; } + + public string DisplayTitle { get; } + + public string? DisplayDescription { get; } + + public string? TargetId { get; } + + public Control? TargetControl { get; } + + public bool IsPageResult { get; } + + public IReadOnlyList Keywords { get; } + + public string SearchText => string.Join( + " ", + new[] + { + PageId, + PageTitle, + PageDescription, + DisplayTitle, + DisplayDescription, + TargetId, + string.Join(" ", Keywords) + }.Where(static value => !string.IsNullOrWhiteSpace(value))); + + public override string ToString() => DisplayTitle; + + private static string? NormalizeText(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +internal sealed class SettingsSearchService +{ + private readonly Dictionary> _entriesByPage = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Entries => + _entriesByPage.Values.SelectMany(static entries => entries).ToArray(); + + public void RebuildPageEntries(IEnumerable pages) + { + _entriesByPage.Clear(); + + foreach (var page in pages) + { + _entriesByPage[page.PageId] = + [ + CreatePageResult(page) + ]; + } + } + + public void IndexPage(SettingsPageDescriptor descriptor, Control page) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(page); + + var results = new List { CreatePageResult(descriptor) }; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase) + { + descriptor.PageId + }; + + foreach (var target in page.GetVisualDescendants().OfType()) + { + if (target is not FASettingsExpander && target is not FASettingsExpanderItem) + { + continue; + } + + var title = ReadControlText(target, "Header"); + var description = ReadControlText(target, "Description"); + + if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(description)) + { + continue; + } + + var targetId = string.IsNullOrWhiteSpace(target.Name) + ? $"{descriptor.PageId}:{results.Count}" + : target.Name; + var key = $"{targetId}|{title}|{description}"; + if (!seen.Add(key)) + { + continue; + } + + results.Add(new SettingsSearchResult( + descriptor.PageId, + descriptor.Title, + descriptor.Description, + string.IsNullOrWhiteSpace(title) ? descriptor.Title : title!, + description, + targetId, + target, + isPageResult: false, + keywords: [descriptor.Category.ToString(), descriptor.IconKey])); + } + + _entriesByPage[descriptor.PageId] = results; + } + + public IReadOnlyList Search(string? query, int maxResults = 24) + { + if (string.IsNullOrWhiteSpace(query)) + { + return []; + } + + var terms = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (terms.Length == 0) + { + return []; + } + + return Entries + .Select(entry => new + { + Entry = entry, + Score = Score(entry, terms) + }) + .Where(static item => item.Score > 0) + .OrderByDescending(static item => item.Score) + .ThenBy(static item => item.Entry.IsPageResult) + .ThenBy(static item => item.Entry.PageTitle, StringComparer.CurrentCultureIgnoreCase) + .ThenBy(static item => item.Entry.DisplayTitle, StringComparer.CurrentCultureIgnoreCase) + .Take(Math.Max(1, maxResults)) + .Select(static item => item.Entry) + .ToArray(); + } + + public static bool Filter(string? search, object? item) + { + if (item is not SettingsSearchResult result || string.IsNullOrWhiteSpace(search)) + { + return false; + } + + var terms = search.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return terms.Length > 0 && Score(result, terms) > 0; + } + + private static SettingsSearchResult CreatePageResult(SettingsPageDescriptor descriptor) + { + return new SettingsSearchResult( + descriptor.PageId, + descriptor.Title, + descriptor.Description, + descriptor.Title, + descriptor.Description, + descriptor.PageId, + null, + isPageResult: true, + keywords: + [ + descriptor.Category.ToString(), + descriptor.IconKey, + descriptor.PluginId ?? string.Empty, + descriptor.GroupId ?? string.Empty + ]); + } + + private static int Score(SettingsSearchResult entry, IReadOnlyList terms) + { + var score = 0; + foreach (var term in terms) + { + if (entry.DisplayTitle.StartsWith(term, StringComparison.OrdinalIgnoreCase)) + { + score += 100; + continue; + } + + if (entry.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + score += 75; + continue; + } + + if (entry.PageTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + score += 50; + continue; + } + + if (entry.SearchText.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + score += 25; + continue; + } + + return 0; + } + + return score + (entry.IsPageResult ? 0 : 12); + } + + private static string? ReadControlText(Control control, string propertyName) + { + var value = control.GetType().GetProperty(propertyName)?.GetValue(control); + return value switch + { + null => null, + string text => string.IsNullOrWhiteSpace(text) ? null : text.Trim(), + TextBlock textBlock => string.IsNullOrWhiteSpace(textBlock.Text) ? null : textBlock.Text.Trim(), + _ => value.ToString() + }; + } +} diff --git a/LanMountainDesktop/Services/ThemeAppearanceValues.cs b/LanMountainDesktop/Services/ThemeAppearanceValues.cs index 19553a2..ddcf70d 100644 --- a/LanMountainDesktop/Services/ThemeAppearanceValues.cs +++ b/LanMountainDesktop/Services/ThemeAppearanceValues.cs @@ -18,6 +18,7 @@ public static class ThemeAppearanceValues public const string ThemeModeFollowSystem = "follow_system"; public const string MaterialNone = "none"; + public const string MaterialAuto = "auto"; public const string MaterialMica = "mica"; public const string MaterialAcrylic = "acrylic"; @@ -30,6 +31,7 @@ public static class ThemeAppearanceValues public static readonly IReadOnlyList AllMaterialModes = [ + MaterialAuto, MaterialNone, MaterialMica, MaterialAcrylic @@ -59,6 +61,11 @@ public static class ThemeAppearanceValues public static string NormalizeSystemMaterialMode(string? value) { + if (string.Equals(value, MaterialAuto, StringComparison.OrdinalIgnoreCase)) + { + return MaterialAuto; + } + if (string.Equals(value, MaterialMica, StringComparison.OrdinalIgnoreCase)) { return MaterialMica; @@ -72,11 +79,32 @@ public static class ThemeAppearanceValues return MaterialNone; } + public static string ResolveEffectiveSystemMaterialMode(string? value) + { + var normalized = NormalizeSystemMaterialMode(value); + if (!string.Equals(normalized, MaterialAuto, StringComparison.OrdinalIgnoreCase)) + { + return normalized; + } + + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000)) + { + return MaterialMica; + } + + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0)) + { + return MaterialAcrylic; + } + + return MaterialNone; + } + public static IReadOnlyList NormalizeAvailableMaterialModes(IEnumerable? values) { if (values is null) { - return [MaterialNone]; + return [MaterialAuto, MaterialNone]; } var normalized = values @@ -84,9 +112,14 @@ public static class ThemeAppearanceValues .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); + if (!normalized.Contains(MaterialAuto, StringComparer.OrdinalIgnoreCase)) + { + normalized.Insert(0, MaterialAuto); + } + if (!normalized.Contains(MaterialNone, StringComparer.OrdinalIgnoreCase)) { - normalized.Insert(0, MaterialNone); + normalized.Insert(normalized.Count > 0 ? 1 : 0, MaterialNone); } return normalized; diff --git a/LanMountainDesktop/Theme/ThemeColorContext.cs b/LanMountainDesktop/Theme/ThemeColorContext.cs index 08a91ac..2f766c9 100644 --- a/LanMountainDesktop/Theme/ThemeColorContext.cs +++ b/LanMountainDesktop/Theme/ThemeColorContext.cs @@ -13,4 +13,4 @@ public sealed record ThemeColorContext( MonetPalette? MonetPalette = null, IReadOnlyList? MonetColors = null, bool UseNeutralSurfaces = false, - string SystemMaterialMode = ThemeAppearanceValues.MaterialNone); + string SystemMaterialMode = ThemeAppearanceValues.MaterialAuto); diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 46829bc..7bbaa2d 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -88,6 +88,36 @@ public sealed partial class SettingsWindowViewModel : ViewModelBase [ObservableProperty] private bool _isDrawerOpen; + [ObservableProperty] + private bool _canGoBack; + + [ObservableProperty] + private string _searchQuery = string.Empty; + + [ObservableProperty] + private string _searchPlaceholderText = string.Empty; + + [ObservableProperty] + private string _searchNoResultsText = string.Empty; + + [ObservableProperty] + private string _searchPageHintText = string.Empty; + + [ObservableProperty] + private SettingsSearchResult? _selectedSearchResult; + + [ObservableProperty] + private string _moreOptionsText = string.Empty; + + [ObservableProperty] + private string _restartMenuItemText = string.Empty; + + [ObservableProperty] + private string _togglePaneTooltip = string.Empty; + + [ObservableProperty] + private string _backTooltip = string.Empty; + /// 用于标题栏右侧系统按钮占位(与 SecRandom / ClassIsland 一致,仅 Windows 显示)。 [ObservableProperty] private bool _isWindowsOs; @@ -112,6 +142,13 @@ public sealed partial class SettingsWindowViewModel : ViewModelBase "settings.restart_dialog.later", L("settings.restart_dialog.cancel")); DrawerFallbackTitle = L("settings.window.drawer_default"); + SearchPlaceholderText = L("settings.search.placeholder"); + SearchNoResultsText = L("settings.search.no_results"); + SearchPageHintText = L("settings.search.page_hint"); + MoreOptionsText = L("settings.window.more_options"); + RestartMenuItemText = L("settings.window.restart_menu_item"); + TogglePaneTooltip = L("settings.window.toggle_pane"); + BackTooltip = L("settings.window.back"); var nextDefaultRestartMessage = L("settings.restart_dock.description"); if (string.IsNullOrWhiteSpace(RestartMessage) || string.Equals(RestartMessage, _defaultRestartMessage, StringComparison.Ordinal)) @@ -125,6 +162,8 @@ public sealed partial class SettingsWindowViewModel : ViewModelBase public string GetDefaultRestartMessage() => _defaultRestartMessage; public ObservableCollection Pages { get; } = []; + + public ObservableCollection SearchResults { get; } = []; } public sealed class SelectionOption @@ -285,6 +324,21 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo [ObservableProperty] private bool _showInTaskbar; + [ObservableProperty] + private string _fadeTransitionHeader = string.Empty; + + [ObservableProperty] + private string _slideTransitionHeader = string.Empty; + + [ObservableProperty] + private string _slideTransitionDescription = string.Empty; + + [ObservableProperty] + private string _showInTaskbarHeader = string.Empty; + + [ObservableProperty] + private string _showInTaskbarDescription = string.Empty; + public bool IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows); public bool IsFadeTransitionToggleEnabled => !EnableSlideTransition; @@ -512,6 +566,15 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo RenderModeRestartMessage = L( "settings.general.render_mode_restart_message", "Rendering mode changes require restarting the app."); + FadeTransitionHeader = L("settings.general.fade_transition_header", "Fade startup transition"); + SlideTransitionHeader = L("settings.general.slide_transition_header", "Slide startup transition"); + SlideTransitionDescription = L( + "settings.general.slide_transition_desc", + "Use a slide-in startup transition on supported Windows builds. This option disables fade transition."); + ShowInTaskbarHeader = L("settings.general.show_main_window_taskbar_header", "Show main desktop window in taskbar"); + ShowInTaskbarDescription = L( + "settings.general.show_main_window_taskbar_desc", + "Keep the main desktop host window visible in the taskbar. The independent settings window always has its own taskbar entry."); } private void RefreshPreview() @@ -676,7 +739,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase private SelectionOption _selectedThemeColorMode = new(ThemeAppearanceValues.ColorModeSeedMonet, "User theme color Monet"); [ObservableProperty] - private SelectionOption _selectedSystemMaterialMode = new(ThemeAppearanceValues.MaterialNone, "None"); + private SelectionOption _selectedSystemMaterialMode = new(ThemeAppearanceValues.MaterialAuto, "Auto"); [ObservableProperty] private bool _isThemeColorEditable; @@ -777,6 +840,9 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _systemMaterialNoneText = string.Empty; + [ObservableProperty] + private string _systemMaterialAutoText = string.Empty; + [ObservableProperty] private string _systemMaterialMicaText = string.Empty; @@ -789,6 +855,9 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _systemMaterialFixedDescription = string.Empty; + [ObservableProperty] + private string _systemMaterialAutoDescription = string.Empty; + [ObservableProperty] private string _appearanceRestartMessage = string.Empty; @@ -959,10 +1028,12 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase ThemeSourceWallpaperSystemDescription = L("settings.appearance.theme_color_preview.system", "Currently previewing colors extracted from the system wallpaper."); ThemeSourceWallpaperFallbackDescription = L("settings.appearance.theme_color_preview.fallback", "No usable wallpaper was found. The app is using a fallback accent."); SystemMaterialNoneText = L("settings.appearance.system_material.none", "None"); + SystemMaterialAutoText = L("settings.appearance.system_material.auto", "Auto (recommended)"); SystemMaterialMicaText = L("settings.appearance.system_material.mica", "Mica"); SystemMaterialAcrylicText = L("settings.appearance.system_material.acrylic", "Acrylic"); SystemMaterialSwitchableDescription = L("settings.appearance.system_material_desc.switchable", "Apply the selected material to windows, Dock, status bar, and component hosts."); SystemMaterialFixedDescription = L("settings.appearance.system_material_desc.fixed", "Your current system only exposes the available material modes listed here."); + SystemMaterialAutoDescription = L("settings.appearance.system_material_desc.auto", "Auto prefers Mica on Windows 11, Acrylic on Windows 10, and falls back to no material when unavailable."); AppearanceRestartMessage = L( "settings.appearance.restart_message", "Theme source and system material changes require restarting the app."); @@ -984,7 +1055,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase .Select(value => new SelectionOption(value, ResolveMaterialModeLabel(value))) .ToList(); SystemMaterialDescription = snapshot.CanChangeSystemMaterial - ? SystemMaterialSwitchableDescription + ? SystemMaterialAutoDescription : SystemMaterialFixedDescription; } @@ -1145,6 +1216,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase { return ThemeAppearanceValues.NormalizeSystemMaterialMode(value) switch { + ThemeAppearanceValues.MaterialAuto => SystemMaterialAutoText, ThemeAppearanceValues.MaterialMica => SystemMaterialMicaText, ThemeAppearanceValues.MaterialAcrylic => SystemMaterialAcrylicText, _ => SystemMaterialNoneText diff --git a/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml index e1021b4..06f91b0 100644 --- a/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml @@ -103,7 +103,7 @@ - @@ -115,8 +115,8 @@ - @@ -126,8 +126,8 @@ - + diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml b/LanMountainDesktop/Views/SettingsWindow.axaml index 19cd647..c914427 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml +++ b/LanMountainDesktop/Views/SettingsWindow.axaml @@ -1,6 +1,8 @@ + + + + + + + + - - - - - - - - + Grid.Row="1" + Margin="0" + Background="Transparent" + PaneDisplayMode="Auto" + OpenPaneLength="283" + IsSettingsVisible="False" + IsBackButtonVisible="False" + SelectionChanged="OnNavigationSelectionChanged"> @@ -99,7 +106,12 @@ + Grid.Row="1" /> + + + BorderThickness="0,0,0,1" + PointerPressed="OnTitleBarDragZonePointerPressed"> + + + + - + + + + + + + + + + + + + + + SettingsSearchFilter => SettingsSearchService.Filter; + private const double BaseSettingsContainerWidth = 960d; private const double MinSettingsContentWidth = 320d; private const double MinSettingsContainerWidth = 840d; @@ -32,10 +35,15 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext private readonly ISettingsPageRegistry _pageRegistry; private readonly IHostApplicationLifecycle _hostApplicationLifecycle; private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate(); + private readonly SettingsSearchService _searchService = new(); private readonly Dictionary _cachedPages = new(StringComparer.OrdinalIgnoreCase); + private readonly Stack _navigationBackStack = new(); private bool _useSystemChrome; private bool _isResponsiveRefreshPending; private bool _isRestartPromptVisible; + private bool _isHandlingSearchSelection; + private Border? _currentSearchHighlight; + private Action? _searchHighlightCleanup; public SettingsWindow() : this( @@ -87,8 +95,8 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext SyncPendingRestartState(); SyncTitleText(); UpdateChromeMetrics(); - UpdatePaneFooterToggleVisibility(); - UpdatePaneFooterToggleIcon(); + UpdatePaneToggleVisibility(); + UpdatePaneToggleIcon(); UpdateResponsiveLayout(); RequestResponsiveLayoutRefresh(); } @@ -102,10 +110,13 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext } _cachedPages.Clear(); + _navigationBackStack.Clear(); + ViewModel.CanGoBack = false; CloseDrawer(); RebuildNavigationItems(); - NavigateTo(pageId ?? ViewModel.Pages.FirstOrDefault()?.PageId); - UpdatePaneFooterToggleVisibility(); + NavigateTo(pageId ?? ViewModel.Pages.FirstOrDefault()?.PageId, addHistory: false, source: "reload"); + RebuildSearchIndex(scanBuiltInPages: true); + UpdatePaneToggleVisibility(); } public void RebuildAndNavigateToDevPage() @@ -236,11 +247,16 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext private void OnNavigationSelectionChanged(object? sender, FANavigationViewSelectionChangedEventArgs e) { + _ = sender; var selectedItem = e.SelectedItemContainer ?? e.SelectedItem as FANavigationViewItem; - NavigateTo(selectedItem?.Tag as string); + NavigateTo(selectedItem?.Tag as string, addHistory: true, source: "navigation"); } - private void NavigateTo(string? pageId) + private void NavigateTo( + string? pageId, + bool addHistory, + string source, + SettingsSearchResult? searchResult = null) { var previousPageId = ViewModel.CurrentPageId; var descriptor = ResolveDescriptor(pageId); @@ -249,6 +265,21 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext return; } + if (string.Equals(previousPageId, descriptor.PageId, StringComparison.OrdinalIgnoreCase)) + { + if (searchResult is not null) + { + HighlightSearchResult(searchResult); + } + + return; + } + + if (addHistory && !string.IsNullOrWhiteSpace(previousPageId)) + { + _navigationBackStack.Push(previousPageId); + } + var page = GetOrCreatePage(descriptor); if (page is SettingsPageBase settingsPage) { @@ -266,14 +297,21 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext ViewModel.CurrentPageDescription = descriptor.Description; ViewModel.CurrentPageId = descriptor.PageId; ViewModel.IsPageTitleVisible = !descriptor.HidePageTitle; + ViewModel.CanGoBack = _navigationBackStack.Count > 0; + CloseDrawer(); TrySelectNavigationItem(descriptor.PageId); SyncTitleText(); - UpdatePaneFooterToggleVisibility(); + UpdatePaneToggleVisibility(); UpdateResponsiveLayout(); RequestResponsiveLayoutRefresh(); + if (searchResult is not null) + { + HighlightSearchResult(searchResult); + } + if (!string.Equals(previousPageId, descriptor.PageId, StringComparison.OrdinalIgnoreCase)) { - TelemetryServices.Usage?.TrackSettingsNavigation(previousPageId, descriptor.PageId, "navigation"); + TelemetryServices.Usage?.TrackSettingsNavigation(previousPageId, descriptor.PageId, source); } } @@ -303,9 +341,34 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext } _cachedPages[descriptor.PageId] = page; + _searchService.IndexPage(descriptor, page); return page; } + private void RebuildSearchIndex(bool scanBuiltInPages) + { + _searchService.RebuildPageEntries(ViewModel.Pages); + + if (scanBuiltInPages) + { + foreach (var descriptor in ViewModel.Pages.Where(static page => page.IsBuiltIn)) + { + _ = GetOrCreatePage(descriptor); + } + } + + SyncSearchResults(); + } + + private void SyncSearchResults() + { + ViewModel.SearchResults.Clear(); + foreach (var result in _searchService.Entries) + { + ViewModel.SearchResults.Add(result); + } + } + private void TrySelectNavigationItem(string pageId) { if (RootNavigationView is null) @@ -340,6 +403,77 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext CloseDrawer(); } + private void OnBackButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + _ = sender; + _ = e; + + while (_navigationBackStack.Count > 0) + { + var pageId = _navigationBackStack.Pop(); + if (ResolveDescriptor(pageId) is not null) + { + NavigateTo(pageId, addHistory: false, source: "back"); + return; + } + } + + ViewModel.CanGoBack = false; + } + + private void OnRestartMenuItemClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + _ = sender; + _ = e; + ShowRestartPrompt(); + } + + private void OnSearchBoxKeyUp(object? sender, KeyEventArgs e) + { + if (e.Key != Key.Enter) + { + return; + } + + var selected = ViewModel.SelectedSearchResult; + if (selected is null && SettingsSearchBox is not null) + { + selected = _searchService.Search(SettingsSearchBox.Text, maxResults: 1).FirstOrDefault(); + } + + NavigateToSearchResult(selected); + } + + private void OnSearchBoxSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + _ = sender; + if (_isHandlingSearchSelection || e.AddedItems.Count == 0) + { + return; + } + + NavigateToSearchResult(e.AddedItems[0] as SettingsSearchResult); + } + + private void NavigateToSearchResult(SettingsSearchResult? result) + { + if (result is null) + { + return; + } + + _isHandlingSearchSelection = true; + try + { + NavigateTo(result.PageId, addHistory: true, source: "search", searchResult: result); + ViewModel.SelectedSearchResult = null; + } + finally + { + _isHandlingSearchSelection = false; + } + } + private void OnPendingRestartStateChanged() { SyncPendingRestartState(); @@ -504,8 +638,125 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext // Hide the drawer pane on narrow windows. } + private void HighlightSearchResult(SettingsSearchResult result) + { + var target = result.TargetControl; + if (target is null) + { + return; + } + + Dispatcher.UIThread.Post( + () => + { + ExpandSearchTarget(target); + target.BringIntoView(); + target.Focus(); + ShowSearchHighlight(target); + }, + DispatcherPriority.Render); + } + + private static void ExpandSearchTarget(Control target) + { + if (target is FASettingsExpander expander) + { + expander.IsExpanded = true; + } + + foreach (var ancestor in target.GetVisualAncestors().OfType()) + { + ancestor.IsExpanded = true; + } + } + + private void ShowSearchHighlight(Control target) + { + RemoveSearchHighlight(); + + if (SearchHighlightOverlay is null || target.Bounds.Width <= 0 || target.Bounds.Height <= 0) + { + return; + } + + var transform = target.TransformToVisual(SearchHighlightOverlay); + if (transform is null) + { + return; + } + + var position = transform.Value.Transform(new Point(0, 0)); + var accent = HostAppearanceThemeProvider.GetOrCreate().GetCurrent().AccentColor; + var highlight = new Border + { + Width = target.Bounds.Width, + Height = target.Bounds.Height, + Background = new SolidColorBrush(Color.FromArgb(34, accent.R, accent.G, accent.B)), + BorderBrush = new SolidColorBrush(Color.FromArgb(210, accent.R, accent.G, accent.B)), + BorderThickness = new Thickness(2), + CornerRadius = new CornerRadius(8), + IsHitTestVisible = false + }; + + Canvas.SetLeft(highlight, position.X); + Canvas.SetTop(highlight, position.Y); + SearchHighlightOverlay.Children.Add(highlight); + _currentSearchHighlight = highlight; + + void OnLayoutUpdated(object? sender, EventArgs e) + { + _ = sender; + _ = e; + if (_currentSearchHighlight != highlight || SearchHighlightOverlay is null) + { + return; + } + + var nextTransform = target.TransformToVisual(SearchHighlightOverlay); + if (nextTransform is null) + { + return; + } + + var nextPosition = nextTransform.Value.Transform(new Point(0, 0)); + Canvas.SetLeft(highlight, nextPosition.X); + Canvas.SetTop(highlight, nextPosition.Y); + highlight.Width = target.Bounds.Width; + highlight.Height = target.Bounds.Height; + } + + target.LayoutUpdated += OnLayoutUpdated; + _searchHighlightCleanup = () => + { + target.LayoutUpdated -= OnLayoutUpdated; + SearchHighlightOverlay?.Children.Remove(highlight); + }; + + var timer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(2.4) + }; + timer.Tick += (_, _) => + { + timer.Stop(); + if (_currentSearchHighlight == highlight) + { + RemoveSearchHighlight(); + } + }; + timer.Start(); + } + + private void RemoveSearchHighlight() + { + _searchHighlightCleanup?.Invoke(); + _searchHighlightCleanup = null; + _currentSearchHighlight = null; + } + private void OnClosed(object? sender, EventArgs e) { + RemoveSearchHighlight(); _cachedPages.Clear(); PendingRestartStateService.StateChanged -= OnPendingRestartStateChanged; if (RootNavigationView is not null) @@ -520,13 +771,39 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext private void OnTitleBarDragZonePointerPressed(object? sender, PointerPressedEventArgs e) { _ = sender; - if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || IsInteractiveTitleBarSource(e.Source as Control)) { - BeginMoveDrag(e); + return; } + + BeginMoveDrag(e); } - private void OnPaneFooterToggleClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + private bool IsInteractiveTitleBarSource(Control? source) + { + if (source is null) + { + return false; + } + + IEnumerable controls = source.GetVisualAncestors().OfType().Prepend(source); + foreach (var control in controls) + { + if (ReferenceEquals(control, WindowTitleBarHost)) + { + return false; + } + + if (control is Button or AutoCompleteBox or TextBox or MenuItem) + { + return true; + } + } + + return false; + } + + private void OnTitleBarPaneToggleClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { _ = sender; _ = e; @@ -536,7 +813,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext } RootNavigationView.IsPaneOpen = !RootNavigationView.IsPaneOpen; - UpdatePaneFooterToggleIcon(); + UpdatePaneToggleIcon(); UpdateResponsiveLayout(); RequestResponsiveLayoutRefresh(); } @@ -552,10 +829,10 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext { if (e.Property == FANavigationView.IsPaneToggleButtonVisibleProperty) { - UpdatePaneFooterToggleVisibility(); + UpdatePaneToggleVisibility(); } - UpdatePaneFooterToggleIcon(); + UpdatePaneToggleIcon(); RequestResponsiveLayoutRefresh(); } } @@ -564,14 +841,14 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext /// 仅在 :minimal 为 false)时显示侧栏底部备胎按钮。 /// 根 DataContext 为 ViewModel 时,对 #RootNavigationView 的绑定易失效,故用代码同步可见性。 /// - private void UpdatePaneFooterToggleVisibility() + private void UpdatePaneToggleVisibility() { - if (PaneFooterToggleButton is null || RootNavigationView is null) + if (TitleBarPaneToggleButton is null || RootNavigationView is null) { return; } - PaneFooterToggleButton.IsVisible = !RootNavigationView.IsPaneToggleButtonVisible; + TitleBarPaneToggleButton.IsVisible = !RootNavigationView.IsPaneToggleButtonVisible; } private void RequestResponsiveLayoutRefresh() @@ -603,14 +880,14 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext : compactPaneWidth; } - private void UpdatePaneFooterToggleIcon() + private void UpdatePaneToggleIcon() { - if (PaneFooterToggleButtonIcon is null || RootNavigationView is null) + if (TitleBarPaneToggleButtonIcon is null || RootNavigationView is null) { return; } - PaneFooterToggleButtonIcon.Icon = RootNavigationView.IsPaneOpen + TitleBarPaneToggleButtonIcon.Icon = RootNavigationView.IsPaneOpen ? FluentIcons.Common.Icon.LineHorizontal3 : FluentIcons.Common.Icon.Navigation; } diff --git a/design.md b/design.md index 1df479e..34f0a29 100644 --- a/design.md +++ b/design.md @@ -1,5 +1,7 @@ # UI Design System Guide (design.md) +> Settings window shell-specific rules live in `docs/ai/SETTINGS_WINDOW_DESIGN.md`. + > **目标**: 让 AI 正确使用 Fluent Avalonia / Fluent Icons / Material Avalonia,避免窗口套窗口、容器套容器 > > **最后更新**: 2026-04-11 diff --git a/docs/ai/SETTINGS_WINDOW_DESIGN.md b/docs/ai/SETTINGS_WINDOW_DESIGN.md new file mode 100644 index 0000000..d665b71 --- /dev/null +++ b/docs/ai/SETTINGS_WINDOW_DESIGN.md @@ -0,0 +1,48 @@ +# Settings Window Fluent Shell Design + +This document is the authoritative implementation note for the LanMountainDesktop settings window shell. +General visual tokens still come from `docs/VISUAL_SPEC.md` and `docs/CORNER_RADIUS_SPEC.md`. + +## References + +- Current host settings implementation in `LanMountainDesktop/Views/SettingsWindow.axaml`. +- ClassIsland `SettingsWindowNew`: titlebar navigation buttons, titlebar pane toggle, `NavigationView` width, right-side drawer. +- SecRandom v3 Avalonia `SettingsView`: titlebar search, restart action, `NavigationView` compact toggle, search result highlight. +- Awesome Design / Fluent style notes: quiet app surface, token-driven spacing, system material as backdrop instead of decorative panels. + +## Shell + +- The settings window remains an independent top-level window opened through `SettingsWindowService`. +- The shell uses a 48 DIP custom titlebar and one `FANavigationView` as the main container. +- The titlebar left cluster is: Back, pane toggle, app/settings icon, window title. +- The titlebar center is a settings `AutoCompleteBox` search field. +- The titlebar right cluster is: restart prompt, more options, Windows caption-button spacer. +- The fallback pane toggle belongs in the titlebar, not the navigation footer. +- Content remains unframed: pages render directly in the `FAFrame`; drawers are the only side panel. + +## Navigation And Search + +- `FANavigationView.OpenPaneLength` stays near 283 DIP and may scale within the existing responsive limits. +- Navigation history is local to the settings window; using Back does not close the window or affect the desktop shell. +- Search entries always include page-level descriptors. +- Built-in pages are also scanned for `FASettingsExpander` and `FASettingsExpanderItem` text. +- Selecting a search result navigates to its page, expands parent settings expanders, scrolls/focuses the target, and shows a short accent highlight. +- Plugin and generated pages are searchable at page level unless their controls are already loaded and can be scanned. + +## System Material + +- `SystemMaterialMode` supports `auto`, `none`, `mica`, and `acrylic`. +- The default is `auto`. +- The implementation uses Avalonia `Window.TransparencyLevelHint`; it does not use WinUI SDK interop or private platform accessors. +- Auto mode uses this priority: + - Windows 11: `Mica`, then `AcrylicBlur`, then `Blur`, then `None`. + - Windows 10: `AcrylicBlur`, then `Blur`, then `None`. + - Other systems or disabled transparency: `None`. +- The settings-window root brush remains translucent for material modes so it does not cover the OS backdrop. + +## Layout Rules + +- Settings pages use `ScrollViewer -> StackPanel.settings-page-container -> FASettingsExpander`. +- Avoid nested surface cards inside the settings content area. +- Use dynamic design tokens for radius and colors. +- Widget root radius rules still follow `DesignCornerRadiusComponent`; settings shell internals use the smaller design radius tokens. From 574b798092ab420f1cbf2d9fba1f89b08e593c9c Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 4 May 2026 04:50:35 +0800 Subject: [PATCH 05/29] =?UTF-8?q?fix.=E4=BF=AE=E6=8A=98=E5=8F=A0=E4=B8=8E?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LanMountainDesktop/Views/SettingsWindow.axaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LanMountainDesktop/Views/SettingsWindow.axaml b/LanMountainDesktop/Views/SettingsWindow.axaml index c914427..b0be3b2 100644 --- a/LanMountainDesktop/Views/SettingsWindow.axaml +++ b/LanMountainDesktop/Views/SettingsWindow.axaml @@ -162,7 +162,7 @@