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.