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

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

View File

@@ -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": "正在加载官方插件目录...",

View File

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

View File

@@ -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<string> 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<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()
{
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<string, WallpaperSeedExtractionResult> _wallpaperSeedCache = 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.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);
}

View File

@@ -113,7 +113,7 @@ public static class GlassEffectService
/// <summary>可选内容叠层 alpha与设置窗表面色相一致None 为 0 避免重复染色。</summary>
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,

View File

@@ -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) &&

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 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<string> 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<string> NormalizeAvailableMaterialModes(IEnumerable<string>? 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;

View File

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

View File

@@ -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;
/// <summary>用于标题栏右侧系统按钮占位(与 SecRandom / ClassIsland 一致,仅 Windows 显示)。</summary>
[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<SettingsPageDescriptor> Pages { get; } = [];
public ObservableCollection<SettingsSearchResult> 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

View File

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

View File

@@ -1,6 +1,8 @@
<faWindowing:FAAppWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:LanMountainDesktop.Views"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:services="using:LanMountainDesktop.Services"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
xmlns:faWindowing="using:FluentAvalonia.UI.Windowing"
@@ -34,47 +36,52 @@
<Style Selector="TextBlock.page-title-text:narrow">
<Setter Property="FontSize" Value="24" />
</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>
<Grid x:Name="RootGrid"
Classes="settings-scope"
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
RowDefinitions="Auto,*">
<!-- ClassIsland SettingsWindowNew声明顺序为先 FANavigationViewGrid.Row=1再顶栏 BorderGrid.Row=0Row 仍为 0=标题栏、1=导航宿主,最终叠放不变。 -->
<ui:FANavigationView x:Name="RootNavigationView"
Grid.Row="1"
Margin="0"
Background="Transparent"
PaneDisplayMode="Auto"
OpenPaneLength="283"
IsSettingsVisible="False"
IsBackButtonVisible="False"
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>
Grid.Row="1"
Margin="0"
Background="Transparent"
PaneDisplayMode="Auto"
OpenPaneLength="283"
IsSettingsVisible="False"
IsBackButtonVisible="False"
SelectionChanged="OnNavigationSelectionChanged">
<ui:FANavigationView.Styles>
<Style Selector="ui|FANavigationView#RootNavigationView:minimal">
<Setter Property="IsPaneToggleButtonVisible" Value="False"/>
<Setter Property="IsPaneToggleButtonVisible" Value="False" />
</Style>
</ui:FANavigationView.Styles>
@@ -99,7 +106,12 @@
</Grid>
<ui:FAFrame x:Name="ContentFrame"
Grid.Row="1" />
Grid.Row="1" />
<Canvas x:Name="SearchHighlightOverlay"
Grid.Row="1"
IsHitTestVisible="False"
ZIndex="1000" />
</Grid>
<Border x:Name="DrawerBorder"
@@ -141,42 +153,103 @@
<Border x:Name="WindowTitleBarHost"
Grid.Row="0"
Height="48"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
Background="{DynamicResource AdaptiveSettingsWindowBackgroundBrush}"
BorderBrush="{DynamicResource AdaptiveSettingsWindowBorderBrush}"
BorderThickness="0,0,0,1">
BorderThickness="0,0,0,1"
PointerPressed="OnTitleBarDragZonePointerPressed">
<Grid ColumnDefinitions="Auto,*,Auto"
VerticalAlignment="Stretch">
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Margin="8,0,8,0"
Spacing="8"
Margin="0,0,12,0"
Spacing="2"
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"
Icon="Settings"
IconVariant="Filled"
FontSize="18"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
IsHitTestVisible="False"
Margin="8,0,2,0"
VerticalAlignment="Center" />
<TextBlock x:Name="WindowTitleTextBlock"
FontSize="12"
FontWeight="SemiBold"
Margin="8,0,0,0"
Margin="2,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
IsHitTestVisible="False"
Text="{Binding Title}" />
</StackPanel>
<Border x:Name="TitleBarDragZone"
Grid.Column="1"
Background="Transparent"
PointerPressed="OnTitleBarDragZonePointerPressed" />
<AutoCompleteBox x:Name="SettingsSearchBox"
Grid.Column="1"
Classes="settings-search-box"
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"
Orientation="Horizontal"
Spacing="8"
Spacing="6"
VerticalAlignment="Center"
Margin="0,0,8,0">
<Button x:Name="RestartNowButton"
@@ -196,6 +269,26 @@
</StackPanel>
</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"
Background="Transparent"
IsHitTestVisible="False"

View File

@@ -7,6 +7,7 @@ using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Windowing;
using LanMountainDesktop.PluginSdk;
@@ -19,6 +20,8 @@ namespace LanMountainDesktop.Views;
public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
{
public static AutoCompleteFilterPredicate<object?> 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<string, Control> _cachedPages = new(StringComparer.OrdinalIgnoreCase);
private readonly Stack<string> _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<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)
{
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<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;
_ = 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
/// 仅在 <c>:minimal</c><see cref="FANavigationView.IsPaneToggleButtonVisible"/> 为 false时显示侧栏底部备胎按钮。
/// 根 DataContext 为 ViewModel 时,对 <c>#RootNavigationView</c> 的绑定易失效,故用代码同步可见性。
/// </summary>
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;
}