diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs
index dfeb85c..0c20758 100644
--- a/LanMountainDesktop/App.axaml.cs
+++ b/LanMountainDesktop/App.axaml.cs
@@ -43,6 +43,7 @@ public partial class App : Application
}
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
+ private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
private readonly LocalizationService _localizationService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
@@ -84,6 +85,7 @@ public partial class App : Application
public App()
{
_settingsFacade.Settings.Changed += OnSettingsChanged;
+ _appearanceThemeService.Changed += OnAppearanceThemeChanged;
}
public override void Initialize()
@@ -104,7 +106,6 @@ public partial class App : Application
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePluginRuntime();
- AppSettingsService.SettingsSaved += OnAppSettingsSaved;
InitializeTrayIcon();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
@@ -337,11 +338,11 @@ public partial class App : Application
private void ApplyThemeFromSettings()
{
- var themeState = _settingsFacade.Theme.Get();
- RequestedThemeVariant = themeState.IsNightMode
+ var snapshot = _appearanceThemeService.GetCurrent();
+ RequestedThemeVariant = snapshot.IsNightMode
? ThemeVariant.Dark
: ThemeVariant.Light;
- ApplyAdaptiveThemeResources(themeState);
+ ApplyAdaptiveThemeResources();
}
private void ApplyCurrentCultureFromSettings()
@@ -464,19 +465,6 @@ public partial class App : Application
Dispatcher.UIThread.InvokeAsync(Reset, DispatcherPriority.Send).GetAwaiter().GetResult();
}
- private void OnAppSettingsSaved(string _)
- {
- Dispatcher.UIThread.Post(() =>
- {
- ApplyThemeFromSettings();
- ApplyCurrentCultureFromSettings();
- if (_trayIcons is not null)
- {
- InitializeTrayIcon();
- }
- }, DispatcherPriority.Background);
- }
-
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
_ = sender;
@@ -490,13 +478,17 @@ public partial class App : Application
{
var changedKeys = e.ChangedKeys?.ToArray();
var refreshAll = changedKeys is null || changedKeys.Length == 0;
+ var liveAppearance = _appearanceThemeService.GetCurrent();
var themeChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase);
+ changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), 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) &&
+ (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase)));
var languageChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase);
@@ -517,48 +509,17 @@ public partial class App : Application
}, DispatcherPriority.Background);
}
- private void ApplyAdaptiveThemeResources(ThemeAppearanceSettingsState themeState)
+ private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
{
- var wallpaperState = _settingsFacade.Wallpaper.Get();
- var monetPalette = _settingsFacade.Theme.BuildPalette(
- themeState.IsNightMode,
- wallpaperState.WallpaperPath,
- themeState.ThemeColor);
- var accentColor = ResolveAccentColor(themeState.ThemeColor, monetPalette);
- var context = new ThemeColorContext(
- accentColor,
- IsLightBackground: !themeState.IsNightMode,
- IsLightNavBackground: !themeState.IsNightMode,
- IsNightMode: themeState.IsNightMode,
- MonetColors: monetPalette.MonetColors);
- ThemeColorSystemService.ApplyThemeResources(Resources, context);
- GlassEffectService.ApplyGlassResources(Resources, context);
+ _ = sender;
+ _ = e;
+
+ Dispatcher.UIThread.Post(ApplyThemeFromSettings, DispatcherPriority.Background);
}
- private static Color ResolveAccentColor(string? colorText, MonetPalette monetPalette)
+ private void ApplyAdaptiveThemeResources()
{
- if (monetPalette.MonetColors is { Count: > 0 })
- {
- return monetPalette.MonetColors[0];
- }
-
- return TryParseThemeColor(colorText);
- }
-
- private static Color TryParseThemeColor(string? colorText)
- {
- if (!string.IsNullOrWhiteSpace(colorText))
- {
- try
- {
- return Color.Parse(colorText);
- }
- catch
- {
- }
- }
-
- return DefaultAccentColor;
+ _appearanceThemeService.ApplyThemeResources(Resources);
}
private void RegisterUiUnhandledExceptionGuard()
@@ -606,8 +567,8 @@ public partial class App : Application
}
_exitCleanupCompleted = true;
- AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
_settingsFacade.Settings.Changed -= OnSettingsChanged;
+ _appearanceThemeService.Changed -= OnAppearanceThemeChanged;
try
{
HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit();
diff --git a/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs b/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs
index f9ee600..a05fabe 100644
--- a/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs
+++ b/LanMountainDesktop/ComponentSystem/DesktopComponentRuntimeContext.cs
@@ -9,5 +9,6 @@ public sealed record DesktopComponentRuntimeContext(
string? PlacementId,
ISettingsFacadeService SettingsFacade,
ISettingsService SettingsService,
+ IAppearanceThemeService AppearanceTheme,
IComponentSettingsAccessor ComponentSettingsAccessor,
IComponentInstanceSettingsStore ComponentSettingsStore);
diff --git a/LanMountainDesktop/ComponentSystem/IComponentSettingsContextAware.cs b/LanMountainDesktop/ComponentSystem/IComponentSettingsContextAware.cs
index c35ac20..6fc03a6 100644
--- a/LanMountainDesktop/ComponentSystem/IComponentSettingsContextAware.cs
+++ b/LanMountainDesktop/ComponentSystem/IComponentSettingsContextAware.cs
@@ -9,6 +9,7 @@ public sealed record DesktopComponentSettingsContext(
string? PlacementId,
ISettingsFacadeService SettingsFacade,
ISettingsService SettingsService,
+ IAppearanceThemeService AppearanceTheme,
IComponentSettingsAccessor ComponentSettingsAccessor,
IComponentInstanceSettingsStore ComponentSettingsStore);
diff --git a/LanMountainDesktop/LanMountainDesktop.csproj b/LanMountainDesktop/LanMountainDesktop.csproj
index 21ffe83..522d396 100644
--- a/LanMountainDesktop/LanMountainDesktop.csproj
+++ b/LanMountainDesktop/LanMountainDesktop.csproj
@@ -54,6 +54,7 @@
+
diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json
index 3bd6c25..2e56b90 100644
--- a/LanMountainDesktop/Localization/en-US.json
+++ b/LanMountainDesktop/Localization/en-US.json
@@ -26,6 +26,7 @@
"settings.nav.weather": "Weather",
"settings.nav.region": "Region",
"settings.nav.update": "Update",
+ "settings.nav.privacy": "Privacy",
"settings.nav.launcher": "App Launcher",
"settings.nav.plugins": "Plugins",
"settings.nav.about": "About",
@@ -92,6 +93,12 @@
"settings.status_bar.spacing_mode_custom": "Custom",
"settings.status_bar.spacing_custom_label": "Custom spacing (%)",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
+ "settings.privacy.title": "Privacy",
+ "settings.privacy.description": "Manage optional anonymous uploads that help improve the app over time.",
+ "settings.privacy.crash_upload_title": "Anonymous crash data uploads",
+ "settings.privacy.crash_upload_description": "Help us improve application stability.",
+ "settings.privacy.usage_upload_title": "Anonymous usage data uploads",
+ "settings.privacy.usage_upload_description": "Help us improve application features.",
"settings.weather.title": "Weather",
"settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
"settings.weather.location_source_header": "Location Source",
@@ -242,11 +249,38 @@
"settings.general.preview_date_label": "Date",
"settings.general.render_mode_restart_message": "Rendering mode changes require restarting the app.",
"settings.appearance.title": "Appearance",
- "settings.appearance.description": "Adjust theme, wallpaper, and window chrome.",
+ "settings.appearance.description": "Adjust theme source, system material, and window chrome.",
"settings.appearance.theme_header": "Theme",
"settings.color.enable_night_mode_toggle": "Enable night mode",
"settings.color.use_system_chrome_toggle": "Use system window chrome",
"settings.color.theme_color_label": "Theme accent color",
+ "settings.appearance.theme_color_mode_label": "Theme color source",
+ "settings.appearance.system_material_label": "System material",
+ "settings.appearance.theme_color_mode.neutral": "Default neutral",
+ "settings.appearance.theme_color_mode.user": "User theme color Monet",
+ "settings.appearance.theme_color_mode.wallpaper": "Wallpaper Monet",
+ "settings.appearance.theme_color_mode_desc.neutral": "Use the default white and black neutral surfaces for light and dark mode.",
+ "settings.appearance.theme_color_mode_desc.user": "Use the selected theme color as the Monet seed for the whole shell.",
+ "settings.appearance.theme_color_mode_desc.wallpaper": "Use wallpaper colors. The app wallpaper is preferred, then the system wallpaper.",
+ "settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
+ "settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
+ "settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
+ "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.restart_message": "Theme source and system material changes require restarting the app.",
+ "settings.appearance.preview.primary": "Primary",
+ "settings.appearance.preview.secondary": "Secondary",
+ "settings.appearance.preview.tertiary": "Tertiary",
+ "settings.appearance.preview.neutral": "Neutral",
+ "settings.appearance.preview.seed": "Seed",
+ "settings.appearance.preview.neutral_light": "White",
+ "settings.appearance.preview.neutral_dark": "Black",
+ "settings.appearance.preview.apply_seed": "Apply",
+ "settings.appearance.preview.wallpaper_candidates": "Wallpaper seed candidates",
+ "settings.appearance.preview.wallpaper_current": "Current",
"settings.wallpaper.placement.fill": "Fill",
"settings.wallpaper.placement.fit": "Fit",
"settings.wallpaper.placement.stretch": "Stretch",
@@ -345,6 +379,7 @@
"settings.restart_dialog.title": "Restart required",
"settings.restart_dialog.render_mode_message": "Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
"settings.restart_dialog.restart": "Restart now",
+ "settings.restart_dialog.later": "Later",
"settings.restart_dialog.cancel": "Cancel",
"settings.restart_dock.title": "Restart required",
"settings.restart_dock.description": "Some changes will take effect after restarting the app.",
diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json
index e8158f7..eade1f7 100644
--- a/LanMountainDesktop/Localization/zh-CN.json
+++ b/LanMountainDesktop/Localization/zh-CN.json
@@ -26,6 +26,7 @@
"settings.nav.weather": "天气",
"settings.nav.region": "地区",
"settings.nav.update": "更新",
+ "settings.nav.privacy": "隐私",
"settings.nav.launcher": "应用启动台",
"settings.nav.plugins": "插件",
"settings.nav.about": "关于",
@@ -97,6 +98,12 @@
"settings.status_bar.spacing_mode_custom": "自定义",
"settings.status_bar.spacing_custom_label": "自定义间距(%)",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
+ "settings.privacy.title": "隐私",
+ "settings.privacy.description": "管理可选的匿名上传设置,帮助我们逐步改进应用体验。",
+ "settings.privacy.crash_upload_title": "匿名上传崩溃数据",
+ "settings.privacy.crash_upload_description": "帮助我们提高应用稳定性。",
+ "settings.privacy.usage_upload_title": "匿名上传使用数据",
+ "settings.privacy.usage_upload_description": "帮助我们改善应用功能。",
"settings.weather.title": "天气",
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
"settings.weather.location_source_header": "位置来源",
@@ -247,11 +254,38 @@
"settings.general.preview_date_label": "日期",
"settings.general.render_mode_restart_message": "渲染模式变更需要重启应用。",
"settings.appearance.title": "外观",
- "settings.appearance.description": "调整主题、壁纸与窗口外观。",
+ "settings.appearance.description": "调整主题来源、系统材质与窗口外观。",
"settings.appearance.theme_header": "主题",
"settings.color.enable_night_mode_toggle": "启用夜间模式",
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
"settings.color.theme_color_label": "主题强调色",
+ "settings.appearance.theme_color_mode_label": "主题色来源",
+ "settings.appearance.system_material_label": "系统材质",
+ "settings.appearance.theme_color_mode.neutral": "默认中性",
+ "settings.appearance.theme_color_mode.user": "用户主题色 Monet",
+ "settings.appearance.theme_color_mode.wallpaper": "壁纸 Monet 取色",
+ "settings.appearance.theme_color_mode_desc.neutral": "使用标准的日间白底黑字与夜间黑底白字中性色表面。",
+ "settings.appearance.theme_color_mode_desc.user": "使用用户选择的主题色作为整个桌面壳层的 Monet 种子色。",
+ "settings.appearance.theme_color_mode_desc.wallpaper": "使用壁纸颜色。优先取应用壁纸,失败后回退系统桌面壁纸。",
+ "settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
+ "settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
+ "settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
+ "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.restart_message": "主题色来源和系统材质更改需要重启应用。",
+ "settings.appearance.preview.primary": "主色",
+ "settings.appearance.preview.secondary": "次色",
+ "settings.appearance.preview.tertiary": "三次色",
+ "settings.appearance.preview.neutral": "中性色",
+ "settings.appearance.preview.seed": "种子色",
+ "settings.appearance.preview.neutral_light": "白色",
+ "settings.appearance.preview.neutral_dark": "黑色",
+ "settings.appearance.preview.apply_seed": "应用",
+ "settings.appearance.preview.wallpaper_candidates": "壁纸候选主题色",
+ "settings.appearance.preview.wallpaper_current": "当前",
"settings.wallpaper.placement.fill": "填充",
"settings.wallpaper.placement.fit": "适应",
"settings.wallpaper.placement.stretch": "拉伸",
@@ -350,6 +384,7 @@
"settings.restart_dialog.title": "需要重启应用",
"settings.restart_dialog.render_mode_message": "需要重启应用,才能将渲染模式从“{0}”切换到“{1}”。是否现在重启?",
"settings.restart_dialog.restart": "立即重启",
+ "settings.restart_dialog.later": "稍后",
"settings.restart_dialog.cancel": "取消",
"settings.restart_dock.title": "需要重启应用",
"settings.restart_dock.description": "部分更改需要在重启应用后才会生效。",
diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
index 3120c07..ad65e3e 100644
--- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs
+++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs
@@ -16,6 +16,12 @@ public sealed class AppSettingsSnapshot
public bool UseSystemChrome { get; set; }
+ public string ThemeColorMode { get; set; } = "default_neutral";
+
+ public string SystemMaterialMode { get; set; } = "none";
+
+ public string? SelectedWallpaperSeed { get; set; }
+
public string? WallpaperPath { get; set; }
public string WallpaperType { get; set; } = "Image";
@@ -60,6 +66,10 @@ public sealed class AppSettingsSnapshot
public bool IncludePrereleaseUpdates { get; set; }
+ public bool UploadAnonymousCrashData { get; set; }
+
+ public bool UploadAnonymousUsageData { get; set; }
+
public string UpdateChannel { get; set; } = "stable";
public string UpdateMode { get; set; } = "download_then_confirm";
diff --git a/LanMountainDesktop/Models/MonetPalette.cs b/LanMountainDesktop/Models/MonetPalette.cs
index 7b7aad1..e283a17 100644
--- a/LanMountainDesktop/Models/MonetPalette.cs
+++ b/LanMountainDesktop/Models/MonetPalette.cs
@@ -1,8 +1,49 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using Avalonia.Media;
namespace LanMountainDesktop.Models;
-public sealed record MonetPalette(
- IReadOnlyList RecommendedColors,
- IReadOnlyList MonetColors);
+public sealed record MonetPalette
+{
+ public MonetPalette(
+ IReadOnlyList recommendedColors,
+ Color seed,
+ Color primary,
+ Color secondary,
+ Color tertiary,
+ Color neutral,
+ Color neutralVariant)
+ {
+ RecommendedColors = recommendedColors;
+ Seed = seed;
+ Primary = primary;
+ Secondary = secondary;
+ Tertiary = tertiary;
+ Neutral = neutral;
+ NeutralVariant = neutralVariant;
+ MonetColors =
+ [
+ primary,
+ secondary,
+ tertiary,
+ neutral,
+ neutralVariant
+ ];
+ }
+
+ public IReadOnlyList RecommendedColors { get; }
+
+ public IReadOnlyList MonetColors { get; }
+
+ public Color Seed { get; }
+
+ public Color Primary { get; }
+
+ public Color Secondary { get; }
+
+ public Color Tertiary { get; }
+
+ public Color Neutral { get; }
+
+ public Color NeutralVariant { get; }
+}
diff --git a/LanMountainDesktop/Services/AppearanceThemeService.cs b/LanMountainDesktop/Services/AppearanceThemeService.cs
new file mode 100644
index 0000000..d90a3a3
--- /dev/null
+++ b/LanMountainDesktop/Services/AppearanceThemeService.cs
@@ -0,0 +1,1069 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Styling;
+using Avalonia.Threading;
+using Avalonia.Media.Imaging;
+using LanMountainDesktop.Models;
+using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.Services.Settings;
+using LanMountainDesktop.Theme;
+using LibVLCSharp.Shared;
+using Microsoft.Win32;
+
+namespace LanMountainDesktop.Services;
+
+public enum MaterialSurfaceRole
+{
+ WindowBackground = 0,
+ SettingsWindowBackground = 1,
+ DockBackground = 2,
+ StatusBarBackground = 3,
+ DesktopComponentHost = 4,
+ StatusBarComponentHost = 5,
+ OverlayPanel = 6
+}
+
+public sealed record AppearanceMaterialSurface(
+ Color BackgroundColor,
+ Color BorderColor,
+ double BlurRadius,
+ double Opacity);
+
+public sealed record AppearanceThemeSnapshot(
+ bool IsNightMode,
+ string ThemeColorMode,
+ string? UserThemeColor,
+ string? SelectedWallpaperSeed,
+ string ResolvedSeedSource,
+ MonetPalette MonetPalette,
+ Color AccentColor,
+ Color EffectiveSeedColor,
+ IReadOnlyList WallpaperSeedCandidates,
+ string SystemMaterialMode,
+ IReadOnlyList AvailableSystemMaterialModes,
+ bool CanChangeSystemMaterial,
+ bool UseSystemChrome,
+ string? ResolvedWallpaperPath);
+
+public interface IAppearanceThemeService
+{
+ AppearanceThemeSnapshot GetCurrent();
+
+ AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState);
+
+ event EventHandler? Changed;
+
+ void ApplyThemeResources(IResourceDictionary resources);
+
+ AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role);
+
+ void ApplyWindowMaterial(Window window, MaterialSurfaceRole role);
+}
+
+internal interface ISystemWallpaperService
+{
+ bool IsSupported { get; }
+
+ string? GetWallpaperPath();
+}
+
+internal interface IWindowMaterialService
+{
+ IReadOnlyList GetAvailableModes();
+
+ bool CanChangeMode { get; }
+
+ void Apply(Window window, string materialMode);
+}
+
+internal interface IMaterialSurfaceService
+{
+ AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role);
+}
+
+internal interface IVideoWallpaperSeedExtractor
+{
+ IReadOnlyList ExtractSeedCandidates(string videoPath, MonetColorService monetColorService);
+}
+
+internal readonly record struct WallpaperSeedSourceDescriptor(
+ string SourceKind,
+ string SourceKey,
+ string? ResolvedWallpaperPath,
+ string? FilePath,
+ Color? SolidColor);
+
+internal sealed record WallpaperSeedExtractionResult(
+ string SourceKind,
+ string SourceKey,
+ string? ResolvedWallpaperPath,
+ IReadOnlyList SeedCandidates);
+
+internal readonly record struct WallpaperPaletteResolution(
+ MonetPalette Palette,
+ IReadOnlyList SeedCandidates,
+ string ResolvedSeedSource,
+ Color EffectiveSeedColor,
+ string? ResolvedWallpaperPath);
+
+internal sealed class LibVlcVideoWallpaperSeedExtractor : IVideoWallpaperSeedExtractor
+{
+ public IReadOnlyList ExtractSeedCandidates(string videoPath, MonetColorService monetColorService)
+ {
+ if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
+ {
+ return [];
+ }
+
+ var snapshotPath = Path.Combine(
+ Path.GetTempPath(),
+ $"lanmountaindesktop-video-seed-{Guid.NewGuid():N}.png");
+
+ try
+ {
+ using var libVlc = new LibVLC("--no-audio", "--intf=dummy", "--no-video-title-show");
+ using var media = new Media(libVlc, new Uri(videoPath));
+ using var mediaPlayer = new MediaPlayer(libVlc)
+ {
+ Media = media
+ };
+
+ mediaPlayer.Play();
+
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < TimeSpan.FromSeconds(5))
+ {
+ Thread.Sleep(180);
+ if (!mediaPlayer.TakeSnapshot(0, snapshotPath, 320, 180))
+ {
+ continue;
+ }
+
+ var fileInfo = new FileInfo(snapshotPath);
+ if (!fileInfo.Exists || fileInfo.Length <= 0)
+ {
+ continue;
+ }
+
+ using var bitmap = new Bitmap(snapshotPath);
+ return monetColorService.ExtractSeedCandidates(bitmap);
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(
+ "Appearance.VideoWallpaperPalette",
+ $"Failed to extract wallpaper seed candidates from video '{videoPath}'.",
+ ex);
+ }
+ finally
+ {
+ try
+ {
+ if (File.Exists(snapshotPath))
+ {
+ File.Delete(snapshotPath);
+ }
+ }
+ catch
+ {
+ // Best effort cleanup only.
+ }
+ }
+
+ return [];
+ }
+}
+
+internal sealed class SystemWallpaperService : ISystemWallpaperService
+{
+ public bool IsSupported => OperatingSystem.IsWindows();
+
+ public string? GetWallpaperPath()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return null;
+ }
+
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(@"Control Panel\Desktop", writable: false);
+ var wallpaperPath = key?.GetValue("WallPaper") as string;
+ return string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath)
+ ? null
+ : wallpaperPath;
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn("Appearance.SystemWallpaper", "Failed to resolve the current system wallpaper path.", ex);
+ return null;
+ }
+ }
+}
+
+internal sealed class WindowMaterialService : IWindowMaterialService
+{
+ private const int Windows11Build = 22000;
+ private const int Windows11_24H2Build = 26100;
+
+ public bool CanChangeMode => GetSupportProfile() == WindowMaterialSupportProfile.FullSwitching;
+
+ public IReadOnlyList GetAvailableModes()
+ {
+ return GetSupportProfile() switch
+ {
+ WindowMaterialSupportProfile.FullSwitching =>
+ [
+ ThemeAppearanceValues.MaterialNone,
+ ThemeAppearanceValues.MaterialMica,
+ ThemeAppearanceValues.MaterialAcrylic
+ ],
+ WindowMaterialSupportProfile.FixedMica =>
+ [
+ ThemeAppearanceValues.MaterialNone,
+ ThemeAppearanceValues.MaterialMica
+ ],
+ WindowMaterialSupportProfile.FixedAcrylic =>
+ [
+ ThemeAppearanceValues.MaterialNone,
+ ThemeAppearanceValues.MaterialAcrylic
+ ],
+ _ =>
+ [
+ ThemeAppearanceValues.MaterialNone
+ ]
+ };
+ }
+
+ public void Apply(Window window, string materialMode)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+
+ window.Background = Brushes.Transparent;
+
+ if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled())
+ {
+ window.TransparencyLevelHint =
+ [
+ WindowTransparencyLevel.None
+ ];
+ return;
+ }
+
+ var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode);
+ window.TransparencyLevelHint = normalizedMode switch
+ {
+ ThemeAppearanceValues.MaterialMica =>
+ [
+ WindowTransparencyLevel.Mica,
+ WindowTransparencyLevel.Blur,
+ WindowTransparencyLevel.None
+ ],
+ ThemeAppearanceValues.MaterialAcrylic =>
+ [
+ WindowTransparencyLevel.AcrylicBlur,
+ WindowTransparencyLevel.Blur,
+ WindowTransparencyLevel.None
+ ],
+ _ =>
+ [
+ WindowTransparencyLevel.None
+ ]
+ };
+ }
+
+ private static bool IsTransparencyEnabled()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return false;
+ }
+
+ try
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(
+ @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
+ writable: false);
+ var value = key?.GetValue("EnableTransparency");
+ return value switch
+ {
+ int intValue => intValue != 0,
+ byte byteValue => byteValue != 0,
+ _ => true
+ };
+ }
+ catch
+ {
+ return true;
+ }
+ }
+
+ private static WindowMaterialSupportProfile GetSupportProfile()
+ {
+ if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled())
+ {
+ return WindowMaterialSupportProfile.NoneOnly;
+ }
+
+ if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, Windows11_24H2Build))
+ {
+ return WindowMaterialSupportProfile.FullSwitching;
+ }
+
+ if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, Windows11Build))
+ {
+ return WindowMaterialSupportProfile.FixedMica;
+ }
+
+ if (OperatingSystem.IsWindowsVersionAtLeast(10, 0))
+ {
+ return WindowMaterialSupportProfile.FixedAcrylic;
+ }
+
+ return WindowMaterialSupportProfile.NoneOnly;
+ }
+
+ private enum WindowMaterialSupportProfile
+ {
+ NoneOnly = 0,
+ FixedMica = 1,
+ FixedAcrylic = 2,
+ FullSwitching = 3
+ }
+}
+
+internal sealed class MaterialSurfaceService : IMaterialSurfaceService
+{
+ public AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role)
+ {
+ var monetPalette = context.MonetPalette;
+ var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
+ var primary = context.UseNeutralSurfaces
+ ? context.AccentColor
+ : monetPalette?.Primary ?? (monetColors.Length > 0 ? monetColors[0] : context.AccentColor);
+ var secondary = monetPalette?.Secondary
+ ?? (monetColors.Length > 1
+ ? monetColors[1]
+ : ColorMath.Blend(primary, Color.Parse("#FFFFFFFF"), 0.14));
+ var neutralPrimary = monetPalette?.Neutral
+ ?? (monetColors.Length > 3
+ ? monetColors[3]
+ : ResolveNeutralBase(context.IsNightMode, role));
+ var neutralSecondary = monetPalette?.NeutralVariant
+ ?? (monetColors.Length > 4
+ ? monetColors[4]
+ : ResolveLiftBase(context.IsNightMode, role));
+ var materialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(context.SystemMaterialMode);
+
+ var (tintStrength, liftStrength, alpha, blurRadius) = ResolveModeParameters(materialMode, role, context.IsNightMode);
+ var neutralBase = ResolveNeutralBase(context.IsNightMode, role);
+ var neutralLift = ResolveLiftBase(context.IsNightMode, role);
+ var isDockLike = role is MaterialSurfaceRole.DockBackground;
+ var isComponentLike = role is MaterialSurfaceRole.DesktopComponentHost or MaterialSurfaceRole.StatusBarComponentHost;
+ var baseMix = isDockLike ? 0.88 : isComponentLike ? 0.74 : 0.82;
+ var liftMix = isDockLike ? 0.58 : isComponentLike ? 0.34 : 0.46;
+ var neutralMix = isDockLike ? 0.22 : 0.16;
+
+ var background = ColorMath.Blend(neutralBase, neutralPrimary, baseMix);
+ background = ColorMath.Blend(background, neutralLift, liftMix);
+ background = ColorMath.Blend(background, neutralSecondary, neutralMix);
+ if (!context.UseNeutralSurfaces)
+ {
+ background = ColorMath.Blend(background, primary, tintStrength);
+ background = ColorMath.Blend(background, secondary, liftStrength);
+ }
+
+ if (isDockLike && !context.IsNightMode)
+ {
+ background = ColorMath.Blend(background, Color.Parse("#FFFFFFFF"), 0.12);
+ }
+
+ background = Color.FromArgb(alpha, background.R, background.G, background.B);
+
+ var borderSeed = context.IsNightMode
+ ? ColorMath.Blend(neutralSecondary, Color.Parse("#FFFFFFFF"), 0.16)
+ : ColorMath.Blend(neutralSecondary, Color.Parse("#FF334155"), 0.08);
+ if (!context.UseNeutralSurfaces && !isComponentLike)
+ {
+ borderSeed = ColorMath.Blend(borderSeed, primary, 0.08);
+ }
+
+ var borderAlpha = role switch
+ {
+ MaterialSurfaceRole.DockBackground => context.IsNightMode ? (byte)0x34 : (byte)0x18,
+ MaterialSurfaceRole.DesktopComponentHost or MaterialSurfaceRole.StatusBarComponentHost =>
+ context.IsNightMode ? (byte)0x18 : (byte)0x10,
+ MaterialSurfaceRole.StatusBarBackground => (byte)0x00,
+ _ => context.IsNightMode ? (byte)0x26 : (byte)0x16
+ };
+ var border = ColorMath.WithAlpha(borderSeed, borderAlpha);
+
+ return new AppearanceMaterialSurface(background, border, blurRadius, 1.0);
+ }
+
+ private static (double TintStrength, double LiftStrength, byte Alpha, double BlurRadius) ResolveModeParameters(
+ string materialMode,
+ MaterialSurfaceRole role,
+ bool isNightMode)
+ {
+ var isOverlay = role is MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel;
+ return materialMode switch
+ {
+ ThemeAppearanceValues.MaterialAcrylic => (
+ isOverlay ? 0.30 : 0.20,
+ isOverlay ? 0.22 : 0.14,
+ isNightMode ? (byte)0xD8 : (byte)0xE0,
+ isOverlay ? 36 : 28),
+ ThemeAppearanceValues.MaterialMica => (
+ isOverlay ? 0.20 : 0.14,
+ isOverlay ? 0.12 : 0.08,
+ isNightMode ? (byte)0xEC : (byte)0xF2,
+ isOverlay ? 28 : 20),
+ _ => (
+ isOverlay ? 0.12 : 0.08,
+ isOverlay ? 0.08 : 0.05,
+ (byte)0xFF,
+ 0)
+ };
+ }
+
+ private static Color ResolveNeutralBase(bool isNightMode, MaterialSurfaceRole role)
+ {
+ return role switch
+ {
+ MaterialSurfaceRole.WindowBackground => isNightMode ? Color.Parse("#FF0A0F16") : Color.Parse("#FFF7F8FA"),
+ MaterialSurfaceRole.SettingsWindowBackground => isNightMode ? Color.Parse("#FF0C121A") : Color.Parse("#FFF8FAFC"),
+ MaterialSurfaceRole.DockBackground => isNightMode ? Color.Parse("#FF111A24") : Color.Parse("#FFFAFBFD"),
+ MaterialSurfaceRole.StatusBarBackground => isNightMode ? Color.Parse("#FF101720") : Color.Parse("#FFF9FBFE"),
+ MaterialSurfaceRole.StatusBarComponentHost => isNightMode ? Color.Parse("#FF111A23") : Color.Parse("#FFFCFDFE"),
+ MaterialSurfaceRole.OverlayPanel => isNightMode ? Color.Parse("#FF131C27") : Color.Parse("#FFF4F7FB"),
+ _ => isNightMode ? Color.Parse("#FF121B26") : Color.Parse("#FFFDFEFF")
+ };
+ }
+
+ private static Color ResolveLiftBase(bool isNightMode, MaterialSurfaceRole role)
+ {
+ return role switch
+ {
+ MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel =>
+ isNightMode ? Color.Parse("#FF1B2633") : Color.Parse("#FFFFFFFF"),
+ _ => isNightMode ? Color.Parse("#FF17212D") : Color.Parse("#FFFFFFFF")
+ };
+ }
+}
+
+internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposable
+{
+ private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6");
+ private readonly ISettingsFacadeService _settingsFacade;
+ private readonly ISystemWallpaperService _systemWallpaperService;
+ private readonly IWindowMaterialService _windowMaterialService;
+ private readonly IMaterialSurfaceService _materialSurfaceService;
+ private readonly IVideoWallpaperSeedExtractor _videoWallpaperSeedExtractor;
+ private readonly MonetColorService _monetColorService = new();
+ private readonly string _liveThemeColorMode;
+ private readonly string _liveSystemMaterialMode;
+ private readonly string? _liveSelectedWallpaperSeed;
+ private readonly object _paletteGate = new();
+ private readonly Dictionary _wallpaperSeedCache = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _pendingWallpaperSeedKeys = new(StringComparer.OrdinalIgnoreCase);
+
+ public AppearanceThemeService(
+ ISettingsFacadeService settingsFacade,
+ ISystemWallpaperService systemWallpaperService,
+ IWindowMaterialService windowMaterialService,
+ IMaterialSurfaceService materialSurfaceService,
+ IVideoWallpaperSeedExtractor? videoWallpaperSeedExtractor = null)
+ {
+ _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ _systemWallpaperService = systemWallpaperService ?? throw new ArgumentNullException(nameof(systemWallpaperService));
+ _windowMaterialService = windowMaterialService ?? throw new ArgumentNullException(nameof(windowMaterialService));
+ _materialSurfaceService = materialSurfaceService ?? throw new ArgumentNullException(nameof(materialSurfaceService));
+ _videoWallpaperSeedExtractor = videoWallpaperSeedExtractor ?? new LibVlcVideoWallpaperSeedExtractor();
+ var initialThemeState = _settingsFacade.Theme.Get();
+ _liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
+ initialThemeState.ThemeColorMode,
+ initialThemeState.ThemeColor);
+ _liveSystemMaterialMode = ResolveSupportedMaterialMode(initialThemeState.SystemMaterialMode);
+ _liveSelectedWallpaperSeed = initialThemeState.SelectedWallpaperSeed;
+ _settingsFacade.Settings.Changed += OnSettingsChanged;
+ }
+
+ public event EventHandler? Changed;
+
+ public AppearanceThemeSnapshot GetCurrent()
+ {
+ return BuildCurrentSnapshot(queueWallpaperPaletteBuild: true);
+ }
+
+ public AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState)
+ {
+ ArgumentNullException.ThrowIfNull(pendingState);
+
+ var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
+ pendingState.ThemeColorMode,
+ pendingState.ThemeColor);
+ var normalizedSystemMaterialMode = ResolveSupportedMaterialMode(pendingState.SystemMaterialMode);
+ return BuildSnapshot(
+ pendingState with
+ {
+ ThemeColorMode = normalizedThemeColorMode,
+ SystemMaterialMode = normalizedSystemMaterialMode
+ },
+ normalizedThemeColorMode,
+ normalizedSystemMaterialMode,
+ pendingState.SelectedWallpaperSeed,
+ queueWallpaperPaletteBuild: true);
+ }
+
+ public void ApplyThemeResources(IResourceDictionary resources)
+ {
+ ArgumentNullException.ThrowIfNull(resources);
+
+ var snapshot = GetCurrent();
+ var context = CreateThemeContext(snapshot);
+ ThemeColorSystemService.ApplyThemeResources(resources, context);
+ GlassEffectService.ApplyGlassResources(resources, context);
+ }
+
+ public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)
+ {
+ var snapshot = GetCurrent();
+ return _materialSurfaceService.GetSurface(CreateThemeContext(snapshot), role);
+ }
+
+ public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+
+ // Avoid hot-switching real backdrops on already-visible windows. This has been
+ // a stability hotspot when users flip theme source/material at runtime.
+ if (window.IsVisible)
+ {
+ return;
+ }
+
+ var snapshot = GetCurrent();
+
+ try
+ {
+ _windowMaterialService.Apply(window, snapshot.SystemMaterialMode);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(
+ "Appearance.WindowMaterial",
+ $"Failed to apply window material '{snapshot.SystemMaterialMode}'. Falling back to none.",
+ ex);
+ _windowMaterialService.Apply(window, ThemeAppearanceValues.MaterialNone);
+ }
+ }
+
+ public void Dispose()
+ {
+ _settingsFacade.Settings.Changed -= OnSettingsChanged;
+ }
+
+ private AppearanceThemeSnapshot BuildCurrentSnapshot(bool queueWallpaperPaletteBuild)
+ {
+ var themeState = _settingsFacade.Theme.Get();
+ return BuildSnapshot(
+ themeState,
+ _liveThemeColorMode,
+ _liveSystemMaterialMode,
+ _liveSelectedWallpaperSeed,
+ queueWallpaperPaletteBuild);
+ }
+
+ private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
+ {
+ _ = sender;
+
+ if (e.Scope != SettingsScope.App)
+ {
+ return;
+ }
+
+ var changedKeys = e.ChangedKeys?.ToArray();
+ var refreshAll = changedKeys is null || changedKeys.Length == 0;
+ var respondsToThemeColor = string.Equals(
+ _liveThemeColorMode,
+ ThemeAppearanceValues.ColorModeSeedMonet,
+ StringComparison.OrdinalIgnoreCase);
+ var respondsToWallpaper = string.Equals(
+ _liveThemeColorMode,
+ ThemeAppearanceValues.ColorModeWallpaperMonet,
+ StringComparison.OrdinalIgnoreCase);
+
+ if (!refreshAll &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
+ !changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
+ !(respondsToThemeColor &&
+ changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
+ !(respondsToWallpaper &&
+ (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))))
+ {
+ return;
+ }
+
+ RaiseChanged(queueWallpaperPaletteBuild: true);
+ }
+
+ private AppearanceThemeSnapshot BuildSnapshot(
+ ThemeAppearanceSettingsState themeState,
+ string themeColorMode,
+ string systemMaterialMode,
+ string? selectedWallpaperSeed,
+ bool queueWallpaperPaletteBuild)
+ {
+ var availableModes = _windowMaterialService.GetAvailableModes();
+ MonetPalette palette;
+ IReadOnlyList wallpaperSeedCandidates;
+ Color effectiveSeedColor;
+ string resolvedSeedSource;
+ string? resolvedWallpaperPath;
+
+ if (string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase))
+ {
+ var wallpaperState = _settingsFacade.Wallpaper.Get();
+ var wallpaperResolution = ResolveWallpaperPalette(
+ themeState.IsNightMode,
+ wallpaperState,
+ selectedWallpaperSeed,
+ queueWallpaperPaletteBuild);
+ palette = wallpaperResolution.Palette;
+ wallpaperSeedCandidates = wallpaperResolution.SeedCandidates;
+ effectiveSeedColor = wallpaperResolution.EffectiveSeedColor;
+ resolvedSeedSource = wallpaperResolution.ResolvedSeedSource;
+ resolvedWallpaperPath = wallpaperResolution.ResolvedWallpaperPath;
+ }
+ else
+ {
+ var preferredSeedColor = string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase)
+ ? themeState.ThemeColor
+ : null;
+ palette = _settingsFacade.Theme.BuildPalette(themeState.IsNightMode, null, preferredSeedColor);
+ wallpaperSeedCandidates = [];
+ effectiveSeedColor = ResolveEffectiveSeedColor(themeColorMode, themeState.ThemeColor, palette);
+ resolvedSeedSource = string.Equals(themeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase)
+ ? "neutral"
+ : "user_color";
+ resolvedWallpaperPath = null;
+ }
+
+ return new AppearanceThemeSnapshot(
+ themeState.IsNightMode,
+ themeColorMode,
+ themeState.ThemeColor,
+ selectedWallpaperSeed,
+ resolvedSeedSource,
+ palette,
+ ResolveAccentColor(themeColorMode, themeState.ThemeColor, palette),
+ effectiveSeedColor,
+ wallpaperSeedCandidates,
+ systemMaterialMode,
+ availableModes,
+ _windowMaterialService.CanChangeMode,
+ themeState.UseSystemChrome,
+ resolvedWallpaperPath);
+ }
+
+ private ThemeColorContext CreateThemeContext(AppearanceThemeSnapshot snapshot)
+ {
+ return new ThemeColorContext(
+ snapshot.AccentColor,
+ IsLightBackground: !snapshot.IsNightMode,
+ IsLightNavBackground: !snapshot.IsNightMode,
+ IsNightMode: snapshot.IsNightMode,
+ MonetPalette: snapshot.MonetPalette,
+ MonetColors: snapshot.MonetPalette.MonetColors,
+ UseNeutralSurfaces: snapshot.ThemeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral,
+ SystemMaterialMode: snapshot.SystemMaterialMode);
+ }
+
+ private string ResolveSupportedMaterialMode(string? requestedMode)
+ {
+ var normalized = ThemeAppearanceValues.NormalizeSystemMaterialMode(requestedMode);
+ var availableModes = _windowMaterialService.GetAvailableModes();
+ return availableModes.Contains(normalized, StringComparer.OrdinalIgnoreCase)
+ ? normalized
+ : ThemeAppearanceValues.MaterialNone;
+ }
+
+ private WallpaperPaletteResolution ResolveWallpaperPalette(
+ bool nightMode,
+ WallpaperSettingsState wallpaperState,
+ string? selectedWallpaperSeed,
+ bool queueWallpaperPaletteBuild)
+ {
+ var source = ResolveWallpaperSeedSource(wallpaperState);
+ if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase))
+ {
+ return BuildFallbackWallpaperPaletteResolution(nightMode, source.ResolvedWallpaperPath);
+ }
+
+ if (string.Equals(source.SourceKind, "app_solid", StringComparison.OrdinalIgnoreCase))
+ {
+ var candidates = source.SolidColor is { } solidColor
+ ? new[] { solidColor }
+ : [];
+ return BuildWallpaperPaletteResolution(nightMode, source, candidates, selectedWallpaperSeed);
+ }
+
+ lock (_paletteGate)
+ {
+ if (_wallpaperSeedCache.TryGetValue(source.SourceKey, out var cachedSeedResult))
+ {
+ if (cachedSeedResult.SeedCandidates.Count > 0)
+ {
+ return BuildWallpaperPaletteResolution(
+ nightMode,
+ source with
+ {
+ SourceKind = cachedSeedResult.SourceKind,
+ ResolvedWallpaperPath = cachedSeedResult.ResolvedWallpaperPath
+ },
+ cachedSeedResult.SeedCandidates,
+ selectedWallpaperSeed);
+ }
+
+ return BuildFallbackWallpaperPaletteResolution(nightMode, cachedSeedResult.ResolvedWallpaperPath);
+ }
+ }
+
+ if (queueWallpaperPaletteBuild)
+ {
+ QueueWallpaperSeedExtraction(source);
+ }
+
+ return BuildFallbackWallpaperPaletteResolution(nightMode, source.ResolvedWallpaperPath);
+ }
+
+ private static Color ResolveAccentColor(
+ string themeColorMode,
+ string? colorText,
+ MonetPalette monetPalette)
+ {
+ if (themeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral)
+ {
+ return DefaultAccentColor;
+ }
+
+ if (monetPalette.Primary.A > 0)
+ {
+ return monetPalette.Primary;
+ }
+
+ if (!string.IsNullOrWhiteSpace(colorText) && Color.TryParse(colorText, out var parsedColor))
+ {
+ return parsedColor;
+ }
+
+ return DefaultAccentColor;
+ }
+
+ private static Color ResolveEffectiveSeedColor(
+ string themeColorMode,
+ string? userThemeColor,
+ MonetPalette monetPalette)
+ {
+ if (themeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral)
+ {
+ return DefaultAccentColor;
+ }
+
+ if (themeColorMode == ThemeAppearanceValues.ColorModeSeedMonet &&
+ !string.IsNullOrWhiteSpace(userThemeColor) &&
+ Color.TryParse(userThemeColor, out var parsedColor))
+ {
+ return parsedColor;
+ }
+
+ return monetPalette.Seed;
+ }
+
+ private WallpaperPaletteResolution BuildWallpaperPaletteResolution(
+ bool nightMode,
+ WallpaperSeedSourceDescriptor source,
+ IReadOnlyList seedCandidates,
+ string? selectedWallpaperSeed)
+ {
+ var validatedSeed = ResolveSelectedWallpaperSeed(seedCandidates, selectedWallpaperSeed);
+ var palette = _monetColorService.BuildPaletteFromSeedCandidates(seedCandidates, nightMode, validatedSeed);
+ return new WallpaperPaletteResolution(
+ palette,
+ seedCandidates,
+ source.SourceKind,
+ palette.Seed,
+ source.ResolvedWallpaperPath);
+ }
+
+ private WallpaperPaletteResolution BuildFallbackWallpaperPaletteResolution(bool nightMode, string? resolvedWallpaperPath)
+ {
+ var palette = _settingsFacade.Theme.BuildPalette(nightMode, null, null);
+ return new WallpaperPaletteResolution(
+ palette,
+ [],
+ "fallback",
+ palette.Seed,
+ resolvedWallpaperPath);
+ }
+
+ private void QueueWallpaperSeedExtraction(WallpaperSeedSourceDescriptor source)
+ {
+ if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(source.SourceKind, "app_solid", StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ lock (_paletteGate)
+ {
+ if (_pendingWallpaperSeedKeys.Contains(source.SourceKey))
+ {
+ return;
+ }
+
+ _pendingWallpaperSeedKeys.Add(source.SourceKey);
+ }
+
+ _ = Task.Run(() =>
+ {
+ WallpaperSeedExtractionResult? extractionResult = null;
+
+ try
+ {
+ extractionResult = ExtractWallpaperSeedCandidates(source);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(
+ "Appearance.WallpaperSeed",
+ $"Failed to build wallpaper seed candidates asynchronously. Source='{source.SourceKind}'; Path='{source.FilePath}'.",
+ ex);
+ }
+ finally
+ {
+ lock (_paletteGate)
+ {
+ _pendingWallpaperSeedKeys.Remove(source.SourceKey);
+ if (extractionResult is not null)
+ {
+ _wallpaperSeedCache[source.SourceKey] = extractionResult;
+ }
+ }
+ }
+
+ if (extractionResult is not null)
+ {
+ RaiseChanged(queueWallpaperPaletteBuild: false);
+ }
+ });
+ }
+
+ private WallpaperSeedExtractionResult ExtractWallpaperSeedCandidates(WallpaperSeedSourceDescriptor source)
+ {
+ IReadOnlyList seedCandidates = source.SourceKind switch
+ {
+ "app_wallpaper" or "system_wallpaper" => ExtractImageSeedCandidates(source.FilePath),
+ "app_video" => ExtractVideoSeedCandidates(source.FilePath),
+ "app_solid" when source.SolidColor is { } solidColor => new[] { solidColor },
+ _ => []
+ };
+
+ return new WallpaperSeedExtractionResult(
+ source.SourceKind,
+ source.SourceKey,
+ source.ResolvedWallpaperPath,
+ seedCandidates);
+ }
+
+ private IReadOnlyList ExtractImageSeedCandidates(string? wallpaperPath)
+ {
+ if (string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath))
+ {
+ return [];
+ }
+
+ try
+ {
+ using var bitmap = new Bitmap(wallpaperPath);
+ return _monetColorService.ExtractSeedCandidates(bitmap);
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Warn(
+ "Appearance.WallpaperSeed",
+ $"Failed to extract wallpaper seed candidates from image '{wallpaperPath}'.",
+ ex);
+ return [];
+ }
+ }
+
+ private IReadOnlyList ExtractVideoSeedCandidates(string? wallpaperPath)
+ {
+ if (string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath))
+ {
+ return [];
+ }
+
+ return _videoWallpaperSeedExtractor.ExtractSeedCandidates(wallpaperPath, _monetColorService);
+ }
+
+ private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource(WallpaperSettingsState wallpaperState)
+ {
+ if (string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) &&
+ !string.IsNullOrWhiteSpace(wallpaperState.Color) &&
+ Color.TryParse(wallpaperState.Color, out var solidColor))
+ {
+ var solidText = solidColor.ToString();
+ return new WallpaperSeedSourceDescriptor(
+ "app_solid",
+ $"app_solid|{solidText}",
+ null,
+ null,
+ solidColor);
+ }
+
+ var wallpaperPath = string.IsNullOrWhiteSpace(wallpaperState.WallpaperPath)
+ ? null
+ : wallpaperState.WallpaperPath.Trim();
+ var appWallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperPath);
+ if (!string.IsNullOrWhiteSpace(wallpaperPath) && File.Exists(wallpaperPath))
+ {
+ if (appWallpaperMediaType == WallpaperMediaType.Image)
+ {
+ return new WallpaperSeedSourceDescriptor(
+ "app_wallpaper",
+ CreateWallpaperSourceKey("app_wallpaper", wallpaperPath),
+ wallpaperPath,
+ wallpaperPath,
+ null);
+ }
+
+ if (appWallpaperMediaType == WallpaperMediaType.Video)
+ {
+ return new WallpaperSeedSourceDescriptor(
+ "app_video",
+ CreateWallpaperSourceKey("app_video", wallpaperPath),
+ wallpaperPath,
+ wallpaperPath,
+ null);
+ }
+ }
+
+ var systemWallpaper = _systemWallpaperService.GetWallpaperPath();
+ if (!string.IsNullOrWhiteSpace(systemWallpaper) &&
+ File.Exists(systemWallpaper) &&
+ _settingsFacade.WallpaperMedia.DetectMediaType(systemWallpaper) == WallpaperMediaType.Image)
+ {
+ return new WallpaperSeedSourceDescriptor(
+ "system_wallpaper",
+ CreateWallpaperSourceKey("system_wallpaper", systemWallpaper),
+ systemWallpaper,
+ systemWallpaper,
+ null);
+ }
+
+ return new WallpaperSeedSourceDescriptor(
+ "fallback",
+ "fallback",
+ null,
+ null,
+ null);
+ }
+
+ private void RaiseChanged(bool queueWallpaperPaletteBuild)
+ {
+ var snapshot = BuildCurrentSnapshot(queueWallpaperPaletteBuild);
+ if (Dispatcher.UIThread.CheckAccess())
+ {
+ Changed?.Invoke(this, snapshot);
+ return;
+ }
+
+ Dispatcher.UIThread.Post(() => Changed?.Invoke(this, snapshot), DispatcherPriority.Background);
+ }
+
+ private static Color? ResolveSelectedWallpaperSeed(
+ IReadOnlyList seedCandidates,
+ string? selectedWallpaperSeed)
+ {
+ if (seedCandidates.Count == 0)
+ {
+ return null;
+ }
+
+ if (!string.IsNullOrWhiteSpace(selectedWallpaperSeed) &&
+ Color.TryParse(selectedWallpaperSeed, out var parsedSeed))
+ {
+ foreach (var candidate in seedCandidates)
+ {
+ if (candidate == parsedSeed)
+ {
+ return candidate;
+ }
+ }
+ }
+
+ return seedCandidates[0];
+ }
+
+ private static string CreateWallpaperSourceKey(string sourceKind, string wallpaperPath)
+ {
+ long lastWriteTicks = 0;
+ long length = 0;
+
+ try
+ {
+ var fileInfo = new FileInfo(wallpaperPath);
+ if (fileInfo.Exists)
+ {
+ lastWriteTicks = fileInfo.LastWriteTimeUtc.Ticks;
+ length = fileInfo.Length;
+ }
+ }
+ catch
+ {
+ // Keep the cache key resilient even if metadata lookup fails.
+ }
+
+ return string.Concat(
+ sourceKind,
+ "|",
+ wallpaperPath,
+ "|",
+ lastWriteTicks.ToString(),
+ "|",
+ length.ToString());
+ }
+}
+
+internal static class HostAppearanceThemeProvider
+{
+ private static readonly object Gate = new();
+ private static AppearanceThemeService? _instance;
+
+ public static IAppearanceThemeService GetOrCreate()
+ {
+ lock (Gate)
+ {
+ return _instance ??= new AppearanceThemeService(
+ HostSettingsFacadeProvider.GetOrCreate(),
+ new SystemWallpaperService(),
+ new WindowMaterialService(),
+ new MaterialSurfaceService());
+ }
+ }
+}
diff --git a/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs b/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs
index a0a6848..47a9006 100644
--- a/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs
+++ b/LanMountainDesktop/Services/ComponentEditorMaterialThemeAdapter.cs
@@ -49,15 +49,19 @@ internal static class ComponentEditorMaterialThemeAdapter
ArgumentNullException.ThrowIfNull(monetPalette);
var isNightMode = themeState.IsNightMode;
- var monetColors = monetPalette.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
var fallbackThemeColor = TryParseColor(themeState.ThemeColor);
- var useWallpaperPalette = wallpaperMediaType == WallpaperMediaType.Image && monetColors.Length > 0;
+ var useWallpaperPalette = wallpaperMediaType == WallpaperMediaType.Image && monetPalette.Primary.A > 0;
var primary = useWallpaperPalette
- ? monetColors[0]
- : fallbackThemeColor ?? monetColors.FirstOrDefault(DefaultPrimary);
- var secondary = ResolveSecondaryColor(primary, monetColors, isNightMode);
- var tertiary = ResolveTertiaryColor(primary, secondary, monetColors, isNightMode);
+ ? monetPalette.Primary
+ : fallbackThemeColor ?? monetPalette.Primary;
+ if (primary == default)
+ {
+ primary = DefaultPrimary;
+ }
+
+ var secondary = ResolveSecondaryColor(primary, monetPalette, isNightMode);
+ var tertiary = ResolveTertiaryColor(primary, secondary, monetPalette, isNightMode);
var backgroundBase = isNightMode ? DarkBackgroundBase : LightBackgroundBase;
var surfaceBase = isNightMode ? DarkSurfaceBase : LightSurfaceBase;
@@ -120,11 +124,11 @@ internal static class ComponentEditorMaterialThemeAdapter
onPrimary);
}
- private static Color ResolveSecondaryColor(Color primary, IReadOnlyList monetColors, bool isNightMode)
+ private static Color ResolveSecondaryColor(Color primary, MonetPalette monetPalette, bool isNightMode)
{
- if (monetColors.Count > 1)
+ if (monetPalette.Secondary != default)
{
- return monetColors[1];
+ return monetPalette.Secondary;
}
return ColorMath.Blend(
@@ -136,12 +140,12 @@ internal static class ComponentEditorMaterialThemeAdapter
private static Color ResolveTertiaryColor(
Color primary,
Color secondary,
- IReadOnlyList monetColors,
+ MonetPalette monetPalette,
bool isNightMode)
{
- if (monetColors.Count > 2)
+ if (monetPalette.Tertiary != default)
{
- return monetColors[2];
+ return monetPalette.Tertiary;
}
var blendTarget = isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF2A2230");
diff --git a/LanMountainDesktop/Services/ComponentEditorWindowService.cs b/LanMountainDesktop/Services/ComponentEditorWindowService.cs
index 5749347..2b51d45 100644
--- a/LanMountainDesktop/Services/ComponentEditorWindowService.cs
+++ b/LanMountainDesktop/Services/ComponentEditorWindowService.cs
@@ -31,13 +31,16 @@ public interface IComponentEditorWindowService
internal sealed class ComponentEditorWindowService : IComponentEditorWindowService
{
private readonly ISettingsFacadeService _settingsFacade;
+ private readonly IAppearanceThemeService _appearanceThemeService;
private ComponentEditorWindow? _window;
private string? _currentPlacementId;
public ComponentEditorWindowService(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
+ _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
_settingsFacade.Settings.Changed += OnSettingsChanged;
+ _appearanceThemeService.Changed += OnAppearanceThemeChanged;
}
public bool IsOpen => _window is { IsVisible: true };
@@ -105,12 +108,15 @@ internal sealed class ComponentEditorWindowService : IComponentEditorWindowServi
}
var changedKeys = e.ChangedKeys?.ToArray() ?? [];
+ var liveAppearance = _appearanceThemeService.GetCurrent();
if (changedKeys.Length > 0 &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
- !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) &&
- !changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) &&
- !changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) &&
- !changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), 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) &&
+ (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase))
{
return;
@@ -121,21 +127,33 @@ internal sealed class ComponentEditorWindowService : IComponentEditorWindowServi
private void ApplyTheme(ComponentEditorWindow window)
{
+ var appearanceSnapshot = _appearanceThemeService.GetCurrent();
var themeState = _settingsFacade.Theme.Get();
var wallpaperState = _settingsFacade.Wallpaper.Get();
- var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(wallpaperState.WallpaperPath);
- var monetPalette = _settingsFacade.Theme.BuildPalette(
- themeState.IsNightMode,
- wallpaperState.WallpaperPath,
- themeState.ThemeColor);
+ var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(
+ appearanceSnapshot.ResolvedWallpaperPath ?? wallpaperState.WallpaperPath);
var palette = ComponentEditorMaterialThemeAdapter.Build(
themeState,
wallpaperState,
- monetPalette,
+ appearanceSnapshot.MonetPalette,
wallpaperMediaType);
window.ApplyTheme(palette);
window.ApplyChromeMode(themeState.UseSystemChrome);
+ _appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.WindowBackground);
+ }
+
+ private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
+ {
+ _ = sender;
+ _ = e;
+
+ if (_window is null)
+ {
+ return;
+ }
+
+ ApplyTheme(_window);
}
private sealed class HostContext : IComponentEditorHostContext
diff --git a/LanMountainDesktop/Services/GlassEffectService.cs b/LanMountainDesktop/Services/GlassEffectService.cs
index 50bb293..2f071b7 100644
--- a/LanMountainDesktop/Services/GlassEffectService.cs
+++ b/LanMountainDesktop/Services/GlassEffectService.cs
@@ -9,57 +9,95 @@ public static class GlassEffectService
{
public static void ApplyGlassResources(IResourceDictionary resources, ThemeColorContext context)
{
+ var materialSurfaceService = new MaterialSurfaceService();
+ var monetPalette = context.MonetPalette;
var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
- var primary = monetColors.Length > 0 ? monetColors[0] : context.AccentColor;
- var secondary = monetColors.Length > 1
- ? monetColors[1]
- : ColorMath.Blend(primary, Color.Parse("#FFFFFFFF"), 0.12);
-
- var panelBase = context.IsNightMode
- ? ColorMath.Blend(Color.Parse("#FF101722"), primary, 0.26)
- : ColorMath.Blend(Color.Parse("#FFF9FBFE"), primary, 0.14);
- var panelRaised = context.IsNightMode
- ? ColorMath.Blend(Color.Parse("#FF15202C"), secondary, 0.30)
- : ColorMath.Blend(Color.Parse("#FFFFFFFF"), secondary, 0.18);
- var overlayBase = context.IsNightMode
- ? ColorMath.Blend(Color.Parse("#FF0E1622"), primary, 0.36)
- : ColorMath.Blend(Color.Parse("#FFF3F7FD"), primary, 0.20);
+ var primary = context.UseNeutralSurfaces
+ ? context.AccentColor
+ : monetPalette?.Primary ?? (monetColors.Length > 0 ? monetColors[0] : context.AccentColor);
+ var neutralButtonBase = context.IsNightMode
+ ? Color.Parse("#FF171C24")
+ : Color.Parse("#FFFFFFFF");
+ if (!context.UseNeutralSurfaces)
+ {
+ neutralButtonBase = ColorMath.Blend(
+ neutralButtonBase,
+ primary,
+ context.IsNightMode ? 0.08 : 0.04);
+ }
var buttonBackground = Color.FromArgb(
- context.IsNightMode ? (byte)0x4D : (byte)0x52,
- panelRaised.R,
- panelRaised.G,
- panelRaised.B);
- var buttonBorder = Color.FromArgb(
- context.IsNightMode ? (byte)0x36 : (byte)0x26,
- primary.R,
- primary.G,
- primary.B);
+ context.IsNightMode ? (byte)0xF0 : (byte)0xFF,
+ neutralButtonBase.R,
+ neutralButtonBase.G,
+ neutralButtonBase.B);
+ var buttonBorder = ColorMath.WithAlpha(
+ context.IsNightMode
+ ? ColorMath.Blend(neutralButtonBase, Color.Parse("#FFFFFFFF"), 0.14)
+ : ColorMath.Blend(neutralButtonBase, Color.Parse("#FF334155"), 0.10),
+ context.IsNightMode ? (byte)0x26 : (byte)0x14);
resources["AdaptiveButtonBackgroundBrush"] = new SolidColorBrush(buttonBackground);
resources["AdaptiveButtonBorderBrush"] = new SolidColorBrush(buttonBorder);
resources["AdaptiveButtonHoverBackgroundBrush"] = new SolidColorBrush(
- ColorMath.WithAlpha(ColorMath.Blend(buttonBackground, primary, 0.18), context.IsNightMode ? (byte)0x72 : (byte)0x7A));
+ ColorMath.WithAlpha(
+ ColorMath.Blend(buttonBackground, primary, context.IsNightMode ? 0.14 : 0.08),
+ context.IsNightMode ? (byte)0xF4 : (byte)0xFF));
resources["AdaptiveButtonPressedBackgroundBrush"] = new SolidColorBrush(
- ColorMath.WithAlpha(ColorMath.Blend(buttonBackground, primary, 0.30), context.IsNightMode ? (byte)0x8A : (byte)0x8C));
+ ColorMath.WithAlpha(
+ ColorMath.Blend(buttonBackground, primary, context.IsNightMode ? 0.24 : 0.16),
+ context.IsNightMode ? (byte)0xF8 : (byte)0xFF));
- resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush(
- Color.FromArgb(context.IsNightMode ? (byte)0xF2 : (byte)0xFA, panelBase.R, panelBase.G, panelBase.B));
- resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush(
- Color.FromArgb(context.IsNightMode ? (byte)0x38 : (byte)0x24, primary.R, primary.G, primary.B));
- resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush(
- Color.FromArgb(context.IsNightMode ? (byte)0xF6 : (byte)0xFC, panelRaised.R, panelRaised.G, panelRaised.B));
- resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush(
- Color.FromArgb(context.IsNightMode ? (byte)0x4A : (byte)0x2C, secondary.R, secondary.G, secondary.B));
- resources["AdaptiveGlassOverlayBackgroundBrush"] = new SolidColorBrush(
- Color.FromArgb(context.IsNightMode ? (byte)0xEA : (byte)0xF4, overlayBase.R, overlayBase.G, overlayBase.B));
+ var windowSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.WindowBackground);
+ var settingsWindowSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.SettingsWindowBackground);
+ var dockSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.DockBackground);
+ var statusBarSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.StatusBarBackground);
+ var desktopComponentSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.DesktopComponentHost);
+ var statusBarComponentSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.StatusBarComponentHost);
+ var overlaySurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.OverlayPanel);
+ var strongSurfaceColor = ColorMath.Blend(
+ desktopComponentSurface.BackgroundColor,
+ overlaySurface.BackgroundColor,
+ context.IsNightMode ? 0.18 : 0.12);
+ var strongBorderColor = ColorMath.WithAlpha(
+ desktopComponentSurface.BorderColor,
+ context.IsNightMode ? (byte)0x20 : (byte)0x12);
+ var panelBorderColor = ColorMath.WithAlpha(
+ desktopComponentSurface.BorderColor,
+ context.IsNightMode ? (byte)0x18 : (byte)0x10);
- resources["AdaptiveGlassPanelBlurRadius"] = context.IsNightMode ? 22.0 : 28.0;
- resources["AdaptiveGlassStrongBlurRadius"] = context.IsNightMode ? 28.0 : 34.0;
- resources["AdaptiveGlassOverlayBlurRadius"] = context.IsNightMode ? 34.0 : 40.0;
+ resources["AdaptiveWindowBackgroundBrush"] = new SolidColorBrush(windowSurface.BackgroundColor);
+ resources["AdaptiveWindowBorderBrush"] = new SolidColorBrush(windowSurface.BorderColor);
+ resources["AdaptiveSettingsWindowBackgroundBrush"] = new SolidColorBrush(settingsWindowSurface.BackgroundColor);
+ resources["AdaptiveSettingsWindowBorderBrush"] = new SolidColorBrush(settingsWindowSurface.BorderColor);
+ resources["AdaptiveDockBackgroundBrush"] = new SolidColorBrush(dockSurface.BackgroundColor);
+ resources["AdaptiveDockBorderBrush"] = new SolidColorBrush(dockSurface.BorderColor);
+ resources["AdaptiveStatusBarBackgroundBrush"] = new SolidColorBrush(statusBarSurface.BackgroundColor);
+ resources["AdaptiveStatusBarBorderBrush"] = new SolidColorBrush(statusBarSurface.BorderColor);
+ resources["AdaptiveDesktopComponentHostBackgroundBrush"] = new SolidColorBrush(desktopComponentSurface.BackgroundColor);
+ resources["AdaptiveDesktopComponentHostBorderBrush"] = new SolidColorBrush(desktopComponentSurface.BorderColor);
+ resources["AdaptiveStatusBarComponentHostBackgroundBrush"] = new SolidColorBrush(statusBarComponentSurface.BackgroundColor);
+ resources["AdaptiveStatusBarComponentHostBorderBrush"] = new SolidColorBrush(statusBarComponentSurface.BorderColor);
+
+ resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush(desktopComponentSurface.BackgroundColor);
+ resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush(panelBorderColor);
+ resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush(strongSurfaceColor);
+ resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush(strongBorderColor);
+ resources["AdaptiveDockGlassBackgroundBrush"] = new SolidColorBrush(dockSurface.BackgroundColor);
+ resources["AdaptiveDockGlassBorderBrush"] = new SolidColorBrush(dockSurface.BorderColor);
+ resources["AdaptiveGlassOverlayBackgroundBrush"] = new SolidColorBrush(overlaySurface.BackgroundColor);
+
+ resources["AdaptiveGlassPanelBlurRadius"] = desktopComponentSurface.BlurRadius;
+ resources["AdaptiveGlassStrongBlurRadius"] = dockSurface.BlurRadius;
+ resources["AdaptiveGlassOverlayBlurRadius"] = overlaySurface.BlurRadius;
resources["AdaptiveGlassPanelOpacity"] = 1.0;
resources["AdaptiveGlassStrongOpacity"] = 1.0;
- resources["AdaptiveGlassOverlayOpacity"] = context.IsNightMode ? 0.95 : 0.98;
+ resources["AdaptiveGlassOverlayOpacity"] = overlaySurface.Opacity;
resources["AdaptiveGlassNoiseOpacity"] = context.IsNightMode ? 0.012 : 0.008;
+
+ resources["AdaptiveDockOpacity"] = dockSurface.Opacity;
+ resources["AdaptiveStatusBarOpacity"] = statusBarSurface.Opacity;
+ resources["AdaptiveDesktopComponentHostOpacity"] = desktopComponentSurface.Opacity;
+ resources["AdaptiveStatusBarComponentHostOpacity"] = statusBarComponentSurface.Opacity;
}
}
diff --git a/LanMountainDesktop/Services/MonetColorService.cs b/LanMountainDesktop/Services/MonetColorService.cs
index 33cc00f..28ed25f 100644
--- a/LanMountainDesktop/Services/MonetColorService.cs
+++ b/LanMountainDesktop/Services/MonetColorService.cs
@@ -1,84 +1,86 @@
-using System;
+using System;
using System.Collections.Generic;
+using System.Linq;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using LanMountainDesktop.Models;
+using MaterialColorUtilities.Palettes;
+using MaterialColorUtilities.Utils;
using Microsoft.Win32;
namespace LanMountainDesktop.Services;
public sealed class MonetColorService
{
+ private static readonly Color DefaultSeedColor = Color.Parse("#FF3B82F6");
+
public MonetPalette BuildPalette(Bitmap? wallpaper, bool nightMode, Color? preferredSeed = null)
{
- var recommended = BuildRecommendedPalette(nightMode);
- var seed = preferredSeed ?? TryExtractSeedColor(wallpaper) ?? TryGetSystemMonetSeedColor() ?? Color.Parse("#FF3B82F6");
- var monet = BuildMonetPalette(seed, nightMode);
- return new MonetPalette(recommended, monet);
+ var wallpaperCandidates = wallpaper is null
+ ? []
+ : ExtractSeedCandidates(wallpaper);
+ return BuildPaletteCore(wallpaperCandidates, nightMode, preferredSeed);
}
- private static IReadOnlyList BuildRecommendedPalette(bool nightMode)
+ public MonetPalette BuildPaletteFromSeedCandidates(
+ IReadOnlyList? seedCandidates,
+ bool nightMode,
+ Color? preferredSeed = null)
{
- if (nightMode)
- {
- return
- [
- Color.Parse("#FF3B82F6"),
- Color.Parse("#FF22C55E"),
- Color.Parse("#FFF59E0B"),
- Color.Parse("#FFF97316"),
- Color.Parse("#FFA855F7"),
- Color.Parse("#FFEF4444")
- ];
- }
-
- return
- [
- Color.Parse("#FF1D4ED8"),
- Color.Parse("#FF15803D"),
- Color.Parse("#FFB45309"),
- Color.Parse("#FFC2410C"),
- Color.Parse("#FF7E22CE"),
- Color.Parse("#FFB91C1C")
- ];
+ return BuildPaletteCore(seedCandidates ?? [], nightMode, preferredSeed);
}
- private static IReadOnlyList BuildMonetPalette(Color seed, bool nightMode)
+ public IReadOnlyList ExtractSeedCandidates(Bitmap wallpaper)
{
- var (hue, saturation, value) = ToHsv(seed);
- var valueBase = nightMode ? Math.Max(0.70, value) : Math.Min(0.72, Math.Max(0.35, value));
- var saturationBase = Math.Clamp(saturation, 0.22, 0.74);
- var offsets = new[] { 0d, 16d, -16d, 36d, -36d, 180d };
- var palette = new List(offsets.Length);
-
- for (var i = 0; i < offsets.Length; i++)
- {
- var hueShift = NormalizeHue(hue + offsets[i]);
- var sat = Math.Clamp(saturationBase + ((i % 2 == 0) ? 0.05 : -0.05), 0.18, 0.86);
- var val = Math.Clamp(valueBase + ((i < 3) ? 0.06 : -0.04), 0.32, 0.92);
- palette.Add(FromHsv(hueShift, sat, val));
- }
-
- return palette;
+ ArgumentNullException.ThrowIfNull(wallpaper);
+ return ExtractWallpaperSeedCandidates(wallpaper);
}
- private static Color? TryExtractSeedColor(Bitmap? wallpaper)
+ private static Color? ResolveSeedColor(
+ IReadOnlyList wallpaperCandidates,
+ Color? preferredSeed)
{
- if (wallpaper is null)
+ if (wallpaperCandidates.Count == 0)
{
return null;
}
+ if (preferredSeed is { } explicitSeed)
+ {
+ var exact = wallpaperCandidates.FirstOrDefault(candidate => candidate == explicitSeed);
+ if (exact != default)
+ {
+ return exact;
+ }
+ }
+
+ return wallpaperCandidates[0];
+ }
+
+ private static IReadOnlyList BuildFallbackSeedCandidates()
+ {
+ return
+ [
+ Color.Parse("#FF3B82F6"),
+ Color.Parse("#FF22C55E"),
+ Color.Parse("#FFF59E0B"),
+ Color.Parse("#FFF97316"),
+ Color.Parse("#FFA855F7")
+ ];
+ }
+
+ private static IReadOnlyList ExtractWallpaperSeedCandidates(Bitmap wallpaper)
+ {
try
{
- var sampleWidth = Math.Clamp(wallpaper.PixelSize.Width, 1, 48);
- var sampleHeight = Math.Clamp(wallpaper.PixelSize.Height, 1, 48);
+ var width = Math.Clamp(wallpaper.PixelSize.Width, 1, 96);
+ var height = Math.Clamp(wallpaper.PixelSize.Height, 1, 96);
using var scaledBitmap = wallpaper.CreateScaledBitmap(
- new PixelSize(sampleWidth, sampleHeight),
+ new PixelSize(width, height),
BitmapInterpolationMode.MediumQuality);
using var writeable = new WriteableBitmap(
scaledBitmap.PixelSize,
@@ -91,55 +93,52 @@ public sealed class MonetColorService
var byteCount = framebuffer.RowBytes * framebuffer.Size.Height;
if (byteCount <= 0 || framebuffer.Address == IntPtr.Zero)
{
- return null;
+ return [];
}
var pixelBuffer = new byte[byteCount];
Marshal.Copy(framebuffer.Address, pixelBuffer, 0, byteCount);
- double bestScore = double.MinValue;
- Color? bestColor = null;
-
+ var argbPixels = new List(framebuffer.Size.Width * framebuffer.Size.Height);
for (var y = 0; y < framebuffer.Size.Height; y++)
{
var rowOffset = y * framebuffer.RowBytes;
for (var x = 0; x < framebuffer.Size.Width; x++)
{
var index = rowOffset + (x * 4);
- var alpha = pixelBuffer[index + 3] / 255d;
- if (alpha <= 0.15)
+ var alpha = pixelBuffer[index + 3];
+ if (alpha <= 32)
{
continue;
}
- var blue = (pixelBuffer[index] / 255d) / alpha;
- var green = (pixelBuffer[index + 1] / 255d) / alpha;
- var red = (pixelBuffer[index + 2] / 255d) / alpha;
- red = Math.Clamp(red, 0, 1);
- green = Math.Clamp(green, 0, 1);
- blue = Math.Clamp(blue, 0, 1);
-
- var color = Color.FromRgb(
- (byte)Math.Round(red * 255),
- (byte)Math.Round(green * 255),
- (byte)Math.Round(blue * 255));
- var (_, saturation, value) = ToHsv(color);
- var score = (saturation * 1.8) + (value * 0.6);
- if (score <= bestScore)
- {
- continue;
- }
-
- bestScore = score;
- bestColor = color;
+ var blue = pixelBuffer[index];
+ var green = pixelBuffer[index + 1];
+ var red = pixelBuffer[index + 2];
+ argbPixels.Add(
+ ((uint)alpha << 24) |
+ ((uint)red << 16) |
+ ((uint)green << 8) |
+ blue);
}
}
- return bestColor;
+ if (argbPixels.Count == 0)
+ {
+ return [];
+ }
+
+ var extracted = ImageUtils.ColorsFromImage(argbPixels.ToArray());
+ return extracted
+ .Select(FromArgb)
+ .Distinct()
+ .Take(6)
+ .ToArray();
}
- catch
+ catch (Exception ex)
{
- return null;
+ AppLogger.Warn("Appearance.WallpaperPalette", "Failed to extract wallpaper seed candidates.", ex);
+ return [];
}
}
@@ -161,11 +160,17 @@ public sealed class MonetColorService
return null;
}
- var bytes = BitConverter.GetBytes(accentDword);
- var blue = bytes[0];
- var green = bytes[1];
- var red = bytes[2];
- return Color.FromRgb(red, green, blue);
+ var accentColor = unchecked((uint)accentDword);
+ var a = (byte)((accentColor >> 24) & 0xFF);
+ var b = (byte)((accentColor >> 16) & 0xFF);
+ var g = (byte)((accentColor >> 8) & 0xFF);
+ var r = (byte)(accentColor & 0xFF);
+ if (a == 0)
+ {
+ a = 0xFF;
+ }
+
+ return Color.FromArgb(a, r, g, b);
}
catch
{
@@ -173,78 +178,51 @@ public sealed class MonetColorService
}
}
- private static (double Hue, double Saturation, double Value) ToHsv(Color color)
+ private static uint ToArgb(Color color)
{
- var red = color.R / 255d;
- var green = color.G / 255d;
- var blue = color.B / 255d;
- var max = Math.Max(red, Math.Max(green, blue));
- var min = Math.Min(red, Math.Min(green, blue));
- var delta = max - min;
-
- double hue;
- if (delta < 0.0001)
- {
- hue = 0;
- }
- else if (Math.Abs(max - red) < 0.0001)
- {
- hue = 60 * (((green - blue) / delta) % 6);
- }
- else if (Math.Abs(max - green) < 0.0001)
- {
- hue = 60 * (((blue - red) / delta) + 2);
- }
- else
- {
- hue = 60 * (((red - green) / delta) + 4);
- }
-
- hue = NormalizeHue(hue);
- var saturation = max <= 0.0001 ? 0 : delta / max;
- return (hue, saturation, max);
+ return
+ ((uint)color.A << 24) |
+ ((uint)color.R << 16) |
+ ((uint)color.G << 8) |
+ color.B;
}
- private static Color FromHsv(double hue, double saturation, double value)
+ private static Color FromArgb(uint argb)
{
- hue = NormalizeHue(hue);
- saturation = Math.Clamp(saturation, 0, 1);
- value = Math.Clamp(value, 0, 1);
-
- if (saturation <= 0.0001)
- {
- var gray = (byte)Math.Round(value * 255);
- return Color.FromRgb(gray, gray, gray);
- }
-
- var chroma = value * saturation;
- var x = chroma * (1 - Math.Abs(((hue / 60d) % 2) - 1));
- var m = value - chroma;
-
- (double r, double g, double b) = hue switch
- {
- >= 0 and < 60 => (chroma, x, 0d),
- >= 60 and < 120 => (x, chroma, 0d),
- >= 120 and < 180 => (0d, chroma, x),
- >= 180 and < 240 => (0d, x, chroma),
- >= 240 and < 300 => (x, 0d, chroma),
- _ => (chroma, 0d, x)
- };
-
- var red = (byte)Math.Round((r + m) * 255);
- var green = (byte)Math.Round((g + m) * 255);
- var blue = (byte)Math.Round((b + m) * 255);
- return Color.FromRgb(red, green, blue);
+ var a = (byte)((argb >> 24) & 0xFF);
+ var r = (byte)((argb >> 16) & 0xFF);
+ var g = (byte)((argb >> 8) & 0xFF);
+ var b = (byte)(argb & 0xFF);
+ return Color.FromArgb(a, r, g, b);
}
- private static double NormalizeHue(double hue)
+ private static MonetPalette BuildPaletteCore(
+ IReadOnlyList wallpaperCandidates,
+ bool nightMode,
+ Color? preferredSeed)
{
- hue %= 360;
- if (hue < 0)
- {
- hue += 360;
- }
+ var recommendedColors = wallpaperCandidates.Count > 0
+ ? wallpaperCandidates
+ : BuildFallbackSeedCandidates();
+ var seed = ResolveSeedColor(wallpaperCandidates, preferredSeed)
+ ?? preferredSeed
+ ?? TryGetSystemMonetSeedColor()
+ ?? DefaultSeedColor;
- return hue;
+ var corePalette = CorePalette.Of(ToArgb(seed), Style.TonalSpot);
+ var primary = FromArgb(corePalette.Primary.Tone(nightMode ? 80u : 40u));
+ var secondary = FromArgb(corePalette.Secondary.Tone(nightMode ? 80u : 40u));
+ var tertiary = FromArgb(corePalette.Tertiary.Tone(nightMode ? 80u : 40u));
+ var neutral = FromArgb(corePalette.Neutral.Tone(nightMode ? 20u : 94u));
+ var neutralVariant = FromArgb(corePalette.NeutralVariant.Tone(nightMode ? 30u : 90u));
+
+ return new MonetPalette(
+ recommendedColors,
+ seed,
+ primary,
+ secondary,
+ tertiary,
+ neutral,
+ neutralVariant);
}
}
diff --git a/LanMountainDesktop/Services/PendingRestartStateService.cs b/LanMountainDesktop/Services/PendingRestartStateService.cs
index 43e51fb..b392d60 100644
--- a/LanMountainDesktop/Services/PendingRestartStateService.cs
+++ b/LanMountainDesktop/Services/PendingRestartStateService.cs
@@ -7,6 +7,7 @@ public static class PendingRestartStateService
{
public const string RenderModeReason = "RenderMode";
public const string PluginCatalogReason = "PluginCatalog";
+ public const string SettingsWindowReason = "SettingsWindow";
private static readonly object Gate = new();
private static readonly HashSet PendingReasons = new(StringComparer.OrdinalIgnoreCase);
diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs
index fced06e..3a33d36 100644
--- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs
@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
+using LanMountainDesktop.Services;
namespace LanMountainDesktop.Services.Settings;
@@ -16,7 +17,13 @@ public enum WallpaperMediaType
public sealed record GridSettingsState(int ShortSideCells, string SpacingPreset, int EdgeInsetPercent);
public sealed record WallpaperSettingsState(string? WallpaperPath, string Type, string? Color, string Placement);
-public sealed record ThemeAppearanceSettingsState(bool IsNightMode, string? ThemeColor, bool UseSystemChrome);
+public sealed record ThemeAppearanceSettingsState(
+ bool IsNightMode,
+ string? ThemeColor,
+ bool UseSystemChrome,
+ string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
+ string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
+ string? SelectedWallpaperSeed = null);
public sealed record StatusBarSettingsState(
IReadOnlyList TopStatusComponentIds,
IReadOnlyList PinnedTaskbarActions,
@@ -37,6 +44,9 @@ public sealed record WeatherSettingsState(
bool NoTlsRequests,
string LocationQuery);
public sealed record RegionSettingsState(string LanguageCode, string? TimeZoneId);
+public sealed record PrivacySettingsState(
+ bool UploadAnonymousCrashData,
+ bool UploadAnonymousUsageData);
public sealed record UpdateSettingsState(
bool AutoCheckUpdates,
bool IncludePrereleaseUpdates,
@@ -166,6 +176,12 @@ public interface IRegionSettingsService
TimeZoneService GetTimeZoneService();
}
+public interface IPrivacySettingsService
+{
+ PrivacySettingsState Get();
+ void Save(PrivacySettingsState state);
+}
+
public interface IUpdateSettingsService
{
UpdateSettingsState Get();
@@ -224,6 +240,7 @@ public interface ISettingsFacadeService
IStatusBarSettingsService StatusBar { get; }
IWeatherSettingsService Weather { get; }
IRegionSettingsService Region { get; }
+ IPrivacySettingsService Privacy { get; }
IUpdateSettingsService Update { get; }
ILauncherCatalogService LauncherCatalog { get; }
ILauncherPolicyService LauncherPolicy { get; }
diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
index 50e127d..11ff815 100644
--- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
@@ -93,9 +93,12 @@ internal sealed class WallpaperSettingsService : IWallpaperSettingsService
public WallpaperSettingsState Get()
{
var snapshot = _settingsService.Load();
+ var normalizedType = snapshot.WallpaperType ?? "Image";
return new WallpaperSettingsState(
- snapshot.WallpaperPath,
- snapshot.WallpaperType ?? "Image",
+ string.Equals(normalizedType, "SolidColor", StringComparison.OrdinalIgnoreCase)
+ ? null
+ : snapshot.WallpaperPath,
+ normalizedType,
snapshot.WallpaperColor,
snapshot.WallpaperPlacement);
}
@@ -103,9 +106,24 @@ internal sealed class WallpaperSettingsService : IWallpaperSettingsService
public void Save(WallpaperSettingsState state)
{
var snapshot = _settingsService.Load();
- snapshot.WallpaperPath = state.WallpaperPath;
- snapshot.WallpaperType = state.Type;
- snapshot.WallpaperColor = state.Color;
+ var normalizedType = string.IsNullOrWhiteSpace(state.Type)
+ ? "Image"
+ : state.Type.Trim();
+ var normalizedPath = string.IsNullOrWhiteSpace(state.WallpaperPath)
+ ? null
+ : state.WallpaperPath.Trim();
+ var normalizedColor = string.IsNullOrWhiteSpace(state.Color)
+ ? null
+ : state.Color.Trim();
+
+ if (string.Equals(normalizedType, "SolidColor", StringComparison.OrdinalIgnoreCase))
+ {
+ normalizedPath = null;
+ }
+
+ snapshot.WallpaperPath = normalizedPath;
+ snapshot.WallpaperType = normalizedType;
+ snapshot.WallpaperColor = normalizedColor;
snapshot.WallpaperPlacement = string.IsNullOrWhiteSpace(state.Placement)
? "Fill"
: state.Placement.Trim();
@@ -233,24 +251,68 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
return new ThemeAppearanceSettingsState(
snapshot.IsNightMode ?? false,
snapshot.ThemeColor,
- snapshot.UseSystemChrome);
+ snapshot.UseSystemChrome,
+ ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
+ ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
+ snapshot.SelectedWallpaperSeed);
}
public void Save(ThemeAppearanceSettingsState state)
{
var snapshot = _settingsService.Load();
- snapshot.IsNightMode = state.IsNightMode;
- snapshot.ThemeColor = state.ThemeColor;
- snapshot.UseSystemChrome = state.UseSystemChrome;
+ var changedKeys = new List();
+ var normalizedThemeColor = string.IsNullOrWhiteSpace(state.ThemeColor) ? null : state.ThemeColor;
+ var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(state.ThemeColorMode, state.ThemeColor);
+ var normalizedSystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(state.SystemMaterialMode);
+ var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed)
+ ? null
+ : state.SelectedWallpaperSeed;
+
+ if ((snapshot.IsNightMode ?? false) != state.IsNightMode)
+ {
+ snapshot.IsNightMode = state.IsNightMode;
+ changedKeys.Add(nameof(AppSettingsSnapshot.IsNightMode));
+ }
+
+ if (!string.Equals(snapshot.ThemeColor, normalizedThemeColor, StringComparison.OrdinalIgnoreCase))
+ {
+ snapshot.ThemeColor = normalizedThemeColor;
+ changedKeys.Add(nameof(AppSettingsSnapshot.ThemeColor));
+ }
+
+ if (snapshot.UseSystemChrome != state.UseSystemChrome)
+ {
+ snapshot.UseSystemChrome = state.UseSystemChrome;
+ changedKeys.Add(nameof(AppSettingsSnapshot.UseSystemChrome));
+ }
+
+ if (!string.Equals(snapshot.ThemeColorMode, normalizedThemeColorMode, StringComparison.OrdinalIgnoreCase))
+ {
+ snapshot.ThemeColorMode = normalizedThemeColorMode;
+ changedKeys.Add(nameof(AppSettingsSnapshot.ThemeColorMode));
+ }
+
+ if (!string.Equals(snapshot.SystemMaterialMode, normalizedSystemMaterialMode, StringComparison.OrdinalIgnoreCase))
+ {
+ snapshot.SystemMaterialMode = normalizedSystemMaterialMode;
+ changedKeys.Add(nameof(AppSettingsSnapshot.SystemMaterialMode));
+ }
+
+ if (!string.Equals(snapshot.SelectedWallpaperSeed, normalizedSelectedWallpaperSeed, StringComparison.OrdinalIgnoreCase))
+ {
+ snapshot.SelectedWallpaperSeed = normalizedSelectedWallpaperSeed;
+ changedKeys.Add(nameof(AppSettingsSnapshot.SelectedWallpaperSeed));
+ }
+
+ if (changedKeys.Count == 0)
+ {
+ return;
+ }
+
_settingsService.SaveSnapshot(
SettingsScope.App,
snapshot,
- changedKeys:
- [
- nameof(AppSettingsSnapshot.IsNightMode),
- nameof(AppSettingsSnapshot.ThemeColor),
- nameof(AppSettingsSnapshot.UseSystemChrome)
- ]);
+ changedKeys: changedKeys);
}
public MonetPalette BuildPalette(bool nightMode, string? wallpaperPath, string? preferredSeedColor = null)
@@ -525,6 +587,39 @@ internal sealed class RegionSettingsService : IRegionSettingsService
}
}
+internal sealed class PrivacySettingsService : IPrivacySettingsService
+{
+ private readonly ISettingsService _settingsService;
+
+ public PrivacySettingsService(ISettingsService settingsService)
+ {
+ _settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
+ }
+
+ public PrivacySettingsState Get()
+ {
+ var snapshot = _settingsService.Load();
+ return new PrivacySettingsState(
+ snapshot.UploadAnonymousCrashData,
+ snapshot.UploadAnonymousUsageData);
+ }
+
+ public void Save(PrivacySettingsState state)
+ {
+ var snapshot = _settingsService.Load();
+ snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
+ snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
+ _settingsService.SaveSnapshot(
+ SettingsScope.App,
+ snapshot,
+ changedKeys:
+ [
+ nameof(AppSettingsSnapshot.UploadAnonymousCrashData),
+ nameof(AppSettingsSnapshot.UploadAnonymousUsageData)
+ ]);
+ }
+}
+
internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposable
{
private readonly ISettingsService _settingsService;
@@ -920,6 +1015,7 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
_weatherSettingsService = new WeatherSettingsService(Settings);
Weather = _weatherSettingsService;
Region = new RegionSettingsService(Settings);
+ Privacy = new PrivacySettingsService(Settings);
_updateSettingsService = new UpdateSettingsService(Settings);
Update = _updateSettingsService;
LauncherCatalog = new LauncherCatalogService();
@@ -949,6 +1045,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
public IRegionSettingsService Region { get; }
+ public IPrivacySettingsService Privacy { get; }
+
public IUpdateSettingsService Update { get; }
public ILauncherCatalogService LauncherCatalog { get; }
diff --git a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs
index 59f4cd5..bc33847 100644
--- a/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs
@@ -177,6 +177,7 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
services.AddSingleton(_settingsFacade);
services.AddSingleton(_settingsFacade.Settings);
services.AddSingleton(_settingsFacade.Catalog);
+ services.AddSingleton(_ => HostAppearanceThemeProvider.GetOrCreate());
services.AddSingleton(_hostApplicationLifecycle);
services.AddSingleton(_localizationService);
services.AddSingleton(_ => HostLocationServiceProvider.GetOrCreate());
diff --git a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs
index 30e3d52..f476e2b 100644
--- a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs
@@ -52,10 +52,10 @@ public interface ISettingsWindowService
internal sealed class SettingsWindowService : ISettingsWindowService
{
- private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6");
private readonly ISettingsPageRegistry _pageRegistry;
private readonly IHostApplicationLifecycle _hostApplicationLifecycle;
private readonly ISettingsFacadeService _settingsFacade;
+ private readonly IAppearanceThemeService _appearanceThemeService;
private readonly LocalizationService _localizationService;
private SettingsWindowViewModel _viewModel = null!;
private SettingsWindow? _window;
@@ -68,8 +68,10 @@ internal sealed class SettingsWindowService : ISettingsWindowService
_pageRegistry = pageRegistry;
_hostApplicationLifecycle = hostApplicationLifecycle;
_settingsFacade = settingsFacade;
+ _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
_localizationService = new();
_settingsFacade.Settings.Changed += OnSettingsChanged;
+ _appearanceThemeService.Changed += OnAppearanceThemeChanged;
}
private string L(string key)
@@ -86,9 +88,9 @@ internal sealed class SettingsWindowService : ISettingsWindowService
{
_pageRegistry.Rebuild();
_window ??= CreateWindow();
- var themeState = _settingsFacade.Theme.Get();
- _window.ApplyChromeMode(themeState.UseSystemChrome);
- ApplyTheme(_window, themeState);
+ var appearanceSnapshot = _appearanceThemeService.GetCurrent();
+ _window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome);
+ ApplyTheme(_window);
_window.ReloadPages(request.PageId);
PositionWindow(_window, request);
@@ -135,15 +137,15 @@ internal sealed class SettingsWindowService : ISettingsWindowService
_viewModel = new SettingsWindowViewModel(_localizationService, languageCode).Initialize();
- var themeState = _settingsFacade.Theme.Get();
- var useSystemChrome = themeState.UseSystemChrome;
+ var appearanceSnapshot = _appearanceThemeService.GetCurrent();
+ var useSystemChrome = appearanceSnapshot.UseSystemChrome;
var window = new SettingsWindow(
_viewModel,
_pageRegistry,
_hostApplicationLifecycle,
useSystemChrome);
- ApplyTheme(window, themeState);
+ ApplyTheme(window);
window.ShowInTaskbar = false;
window.Closed += (_, _) =>
{
@@ -277,13 +279,16 @@ internal sealed class SettingsWindowService : ISettingsWindowService
var changedKeys = e.ChangedKeys?.ToArray();
var refreshAll = changedKeys is null || changedKeys.Length == 0;
var languageChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase);
+ var liveAppearance = _appearanceThemeService.GetCurrent();
var themeChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
- changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), 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) &&
+ (changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
+ changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase);
if (languageChanged)
@@ -297,59 +302,36 @@ internal sealed class SettingsWindowService : ISettingsWindowService
if (themeChanged)
{
- var themeState = _settingsFacade.Theme.Get();
- _window.ApplyChromeMode(themeState.UseSystemChrome);
- ApplyTheme(_window, themeState);
+ var appearanceSnapshot = _appearanceThemeService.GetCurrent();
+ _window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome);
+ ApplyTheme(_window);
}
}, DispatcherPriority.Background);
}
- private static void ApplyTheme(SettingsWindow window, ThemeAppearanceSettingsState themeState)
+ private void ApplyTheme(SettingsWindow window)
{
- window.RequestedThemeVariant = themeState.IsNightMode
+ var appearanceSnapshot = _appearanceThemeService.GetCurrent();
+ window.RequestedThemeVariant = appearanceSnapshot.IsNightMode
? ThemeVariant.Dark
: ThemeVariant.Light;
-
- var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
- var wallpaperState = settingsFacade.Wallpaper.Get();
- var monetPalette = settingsFacade.Theme.BuildPalette(
- themeState.IsNightMode,
- wallpaperState.WallpaperPath,
- themeState.ThemeColor);
- var accentColor = ResolveAccentColor(themeState.ThemeColor, monetPalette);
- var context = new ThemeColorContext(
- accentColor,
- IsLightBackground: !themeState.IsNightMode,
- IsLightNavBackground: !themeState.IsNightMode,
- IsNightMode: themeState.IsNightMode,
- MonetColors: monetPalette.MonetColors);
- ThemeColorSystemService.ApplyThemeResources(window.Resources, context);
- GlassEffectService.ApplyGlassResources(window.Resources, context);
+ _appearanceThemeService.ApplyThemeResources(window.Resources);
+ _appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.SettingsWindowBackground);
}
- private static Color ResolveAccentColor(string? colorText, MonetPalette monetPalette)
+ private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
{
- if (monetPalette.MonetColors is { Count: > 0 })
+ _ = sender;
+ _ = e;
+
+ Dispatcher.UIThread.Post(() =>
{
- return monetPalette.MonetColors[0];
- }
-
- return TryParseThemeColor(colorText);
- }
-
- private static Color TryParseThemeColor(string? colorText)
- {
- if (!string.IsNullOrWhiteSpace(colorText))
- {
- try
+ if (_window is null || _viewModel is null)
{
- return Color.Parse(colorText);
+ return;
}
- catch
- {
- }
- }
- return DefaultAccentColor;
+ ApplyTheme(_window);
+ }, DispatcherPriority.Background);
}
}
diff --git a/LanMountainDesktop/Services/ThemeAppearanceValues.cs b/LanMountainDesktop/Services/ThemeAppearanceValues.cs
new file mode 100644
index 0000000..7f1093d
--- /dev/null
+++ b/LanMountainDesktop/Services/ThemeAppearanceValues.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace LanMountainDesktop.Services;
+
+public static class ThemeAppearanceValues
+{
+ public const string ColorModeDefaultNeutral = "default_neutral";
+ public const string ColorModeSeedMonet = "seed_monet";
+ public const string ColorModeWallpaperMonet = "wallpaper_monet";
+
+ public const string MaterialNone = "none";
+ public const string MaterialMica = "mica";
+ public const string MaterialAcrylic = "acrylic";
+
+ public static readonly IReadOnlyList AllColorModes =
+ [
+ ColorModeDefaultNeutral,
+ ColorModeSeedMonet,
+ ColorModeWallpaperMonet
+ ];
+
+ public static readonly IReadOnlyList AllMaterialModes =
+ [
+ MaterialNone,
+ MaterialMica,
+ MaterialAcrylic
+ ];
+
+ public static string NormalizeThemeColorMode(string? value, string? themeColor = null)
+ {
+ if (string.Equals(value, ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase))
+ {
+ return ColorModeDefaultNeutral;
+ }
+
+ if (string.Equals(value, ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase))
+ {
+ return ColorModeWallpaperMonet;
+ }
+
+ if (string.Equals(value, ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase))
+ {
+ return ColorModeSeedMonet;
+ }
+
+ return string.IsNullOrWhiteSpace(themeColor)
+ ? ColorModeDefaultNeutral
+ : ColorModeSeedMonet;
+ }
+
+ public static string NormalizeSystemMaterialMode(string? value)
+ {
+ if (string.Equals(value, MaterialMica, StringComparison.OrdinalIgnoreCase))
+ {
+ return MaterialMica;
+ }
+
+ if (string.Equals(value, MaterialAcrylic, StringComparison.OrdinalIgnoreCase))
+ {
+ return MaterialAcrylic;
+ }
+
+ return MaterialNone;
+ }
+
+ public static IReadOnlyList NormalizeAvailableMaterialModes(IEnumerable? values)
+ {
+ if (values is null)
+ {
+ return [MaterialNone];
+ }
+
+ var normalized = values
+ .Select(NormalizeSystemMaterialMode)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (!normalized.Contains(MaterialNone, StringComparer.OrdinalIgnoreCase))
+ {
+ normalized.Insert(0, MaterialNone);
+ }
+
+ return normalized;
+ }
+}
diff --git a/LanMountainDesktop/Services/ThemeColorSystemService.cs b/LanMountainDesktop/Services/ThemeColorSystemService.cs
index 9262ebf..eec7681 100644
--- a/LanMountainDesktop/Services/ThemeColorSystemService.cs
+++ b/LanMountainDesktop/Services/ThemeColorSystemService.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Media;
@@ -68,11 +69,19 @@ public static class ThemeColorSystemService
private static AppThemePalette BuildPalette(ThemeColorContext context)
{
+ var monetPalette = context.MonetPalette;
var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
- var accent = monetColors.Length > 0 ? monetColors[0] : context.AccentColor;
- var secondarySeed = monetColors.Length > 1
- ? monetColors[1]
- : ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.14);
+ var accent = context.UseNeutralSurfaces
+ ? context.AccentColor
+ : monetPalette?.Primary ?? GetColorOrDefault(monetColors, 0, context.AccentColor);
+ var secondarySeed = monetPalette?.Secondary
+ ?? GetColorOrDefault(monetColors, 1, ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.14));
+ var tertiarySeed = monetPalette?.Tertiary
+ ?? GetColorOrDefault(monetColors, 2, ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.22));
+ var neutralSeed = monetPalette?.Neutral
+ ?? GetColorOrDefault(monetColors, 3, context.IsNightMode ? Color.Parse("#FF171C23") : Color.Parse("#FFF2F4F7"));
+ var neutralVariantSeed = monetPalette?.NeutralVariant
+ ?? GetColorOrDefault(monetColors, 4, context.IsNightMode ? Color.Parse("#FF20262E") : Color.Parse("#FFE8EDF2"));
var accentLight1 = ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.22);
var accentLight2 = ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.38);
@@ -83,18 +92,23 @@ public static class ThemeColorSystemService
var primary = context.IsNightMode ? accentLight1 : accentDark1;
var secondary = context.IsNightMode
- ? ColorMath.Blend(secondarySeed, Color.Parse("#FFFFFFFF"), 0.16)
- : ColorMath.Blend(secondarySeed, Color.Parse("#FF111827"), 0.14);
+ ? ColorMath.Blend(secondarySeed, Color.Parse("#FFFFFFFF"), 0.12)
+ : ColorMath.Blend(secondarySeed, Color.Parse("#FF111827"), 0.10);
- var surfaceBase = context.IsNightMode
- ? ColorMath.Blend(Color.Parse("#FF0A1018"), accent, 0.18)
- : ColorMath.Blend(Color.Parse("#FFF7F9FD"), accent, 0.09);
- var surfaceRaised = context.IsNightMode
- ? ColorMath.Blend(Color.Parse("#FF121A24"), secondarySeed, 0.24)
- : ColorMath.Blend(Color.Parse("#FFFCFEFF"), secondarySeed, 0.12);
- var surfaceOverlayBase = context.IsNightMode
- ? ColorMath.Blend(Color.Parse("#FF18212D"), accent, 0.28)
- : ColorMath.Blend(Color.Parse("#FFF1F5FB"), accent, 0.16);
+ var baseSurface = context.IsNightMode ? Color.Parse("#FF0B0F14") : Color.Parse("#FFF7F8FA");
+ var raisedSurface = context.IsNightMode ? Color.Parse("#FF131922") : Color.Parse("#FFFFFFFF");
+ var overlaySurface = context.IsNightMode ? Color.Parse("#FF171E28") : Color.Parse("#FFF1F4F8");
+ var navSurfaceBase = context.IsLightNavBackground ? Color.Parse("#FFF8FAFC") : Color.Parse("#FF111827");
+
+ var surfaceBase = context.UseNeutralSurfaces
+ ? baseSurface
+ : ColorMath.Blend(baseSurface, neutralSeed, context.IsNightMode ? 0.84 : 0.78);
+ var surfaceRaised = context.UseNeutralSurfaces
+ ? raisedSurface
+ : ColorMath.Blend(raisedSurface, neutralVariantSeed, context.IsNightMode ? 0.72 : 0.60);
+ var surfaceOverlayBase = context.UseNeutralSurfaces
+ ? overlaySurface
+ : ColorMath.Blend(overlaySurface, neutralVariantSeed, context.IsNightMode ? 0.76 : 0.64);
var surfaceOverlay = Color.FromArgb(
context.IsNightMode ? (byte)0xE8 : (byte)0xF2,
surfaceOverlayBase.R,
@@ -115,9 +129,9 @@ public static class ThemeColorSystemService
? ColorMath.EnsureContrast(ColorMath.Blend(accent, Color.Parse("#FF0B1220"), 0.20), surfaceRaised, WcagNormalTextContrast)
: ColorMath.EnsureContrast(ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.16), surfaceRaised, WcagNormalTextContrast);
- var navSurface = context.IsLightNavBackground
- ? ColorMath.Blend(surfaceRaised, accentLight2, 0.08)
- : ColorMath.Blend(Color.Parse("#FF111827"), accentDark2, 0.24);
+ var navSurface = context.UseNeutralSurfaces
+ ? navSurfaceBase
+ : ColorMath.Blend(navSurfaceBase, neutralSeed, context.IsNightMode ? 0.66 : 0.70);
var navText = ColorMath.EnsureContrast(
context.IsLightNavBackground ? Color.Parse("#FF0B1220") : Color.Parse("#FFF8FAFC"),
navSurface,
@@ -129,18 +143,23 @@ public static class ThemeColorSystemService
? Color.FromArgb(0x33, surfaceRaised.R, surfaceRaised.G, surfaceRaised.B)
: Color.FromArgb(0x38, navSurface.R, navSurface.G, navSurface.B);
var navItemHoverBackground = context.IsLightNavBackground
- ? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, surfaceRaised, 0.30), 0x7A)
- : ColorMath.WithAlpha(ColorMath.Blend(accentDark1, navSurface, 0.26), 0x88);
- var navItemSelectedBackground = ColorMath.WithAlpha(accent, context.IsNightMode ? (byte)0xCE : (byte)0xD9);
- var navSelectionIndicator = ColorMath.EnsureContrast(accentLight1, navSurface, WcagLargeTextContrast);
+ ? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, navSurface, 0.12), 0x64)
+ : ColorMath.WithAlpha(ColorMath.Blend(accentDark1, navSurface, 0.18), 0x74);
+ var navItemSelectedBackground = context.UseNeutralSurfaces
+ ? ColorMath.WithAlpha(ColorMath.Blend(accent, navSurface, 0.24), context.IsNightMode ? (byte)0x8F : (byte)0x6A)
+ : ColorMath.WithAlpha(ColorMath.Blend(accent, navSurface, context.IsNightMode ? 0.28 : 0.22), context.IsNightMode ? (byte)0x88 : (byte)0x72);
+ var navSelectionIndicator = ColorMath.EnsureContrast(
+ context.UseNeutralSurfaces ? accent : accentLight1,
+ navSurface,
+ WcagLargeTextContrast);
var toggleOn = context.IsNightMode ? accent : accentDark1;
var toggleOff = context.IsNightMode
- ? Color.FromArgb(0x88, accentDark2.R, accentDark2.G, accentDark2.B)
- : Color.FromArgb(0x88, accentLight2.R, accentLight2.G, accentLight2.B);
+ ? Color.FromArgb(0x88, neutralVariantSeed.R, neutralVariantSeed.G, neutralVariantSeed.B)
+ : Color.FromArgb(0x88, neutralVariantSeed.R, neutralVariantSeed.G, neutralVariantSeed.B);
var toggleBorder = context.IsNightMode
- ? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, Color.Parse("#FFF8FAFC"), 0.28), 0x8C)
- : ColorMath.WithAlpha(ColorMath.Blend(accentDark2, Color.Parse("#FF334155"), 0.26), 0x78);
+ ? ColorMath.WithAlpha(ColorMath.Blend(neutralVariantSeed, Color.Parse("#FFF8FAFC"), 0.20), 0x8C)
+ : ColorMath.WithAlpha(ColorMath.Blend(tertiarySeed, Color.Parse("#FF334155"), 0.18), 0x78);
var onAccent = ColorMath.EnsureContrast(Color.Parse("#FFFFFFFF"), accent, WcagNormalTextContrast);
return new AppThemePalette(
@@ -171,4 +190,11 @@ public static class ThemeColorSystemService
toggleOff,
toggleBorder);
}
+
+ private static Color GetColorOrDefault(IReadOnlyList colors, int index, Color fallback)
+ {
+ return index >= 0 && index < colors.Count
+ ? colors[index]
+ : fallback;
+ }
}
diff --git a/LanMountainDesktop/Services/XiaomiWeatherCodeMapper.cs b/LanMountainDesktop/Services/XiaomiWeatherCodeMapper.cs
new file mode 100644
index 0000000..d199c49
--- /dev/null
+++ b/LanMountainDesktop/Services/XiaomiWeatherCodeMapper.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Collections.Generic;
+
+namespace LanMountainDesktop.Services;
+
+internal enum WeatherConditionBucket
+{
+ Unknown,
+ Clear,
+ PartlyCloudy,
+ Cloudy,
+ Haze,
+ Fog,
+ RainLight,
+ RainHeavy,
+ Storm,
+ Sleet,
+ Snow
+}
+
+internal static class XiaomiWeatherCodeMapper
+{
+ private readonly record struct WeatherCodeEntry(string Zh, string En, WeatherConditionBucket Bucket);
+
+ private static readonly IReadOnlyDictionary Entries = new Dictionary
+ {
+ [0] = new("\u6674", "Clear", WeatherConditionBucket.Clear),
+ [1] = new("\u591a\u4e91", "Partly Cloudy", WeatherConditionBucket.PartlyCloudy),
+ [2] = new("\u9634", "Cloudy", WeatherConditionBucket.Cloudy),
+ [3] = new("\u9635\u96e8", "Shower", WeatherConditionBucket.RainLight),
+ [4] = new("\u96f7\u9635\u96e8", "Thunder Shower", WeatherConditionBucket.Storm),
+ [5] = new("\u96f7\u9635\u96e8\u4f34\u6709\u51b0\u96f9", "Thunder Shower with Hail", WeatherConditionBucket.Storm),
+ [6] = new("\u96e8\u5939\u96ea", "Sleet", WeatherConditionBucket.Sleet),
+ [7] = new("\u5c0f\u96e8", "Light Rain", WeatherConditionBucket.RainLight),
+ [8] = new("\u4e2d\u96e8", "Moderate Rain", WeatherConditionBucket.RainHeavy),
+ [9] = new("\u5927\u96e8", "Heavy Rain", WeatherConditionBucket.RainHeavy),
+ [10] = new("\u66b4\u96e8", "Storm", WeatherConditionBucket.RainHeavy),
+ [11] = new("\u5927\u66b4\u96e8", "Heavy Storm", WeatherConditionBucket.RainHeavy),
+ [12] = new("\u7279\u5927\u66b4\u96e8", "Severe Storm", WeatherConditionBucket.RainHeavy),
+ [13] = new("\u9635\u96ea", "Snow Flurry", WeatherConditionBucket.Snow),
+ [14] = new("\u5c0f\u96ea", "Light Snow", WeatherConditionBucket.Snow),
+ [15] = new("\u4e2d\u96ea", "Moderate Snow", WeatherConditionBucket.Snow),
+ [16] = new("\u5927\u96ea", "Heavy Snow", WeatherConditionBucket.Snow),
+ [17] = new("\u66b4\u96ea", "Snowstorm", WeatherConditionBucket.Snow),
+ [18] = new("\u96fe", "Fog", WeatherConditionBucket.Fog),
+ [19] = new("\u51bb\u96e8", "Freezing Rain", WeatherConditionBucket.RainLight),
+ [20] = new("\u6c99\u5c18\u66b4", "Duststorm", WeatherConditionBucket.Haze),
+ [21] = new("\u5c0f\u5230\u4e2d\u96e8", "Light to Moderate Rain", WeatherConditionBucket.RainLight),
+ [22] = new("\u4e2d\u5230\u5927\u96e8", "Moderate to Heavy Rain", WeatherConditionBucket.RainHeavy),
+ [23] = new("\u5927\u5230\u66b4\u96e8", "Heavy Rain to Storm", WeatherConditionBucket.RainHeavy),
+ [24] = new("\u66b4\u96e8\u5230\u5927\u66b4\u96e8", "Storm to Heavy Storm", WeatherConditionBucket.RainHeavy),
+ [25] = new("\u5927\u66b4\u96e8\u5230\u7279\u5927\u66b4\u96e8", "Heavy to Severe Storm", WeatherConditionBucket.RainHeavy),
+ [26] = new("\u5c0f\u5230\u4e2d\u96ea", "Light to Moderate Snow", WeatherConditionBucket.Snow),
+ [27] = new("\u4e2d\u5230\u5927\u96ea", "Moderate to Heavy Snow", WeatherConditionBucket.Snow),
+ [28] = new("\u5927\u5230\u66b4\u96ea", "Heavy Snow to Snowstorm", WeatherConditionBucket.Snow),
+ [29] = new("\u6d6e\u5c18", "Dust", WeatherConditionBucket.Haze),
+ [30] = new("\u626c\u6c99", "Sand", WeatherConditionBucket.Haze),
+ [31] = new("\u5f3a\u6c99\u5c18\u66b4", "Sandstorm", WeatherConditionBucket.Haze),
+ [32] = new("\u6d53\u96fe", "Dense Fog", WeatherConditionBucket.Fog),
+ [49] = new("\u5f3a\u6d53\u96fe", "Strong Fog", WeatherConditionBucket.Fog),
+ [53] = new("\u973e", "Haze", WeatherConditionBucket.Haze),
+ [54] = new("\u4e2d\u5ea6\u973e", "Moderate Haze", WeatherConditionBucket.Haze),
+ [55] = new("\u91cd\u5ea6\u973e", "Heavy Haze", WeatherConditionBucket.Haze),
+ [56] = new("\u4e25\u91cd\u973e", "Severe Haze", WeatherConditionBucket.Haze),
+ [57] = new("\u5927\u96fe", "Heavy Fog", WeatherConditionBucket.Fog),
+ [58] = new("\u7279\u5f3a\u6d53\u96fe", "Extra Heavy Fog", WeatherConditionBucket.Fog),
+ [301] = new("\u96e8", "Rain", WeatherConditionBucket.RainLight),
+ [302] = new("\u96ea", "Snow", WeatherConditionBucket.Snow)
+ };
+
+ public static WeatherConditionBucket ResolveBucket(int? code)
+ {
+ if (!code.HasValue)
+ {
+ return WeatherConditionBucket.Unknown;
+ }
+
+ return Entries.TryGetValue(code.Value, out var entry)
+ ? entry.Bucket
+ : WeatherConditionBucket.Unknown;
+ }
+
+ public static string? ResolveDisplayText(int? code, string locale)
+ {
+ if (!code.HasValue)
+ {
+ return null;
+ }
+
+ if (Entries.TryGetValue(code.Value, out var entry))
+ {
+ return locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase)
+ ? entry.Zh
+ : entry.En;
+ }
+
+ return locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase)
+ ? $"\u5929\u6c14\u7801 {code.Value}"
+ : $"Weather {code.Value}";
+ }
+}
diff --git a/LanMountainDesktop/Services/XiaomiWeatherService.cs b/LanMountainDesktop/Services/XiaomiWeatherService.cs
index 7985e75..0fc8df9 100644
--- a/LanMountainDesktop/Services/XiaomiWeatherService.cs
+++ b/LanMountainDesktop/Services/XiaomiWeatherService.cs
@@ -39,42 +39,6 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
{
private sealed record CacheEntry(WeatherSnapshot Snapshot, DateTimeOffset ExpireAt);
- private static readonly IReadOnlyDictionary ZhWeatherDescriptions = new Dictionary
- {
- [0] = "\u6674",
- [1] = "\u591a\u4e91",
- [2] = "\u9634",
- [3] = "\u9635\u96e8",
- [4] = "\u96f7\u9635\u96e8",
- [7] = "\u5c0f\u96e8",
- [8] = "\u4e2d\u96e8",
- [9] = "\u5927\u96e8",
- [13] = "\u9635\u96ea",
- [14] = "\u5c0f\u96ea",
- [15] = "\u4e2d\u96ea",
- [16] = "\u5927\u96ea",
- [18] = "\u96fe",
- [32] = "\u973e"
- };
-
- private static readonly IReadOnlyDictionary EnWeatherDescriptions = new Dictionary
- {
- [0] = "Clear",
- [1] = "Partly Cloudy",
- [2] = "Cloudy",
- [3] = "Shower",
- [4] = "Thunder Shower",
- [7] = "Light Rain",
- [8] = "Moderate Rain",
- [9] = "Heavy Rain",
- [13] = "Snow Flurry",
- [14] = "Light Snow",
- [15] = "Moderate Snow",
- [16] = "Heavy Snow",
- [18] = "Fog",
- [32] = "Haze"
- };
-
private readonly XiaomiWeatherApiOptions _options;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
@@ -412,9 +376,7 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
TryGetNode(payload, "hourly") ??
TryGetNode(payload, "hourlyForecast");
- var weatherCode = ReadInt(currentNode, "weather", "value") ??
- ReadInt(currentNode, "weatherCode") ??
- ReadInt(currentNode, "code");
+ var weatherCode = ReadWeatherCode(currentNode);
var weatherText = ReadString(currentNode, "weather", "desc") ??
ReadString(currentNode, "weather", "text") ??
@@ -497,8 +459,12 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
var sunItem = GetArrayItem(sunArray, i);
var precipitationItem = GetArrayItem(precipitationArray, i);
- var dayCode = ReadInt(weatherItem, "from") ?? ReadInt(weatherItem, "day");
- var nightCode = ReadInt(weatherItem, "to") ?? ReadInt(weatherItem, "night");
+ var dayCode = ReadInt(weatherItem, "from") ??
+ ReadInt(weatherItem, "day") ??
+ ReadWeatherCode(weatherItem);
+ var nightCode = ReadInt(weatherItem, "to") ??
+ ReadInt(weatherItem, "night") ??
+ ReadWeatherCode(weatherItem);
var dayText = ResolveWeatherDescription(dayCode, locale);
var nightText = ResolveWeatherDescription(nightCode, locale);
@@ -591,11 +557,7 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
continue;
}
- var code = ReadInt(weatherItem, "value") ??
- ReadInt(weatherItem, "code") ??
- ReadInt(weatherItem, "weatherCode") ??
- ReadInt(weatherItem, "from") ??
- ReadInt(weatherItem);
+ var code = ReadInt(weatherItem, "from") ?? ReadWeatherCode(weatherItem);
var weatherText = ReadString(weatherItem, "text") ??
ReadString(weatherItem, "desc") ??
ResolveWeatherDescription(code, locale);
@@ -640,11 +602,7 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
continue;
}
- var code = ReadInt(item, "weatherCode") ??
- ReadInt(item, "code") ??
- ReadInt(item, "weather", "value") ??
- ReadInt(item, "weather") ??
- ReadInt(item, "from");
+ var code = ReadInt(item, "from") ?? ReadWeatherCode(item);
var weatherText = ReadString(item, "weatherText") ??
ReadString(item, "weather", "desc") ??
ReadString(item, "weather", "text") ??
@@ -903,6 +861,16 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
return null;
}
+ private static int? ReadWeatherCode(JsonElement? node)
+ {
+ return ReadInt(node, "weather", "value") ??
+ ReadInt(node, "weatherCode") ??
+ ReadInt(node, "code") ??
+ ReadInt(node, "weather") ??
+ ReadInt(node, "value") ??
+ ReadInt(node);
+ }
+
private static double? ReadDouble(JsonElement? node, params string[] path)
{
if (!node.HasValue)
@@ -995,19 +963,7 @@ public sealed class XiaomiWeatherService : IWeatherDataService, IDisposable
private static string? ResolveWeatherDescription(int? code, string locale)
{
- if (!code.HasValue)
- {
- return null;
- }
-
- var isZh = locale.StartsWith("zh", StringComparison.OrdinalIgnoreCase);
- var source = isZh ? ZhWeatherDescriptions : EnWeatherDescriptions;
- if (source.TryGetValue(code.Value, out var text))
- {
- return text;
- }
-
- return isZh ? $"\u5929\u6c14\u7801 {code.Value}" : $"Weather {code.Value}";
+ return XiaomiWeatherCodeMapper.ResolveDisplayText(code, locale);
}
private static string Truncate(string? text, int maxLength)
diff --git a/LanMountainDesktop/Styles/GlassModule.axaml b/LanMountainDesktop/Styles/GlassModule.axaml
index 9fd498e..10b1142 100644
--- a/LanMountainDesktop/Styles/GlassModule.axaml
+++ b/LanMountainDesktop/Styles/GlassModule.axaml
@@ -184,8 +184,8 @@