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.
This commit is contained in:
lincube
2026-05-04 04:46:12 +08:00
parent 1d7df5a105
commit 49bbae29af
19 changed files with 1045 additions and 82 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -346,6 +346,11 @@
"settings.general.preview_time_label": "Time", "settings.general.preview_time_label": "Time",
"settings.general.preview_date_label": "Date", "settings.general.preview_date_label": "Date",
"settings.general.render_mode_restart_message": "Rendering mode changes require restarting the app.", "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.title": "Appearance",
"settings.appearance.description": "Adjust theme source, system material, and window chrome.", "settings.appearance.description": "Adjust theme source, system material, and window chrome.",
"settings.appearance.theme_header": "Theme", "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.", "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.follow_system": "Follow system color scheme",
"component.color_scheme.native": "Use component custom 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.none": "None",
"settings.appearance.system_material.mica": "Mica", "settings.appearance.system_material.mica": "Mica",
"settings.appearance.system_material.acrylic": "Acrylic", "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.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.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.restart_message": "Theme source and system material changes require restarting the app.",
"settings.appearance.preview.primary": "Primary", "settings.appearance.preview.primary": "Primary",
"settings.appearance.preview.secondary": "Secondary", "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.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.update.status_check_failed_plonds": "PLONDS update check failed, falling back to GitHub...",
"settings.window.drawer_default": "Details", "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.search_placeholder": "Search plugins",
"market.toolbar.refresh": "Refresh", "market.toolbar.refresh": "Refresh",
"market.status.loading": "Loading the official plugin market...", "market.status.loading": "Loading the official plugin market...",

View File

@@ -347,6 +347,11 @@
"settings.general.preview_time_label": "时间", "settings.general.preview_time_label": "时间",
"settings.general.preview_date_label": "日期", "settings.general.preview_date_label": "日期",
"settings.general.render_mode_restart_message": "渲染模式变更需要重启应用。", "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.title": "外观",
"settings.appearance.description": "调整主题来源、系统材质与窗口外观。", "settings.appearance.description": "调整主题来源、系统材质与窗口外观。",
"settings.appearance.theme_header": "主题", "settings.appearance.theme_header": "主题",
@@ -370,11 +375,13 @@
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。", "settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
"component.color_scheme.follow_system": "跟随系统配色", "component.color_scheme.follow_system": "跟随系统配色",
"component.color_scheme.native": "使用组件自定义配色", "component.color_scheme.native": "使用组件自定义配色",
"settings.appearance.system_material.auto": "自动(推荐)",
"settings.appearance.system_material.none": "无", "settings.appearance.system_material.none": "无",
"settings.appearance.system_material.mica": "Mica", "settings.appearance.system_material.mica": "Mica",
"settings.appearance.system_material.acrylic": "Acrylic", "settings.appearance.system_material.acrylic": "Acrylic",
"settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。", "settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。",
"settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。", "settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。",
"settings.appearance.system_material_desc.auto": "自动模式会在 Windows 11 优先使用 Mica在 Windows 10 优先使用 Acrylic不可用时回退到无材质。",
"settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。", "settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。",
"settings.appearance.preview.primary": "主色", "settings.appearance.preview.primary": "主色",
"settings.appearance.preview.secondary": "次色", "settings.appearance.preview.secondary": "次色",
@@ -741,6 +748,13 @@
"settings.update.source_plonds_desc": "优先使用 PLONDS 分发端点,不可用时自动回退到 GitHub。", "settings.update.source_plonds_desc": "优先使用 PLONDS 分发端点,不可用时自动回退到 GitHub。",
"settings.update.status_check_failed_plonds": "PLONDS 更新检查失败,正在回退到 GitHub...", "settings.update.status_check_failed_plonds": "PLONDS 更新检查失败,正在回退到 GitHub...",
"settings.window.drawer_default": "详情", "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.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新", "market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件目录...", "market.status.loading": "正在加载官方插件目录...",

View File

@@ -23,7 +23,7 @@ public sealed class AppSettingsSnapshot
public string ThemeColorMode { get; set; } = "default_neutral"; public string ThemeColorMode { get; set; } = "default_neutral";
public string SystemMaterialMode { get; set; } = "none"; public string SystemMaterialMode { get; set; } = "auto";
public string? SelectedWallpaperSeed { get; set; } public string? SelectedWallpaperSeed { get; set; }

View File

@@ -145,7 +145,7 @@ internal sealed class WindowMaterialService : IWindowMaterialService
private const int Windows11Build = 22000; private const int Windows11Build = 22000;
private const int Windows11_24H2Build = 26100; private const int Windows11_24H2Build = 26100;
public bool CanChangeMode => GetSupportProfile() == WindowMaterialSupportProfile.FullSwitching; public bool CanChangeMode => GetAvailableModes().Count > 1;
public IReadOnlyList<string> GetAvailableModes() public IReadOnlyList<string> GetAvailableModes()
{ {
@@ -153,22 +153,26 @@ internal sealed class WindowMaterialService : IWindowMaterialService
{ {
WindowMaterialSupportProfile.FullSwitching => WindowMaterialSupportProfile.FullSwitching =>
[ [
ThemeAppearanceValues.MaterialAuto,
ThemeAppearanceValues.MaterialNone, ThemeAppearanceValues.MaterialNone,
ThemeAppearanceValues.MaterialMica, ThemeAppearanceValues.MaterialMica,
ThemeAppearanceValues.MaterialAcrylic ThemeAppearanceValues.MaterialAcrylic
], ],
WindowMaterialSupportProfile.FixedMica => WindowMaterialSupportProfile.FixedMica =>
[ [
ThemeAppearanceValues.MaterialAuto,
ThemeAppearanceValues.MaterialNone, ThemeAppearanceValues.MaterialNone,
ThemeAppearanceValues.MaterialMica ThemeAppearanceValues.MaterialMica
], ],
WindowMaterialSupportProfile.FixedAcrylic => WindowMaterialSupportProfile.FixedAcrylic =>
[ [
ThemeAppearanceValues.MaterialAuto,
ThemeAppearanceValues.MaterialNone, ThemeAppearanceValues.MaterialNone,
ThemeAppearanceValues.MaterialAcrylic ThemeAppearanceValues.MaterialAcrylic
], ],
_ => _ =>
[ [
ThemeAppearanceValues.MaterialAuto,
ThemeAppearanceValues.MaterialNone ThemeAppearanceValues.MaterialNone
] ]
}; };
@@ -179,8 +183,12 @@ internal sealed class WindowMaterialService : IWindowMaterialService
ArgumentNullException.ThrowIfNull(window); ArgumentNullException.ThrowIfNull(window);
var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode); 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.Background = Brushes.White;
window.TransparencyLevelHint = [WindowTransparencyLevel.None]; window.TransparencyLevelHint = [WindowTransparencyLevel.None];
@@ -189,7 +197,7 @@ internal sealed class WindowMaterialService : IWindowMaterialService
window.Background = Brushes.Transparent; window.Background = Brushes.Transparent;
if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled()) if (supportProfile == WindowMaterialSupportProfile.NoneOnly)
{ {
window.TransparencyLevelHint = window.TransparencyLevelHint =
[ [
@@ -198,7 +206,9 @@ internal sealed class WindowMaterialService : IWindowMaterialService
return; return;
} }
window.TransparencyLevelHint = normalizedMode switch window.TransparencyLevelHint = normalizedMode == ThemeAppearanceValues.MaterialAuto
? ResolveAutoTransparencyLevels(supportProfile)
: effectiveMode switch
{ {
ThemeAppearanceValues.MaterialMica => 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<WindowTransparencyLevel> 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() private static bool IsTransparencyEnabled()
{ {
if (!OperatingSystem.IsWindows()) if (!OperatingSystem.IsWindows())
@@ -300,7 +346,7 @@ internal sealed class MaterialSurfaceService : IMaterialSurfaceService
?? (monetColors.Length > 4 ?? (monetColors.Length > 4
? monetColors[4] ? monetColors[4]
: ResolveLiftBase(context.IsNightMode, role)); : 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 (tintStrength, liftStrength, alpha, blurRadius) = ResolveModeParameters(materialMode, role, context.IsNightMode);
var neutralBase = ResolveNeutralBase(context.IsNightMode, role); var neutralBase = ResolveNeutralBase(context.IsNightMode, role);
@@ -428,9 +474,9 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
private readonly IWindowMaterialService _windowMaterialService; private readonly IWindowMaterialService _windowMaterialService;
private readonly IMaterialSurfaceService _materialSurfaceService; private readonly IMaterialSurfaceService _materialSurfaceService;
private readonly MonetColorService _monetColorService = new(); private readonly MonetColorService _monetColorService = new();
private readonly string _liveThemeColorMode; private string _liveThemeColorMode;
private readonly string _liveSystemMaterialMode; private string _liveSystemMaterialMode;
private readonly string? _liveSelectedWallpaperSeed; private string? _liveSelectedWallpaperSeed;
private readonly object _paletteGate = new(); private readonly object _paletteGate = new();
private readonly Dictionary<string, WallpaperSeedExtractionResult> _wallpaperSeedCache = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, WallpaperSeedExtractionResult> _wallpaperSeedCache = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _pendingWallpaperSeedKeys = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _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.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) && !changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), 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 && !(respondsToThemeColor &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) && changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
!(respondsToWallpaper && !(respondsToWallpaper &&
@@ -583,6 +632,12 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
return; return;
} }
var latestThemeState = _settingsFacade.Theme.Get();
_liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
latestThemeState.ThemeColorMode,
latestThemeState.ThemeColor);
_liveSystemMaterialMode = ResolveSupportedMaterialMode(latestThemeState.SystemMaterialMode);
_liveSelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed;
RaiseChanged(queueWallpaperPaletteBuild: true); RaiseChanged(queueWallpaperPaletteBuild: true);
} }

View File

@@ -113,7 +113,7 @@ public static class GlassEffectService
/// <summary>可选内容叠层 alpha与设置窗表面色相一致None 为 0 避免重复染色。</summary> /// <summary>可选内容叠层 alpha与设置窗表面色相一致None 为 0 避免重复染色。</summary>
private static byte ResolveSettingsWindowTintAlpha(ThemeColorContext context) private static byte ResolveSettingsWindowTintAlpha(ThemeColorContext context)
{ {
var mode = ThemeAppearanceValues.NormalizeSystemMaterialMode(context.SystemMaterialMode); var mode = ThemeAppearanceValues.ResolveEffectiveSystemMaterialMode(context.SystemMaterialMode);
return mode switch return mode switch
{ {
ThemeAppearanceValues.MaterialAcrylic => context.IsNightMode ? (byte)0x58 : (byte)0x4C, ThemeAppearanceValues.MaterialAcrylic => context.IsNightMode ? (byte)0x58 : (byte)0x4C,

View File

@@ -234,6 +234,9 @@ internal sealed class SettingsWindowService : ISettingsWindowService
var themeChanged = var themeChanged =
refreshAll || refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) || 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) && (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) || changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && (string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&

View File

@@ -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<string>? 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<string> 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<string, List<SettingsSearchResult>> _entriesByPage = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<SettingsSearchResult> Entries =>
_entriesByPage.Values.SelectMany(static entries => entries).ToArray();
public void RebuildPageEntries(IEnumerable<SettingsPageDescriptor> 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<SettingsSearchResult> { CreatePageResult(descriptor) };
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
descriptor.PageId
};
foreach (var target in page.GetVisualDescendants().OfType<Control>())
{
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<SettingsSearchResult> 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<string> 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()
};
}
}

View File

@@ -18,6 +18,7 @@ public static class ThemeAppearanceValues
public const string ThemeModeFollowSystem = "follow_system"; public const string ThemeModeFollowSystem = "follow_system";
public const string MaterialNone = "none"; public const string MaterialNone = "none";
public const string MaterialAuto = "auto";
public const string MaterialMica = "mica"; public const string MaterialMica = "mica";
public const string MaterialAcrylic = "acrylic"; public const string MaterialAcrylic = "acrylic";
@@ -30,6 +31,7 @@ public static class ThemeAppearanceValues
public static readonly IReadOnlyList<string> AllMaterialModes = public static readonly IReadOnlyList<string> AllMaterialModes =
[ [
MaterialAuto,
MaterialNone, MaterialNone,
MaterialMica, MaterialMica,
MaterialAcrylic MaterialAcrylic
@@ -59,6 +61,11 @@ public static class ThemeAppearanceValues
public static string NormalizeSystemMaterialMode(string? value) public static string NormalizeSystemMaterialMode(string? value)
{ {
if (string.Equals(value, MaterialAuto, StringComparison.OrdinalIgnoreCase))
{
return MaterialAuto;
}
if (string.Equals(value, MaterialMica, StringComparison.OrdinalIgnoreCase)) if (string.Equals(value, MaterialMica, StringComparison.OrdinalIgnoreCase))
{ {
return MaterialMica; return MaterialMica;
@@ -72,11 +79,32 @@ public static class ThemeAppearanceValues
return MaterialNone; 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<string> NormalizeAvailableMaterialModes(IEnumerable<string>? values) public static IReadOnlyList<string> NormalizeAvailableMaterialModes(IEnumerable<string>? values)
{ {
if (values is null) if (values is null)
{ {
return [MaterialNone]; return [MaterialAuto, MaterialNone];
} }
var normalized = values var normalized = values
@@ -84,9 +112,14 @@ public static class ThemeAppearanceValues
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
if (!normalized.Contains(MaterialAuto, StringComparer.OrdinalIgnoreCase))
{
normalized.Insert(0, MaterialAuto);
}
if (!normalized.Contains(MaterialNone, StringComparer.OrdinalIgnoreCase)) if (!normalized.Contains(MaterialNone, StringComparer.OrdinalIgnoreCase))
{ {
normalized.Insert(0, MaterialNone); normalized.Insert(normalized.Count > 0 ? 1 : 0, MaterialNone);
} }
return normalized; return normalized;

View File

@@ -13,4 +13,4 @@ public sealed record ThemeColorContext(
MonetPalette? MonetPalette = null, MonetPalette? MonetPalette = null,
IReadOnlyList<Color>? MonetColors = null, IReadOnlyList<Color>? MonetColors = null,
bool UseNeutralSurfaces = false, bool UseNeutralSurfaces = false,
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone); string SystemMaterialMode = ThemeAppearanceValues.MaterialAuto);

View File

@@ -88,6 +88,36 @@ public sealed partial class SettingsWindowViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private bool _isDrawerOpen; 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;
/// <summary>用于标题栏右侧系统按钮占位(与 SecRandom / ClassIsland 一致,仅 Windows 显示)。</summary> /// <summary>用于标题栏右侧系统按钮占位(与 SecRandom / ClassIsland 一致,仅 Windows 显示)。</summary>
[ObservableProperty] [ObservableProperty]
private bool _isWindowsOs; private bool _isWindowsOs;
@@ -112,6 +142,13 @@ public sealed partial class SettingsWindowViewModel : ViewModelBase
"settings.restart_dialog.later", "settings.restart_dialog.later",
L("settings.restart_dialog.cancel")); L("settings.restart_dialog.cancel"));
DrawerFallbackTitle = L("settings.window.drawer_default"); 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"); var nextDefaultRestartMessage = L("settings.restart_dock.description");
if (string.IsNullOrWhiteSpace(RestartMessage) || string.Equals(RestartMessage, _defaultRestartMessage, StringComparison.Ordinal)) 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 string GetDefaultRestartMessage() => _defaultRestartMessage;
public ObservableCollection<SettingsPageDescriptor> Pages { get; } = []; public ObservableCollection<SettingsPageDescriptor> Pages { get; } = [];
public ObservableCollection<SettingsSearchResult> SearchResults { get; } = [];
} }
public sealed class SelectionOption public sealed class SelectionOption
@@ -285,6 +324,21 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
[ObservableProperty] [ObservableProperty]
private bool _showInTaskbar; 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 IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);
public bool IsFadeTransitionToggleEnabled => !EnableSlideTransition; public bool IsFadeTransitionToggleEnabled => !EnableSlideTransition;
@@ -512,6 +566,15 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
RenderModeRestartMessage = L( RenderModeRestartMessage = L(
"settings.general.render_mode_restart_message", "settings.general.render_mode_restart_message",
"Rendering mode changes require restarting the app."); "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() private void RefreshPreview()
@@ -676,7 +739,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
private SelectionOption _selectedThemeColorMode = new(ThemeAppearanceValues.ColorModeSeedMonet, "User theme color Monet"); private SelectionOption _selectedThemeColorMode = new(ThemeAppearanceValues.ColorModeSeedMonet, "User theme color Monet");
[ObservableProperty] [ObservableProperty]
private SelectionOption _selectedSystemMaterialMode = new(ThemeAppearanceValues.MaterialNone, "None"); private SelectionOption _selectedSystemMaterialMode = new(ThemeAppearanceValues.MaterialAuto, "Auto");
[ObservableProperty] [ObservableProperty]
private bool _isThemeColorEditable; private bool _isThemeColorEditable;
@@ -777,6 +840,9 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string _systemMaterialNoneText = string.Empty; private string _systemMaterialNoneText = string.Empty;
[ObservableProperty]
private string _systemMaterialAutoText = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _systemMaterialMicaText = string.Empty; private string _systemMaterialMicaText = string.Empty;
@@ -789,6 +855,9 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string _systemMaterialFixedDescription = string.Empty; private string _systemMaterialFixedDescription = string.Empty;
[ObservableProperty]
private string _systemMaterialAutoDescription = string.Empty;
[ObservableProperty] [ObservableProperty]
private string _appearanceRestartMessage = string.Empty; 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."); 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."); 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"); 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"); SystemMaterialMicaText = L("settings.appearance.system_material.mica", "Mica");
SystemMaterialAcrylicText = L("settings.appearance.system_material.acrylic", "Acrylic"); 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."); 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."); 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( AppearanceRestartMessage = L(
"settings.appearance.restart_message", "settings.appearance.restart_message",
"Theme source and system material changes require restarting the app."); "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))) .Select(value => new SelectionOption(value, ResolveMaterialModeLabel(value)))
.ToList(); .ToList();
SystemMaterialDescription = snapshot.CanChangeSystemMaterial SystemMaterialDescription = snapshot.CanChangeSystemMaterial
? SystemMaterialSwitchableDescription ? SystemMaterialAutoDescription
: SystemMaterialFixedDescription; : SystemMaterialFixedDescription;
} }
@@ -1145,6 +1216,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
{ {
return ThemeAppearanceValues.NormalizeSystemMaterialMode(value) switch return ThemeAppearanceValues.NormalizeSystemMaterialMode(value) switch
{ {
ThemeAppearanceValues.MaterialAuto => SystemMaterialAutoText,
ThemeAppearanceValues.MaterialMica => SystemMaterialMicaText, ThemeAppearanceValues.MaterialMica => SystemMaterialMicaText,
ThemeAppearanceValues.MaterialAcrylic => SystemMaterialAcrylicText, ThemeAppearanceValues.MaterialAcrylic => SystemMaterialAcrylicText,
_ => SystemMaterialNoneText _ => SystemMaterialNoneText

View File

@@ -103,7 +103,7 @@
</ui:FASettingsExpanderItem> </ui:FASettingsExpanderItem>
</ui:FASettingsExpander> </ui:FASettingsExpander>
<ui:FASettingsExpander Header="Fade startup transition" <ui:FASettingsExpander Header="{Binding FadeTransitionHeader}"
Description="{Binding FadeTransitionDescription}" Description="{Binding FadeTransitionDescription}"
IsVisible="{Binding IsSlideTransitionAvailable}"> IsVisible="{Binding IsSlideTransitionAvailable}">
<ui:FASettingsExpander.IconSource> <ui:FASettingsExpander.IconSource>
@@ -115,8 +115,8 @@
</ui:FASettingsExpander.Footer> </ui:FASettingsExpander.Footer>
</ui:FASettingsExpander> </ui:FASettingsExpander>
<ui:FASettingsExpander Header="Slide startup transition" <ui:FASettingsExpander Header="{Binding SlideTransitionHeader}"
Description="Use a slide-in startup transition on supported Windows builds. This option disables the fade transition." Description="{Binding SlideTransitionDescription}"
IsVisible="{Binding IsSlideTransitionAvailable}"> IsVisible="{Binding IsSlideTransitionAvailable}">
<ui:FASettingsExpander.IconSource> <ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF08E8;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" /> <ui:FAFontIconSource Glyph="&#xF08E8;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
@@ -126,8 +126,8 @@
</ui:FASettingsExpander.Footer> </ui:FASettingsExpander.Footer>
</ui:FASettingsExpander> </ui:FASettingsExpander>
<ui:FASettingsExpander Header="Show in taskbar" <ui:FASettingsExpander Header="{Binding ShowInTaskbarHeader}"
Description="Keep the main window visible in the taskbar while the desktop host is running."> Description="{Binding ShowInTaskbarDescription}">
<ui:FASettingsExpander.IconSource> <ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF1BE0;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" /> <ui:FAFontIconSource Glyph="&#xF1BE0;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource> </ui:FASettingsExpander.IconSource>

View File

@@ -1,6 +1,8 @@
<faWindowing:FAAppWindow xmlns="https://github.com/avaloniaui" <faWindowing:FAAppWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:LanMountainDesktop.Views"
xmlns:vm="using:LanMountainDesktop.ViewModels" xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:services="using:LanMountainDesktop.Services"
xmlns:ui="using:FluentAvalonia.UI.Controls" xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia" xmlns:fi="using:FluentIcons.Avalonia"
xmlns:faWindowing="using:FluentAvalonia.UI.Windowing" xmlns:faWindowing="using:FluentAvalonia.UI.Windowing"
@@ -34,47 +36,52 @@
<Style Selector="TextBlock.page-title-text:narrow"> <Style Selector="TextBlock.page-title-text:narrow">
<Setter Property="FontSize" Value="24" /> <Setter Property="FontSize" Value="24" />
</Style> </Style>
<Style Selector="Button.titlebar-icon-button">
<Setter Property="Width" Value="40" />
<Setter Property="Height" Value="40" />
<Setter Property="MinWidth" Value="40" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button.titlebar-icon-button:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonHoverBackgroundBrush}" />
</Style>
<Style Selector="Button.titlebar-icon-button:pressed">
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonPressedBackgroundBrush}" />
</Style>
<Style Selector="AutoCompleteBox.settings-search-box">
<Setter Property="MaxWidth" Value="440" />
<Setter Property="MinWidth" Value="240" />
<Setter Property="Height" Value="34" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</faWindowing:FAAppWindow.Styles> </faWindowing:FAAppWindow.Styles>
<Grid x:Name="RootGrid" <Grid x:Name="RootGrid"
Classes="settings-scope" Classes="settings-scope"
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}" Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
RowDefinitions="Auto,*"> RowDefinitions="Auto,*">
<!-- ClassIsland SettingsWindowNew声明顺序为先 FANavigationViewGrid.Row=1再顶栏 BorderGrid.Row=0Row 仍为 0=标题栏、1=导航宿主,最终叠放不变。 -->
<ui:FANavigationView x:Name="RootNavigationView" <ui:FANavigationView x:Name="RootNavigationView"
Grid.Row="1" Grid.Row="1"
Margin="0" Margin="0"
Background="Transparent" Background="Transparent"
PaneDisplayMode="Auto" PaneDisplayMode="Auto"
OpenPaneLength="283" OpenPaneLength="283"
IsSettingsVisible="False" IsSettingsVisible="False"
IsBackButtonVisible="False" IsBackButtonVisible="False"
SelectionChanged="OnNavigationSelectionChanged"> SelectionChanged="OnNavigationSelectionChanged">
<ui:FANavigationView.PaneFooter>
<!-- 仅在 :minimalIsPaneToggleButtonVisible=False时由代码显示与模板内 pane-toggle-button 一致,不用顶栏备胎 -->
<StackPanel Orientation="Vertical">
<Button x:Name="PaneFooterToggleButton"
Classes="pane-toggle-button"
Margin="0,-8,0,0"
MinWidth="40"
Width="48"
VerticalAlignment="Bottom"
Click="OnPaneFooterToggleClick">
<Grid>
<fi:FluentIcon x:Name="PaneFooterToggleButtonIcon"
Icon="Navigation"
IconVariant="Regular"
FontSize="16"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
</Grid>
</Button>
</StackPanel>
</ui:FANavigationView.PaneFooter>
<ui:FANavigationView.Styles> <ui:FANavigationView.Styles>
<Style Selector="ui|FANavigationView#RootNavigationView:minimal"> <Style Selector="ui|FANavigationView#RootNavigationView:minimal">
<Setter Property="IsPaneToggleButtonVisible" Value="False"/> <Setter Property="IsPaneToggleButtonVisible" Value="False" />
</Style> </Style>
</ui:FANavigationView.Styles> </ui:FANavigationView.Styles>
@@ -99,7 +106,12 @@
</Grid> </Grid>
<ui:FAFrame x:Name="ContentFrame" <ui:FAFrame x:Name="ContentFrame"
Grid.Row="1" /> Grid.Row="1" />
<Canvas x:Name="SearchHighlightOverlay"
Grid.Row="1"
IsHitTestVisible="False"
ZIndex="1000" />
</Grid> </Grid>
<Border x:Name="DrawerBorder" <Border x:Name="DrawerBorder"
@@ -141,42 +153,103 @@
<Border x:Name="WindowTitleBarHost" <Border x:Name="WindowTitleBarHost"
Grid.Row="0" Grid.Row="0"
Height="48" Height="48"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}" Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
BorderBrush="{DynamicResource AdaptiveSettingsWindowBorderBrush}" BorderBrush="{DynamicResource AdaptiveSettingsWindowBorderBrush}"
BorderThickness="0,0,0,1"> BorderThickness="0,0,0,1"
PointerPressed="OnTitleBarDragZonePointerPressed">
<Grid ColumnDefinitions="Auto,*,Auto" <Grid ColumnDefinitions="Auto,*,Auto"
VerticalAlignment="Stretch"> VerticalAlignment="Stretch">
<StackPanel Grid.Column="0" <StackPanel Grid.Column="0"
Orientation="Horizontal" Orientation="Horizontal"
Margin="8,0,8,0" Margin="0,0,12,0"
Spacing="8" Spacing="2"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Button x:Name="BackButton"
Classes="titlebar-icon-button"
Width="48"
Height="48"
Margin="0,-8,0,-8"
IsVisible="{Binding CanGoBack}"
ToolTip.Tip="{Binding BackTooltip}"
Click="OnBackButtonClick">
<fi:FluentIcon Icon="ArrowLeft"
IconVariant="Regular"
FontSize="16"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
</Button>
<Button x:Name="TitleBarPaneToggleButton"
Classes="titlebar-icon-button"
Width="48"
Height="48"
Margin="0,-8,-8,-8"
ToolTip.Tip="{Binding TogglePaneTooltip}"
Click="OnTitleBarPaneToggleClick">
<fi:FluentIcon x:Name="TitleBarPaneToggleButtonIcon"
Icon="Navigation"
IconVariant="Regular"
FontSize="16"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
</Button>
<fi:FluentIcon x:Name="WindowBrandIcon" <fi:FluentIcon x:Name="WindowBrandIcon"
Icon="Settings" Icon="Settings"
IconVariant="Filled" IconVariant="Filled"
FontSize="18" FontSize="18"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" Foreground="{DynamicResource TextFillColorPrimaryBrush}"
IsHitTestVisible="False" IsHitTestVisible="False"
Margin="8,0,2,0"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<TextBlock x:Name="WindowTitleTextBlock" <TextBlock x:Name="WindowTitleTextBlock"
FontSize="12" FontSize="12"
FontWeight="SemiBold" FontWeight="SemiBold"
Margin="8,0,0,0" Margin="2,0,0,0"
VerticalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" Foreground="{DynamicResource TextFillColorPrimaryBrush}"
IsHitTestVisible="False" IsHitTestVisible="False"
Text="{Binding Title}" /> Text="{Binding Title}" />
</StackPanel> </StackPanel>
<Border x:Name="TitleBarDragZone" <AutoCompleteBox x:Name="SettingsSearchBox"
Grid.Column="1" Grid.Column="1"
Background="Transparent" Classes="settings-search-box"
PointerPressed="OnTitleBarDragZonePointerPressed" /> FilterMode="Custom"
ItemFilter="{x:Static local:SettingsWindow.SettingsSearchFilter}"
MaxDropDownHeight="480"
ItemsSource="{Binding SearchResults}"
SelectedItem="{Binding SelectedSearchResult, Mode=TwoWay}"
PlaceholderText="{Binding SearchPlaceholderText}"
KeyUp="OnSearchBoxKeyUp"
SelectionChanged="OnSearchBoxSelectionChanged">
<AutoCompleteBox.InnerRightContent>
<fi:FluentIcon Icon="Search"
IconVariant="Regular"
Margin="6"
FontSize="15"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</AutoCompleteBox.InnerRightContent>
<AutoCompleteBox.ItemTemplate>
<DataTemplate x:DataType="services:SettingsSearchResult">
<Grid RowDefinitions="Auto,Auto"
RowSpacing="2"
MinWidth="240">
<TextBlock Text="{Binding DisplayTitle}"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Row="1"
Text="{Binding PageTitle}"
FontSize="12"
Opacity="0.72"
TextTrimming="CharacterEllipsis" />
</Grid>
</DataTemplate>
</AutoCompleteBox.ItemTemplate>
</AutoCompleteBox>
<StackPanel Grid.Column="2" <StackPanel Grid.Column="2"
Orientation="Horizontal" Orientation="Horizontal"
Spacing="8" Spacing="6"
VerticalAlignment="Center" VerticalAlignment="Center"
Margin="0,0,8,0"> Margin="0,0,8,0">
<Button x:Name="RestartNowButton" <Button x:Name="RestartNowButton"
@@ -196,6 +269,26 @@
</StackPanel> </StackPanel>
</Button> </Button>
<Button x:Name="MoreOptionsButton"
Classes="titlebar-icon-button"
ToolTip.Tip="{Binding MoreOptionsText}">
<fi:FluentIcon Icon="MoreHorizontal"
IconVariant="Regular"
FontSize="17"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="{Binding RestartMenuItemText}"
Click="OnRestartMenuItemClick">
<MenuItem.Icon>
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular" />
</MenuItem.Icon>
</MenuItem>
</MenuFlyout>
</Button.Flyout>
</Button>
<Border Width="140" <Border Width="140"
Background="Transparent" Background="Transparent"
IsHitTestVisible="False" IsHitTestVisible="False"

View File

@@ -7,6 +7,7 @@ using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Windowing; using FluentAvalonia.UI.Windowing;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
@@ -19,6 +20,8 @@ namespace LanMountainDesktop.Views;
public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
{ {
public static AutoCompleteFilterPredicate<object?> SettingsSearchFilter => SettingsSearchService.Filter;
private const double BaseSettingsContainerWidth = 960d; private const double BaseSettingsContainerWidth = 960d;
private const double MinSettingsContentWidth = 320d; private const double MinSettingsContentWidth = 320d;
private const double MinSettingsContainerWidth = 840d; private const double MinSettingsContainerWidth = 840d;
@@ -32,10 +35,15 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
private readonly ISettingsPageRegistry _pageRegistry; private readonly ISettingsPageRegistry _pageRegistry;
private readonly IHostApplicationLifecycle _hostApplicationLifecycle; private readonly IHostApplicationLifecycle _hostApplicationLifecycle;
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate(); private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
private readonly SettingsSearchService _searchService = new();
private readonly Dictionary<string, Control> _cachedPages = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, Control> _cachedPages = new(StringComparer.OrdinalIgnoreCase);
private readonly Stack<string> _navigationBackStack = new();
private bool _useSystemChrome; private bool _useSystemChrome;
private bool _isResponsiveRefreshPending; private bool _isResponsiveRefreshPending;
private bool _isRestartPromptVisible; private bool _isRestartPromptVisible;
private bool _isHandlingSearchSelection;
private Border? _currentSearchHighlight;
private Action? _searchHighlightCleanup;
public SettingsWindow() public SettingsWindow()
: this( : this(
@@ -87,8 +95,8 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
SyncPendingRestartState(); SyncPendingRestartState();
SyncTitleText(); SyncTitleText();
UpdateChromeMetrics(); UpdateChromeMetrics();
UpdatePaneFooterToggleVisibility(); UpdatePaneToggleVisibility();
UpdatePaneFooterToggleIcon(); UpdatePaneToggleIcon();
UpdateResponsiveLayout(); UpdateResponsiveLayout();
RequestResponsiveLayoutRefresh(); RequestResponsiveLayoutRefresh();
} }
@@ -102,10 +110,13 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
} }
_cachedPages.Clear(); _cachedPages.Clear();
_navigationBackStack.Clear();
ViewModel.CanGoBack = false;
CloseDrawer(); CloseDrawer();
RebuildNavigationItems(); RebuildNavigationItems();
NavigateTo(pageId ?? ViewModel.Pages.FirstOrDefault()?.PageId); NavigateTo(pageId ?? ViewModel.Pages.FirstOrDefault()?.PageId, addHistory: false, source: "reload");
UpdatePaneFooterToggleVisibility(); RebuildSearchIndex(scanBuiltInPages: true);
UpdatePaneToggleVisibility();
} }
public void RebuildAndNavigateToDevPage() public void RebuildAndNavigateToDevPage()
@@ -236,11 +247,16 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
private void OnNavigationSelectionChanged(object? sender, FANavigationViewSelectionChangedEventArgs e) private void OnNavigationSelectionChanged(object? sender, FANavigationViewSelectionChangedEventArgs e)
{ {
_ = sender;
var selectedItem = e.SelectedItemContainer ?? e.SelectedItem as FANavigationViewItem; 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 previousPageId = ViewModel.CurrentPageId;
var descriptor = ResolveDescriptor(pageId); var descriptor = ResolveDescriptor(pageId);
@@ -249,6 +265,21 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
return; 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); var page = GetOrCreatePage(descriptor);
if (page is SettingsPageBase settingsPage) if (page is SettingsPageBase settingsPage)
{ {
@@ -266,14 +297,21 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
ViewModel.CurrentPageDescription = descriptor.Description; ViewModel.CurrentPageDescription = descriptor.Description;
ViewModel.CurrentPageId = descriptor.PageId; ViewModel.CurrentPageId = descriptor.PageId;
ViewModel.IsPageTitleVisible = !descriptor.HidePageTitle; ViewModel.IsPageTitleVisible = !descriptor.HidePageTitle;
ViewModel.CanGoBack = _navigationBackStack.Count > 0;
CloseDrawer();
TrySelectNavigationItem(descriptor.PageId); TrySelectNavigationItem(descriptor.PageId);
SyncTitleText(); SyncTitleText();
UpdatePaneFooterToggleVisibility(); UpdatePaneToggleVisibility();
UpdateResponsiveLayout(); UpdateResponsiveLayout();
RequestResponsiveLayoutRefresh(); RequestResponsiveLayoutRefresh();
if (searchResult is not null)
{
HighlightSearchResult(searchResult);
}
if (!string.Equals(previousPageId, descriptor.PageId, StringComparison.OrdinalIgnoreCase)) 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; _cachedPages[descriptor.PageId] = page;
_searchService.IndexPage(descriptor, page);
return 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) private void TrySelectNavigationItem(string pageId)
{ {
if (RootNavigationView is null) if (RootNavigationView is null)
@@ -340,6 +403,77 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
CloseDrawer(); 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() private void OnPendingRestartStateChanged()
{ {
SyncPendingRestartState(); SyncPendingRestartState();
@@ -504,8 +638,125 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
// Hide the drawer pane on narrow windows. // 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<FASettingsExpander>())
{
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) private void OnClosed(object? sender, EventArgs e)
{ {
RemoveSearchHighlight();
_cachedPages.Clear(); _cachedPages.Clear();
PendingRestartStateService.StateChanged -= OnPendingRestartStateChanged; PendingRestartStateService.StateChanged -= OnPendingRestartStateChanged;
if (RootNavigationView is not null) if (RootNavigationView is not null)
@@ -520,13 +771,39 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
private void OnTitleBarDragZonePointerPressed(object? sender, PointerPressedEventArgs e) private void OnTitleBarDragZonePointerPressed(object? sender, PointerPressedEventArgs e)
{ {
_ = sender; _ = 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<Control> controls = source.GetVisualAncestors().OfType<Control>().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; _ = sender;
_ = e; _ = e;
@@ -536,7 +813,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
} }
RootNavigationView.IsPaneOpen = !RootNavigationView.IsPaneOpen; RootNavigationView.IsPaneOpen = !RootNavigationView.IsPaneOpen;
UpdatePaneFooterToggleIcon(); UpdatePaneToggleIcon();
UpdateResponsiveLayout(); UpdateResponsiveLayout();
RequestResponsiveLayoutRefresh(); RequestResponsiveLayoutRefresh();
} }
@@ -552,10 +829,10 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
{ {
if (e.Property == FANavigationView.IsPaneToggleButtonVisibleProperty) if (e.Property == FANavigationView.IsPaneToggleButtonVisibleProperty)
{ {
UpdatePaneFooterToggleVisibility(); UpdatePaneToggleVisibility();
} }
UpdatePaneFooterToggleIcon(); UpdatePaneToggleIcon();
RequestResponsiveLayoutRefresh(); RequestResponsiveLayoutRefresh();
} }
} }
@@ -564,14 +841,14 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
/// 仅在 <c>:minimal</c><see cref="FANavigationView.IsPaneToggleButtonVisible"/> 为 false时显示侧栏底部备胎按钮。 /// 仅在 <c>:minimal</c><see cref="FANavigationView.IsPaneToggleButtonVisible"/> 为 false时显示侧栏底部备胎按钮。
/// 根 DataContext 为 ViewModel 时,对 <c>#RootNavigationView</c> 的绑定易失效,故用代码同步可见性。 /// 根 DataContext 为 ViewModel 时,对 <c>#RootNavigationView</c> 的绑定易失效,故用代码同步可见性。
/// </summary> /// </summary>
private void UpdatePaneFooterToggleVisibility() private void UpdatePaneToggleVisibility()
{ {
if (PaneFooterToggleButton is null || RootNavigationView is null) if (TitleBarPaneToggleButton is null || RootNavigationView is null)
{ {
return; return;
} }
PaneFooterToggleButton.IsVisible = !RootNavigationView.IsPaneToggleButtonVisible; TitleBarPaneToggleButton.IsVisible = !RootNavigationView.IsPaneToggleButtonVisible;
} }
private void RequestResponsiveLayoutRefresh() private void RequestResponsiveLayoutRefresh()
@@ -603,14 +880,14 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
: compactPaneWidth; : compactPaneWidth;
} }
private void UpdatePaneFooterToggleIcon() private void UpdatePaneToggleIcon()
{ {
if (PaneFooterToggleButtonIcon is null || RootNavigationView is null) if (TitleBarPaneToggleButtonIcon is null || RootNavigationView is null)
{ {
return; return;
} }
PaneFooterToggleButtonIcon.Icon = RootNavigationView.IsPaneOpen TitleBarPaneToggleButtonIcon.Icon = RootNavigationView.IsPaneOpen
? FluentIcons.Common.Icon.LineHorizontal3 ? FluentIcons.Common.Icon.LineHorizontal3
: FluentIcons.Common.Icon.Navigation; : FluentIcons.Common.Icon.Navigation;
} }

View File

@@ -1,5 +1,7 @@
# UI Design System Guide (design.md) # 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避免窗口套窗口、容器套容器 > **目标**: 让 AI 正确使用 Fluent Avalonia / Fluent Icons / Material Avalonia避免窗口套窗口、容器套容器
> >
> **最后更新**: 2026-04-11 > **最后更新**: 2026-04-11

View File

@@ -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.