From 6b1c738d8c470766e818beb2d12076fdc082d607 Mon Sep 17 00:00:00 2001 From: lincube Date: Wed, 6 May 2026 19:33:08 +0800 Subject: [PATCH] Add material color services, plugin DTOs, and tests Introduce IPC wire-format appearance DTOs (PluginIsolation.Contracts) and clarify they are distinct from the runtime PluginSdk snapshot. Update PluginSdk comments to document the runtime-facing snapshot shape. Change ComponentColorSchemeHelper to use the HostMaterialColorProvider and add an overload that accepts a MaterialColorSnapshot. Add new services and pipelines (MaterialColorService, MaterialSurfaceService, WindowMaterialService, WallpaperColorPipeline) and refactor AppearanceThemeService to depend on MaterialColorService while removing legacy internal implementations. Add multiple unit tests (ComponentColorSchemeHelper, PluginAppearanceBoundary, SettingsCatalogService, WallpaperSettingsPageViewModel) and update localization resources with new material_color and wallpaper keys. --- .../AppearanceContracts.cs | 11 + .../PluginAppearanceSnapshot.cs | 10 + .../ComponentColorSchemeHelperTests.cs | 125 ++ .../PluginAppearanceBoundaryTests.cs | 379 +++++ .../SettingsCatalogServiceTests.cs | 39 + .../WallpaperSettingsPageViewModelTests.cs | 169 +++ .../ComponentColorSchemeHelper.cs | 12 +- LanMountainDesktop/Localization/en-US.json | 48 +- LanMountainDesktop/Localization/ja-JP.json | 49 +- LanMountainDesktop/Localization/ko-KR.json | 9 +- LanMountainDesktop/Localization/zh-CN.json | 49 +- .../Services/AppearanceThemeService.cs | 1304 +---------------- .../DesktopComponentRegistryFactory.cs | 11 +- .../Services/GlassEffectService.cs | 17 +- .../Services/MaterialColorService.cs | 645 ++++++++ .../Services/MaterialSurfaceService.cs | 144 ++ .../Services/NotificationService.cs | 59 +- .../PluginAppearanceSnapshotMapper.cs | 18 +- .../Settings/SettingsCatalogService.cs | 2 + .../Services/Settings/SettingsContracts.cs | 9 +- .../Settings/SettingsDomainServices.cs | 1 - .../Services/WallpaperColorPipeline.cs | 353 +++++ .../Services/WindowMaterialService.cs | 193 +++ .../MaterialColorSettingsPageViewModel.cs | 16 +- .../WallpaperSettingsPageViewModel.cs | 109 +- .../Views/NotificationDialogWindow.axaml.cs | 40 +- .../Views/NotificationWindow.axaml.cs | 43 +- .../SettingsPages/WallpaperSettingsPage.axaml | 66 - LanMountainDesktop/plugins/PluginLoader.cs | 48 +- .../plugins/PluginRuntimeService.cs | 1 + 30 files changed, 2402 insertions(+), 1577 deletions(-) create mode 100644 LanMountainDesktop.Tests/ComponentColorSchemeHelperTests.cs create mode 100644 LanMountainDesktop.Tests/PluginAppearanceBoundaryTests.cs create mode 100644 LanMountainDesktop.Tests/SettingsCatalogServiceTests.cs create mode 100644 LanMountainDesktop.Tests/WallpaperSettingsPageViewModelTests.cs create mode 100644 LanMountainDesktop/Services/MaterialColorService.cs create mode 100644 LanMountainDesktop/Services/MaterialSurfaceService.cs create mode 100644 LanMountainDesktop/Services/WallpaperColorPipeline.cs create mode 100644 LanMountainDesktop/Services/WindowMaterialService.cs diff --git a/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs b/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs index b7281a8..45e64d0 100644 --- a/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs +++ b/LanMountainDesktop.PluginIsolation.Contracts/AppearanceContracts.cs @@ -1,5 +1,9 @@ namespace LanMountainDesktop.PluginIsolation.Contracts; +/// +/// Wire request for the IPC appearance snapshot payload. This request targets the +/// isolation-contract DTOs, not the runtime SDK snapshot with the same type name. +/// public sealed record PluginAppearanceSnapshotRequest(string SessionId); public sealed record PluginMaterialSurfaceSnapshot( @@ -8,6 +12,10 @@ public sealed record PluginMaterialSurfaceSnapshot( double BlurRadius, double Opacity); +/// +/// Wire-format appearance snapshot exchanged over IPC. +/// Do not treat this as the same type as LanMountainDesktop.PluginSdk.PluginAppearanceSnapshot. +/// public sealed record PluginAppearanceSnapshot( string ThemeVariant, string? AccentColor = null, @@ -21,4 +29,7 @@ public sealed record PluginAppearanceSnapshot( IReadOnlyDictionary? MaterialSurfaces = null, IReadOnlyList? WallpaperSeedCandidates = null); +/// +/// Wire notification carrying the IPC appearance snapshot. +/// public sealed record PluginAppearanceChangedNotification(PluginAppearanceSnapshot Snapshot); diff --git a/LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs b/LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs index 7c13ef1..34ef99e 100644 --- a/LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs +++ b/LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs @@ -1,11 +1,21 @@ namespace LanMountainDesktop.PluginSdk; +/// +/// This is the runtime snapshot shape consumed by plugins inside the host process. +/// It is intentionally distinct from the wire DTO with the same name in +/// LanMountainDesktop.PluginIsolation.Contracts.PluginAppearanceSnapshot. +/// public sealed record PluginMaterialSurfaceSnapshot( string BackgroundColor, string BorderColor, double BlurRadius, double Opacity); +/// +/// Runtime-facing appearance snapshot for plugins. This is not the same contract as the +/// wire-format snapshot in LanMountainDesktop.PluginIsolation.Contracts, even though the +/// type name matches. +/// public sealed record PluginAppearanceSnapshot( PluginCornerRadiusTokens CornerRadiusTokens, string ThemeVariant, diff --git a/LanMountainDesktop.Tests/ComponentColorSchemeHelperTests.cs b/LanMountainDesktop.Tests/ComponentColorSchemeHelperTests.cs new file mode 100644 index 0000000..cb17004 --- /dev/null +++ b/LanMountainDesktop.Tests/ComponentColorSchemeHelperTests.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using Avalonia; +using Avalonia.Media; +using LanMountainDesktop.ComponentSystem; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; +using LanMountainDesktop.Shared.Contracts; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class ComponentColorSchemeHelperTests +{ + [Fact] + public void GetCurrentGlobalThemeColorMode_UsesMaterialColorSnapshot() + { + var snapshot = CreateSnapshot(ThemeAppearanceValues.ColorModeWallpaperMonet); + + var mode = ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode(snapshot); + + Assert.Equal(ThemeAppearanceValues.ColorModeWallpaperMonet, mode); + } + + [Theory] + [InlineData(ThemeAppearanceValues.ColorSchemeNative, ThemeAppearanceValues.ColorModeWallpaperMonet, false)] + [InlineData(ThemeAppearanceValues.ColorSchemeFollowSystem, ThemeAppearanceValues.ColorModeDefaultNeutral, true)] + [InlineData(null, ThemeAppearanceValues.ColorModeDefaultNeutral, false)] + public void ShouldUseMonetColor_FollowsExpectedRules( + string? componentColorScheme, + string globalThemeColorMode, + bool expected) + { + var shouldUseMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(componentColorScheme, globalThemeColorMode); + + Assert.Equal(expected, shouldUseMonetColor); + } + + private static MaterialColorSnapshot CreateSnapshot(string themeColorMode) + { + var seed = Color.Parse("#FF123456"); + var accent = Color.Parse("#FF214365"); + var palette = new MaterialColorPalette( + Color.Parse("#FF315577"), + Color.Parse("#FF557799"), + accent, + Color.Parse("#FFFFFFFF"), + Color.Parse("#FF5F7F9F"), + Color.Parse("#FF7F9FBF"), + Color.Parse("#FF9FBFDF"), + Color.Parse("#FF17314B"), + Color.Parse("#FF102840"), + Color.Parse("#FF082038"), + Color.Parse("#FF0B1118"), + Color.Parse("#FF141C24"), + Color.Parse("#FF1C2630"), + Color.Parse("#FFF5F7FA"), + Color.Parse("#FFC8D0DA"), + Color.Parse("#FF9EA8B4"), + Color.Parse("#FF91B8E8"), + Color.Parse("#FFF5F7FA"), + Color.Parse("#FFFFFFFF"), + Color.Parse("#FF9FBFDF"), + Color.Parse("#33141C24"), + Color.Parse("#441C2630"), + Color.Parse("#55315577"), + Color.Parse("#FF315577"), + Color.Parse("#88557799"), + Color.Parse("#667F9FBF")); + var monetPalette = new MonetPalette( + [seed], + seed, + palette.Primary, + palette.Secondary, + Color.Parse("#FF775577"), + Color.Parse("#FF202830"), + Color.Parse("#FF26313B")); + var surfaces = new Dictionary + { + [MaterialSurfaceRole.WindowBackground] = new( + MaterialSurfaceRole.WindowBackground, + Color.Parse("#FF101820"), + Color.Parse("#33557799"), + 18, + 0.92), + [MaterialSurfaceRole.OverlayPanel] = new( + MaterialSurfaceRole.OverlayPanel, + Color.Parse("#FF202A34"), + Color.Parse("#556688AA"), + 24, + 0.88) + }; + + return new MaterialColorSnapshot( + IsNightMode: true, + ThemeColorMode: themeColorMode, + ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto, + ColorSourceKind: MaterialColorSourceKind.CustomSeed, + ResolvedSeedSource: "user_color", + CornerRadiusTokens: new AppearanceCornerRadiusTokens( + new CornerRadius(2), + new CornerRadius(4), + new CornerRadius(6), + new CornerRadius(8), + new CornerRadius(10), + new CornerRadius(12), + new CornerRadius(14), + new CornerRadius(8)), + UserThemeColor: seed.ToString(), + SelectedWallpaperSeed: seed.ToString(), + EffectiveSeedColor: seed, + AccentColor: accent, + MonetPalette: monetPalette, + Palette: palette, + WallpaperSeedCandidates: [seed], + SystemMaterialMode: ThemeAppearanceValues.MaterialMica, + AvailableSystemMaterialModes: [ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialMica], + CanChangeSystemMaterial: true, + UseSystemChrome: false, + ResolvedWallpaperPath: @"C:\wallpaper.png", + UseNativeWallpaperChangeEvents: true, + NativeWallpaperChangeEventsActive: true, + WallpaperPollingActive: true, + Surfaces: surfaces); + } +} diff --git a/LanMountainDesktop.Tests/PluginAppearanceBoundaryTests.cs b/LanMountainDesktop.Tests/PluginAppearanceBoundaryTests.cs new file mode 100644 index 0000000..4645fcb --- /dev/null +++ b/LanMountainDesktop.Tests/PluginAppearanceBoundaryTests.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text.Json; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using LanMountainDesktop.Appearance; +using LanMountainDesktop.Models; +using LanMountainDesktop.Plugins; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Settings.Core; +using LanMountainDesktop.Shared.Contracts; +using Xunit; +using PluginIsolationAppearanceChangedNotification = LanMountainDesktop.PluginIsolation.Contracts.PluginAppearanceChangedNotification; +using PluginIsolationAppearanceSnapshot = LanMountainDesktop.PluginIsolation.Contracts.PluginAppearanceSnapshot; +using PluginIsolationAppearanceSnapshotRequest = LanMountainDesktop.PluginIsolation.Contracts.PluginAppearanceSnapshotRequest; +using PluginIsolationMaterialSurfaceSnapshot = LanMountainDesktop.PluginIsolation.Contracts.PluginMaterialSurfaceSnapshot; +using PluginIsolationJsonContext = LanMountainDesktop.PluginIsolation.Contracts.PluginIsolationJsonContext; +using MaterialColorPalette = LanMountainDesktop.Models.MaterialColorPalette; +using PluginSdkAppearanceSnapshot = LanMountainDesktop.PluginSdk.PluginAppearanceSnapshot; + +namespace LanMountainDesktop.Tests; + +public sealed class PluginAppearanceBoundaryTests +{ + [Fact] + public void PluginLoader_PrefersMaterialColorService_WhenBothAppearanceSourcesAreAvailable() + { + var materialService = new TrackingMaterialColorService(CreateMaterialSnapshot()); + var themeService = new TrackingAppearanceThemeService(CreateThemeSnapshot()); + var provider = new TrackingServiceProvider(materialService, themeService); + + var snapshot = InvokeLoaderSnapshotBuilder(provider); + + Assert.Equal(1, materialService.GetMaterialColorSnapshotCalls); + Assert.Equal(0, themeService.GetCurrentCalls); + Assert.Equal("Dark", snapshot.ThemeVariant); + Assert.Equal(Color.Parse("#FF214365").ToString(), snapshot.AccentColor); + Assert.Equal(Color.Parse("#FF123456").ToString(), snapshot.SeedColor); + Assert.Equal(MaterialColorSourceKind.CustomSeed.ToString(), snapshot.ColorSource); + Assert.Equal(ThemeAppearanceValues.MaterialMica, snapshot.SystemMaterialMode); + Assert.Equal(Color.Parse("#FF214365").ToString(), snapshot.ColorRoles?["accent"]); + } + + [Fact] + public void PluginIsolationJsonContext_RoundTripsAppearancePayloads() + { + var request = new PluginIsolationAppearanceSnapshotRequest("session-42"); + var snapshot = new PluginIsolationAppearanceSnapshot( + ThemeVariant: "Dark", + AccentColor: "#FF214365", + CornerRadiusScale: 1.25, + CornerRadiusTokens: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["component"] = 24, + ["sm"] = 8 + }, + ResourceAliases: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["surface-base"] = "DesignSurfaceBase" + }, + SeedColor: "#FF123456", + ColorSource: "custom_seed", + SystemMaterialMode: ThemeAppearanceValues.MaterialMica, + ColorRoles: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["accent"] = "#FF214365", + ["primary"] = "#FF315577" + }, + MaterialSurfaces: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["WindowBackground"] = new PluginIsolationMaterialSurfaceSnapshot("#FF101820", "#33557799", 18, 0.92) + }, + WallpaperSeedCandidates: ["#FF123456", "#FF214365"]); + var notification = new PluginIsolationAppearanceChangedNotification(snapshot); + + var requestJson = JsonSerializer.Serialize(request, PluginIsolationJsonContext.Default.PluginAppearanceSnapshotRequest); + var requestRoundTrip = JsonSerializer.Deserialize(requestJson, PluginIsolationJsonContext.Default.PluginAppearanceSnapshotRequest); + + var snapshotJson = JsonSerializer.Serialize(snapshot, PluginIsolationJsonContext.Default.PluginAppearanceSnapshot); + var snapshotRoundTrip = JsonSerializer.Deserialize(snapshotJson, PluginIsolationJsonContext.Default.PluginAppearanceSnapshot); + + var notificationJson = JsonSerializer.Serialize(notification, PluginIsolationJsonContext.Default.PluginAppearanceChangedNotification); + var notificationRoundTrip = JsonSerializer.Deserialize(notificationJson, PluginIsolationJsonContext.Default.PluginAppearanceChangedNotification); + + Assert.Equal(request, requestRoundTrip); + AssertAppearanceSnapshotEqual(snapshot, snapshotRoundTrip!); + AssertAppearanceSnapshotEqual(snapshot, notificationRoundTrip!.Snapshot); + } + + private static PluginSdkAppearanceSnapshot InvokeLoaderSnapshotBuilder(IServiceProvider provider) + { + var method = typeof(PluginLoader).GetMethod( + "BuildAppearanceSnapshot", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + var result = method!.Invoke(null, [provider]); + return Assert.IsType(result); + } + + private static void AssertAppearanceSnapshotEqual(PluginIsolationAppearanceSnapshot expected, PluginIsolationAppearanceSnapshot actual) + { + Assert.Equal(expected.ThemeVariant, actual.ThemeVariant); + Assert.Equal(expected.AccentColor, actual.AccentColor); + Assert.Equal(expected.CornerRadiusScale, actual.CornerRadiusScale); + Assert.Equal(expected.SeedColor, actual.SeedColor); + Assert.Equal(expected.ColorSource, actual.ColorSource); + Assert.Equal(expected.SystemMaterialMode, actual.SystemMaterialMode); + Assert.Equal(expected.WallpaperSeedCandidates, actual.WallpaperSeedCandidates); + AssertDictionaryEqual(expected.CornerRadiusTokens, actual.CornerRadiusTokens); + AssertDictionaryEqual(expected.ResourceAliases, actual.ResourceAliases); + AssertDictionaryEqual(expected.ColorRoles, actual.ColorRoles); + + Assert.NotNull(actual.MaterialSurfaces); + Assert.Equal(expected.MaterialSurfaces!.Count, actual.MaterialSurfaces!.Count); + foreach (var pair in expected.MaterialSurfaces) + { + Assert.True(actual.MaterialSurfaces.TryGetValue(pair.Key, out var actualSurface)); + Assert.Equal(pair.Value.BackgroundColor, actualSurface.BackgroundColor); + Assert.Equal(pair.Value.BorderColor, actualSurface.BorderColor); + Assert.Equal(pair.Value.BlurRadius, actualSurface.BlurRadius); + Assert.Equal(pair.Value.Opacity, actualSurface.Opacity); + } + } + + private static void AssertDictionaryEqual( + IReadOnlyDictionary? expected, + IReadOnlyDictionary? actual) + where TKey : notnull + { + if (expected is null) + { + Assert.Null(actual); + return; + } + + Assert.NotNull(actual); + Assert.Equal(expected.Count, actual!.Count); + foreach (var pair in expected) + { + Assert.True(actual.TryGetValue(pair.Key, out var actualValue)); + Assert.Equal(pair.Value, actualValue); + } + } + + private static MaterialColorSnapshot CreateMaterialSnapshot() + { + var seed = Color.Parse("#FF123456"); + var accent = Color.Parse("#FF214365"); + var palette = new MaterialColorPalette( + Color.Parse("#FF315577"), + Color.Parse("#FF557799"), + accent, + Color.Parse("#FFFFFFFF"), + Color.Parse("#FF5F7F9F"), + Color.Parse("#FF7F9FBF"), + Color.Parse("#FF9FBFDF"), + Color.Parse("#FF17314B"), + Color.Parse("#FF102840"), + Color.Parse("#FF082038"), + Color.Parse("#FF0B1118"), + Color.Parse("#FF141C24"), + Color.Parse("#FF1C2630"), + Color.Parse("#FFF5F7FA"), + Color.Parse("#FFC8D0DA"), + Color.Parse("#FF9EA8B4"), + Color.Parse("#FF91B8E8"), + Color.Parse("#FFF5F7FA"), + Color.Parse("#FFFFFFFF"), + Color.Parse("#FF9FBFDF"), + Color.Parse("#33141C24"), + Color.Parse("#441C2630"), + Color.Parse("#55315577"), + Color.Parse("#FF315577"), + Color.Parse("#88557799"), + Color.Parse("#667F9FBF")); + var monetPalette = new MonetPalette( + [seed], + seed, + palette.Primary, + palette.Secondary, + Color.Parse("#FF775577"), + Color.Parse("#FF202830"), + Color.Parse("#FF26313B")); + var surfaces = new Dictionary + { + [MaterialSurfaceRole.WindowBackground] = new( + MaterialSurfaceRole.WindowBackground, + Color.Parse("#FF101820"), + Color.Parse("#33557799"), + 18, + 0.92), + [MaterialSurfaceRole.DesktopComponentHost] = new( + MaterialSurfaceRole.DesktopComponentHost, + Color.Parse("#FF141C24"), + Color.Parse("#44557799"), + 20, + 0.90), + [MaterialSurfaceRole.OverlayPanel] = new( + MaterialSurfaceRole.OverlayPanel, + Color.Parse("#FF202A34"), + Color.Parse("#556688AA"), + 24, + 0.88) + }; + + return new MaterialColorSnapshot( + IsNightMode: true, + ThemeColorMode: ThemeAppearanceValues.ColorModeSeedMonet, + ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto, + ColorSourceKind: MaterialColorSourceKind.CustomSeed, + ResolvedSeedSource: "user_color", + CornerRadiusTokens: new AppearanceCornerRadiusTokens( + new CornerRadius(2), + new CornerRadius(4), + new CornerRadius(6), + new CornerRadius(8), + new CornerRadius(10), + new CornerRadius(12), + new CornerRadius(14), + new CornerRadius(8)), + UserThemeColor: seed.ToString(), + SelectedWallpaperSeed: seed.ToString(), + EffectiveSeedColor: seed, + AccentColor: accent, + MonetPalette: monetPalette, + Palette: palette, + WallpaperSeedCandidates: [seed], + SystemMaterialMode: ThemeAppearanceValues.MaterialMica, + AvailableSystemMaterialModes: [ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialMica], + CanChangeSystemMaterial: true, + UseSystemChrome: false, + ResolvedWallpaperPath: @"C:\wallpaper.png", + UseNativeWallpaperChangeEvents: true, + NativeWallpaperChangeEventsActive: true, + WallpaperPollingActive: true, + Surfaces: surfaces); + } + + private static AppearanceThemeSnapshot CreateThemeSnapshot() + { + var seed = Color.Parse("#FF456789"); + var monetPalette = new MonetPalette( + [seed], + seed, + Color.Parse("#FF667788"), + Color.Parse("#FF8899AA"), + Color.Parse("#FF112233"), + Color.Parse("#FF334455"), + Color.Parse("#FF556677")); + + return new AppearanceThemeSnapshot( + IsNightMode: false, + ThemeColorMode: ThemeAppearanceValues.ColorModeWallpaperMonet, + UserThemeColor: "#FF456789", + SelectedWallpaperSeed: "#FF456789", + CornerRadiusStyle: GlobalAppearanceSettings.CornerRadiusStyleRounded, + CornerRadiusTokens: new AppearanceCornerRadiusTokens( + new CornerRadius(1), + new CornerRadius(2), + new CornerRadius(3), + new CornerRadius(4), + new CornerRadius(5), + new CornerRadius(6), + new CornerRadius(7), + new CornerRadius(8)), + ResolvedSeedSource: "theme-source", + MonetPalette: monetPalette, + AccentColor: Color.Parse("#FF556677"), + EffectiveSeedColor: seed, + WallpaperSeedCandidates: [seed], + SystemMaterialMode: ThemeAppearanceValues.MaterialAcrylic, + AvailableSystemMaterialModes: [ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialAcrylic], + CanChangeSystemMaterial: true, + UseSystemChrome: true, + ResolvedWallpaperPath: @"C:\theme-wallpaper.png", + ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceSystem, + UseNativeWallpaperChangeEvents: false); + } + + private sealed class TrackingServiceProvider : IServiceProvider + { + private readonly Dictionary _services = new(); + + public TrackingServiceProvider(IMaterialColorService materialColorService, IAppearanceThemeService appearanceThemeService) + { + _services[typeof(IMaterialColorService)] = materialColorService; + _services[typeof(IAppearanceThemeService)] = appearanceThemeService; + } + + public object? GetService(Type serviceType) + { + return _services.TryGetValue(serviceType, out var service) ? service : null; + } + } + + private sealed class TrackingMaterialColorService(MaterialColorSnapshot snapshot) : IMaterialColorService + { + public int GetMaterialColorSnapshotCalls { get; private set; } + + public MaterialColorSnapshot GetMaterialColorSnapshot() + { + GetMaterialColorSnapshotCalls++; + return snapshot; + } + + public MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState) + { + throw new NotSupportedException(); + } + + public event EventHandler? MaterialColorChanged + { + add { } + remove { } + } + + public void ApplyThemeResources(IResourceDictionary resources) + { + throw new NotSupportedException(); + } + + public MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role) + { + throw new NotSupportedException(); + } + + public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role) + { + throw new NotSupportedException(); + } + + public void RefreshWallpaperColors() + { + throw new NotSupportedException(); + } + } + + private sealed class TrackingAppearanceThemeService(AppearanceThemeSnapshot snapshot) : IAppearanceThemeService + { + public int GetCurrentCalls { get; private set; } + + public AppearanceThemeSnapshot GetCurrent() + { + GetCurrentCalls++; + return snapshot; + } + + public AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState) + { + throw new NotSupportedException(); + } + + public event EventHandler? Changed + { + add { } + remove { } + } + + public void ApplyThemeResources(IResourceDictionary resources) + { + throw new NotSupportedException(); + } + + public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role) + { + throw new NotSupportedException(); + } + + public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role) + { + throw new NotSupportedException(); + } + } +} diff --git a/LanMountainDesktop.Tests/SettingsCatalogServiceTests.cs b/LanMountainDesktop.Tests/SettingsCatalogServiceTests.cs new file mode 100644 index 0000000..17cc2ac --- /dev/null +++ b/LanMountainDesktop.Tests/SettingsCatalogServiceTests.cs @@ -0,0 +1,39 @@ +using System.Linq; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services.Settings; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class SettingsCatalogServiceTests +{ + [Fact] + public void BuiltInAppSectionsIncludeIndependentMaterialColorAndWallpaperEntries() + { + var catalog = new SettingsCatalogService(); + + var sections = catalog.GetSections(SettingsScope.App).ToList(); + + Assert.Equal( + [ + "general", + "material-color", + "appearance", + "wallpaper", + "about" + ], + sections.Select(section => section.Id)); + + var materialColor = sections.Single(section => section.Id == "material-color"); + Assert.Equal(SettingsCategories.Appearance, materialColor.Category); + Assert.Equal(SettingsScope.App, materialColor.Scope); + Assert.Equal("settings.material_color.title", materialColor.TitleLocalizationKey); + Assert.Equal("Color", materialColor.IconKey); + + var wallpaper = sections.Single(section => section.Id == "wallpaper"); + Assert.Equal(SettingsCategories.Appearance, wallpaper.Category); + Assert.Equal(SettingsScope.App, wallpaper.Scope); + Assert.Equal("settings.wallpaper.title", wallpaper.TitleLocalizationKey); + Assert.Equal("Image", wallpaper.IconKey); + } +} diff --git a/LanMountainDesktop.Tests/WallpaperSettingsPageViewModelTests.cs b/LanMountainDesktop.Tests/WallpaperSettingsPageViewModelTests.cs new file mode 100644 index 0000000..f6719f8 --- /dev/null +++ b/LanMountainDesktop.Tests/WallpaperSettingsPageViewModelTests.cs @@ -0,0 +1,169 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media; +using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Settings.Core; +using LanMountainDesktop.ViewModels; +using Xunit; + +namespace LanMountainDesktop.Tests; + +public sealed class WallpaperSettingsPageViewModelTests +{ + [Fact] + public void CustomColorRoundTripsThroughWallpaperColorField() + { + var initialWallpaperState = new WallpaperSettingsState( + WallpaperPath: null, + Type: "SolidColor", + Color: "#FF123456", + Placement: "Fill", + SystemWallpaperRefreshIntervalSeconds: 900); + var initialThemeState = CreateThemeState(); + var facade = new FakeSettingsFacade(initialWallpaperState, initialThemeState); + var viewModel = new WallpaperSettingsPageViewModel(facade); + + Assert.Equal("#FF123456", viewModel.SelectedColor); + Assert.Equal(Color.Parse("#FF123456"), viewModel.CustomColor); + + viewModel.CustomColor = Color.Parse("#FFABCDEF"); + + Assert.Equal("#FFABCDEF", facade.WallpaperState.Color); + Assert.Equal(900, facade.WallpaperState.SystemWallpaperRefreshIntervalSeconds); + Assert.Equal(0, facade.ThemeSaveCount); + Assert.Equal(ThemeAppearanceValues.MaterialMica, facade.ThemeState.SystemMaterialMode); + Assert.Equal("#FF998877", facade.ThemeState.SelectedWallpaperSeed); + + var reloaded = new WallpaperSettingsPageViewModel(facade); + Assert.Equal("#FFABCDEF", reloaded.SelectedColor); + Assert.Equal(Color.Parse("#FFABCDEF"), reloaded.CustomColor); + } + + [Fact] + public void SavingWallpaperChanges_DoesNotTouchThemeMaterialFields() + { + var initialWallpaperState = new WallpaperSettingsState( + WallpaperPath: @"C:\\wallpaper\\forest.png", + Type: "Image", + Color: "#FF123456", + Placement: "Fill", + SystemWallpaperRefreshIntervalSeconds: 1800); + var initialThemeState = CreateThemeState(); + var facade = new FakeSettingsFacade(initialWallpaperState, initialThemeState); + var viewModel = new WallpaperSettingsPageViewModel(facade); + + viewModel.SelectedWallpaperPlacement = viewModel.WallpaperPlacements.Single(option => option.Value == "Tile"); + + Assert.Equal(0, facade.ThemeSaveCount); + Assert.Equal(ThemeAppearanceValues.MaterialMica, facade.ThemeState.SystemMaterialMode); + Assert.Equal("#FF998877", facade.ThemeState.SelectedWallpaperSeed); + Assert.Equal(1800, facade.WallpaperState.SystemWallpaperRefreshIntervalSeconds); + } + + private static ThemeAppearanceSettingsState CreateThemeState() + { + return new ThemeAppearanceSettingsState( + IsNightMode: false, + ThemeColor: "#FF445566", + UseSystemChrome: true, + CornerRadiusStyle: GlobalAppearanceSettings.CornerRadiusStyleRounded, + ThemeColorMode: ThemeAppearanceValues.ColorModeWallpaperMonet, + SystemMaterialMode: ThemeAppearanceValues.MaterialMica, + SelectedWallpaperSeed: "#FF998877", + ThemeMode: ThemeAppearanceValues.ThemeModeLight, + ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto, + UseNativeWallpaperChangeEvents: true); + } + + private sealed class FakeSettingsFacade( + WallpaperSettingsState wallpaperState, + ThemeAppearanceSettingsState themeState) : ISettingsFacadeService + { + private readonly FakeWallpaperSettingsService _wallpaper = new(wallpaperState); + private readonly FakeThemeAppearanceService _theme = new(themeState); + private readonly FakeRegionSettingsService _region = new(); + + public WallpaperSettingsState WallpaperState => _wallpaper.State; + public ThemeAppearanceSettingsState ThemeState => _theme.State; + public int ThemeSaveCount => _theme.SaveCount; + + public ISettingsService Settings => throw new NotSupportedException(); + public ISettingsCatalog Catalog => throw new NotSupportedException(); + public IGridSettingsService Grid => throw new NotSupportedException(); + public IWallpaperSettingsService Wallpaper => _wallpaper; + public IWallpaperMediaService WallpaperMedia => new FakeWallpaperMediaService(); + public IThemeAppearanceService Theme => _theme; + public IStatusBarSettingsService StatusBar => throw new NotSupportedException(); + public ITextCapsuleSettingsService TextCapsule => throw new NotSupportedException(); + public IWeatherSettingsService Weather => throw new NotSupportedException(); + public IRegionSettingsService Region => _region; + public IPrivacySettingsService Privacy => throw new NotSupportedException(); + public IUpdateSettingsService Update => throw new NotSupportedException(); + public ILauncherCatalogService LauncherCatalog => throw new NotSupportedException(); + public ILauncherPolicyService LauncherPolicy => throw new NotSupportedException(); + public IPluginManagementSettingsService PluginManagement => throw new NotSupportedException(); + public IPluginCatalogSettingsService PluginCatalog => throw new NotSupportedException(); + public IApplicationInfoService ApplicationInfo => throw new NotSupportedException(); + } + + private sealed class FakeWallpaperSettingsService(WallpaperSettingsState state) : IWallpaperSettingsService + { + public WallpaperSettingsState State { get; private set; } = state; + + public WallpaperSettingsState Get() => State; + + public void Save(WallpaperSettingsState state) + { + State = state; + } + } + + private sealed class FakeThemeAppearanceService(ThemeAppearanceSettingsState state) : IThemeAppearanceService + { + public ThemeAppearanceSettingsState State { get; private set; } = state; + + public int SaveCount { get; private set; } + + public ThemeAppearanceSettingsState Get() => State; + + public void Save(ThemeAppearanceSettingsState state) + { + SaveCount++; + State = state; + } + + public MonetPalette BuildPalette(bool nightMode, string? wallpaperPath, string? preferredSeedColor = null) + { + var seed = Color.Parse(preferredSeedColor ?? "#FF3B82F6"); + return new MonetPalette([seed], seed, seed, seed, seed, seed, seed); + } + } + + private sealed class FakeRegionSettingsService : IRegionSettingsService + { + public RegionSettingsState Get() => new("en-US", null); + + public void Save(RegionSettingsState state) + { + _ = state; + } + + public TimeZoneService GetTimeZoneService() => new(); + } + + private sealed class FakeWallpaperMediaService : IWallpaperMediaService + { + public WallpaperMediaType DetectMediaType(string? path) => WallpaperMediaType.None; + + public Task ImportAssetAsync(string sourcePath, CancellationToken cancellationToken = default) + { + _ = sourcePath; + _ = cancellationToken; + return Task.FromResult(null); + } + } +} diff --git a/LanMountainDesktop/ComponentSystem/ComponentColorSchemeHelper.cs b/LanMountainDesktop/ComponentSystem/ComponentColorSchemeHelper.cs index 2b68ddd..38ff387 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentColorSchemeHelper.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentColorSchemeHelper.cs @@ -1,4 +1,5 @@ using System; +using LanMountainDesktop.Models; using LanMountainDesktop.Services; using LanMountainDesktop.Views; @@ -25,13 +26,18 @@ public static class ComponentColorSchemeHelper { try { - var service = HostAppearanceThemeProvider.GetOrCreate(); - var appearance = service.GetCurrent(); - return appearance?.ThemeColorMode ?? ThemeAppearanceValues.ColorModeDefaultNeutral; + var service = HostMaterialColorProvider.GetOrCreate(); + return service.GetMaterialColorSnapshot().ThemeColorMode; } catch { return ThemeAppearanceValues.ColorModeDefaultNeutral; } } + + public static string GetCurrentGlobalThemeColorMode(MaterialColorSnapshot materialColorSnapshot) + { + ArgumentNullException.ThrowIfNull(materialColorSnapshot); + return materialColorSnapshot.ThemeColorMode; + } } diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 602b869..3148d44 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -38,27 +38,25 @@ "settings.wallpaper.title": "Wallpaper", "settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.", "settings.wallpaper.current_label": "Current Wallpaper", - "settings.wallpaper.type_label": "Wallpaper Type", - "settings.wallpaper.type.image": "Image", - "settings.wallpaper.type.solid_color": "Solid Color", - "settings.wallpaper.type.system": "System Wallpaper", - "settings.wallpaper.system.label": "System Wallpaper", - "settings.wallpaper.system.unavailable": "Unable to read system wallpaper", - "settings.wallpaper.refresh_interval": "Refresh Interval", - "settings.wallpaper.refresh_now": "Refresh Now", - "settings.wallpaper.refresh.30s": "30 seconds", - "settings.wallpaper.refresh.1m": "1 minute", - "settings.wallpaper.refresh.5m": "5 minutes", - "settings.wallpaper.refresh.10m": "10 minutes", - "settings.wallpaper.refresh.15m": "15 minutes", - "settings.wallpaper.refresh.30m": "30 minutes", - "settings.wallpaper.refresh.1h": "1 hour", - "settings.wallpaper.refresh.2h": "2 hours", - "settings.wallpaper.refresh.4h": "4 hours", - "settings.wallpaper.refresh.8h": "8 hours", - "settings.wallpaper.refresh.12h": "12 hours", - "settings.wallpaper.refresh.24h": "24 hours", - "settings.wallpaper.color_label": "Wallpaper Color", +"settings.wallpaper.type_label": "Wallpaper Type", +"settings.wallpaper.type.image": "Image", +"settings.wallpaper.type.solid_color": "Solid Color", +"settings.wallpaper.type.system": "System Wallpaper", +"settings.wallpaper.refresh_interval": "Refresh Interval", +"settings.wallpaper.refresh_now": "Refresh Now", +"settings.wallpaper.refresh.30s": "30 seconds", +"settings.wallpaper.refresh.1m": "1 minute", +"settings.wallpaper.refresh.5m": "5 minutes", +"settings.wallpaper.refresh.10m": "10 minutes", +"settings.wallpaper.refresh.15m": "15 minutes", +"settings.wallpaper.refresh.30m": "30 minutes", +"settings.wallpaper.refresh.1h": "1 hour", +"settings.wallpaper.refresh.2h": "2 hours", +"settings.wallpaper.refresh.4h": "4 hours", +"settings.wallpaper.refresh.8h": "8 hours", +"settings.wallpaper.refresh.12h": "12 hours", +"settings.wallpaper.refresh.24h": "24 hours", +"settings.wallpaper.color_label": "Wallpaper Color", "settings.wallpaper.placement_label": "Placement", "settings.wallpaper.placement_desc": "Adjust how the image fills the desktop.", "settings.wallpaper.pick_button": "Browse Files", @@ -392,6 +390,14 @@ "settings.appearance.preview.apply_seed": "Apply", "settings.appearance.preview.wallpaper_candidates": "Wallpaper seed candidates", "settings.appearance.preview.wallpaper_current": "Current", + "settings.material_color.preview.wallpaper_current": "Current", + "settings.material_color.theme_color_mode.neutral": "Default neutral", + "settings.material_color.theme_color_mode.user": "User theme color Monet", + "settings.material_color.theme_color_mode.wallpaper": "Wallpaper Monet", + "settings.material_color.system_material.auto": "Auto (recommended)", + "settings.material_color.system_material.none": "None", + "settings.material_color.system_material.mica": "Mica", + "settings.material_color.system_material.acrylic": "Acrylic", "settings.wallpaper.placement.fill": "Fill", "settings.wallpaper.placement.fit": "Fit", "settings.wallpaper.placement.stretch": "Stretch", diff --git a/LanMountainDesktop/Localization/ja-JP.json b/LanMountainDesktop/Localization/ja-JP.json index eb50f9d..6ea0f11 100644 --- a/LanMountainDesktop/Localization/ja-JP.json +++ b/LanMountainDesktop/Localization/ja-JP.json @@ -38,27 +38,25 @@ "settings.wallpaper.title": "壁紙", "settings.wallpaper.description": "画像または動画を選択して、アプリウィンドウの壁紙としてすぐに適用します。", "settings.wallpaper.current_label": "現在の壁紙", - "settings.wallpaper.type_label": "壁紙タイプ", - "settings.wallpaper.type.image": "画像", - "settings.wallpaper.type.solid_color": "単色", - "settings.wallpaper.type.system": "システム壁紙", - "settings.wallpaper.system.label": "システム壁紙", - "settings.wallpaper.system.unavailable": "システム壁紙を読み込めません", - "settings.wallpaper.refresh_interval": "更新間隔", - "settings.wallpaper.refresh_now": "今すぐ更新", - "settings.wallpaper.refresh.30s": "30秒", - "settings.wallpaper.refresh.1m": "1分", - "settings.wallpaper.refresh.5m": "5分", - "settings.wallpaper.refresh.10m": "10分", - "settings.wallpaper.refresh.15m": "15分", - "settings.wallpaper.refresh.30m": "30分", - "settings.wallpaper.refresh.1h": "1時間", - "settings.wallpaper.refresh.2h": "2時間", - "settings.wallpaper.refresh.4h": "4時間", - "settings.wallpaper.refresh.8h": "8時間", - "settings.wallpaper.refresh.12h": "12時間", - "settings.wallpaper.refresh.24h": "24時間", - "settings.wallpaper.color_label": "壁紙の色", +"settings.wallpaper.type_label": "壁紙タイプ", +"settings.wallpaper.type.image": "画像", +"settings.wallpaper.type.solid_color": "単色", +"settings.wallpaper.type.system": "システム壁紙", +"settings.wallpaper.refresh_interval": "更新間隔", +"settings.wallpaper.refresh_now": "今すぐ更新", +"settings.wallpaper.refresh.30s": "30秒", +"settings.wallpaper.refresh.1m": "1分", +"settings.wallpaper.refresh.5m": "5分", +"settings.wallpaper.refresh.10m": "10分", +"settings.wallpaper.refresh.15m": "15分", +"settings.wallpaper.refresh.30m": "30分", +"settings.wallpaper.refresh.1h": "1時間", +"settings.wallpaper.refresh.2h": "2時間", +"settings.wallpaper.refresh.4h": "4時間", +"settings.wallpaper.refresh.8h": "8時間", +"settings.wallpaper.refresh.12h": "12時間", +"settings.wallpaper.refresh.24h": "24時間", +"settings.wallpaper.color_label": "壁紙の色", "settings.wallpaper.placement_label": "配置", "settings.wallpaper.placement_desc": "画像がデスクトップにどのように表示されるかを調整します。", "settings.wallpaper.pick_button": "ファイルを参照", @@ -328,7 +326,14 @@ "settings.appearance.preview.apply_seed": "適用", "settings.appearance.preview.wallpaper_candidates": "壁紙シード候補", "settings.appearance.preview.wallpaper_current": "現在", - "settings.wallpaper.placement.fill": "フィル", + "settings.material_color.preview.wallpaper_current": "Current", + "settings.material_color.theme_color_mode.neutral": "Default neutral", + "settings.material_color.theme_color_mode.user": "User theme color Monet", + "settings.material_color.theme_color_mode.wallpaper": "Wallpaper Monet", + "settings.material_color.system_material.auto": "Auto (recommended)", + "settings.material_color.system_material.none": "None", + "settings.material_color.system_material.mica": "Mica", + "settings.material_color.system_material.acrylic": "Acrylic", "settings.wallpaper.placement.fill": "フィル", "settings.wallpaper.placement.fit": "フィット", "settings.wallpaper.placement.stretch": "ストレッチ", "settings.wallpaper.placement.center": "中央", diff --git a/LanMountainDesktop/Localization/ko-KR.json b/LanMountainDesktop/Localization/ko-KR.json index e8b9640..f8c71f5 100644 --- a/LanMountainDesktop/Localization/ko-KR.json +++ b/LanMountainDesktop/Localization/ko-KR.json @@ -374,7 +374,14 @@ "settings.appearance.preview.apply_seed": "적용", "settings.appearance.preview.wallpaper_candidates": "배경화면 후보 테마 색상", "settings.appearance.preview.wallpaper_current": "현재", - "settings.wallpaper.placement.fill": "채우기", + "settings.material_color.preview.wallpaper_current": "Current", + "settings.material_color.theme_color_mode.neutral": "Default neutral", + "settings.material_color.theme_color_mode.user": "User theme color Monet", + "settings.material_color.theme_color_mode.wallpaper": "Wallpaper Monet", + "settings.material_color.system_material.auto": "Auto (recommended)", + "settings.material_color.system_material.none": "None", + "settings.material_color.system_material.mica": "Mica", + "settings.material_color.system_material.acrylic": "Acrylic", "settings.wallpaper.placement.fill": "채우기", "settings.wallpaper.placement.fit": "맞추기", "settings.wallpaper.placement.stretch": "늘리기", "settings.wallpaper.placement.center": "가운데", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index c21d16c..6d6ab51 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -38,27 +38,25 @@ "settings.wallpaper.title": "壁纸", "settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。", "settings.wallpaper.current_label": "当前壁纸", - "settings.wallpaper.type_label": "壁纸类型", - "settings.wallpaper.type.image": "图片", - "settings.wallpaper.type.solid_color": "纯色", - "settings.wallpaper.type.system": "系统壁纸", - "settings.wallpaper.system.label": "系统壁纸", - "settings.wallpaper.system.unavailable": "无法读取系统壁纸", - "settings.wallpaper.refresh_interval": "刷新频率", - "settings.wallpaper.refresh_now": "立即刷新", - "settings.wallpaper.refresh.30s": "30 秒", - "settings.wallpaper.refresh.1m": "1 分钟", - "settings.wallpaper.refresh.5m": "5 分钟", - "settings.wallpaper.refresh.10m": "10 分钟", - "settings.wallpaper.refresh.15m": "15 分钟", - "settings.wallpaper.refresh.30m": "30 分钟", - "settings.wallpaper.refresh.1h": "1 小时", - "settings.wallpaper.refresh.2h": "2 小时", - "settings.wallpaper.refresh.4h": "4 小时", - "settings.wallpaper.refresh.8h": "8 小时", - "settings.wallpaper.refresh.12h": "12 小时", - "settings.wallpaper.refresh.24h": "24 小时", - "settings.wallpaper.color_label": "壁纸颜色", +"settings.wallpaper.type_label": "壁纸类型", +"settings.wallpaper.type.image": "图片", +"settings.wallpaper.type.solid_color": "纯色", +"settings.wallpaper.type.system": "系统壁纸", +"settings.wallpaper.refresh_interval": "刷新频率", +"settings.wallpaper.refresh_now": "立即刷新", +"settings.wallpaper.refresh.30s": "30 秒", +"settings.wallpaper.refresh.1m": "1 分钟", +"settings.wallpaper.refresh.5m": "5 分钟", +"settings.wallpaper.refresh.10m": "10 分钟", +"settings.wallpaper.refresh.15m": "15 分钟", +"settings.wallpaper.refresh.30m": "30 分钟", +"settings.wallpaper.refresh.1h": "1 小时", +"settings.wallpaper.refresh.2h": "2 小时", +"settings.wallpaper.refresh.4h": "4 小时", +"settings.wallpaper.refresh.8h": "8 小时", +"settings.wallpaper.refresh.12h": "12 小时", +"settings.wallpaper.refresh.24h": "24 小时", +"settings.wallpaper.color_label": "壁纸颜色", "settings.wallpaper.custom_color_tooltip": "自定义颜色", "settings.wallpaper.custom_color_apply": "应用", "settings.wallpaper.placement_label": "显示方式", @@ -393,7 +391,14 @@ "settings.appearance.preview.apply_seed": "应用", "settings.appearance.preview.wallpaper_candidates": "壁纸候选主题色", "settings.appearance.preview.wallpaper_current": "当前", - "settings.wallpaper.placement.fill": "填充", + "settings.material_color.preview.wallpaper_current": "Current", + "settings.material_color.theme_color_mode.neutral": "Default neutral", + "settings.material_color.theme_color_mode.user": "User theme color Monet", + "settings.material_color.theme_color_mode.wallpaper": "Wallpaper Monet", + "settings.material_color.system_material.auto": "Auto (recommended)", + "settings.material_color.system_material.none": "None", + "settings.material_color.system_material.mica": "Mica", + "settings.material_color.system_material.acrylic": "Acrylic", "settings.wallpaper.placement.fill": "填充", "settings.wallpaper.placement.fit": "适应", "settings.wallpaper.placement.stretch": "拉伸", "settings.wallpaper.placement.center": "居中", diff --git a/LanMountainDesktop/Services/AppearanceThemeService.cs b/LanMountainDesktop/Services/AppearanceThemeService.cs index 96f5fd7..83f975a 100644 --- a/LanMountainDesktop/Services/AppearanceThemeService.cs +++ b/LanMountainDesktop/Services/AppearanceThemeService.cs @@ -75,13 +75,6 @@ public interface IAppearanceThemeService void ApplyWindowMaterial(Window window, MaterialSurfaceRole role); } -internal interface ISystemWallpaperService -{ - bool IsSupported { get; } - - string? GetWallpaperPath(); -} - internal interface IWindowMaterialService { IReadOnlyList GetAvailableModes(); @@ -96,1320 +89,91 @@ internal interface IMaterialSurfaceService AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role); } -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 SystemWallpaperService : ISystemWallpaperService +internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposable { - public bool IsSupported => OperatingSystem.IsWindows(); + private readonly MaterialColorService _materialColorService; - public string? GetWallpaperPath() + public AppearanceThemeService(MaterialColorService materialColorService) { - 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 => GetAvailableModes().Count > 1; - - public IReadOnlyList GetAvailableModes() - { - return GetSupportProfile() switch - { - WindowMaterialSupportProfile.FullSwitching => - [ - ThemeAppearanceValues.MaterialAuto, - ThemeAppearanceValues.MaterialNone, - ThemeAppearanceValues.MaterialMica, - ThemeAppearanceValues.MaterialAcrylic - ], - WindowMaterialSupportProfile.FixedMica => - [ - ThemeAppearanceValues.MaterialAuto, - ThemeAppearanceValues.MaterialNone, - ThemeAppearanceValues.MaterialMica - ], - WindowMaterialSupportProfile.FixedAcrylic => - [ - ThemeAppearanceValues.MaterialAuto, - ThemeAppearanceValues.MaterialNone, - ThemeAppearanceValues.MaterialAcrylic - ], - _ => - [ - ThemeAppearanceValues.MaterialAuto, - ThemeAppearanceValues.MaterialNone - ] - }; - } - - public void Apply(Window window, string materialMode) - { - ArgumentNullException.ThrowIfNull(window); - - var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode); - var supportProfile = GetSupportProfile(); - var effectiveMode = normalizedMode == ThemeAppearanceValues.MaterialAuto - ? ResolveAutoMaterialMode(supportProfile) - : normalizedMode; - - if (effectiveMode == ThemeAppearanceValues.MaterialNone) - { - window.Background = Brushes.White; - window.TransparencyLevelHint = [WindowTransparencyLevel.None]; - return; - } - - window.Background = Brushes.Transparent; - - if (supportProfile == WindowMaterialSupportProfile.NoneOnly) - { - window.TransparencyLevelHint = - [ - WindowTransparencyLevel.None - ]; - return; - } - - window.TransparencyLevelHint = normalizedMode == ThemeAppearanceValues.MaterialAuto - ? ResolveAutoTransparencyLevels(supportProfile) - : effectiveMode switch - { - ThemeAppearanceValues.MaterialMica => - [ - WindowTransparencyLevel.Mica, - WindowTransparencyLevel.Blur, - WindowTransparencyLevel.None - ], - ThemeAppearanceValues.MaterialAcrylic => - [ - WindowTransparencyLevel.AcrylicBlur, - WindowTransparencyLevel.Blur, - WindowTransparencyLevel.None - ], - _ => - [ - WindowTransparencyLevel.None - ] - }; - } - - private static string ResolveAutoMaterialMode(WindowMaterialSupportProfile supportProfile) - { - return supportProfile switch - { - WindowMaterialSupportProfile.FullSwitching or WindowMaterialSupportProfile.FixedMica => - ThemeAppearanceValues.MaterialMica, - WindowMaterialSupportProfile.FixedAcrylic => - ThemeAppearanceValues.MaterialAcrylic, - _ => ThemeAppearanceValues.MaterialNone - }; - } - - private static IReadOnlyList ResolveAutoTransparencyLevels(WindowMaterialSupportProfile supportProfile) - { - return supportProfile switch - { - WindowMaterialSupportProfile.FullSwitching or WindowMaterialSupportProfile.FixedMica => - [ - WindowTransparencyLevel.Mica, - WindowTransparencyLevel.AcrylicBlur, - WindowTransparencyLevel.Blur, - WindowTransparencyLevel.None - ], - WindowMaterialSupportProfile.FixedAcrylic => - [ - WindowTransparencyLevel.AcrylicBlur, - WindowTransparencyLevel.Blur, - WindowTransparencyLevel.None - ], - _ => - [ - WindowTransparencyLevel.None - ] - }; - } - - private static bool IsTransparencyEnabled() - { - if (!OperatingSystem.IsWindows()) - { - 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.ResolveEffectiveSystemMaterialMode(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) - { - // Settings 根层(如 RootGrid)叠在 Transparent + Mica/Acrylic 上:过高 alpha 会完全盖住系统 backdrop。 - // 保持非 None 下较低 alpha;None 仍用不透明白底等价。BlurRadius=0(由 DWM 提供模糊)。 - if (role == MaterialSurfaceRole.SettingsWindowBackground) - { - return materialMode switch - { - ThemeAppearanceValues.MaterialAcrylic => ( - 0.20, - 0.14, - isNightMode ? (byte)0x8E : (byte)0x96, - 0), - ThemeAppearanceValues.MaterialMica => ( - 0.14, - 0.08, - isNightMode ? (byte)0x9E : (byte)0xA6, - 0), - _ => (0.08, 0.05, (byte)0xFF, 0) - }; - } - - var isOverlay = role is MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel; - return materialMode switch - { - 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, IMaterialColorService, IDisposable -{ - private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6"); - private static readonly Color NeutralFallbackSeedColor = Color.Parse("#FF8A8A8A"); - private readonly ISettingsFacadeService _settingsFacade; - private readonly ISystemWallpaperService _systemWallpaperService; - private readonly IWindowMaterialService _windowMaterialService; - private readonly IMaterialSurfaceService _materialSurfaceService; - private readonly MonetColorService _monetColorService = new(); - private string _liveThemeColorMode; - private string _liveSystemMaterialMode; - private string? _liveSelectedWallpaperSeed; - private string _liveThemeWallpaperColorSource; - private bool _liveUseNativeWallpaperChangeEvents; - private readonly object _paletteGate = new(); - private readonly Dictionary _wallpaperSeedCache = new(StringComparer.OrdinalIgnoreCase); - private readonly HashSet _pendingWallpaperSeedKeys = new(StringComparer.OrdinalIgnoreCase); - private Timer? _systemWallpaperPollTimer; - private string? _lastObservedWallpaperSourceKey; - private bool _nativeWallpaperEventsActive; - private bool _wallpaperPollingActive; - - public AppearanceThemeService( - ISettingsFacadeService settingsFacade, - ISystemWallpaperService systemWallpaperService, - IWindowMaterialService windowMaterialService, - IMaterialSurfaceService materialSurfaceService) - { - _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)); - var initialThemeState = _settingsFacade.Theme.Get(); - _liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode( - initialThemeState.ThemeColorMode, - initialThemeState.ThemeColor); - _liveSystemMaterialMode = ResolveSupportedMaterialMode(initialThemeState.SystemMaterialMode); - _liveSelectedWallpaperSeed = initialThemeState.SelectedWallpaperSeed; - _liveThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(initialThemeState.ThemeWallpaperColorSource); - _liveUseNativeWallpaperChangeEvents = initialThemeState.UseNativeWallpaperChangeEvents; - _settingsFacade.Settings.Changed += OnSettingsChanged; - ConfigureSystemWallpaperMonitoring(initialThemeState); + _materialColorService = materialColorService ?? throw new ArgumentNullException(nameof(materialColorService)); + _materialColorService.AppearanceThemeChanged += OnAppearanceThemeChanged; } public event EventHandler? Changed; - public event EventHandler? MaterialColorChanged; - public AppearanceThemeSnapshot GetCurrent() { - return BuildCurrentSnapshot(queueWallpaperPaletteBuild: true); + return _materialColorService.GetCurrent(); } 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 MaterialColorSnapshot GetMaterialColorSnapshot() - { - return CreateMaterialColorSnapshot(GetCurrent()); - } - - public MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState) - { - return CreateMaterialColorSnapshot(BuildPreview(pendingState)); - } - - public MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role) - { - var surface = GetMaterialSurface(role); - return new MaterialSurfaceSnapshot( - role, - surface.BackgroundColor, - surface.BorderColor, - surface.BlurRadius, - surface.Opacity); - } - - public void RefreshWallpaperColors() - { - lock (_paletteGate) - { - _wallpaperSeedCache.Clear(); - _pendingWallpaperSeedKeys.Clear(); - _lastObservedWallpaperSourceKey = null; - } - - RaiseChanged(queueWallpaperPaletteBuild: true); + return _materialColorService.BuildPreview(pendingState); } public void ApplyThemeResources(IResourceDictionary resources) { - ArgumentNullException.ThrowIfNull(resources); - - var snapshot = GetCurrent(); - var context = CreateThemeContext(snapshot); - ThemeColorSystemService.ApplyThemeResources(resources, context); - GlassEffectService.ApplyGlassResources(resources, context); - resources["DesignCornerRadiusMicro"] = snapshot.CornerRadiusTokens.Micro; - resources["DesignCornerRadiusXs"] = snapshot.CornerRadiusTokens.Xs; - resources["DesignCornerRadiusSm"] = snapshot.CornerRadiusTokens.Sm; - resources["DesignCornerRadiusMd"] = snapshot.CornerRadiusTokens.Md; - resources["DesignCornerRadiusLg"] = snapshot.CornerRadiusTokens.Lg; - resources["DesignCornerRadiusXl"] = snapshot.CornerRadiusTokens.Xl; - resources["DesignCornerRadiusIsland"] = snapshot.CornerRadiusTokens.Island; - resources["DesignCornerRadiusComponent"] = snapshot.CornerRadiusTokens.Component; + _materialColorService.ApplyThemeResources(resources); } public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role) { - var snapshot = GetCurrent(); - return _materialSurfaceService.GetSurface(CreateThemeContext(snapshot), role); + return _materialColorService.GetMaterialSurface(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. - // SettingsWindowBackground 是唯一需要材质与资源同步热切换的宿主角色;其它窗口仍保持「仅创建时」应用以降低风险。 - if (window.IsVisible && role != MaterialSurfaceRole.SettingsWindowBackground) - { - 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); - } + _materialColorService.ApplyWindowMaterial(window, role); } public void Dispose() { - _settingsFacade.Settings.Changed -= OnSettingsChanged; - StopSystemWallpaperMonitoring(); - _systemWallpaperPollTimer?.Dispose(); - _systemWallpaperPollTimer = null; + _materialColorService.AppearanceThemeChanged -= OnAppearanceThemeChanged; } - private AppearanceThemeSnapshot BuildCurrentSnapshot(bool queueWallpaperPaletteBuild) - { - var themeState = _settingsFacade.Theme.Get(); - return BuildSnapshot( - themeState, - _liveThemeColorMode, - _liveSystemMaterialMode, - _liveSelectedWallpaperSeed, - queueWallpaperPaletteBuild); - } - - private void OnSettingsChanged(object? sender, SettingsChangedEvent e) + private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot snapshot) { _ = 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) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColorMode), StringComparer.OrdinalIgnoreCase) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparer.OrdinalIgnoreCase) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparer.OrdinalIgnoreCase) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource), StringComparer.OrdinalIgnoreCase) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents), StringComparer.OrdinalIgnoreCase) && - !changedKeys.Contains(nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds), 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; - } - - var latestThemeState = _settingsFacade.Theme.Get(); - _liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode( - latestThemeState.ThemeColorMode, - latestThemeState.ThemeColor); - _liveSystemMaterialMode = ResolveSupportedMaterialMode(latestThemeState.SystemMaterialMode); - _liveSelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed; - _liveThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(latestThemeState.ThemeWallpaperColorSource); - _liveUseNativeWallpaperChangeEvents = latestThemeState.UseNativeWallpaperChangeEvents; - ConfigureSystemWallpaperMonitoring(latestThemeState); - RaiseChanged(queueWallpaperPaletteBuild: true); - } - - private AppearanceThemeSnapshot BuildSnapshot( - ThemeAppearanceSettingsState themeState, - string themeColorMode, - string systemMaterialMode, - string? selectedWallpaperSeed, - bool queueWallpaperPaletteBuild) - { - var availableModes = _windowMaterialService.GetAvailableModes(); - var cornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(themeState.CornerRadiusStyle); - var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(cornerRadiusStyle); - 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, - ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource), - 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, - cornerRadiusStyle, - cornerRadiusTokens, - resolvedSeedSource, - palette, - ResolveAccentColor(themeColorMode, themeState.ThemeColor, palette), - effectiveSeedColor, - wallpaperSeedCandidates, - systemMaterialMode, - availableModes, - _windowMaterialService.CanChangeMode, - themeState.UseSystemChrome, - resolvedWallpaperPath, - ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource), - themeState.UseNativeWallpaperChangeEvents); - } - - 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 wallpaperColorSource, - string? selectedWallpaperSeed, - bool queueWallpaperPaletteBuild) - { - var source = ResolveWallpaperSeedSource(wallpaperState, wallpaperColorSource); - 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 = _monetColorService.BuildPaletteFromSeedCandidates([], nightMode, NeutralFallbackSeedColor); - 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_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 WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource( - WallpaperSettingsState wallpaperState, - string wallpaperColorSource) - { - var normalizedWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(wallpaperColorSource); - - if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceSystem && - 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 (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceSystem && - !string.IsNullOrWhiteSpace(wallpaperPath) && - File.Exists(wallpaperPath)) - { - if (appWallpaperMediaType == WallpaperMediaType.Image) - { - return new WallpaperSeedSourceDescriptor( - "app_wallpaper", - CreateWallpaperSourceKey("app_wallpaper", wallpaperPath), - wallpaperPath, - wallpaperPath, - null); - } - } - - if (normalizedWallpaperColorSource == ThemeAppearanceValues.WallpaperColorSourceApp) - { - return new WallpaperSeedSourceDescriptor( - "fallback", - "fallback", - null, - null, - null); - } - - var systemWallpaper = _systemWallpaperService.GetWallpaperPath(); - if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceApp && - !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); - var materialSnapshot = CreateMaterialColorSnapshot(snapshot); - if (Dispatcher.UIThread.CheckAccess()) - { - Changed?.Invoke(this, snapshot); - MaterialColorChanged?.Invoke(this, materialSnapshot); - return; - } - - Dispatcher.UIThread.Post(() => - { - Changed?.Invoke(this, snapshot); - MaterialColorChanged?.Invoke(this, materialSnapshot); - }, DispatcherPriority.Background); - } - - private MaterialColorSnapshot CreateMaterialColorSnapshot(AppearanceThemeSnapshot snapshot) - { - var context = CreateThemeContext(snapshot); - var appPalette = ThemeColorSystemService.BuildPalette(context); - var palette = new LanMountainDesktop.Models.MaterialColorPalette( - appPalette.Primary, - appPalette.Secondary, - appPalette.Accent, - appPalette.OnAccent, - appPalette.AccentLight1, - appPalette.AccentLight2, - appPalette.AccentLight3, - appPalette.AccentDark1, - appPalette.AccentDark2, - appPalette.AccentDark3, - appPalette.SurfaceBase, - appPalette.SurfaceRaised, - appPalette.SurfaceOverlay, - appPalette.TextPrimary, - appPalette.TextSecondary, - appPalette.TextMuted, - appPalette.TextAccent, - appPalette.NavText, - appPalette.NavSelectedText, - appPalette.NavSelectionIndicator, - appPalette.NavItemBackground, - appPalette.NavItemHoverBackground, - appPalette.NavItemSelectedBackground, - appPalette.ToggleOn, - appPalette.ToggleOff, - appPalette.ToggleBorder); - var surfaces = Enum.GetValues() - .Select(role => - { - var surface = _materialSurfaceService.GetSurface(context, role); - return new MaterialSurfaceSnapshot( - role, - surface.BackgroundColor, - surface.BorderColor, - surface.BlurRadius, - surface.Opacity); - }) - .ToDictionary(surface => surface.Role); - - return new MaterialColorSnapshot( - snapshot.IsNightMode, - snapshot.ThemeColorMode, - snapshot.ThemeWallpaperColorSource, - ResolveMaterialColorSourceKind(snapshot), - snapshot.ResolvedSeedSource, - snapshot.CornerRadiusTokens, - snapshot.UserThemeColor, - snapshot.SelectedWallpaperSeed, - snapshot.EffectiveSeedColor, - snapshot.AccentColor, - snapshot.MonetPalette, - palette, - snapshot.WallpaperSeedCandidates, - snapshot.SystemMaterialMode, - snapshot.AvailableSystemMaterialModes, - snapshot.CanChangeSystemMaterial, - snapshot.UseSystemChrome, - snapshot.ResolvedWallpaperPath, - snapshot.UseNativeWallpaperChangeEvents, - _nativeWallpaperEventsActive, - _wallpaperPollingActive, - surfaces); - } - - private static MaterialColorSourceKind ResolveMaterialColorSourceKind(AppearanceThemeSnapshot snapshot) - { - if (string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase)) - { - return MaterialColorSourceKind.Neutral; - } - - if (string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase)) - { - return MaterialColorSourceKind.CustomSeed; - } - - if (!string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase)) - { - return MaterialColorSourceKind.Fallback; - } - - if (string.Equals(snapshot.ResolvedSeedSource, "app_wallpaper", StringComparison.OrdinalIgnoreCase) || - string.Equals(snapshot.ResolvedSeedSource, "app_solid", StringComparison.OrdinalIgnoreCase)) - { - return string.Equals(snapshot.ThemeWallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase) - ? MaterialColorSourceKind.AppWallpaper - : MaterialColorSourceKind.WallpaperAuto; - } - - if (string.Equals(snapshot.ResolvedSeedSource, "system_wallpaper", StringComparison.OrdinalIgnoreCase)) - { - return string.Equals(snapshot.ThemeWallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceSystem, StringComparison.OrdinalIgnoreCase) - ? MaterialColorSourceKind.SystemWallpaper - : MaterialColorSourceKind.WallpaperAuto; - } - - return MaterialColorSourceKind.Fallback; - } - - private void ConfigureSystemWallpaperMonitoring(ThemeAppearanceSettingsState themeState) - { - var colorMode = ThemeAppearanceValues.NormalizeThemeColorMode(themeState.ThemeColorMode, themeState.ThemeColor); - var wallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource); - var shouldMonitor = - string.Equals(colorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && - !string.Equals(wallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase); - - if (!shouldMonitor) - { - StopSystemWallpaperMonitoring(); - return; - } - - ConfigureNativeWallpaperEvents(themeState.UseNativeWallpaperChangeEvents); - ConfigureWallpaperPolling(_settingsFacade.Wallpaper.Get().SystemWallpaperRefreshIntervalSeconds); - UpdateObservedWallpaperSourceKey(); - } - - private void ConfigureNativeWallpaperEvents(bool enabled) - { - if (!enabled || !OperatingSystem.IsWindows()) - { - UnregisterNativeWallpaperEvents(); - return; - } - - if (_nativeWallpaperEventsActive) - { - return; - } - - RegisterNativeWallpaperEvents(); - } - - private void UnregisterNativeWallpaperEvents() - { - if (!_nativeWallpaperEventsActive) - { - return; - } - - if (OperatingSystem.IsWindows()) - { - UnregisterNativeWallpaperEventsCore(); - } - - _nativeWallpaperEventsActive = false; - } - - [SupportedOSPlatform("windows")] - private void RegisterNativeWallpaperEvents() - { - try - { - SystemEvents.UserPreferenceChanged += OnNativeWallpaperPreferenceChanged; - _nativeWallpaperEventsActive = true; - } - catch (Exception ex) - { - _nativeWallpaperEventsActive = false; - AppLogger.Warn("Appearance.WallpaperMonitor", "Failed to subscribe to native wallpaper change events; polling will remain active.", ex); - } - } - - [SupportedOSPlatform("windows")] - private void UnregisterNativeWallpaperEventsCore() - { - try - { - SystemEvents.UserPreferenceChanged -= OnNativeWallpaperPreferenceChanged; - } - catch - { - // Ignore shutdown-time native event cleanup failures. - } - } - - private void ConfigureWallpaperPolling(int intervalSeconds) - { - var normalizedInterval = Math.Clamp(intervalSeconds <= 0 ? 300 : intervalSeconds, 30, 86400); - var interval = TimeSpan.FromSeconds(normalizedInterval); - _systemWallpaperPollTimer ??= new Timer(OnSystemWallpaperPollTimer); - _systemWallpaperPollTimer.Change(interval, interval); - _wallpaperPollingActive = true; - } - - private void StopSystemWallpaperMonitoring() - { - UnregisterNativeWallpaperEvents(); - _systemWallpaperPollTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - _wallpaperPollingActive = false; - _lastObservedWallpaperSourceKey = null; - } - - private void OnNativeWallpaperPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) - { - _ = sender; - - if (!OperatingSystem.IsWindows()) - { - return; - } - - if (e.Category is UserPreferenceCategory.Desktop or UserPreferenceCategory.General) - { - RefreshWallpaperColors(); - } - } - - private void OnSystemWallpaperPollTimer(object? state) - { - _ = state; - - try - { - var source = ResolveWallpaperSeedSource(_settingsFacade.Wallpaper.Get(), _liveThemeWallpaperColorSource); - var sourceKey = source.SourceKey; - if (string.Equals(_lastObservedWallpaperSourceKey, sourceKey, StringComparison.OrdinalIgnoreCase)) - { - return; - } - - _lastObservedWallpaperSourceKey = sourceKey; - RefreshWallpaperColors(); - } - catch (Exception ex) - { - AppLogger.Warn("Appearance.WallpaperMonitor", "Failed to poll wallpaper color source.", ex); - } - } - - private void UpdateObservedWallpaperSourceKey() - { - try - { - _lastObservedWallpaperSourceKey = ResolveWallpaperSeedSource( - _settingsFacade.Wallpaper.Get(), - _liveThemeWallpaperColorSource).SourceKey; - } - catch - { - _lastObservedWallpaperSourceKey = null; - } - } - - 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()); + Changed?.Invoke(this, snapshot); } } internal static class HostAppearanceThemeProvider { private static readonly object Gate = new(); - private static AppearanceThemeService? _instance; + private static MaterialColorService? _materialColorService; + private static AppearanceThemeService? _appearanceThemeService; public static IAppearanceThemeService GetOrCreate() { lock (Gate) { - return _instance ??= new AppearanceThemeService( - HostSettingsFacadeProvider.GetOrCreate(), - new SystemWallpaperService(), - new WindowMaterialService(), - new MaterialSurfaceService()); + return _appearanceThemeService ??= new AppearanceThemeService(GetMaterialColorServiceCore()); } } + + internal static MaterialColorService GetMaterialColorService() + { + lock (Gate) + { + return GetMaterialColorServiceCore(); + } + } + + private static MaterialColorService GetMaterialColorServiceCore() + { + return _materialColorService ??= new MaterialColorService( + HostSettingsFacadeProvider.GetOrCreate(), + HostSystemWallpaperProvider.GetOrCreate(), + new WindowMaterialService(), + new MaterialSurfaceService()); + } } internal static class HostMaterialColorProvider { public static IMaterialColorService GetOrCreate() { - return (IMaterialColorService)HostAppearanceThemeProvider.GetOrCreate(); + return HostAppearanceThemeProvider.GetMaterialColorService(); } } diff --git a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs index 047df80..ad15fb6 100644 --- a/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs +++ b/LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs @@ -35,12 +35,14 @@ public static class DesktopComponentRegistryFactory public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry( ComponentRegistry componentRegistry, PluginRuntimeService? pluginRuntimeService, - ISettingsFacadeService settingsFacade) + ISettingsFacadeService settingsFacade, + IMaterialColorService? materialColorService = null) { var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList(); var registeredIds = new HashSet( registrations.Select(registration => registration.ComponentId), StringComparer.OrdinalIgnoreCase); + var resolvedMaterialColorService = materialColorService ?? HostMaterialColorProvider.GetOrCreate(); if (pluginRuntimeService is not null) { @@ -62,7 +64,7 @@ public static class DesktopComponentRegistryFactory registrations.Add(new DesktopComponentRuntimeRegistration( registration.ComponentId, registration.DisplayNameLocalizationKey, - factoryContext => CreatePluginControl(contribution, factoryContext), + factoryContext => CreatePluginControl(contribution, factoryContext, resolvedMaterialColorService), chromeContext => { var appearanceContext = CreatePluginAppearanceContext(chromeContext); @@ -118,7 +120,8 @@ public static class DesktopComponentRegistryFactory private static Control CreatePluginControl( PluginDesktopComponentContribution contribution, - DesktopComponentControlFactoryContext context) + DesktopComponentControlFactoryContext context, + IMaterialColorService materialColorService) { try { @@ -129,7 +132,7 @@ public static class DesktopComponentRegistryFactory settingsService); var pluginAppearance = new PluginAppearanceContext( PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot( - HostMaterialColorProvider.GetOrCreate().GetMaterialColorSnapshot())); + materialColorService.GetMaterialColorSnapshot())); var pluginContext = new PluginDesktopComponentContext( contribution.Plugin.Manifest, contribution.Plugin.Context.PluginDirectory, diff --git a/LanMountainDesktop/Services/GlassEffectService.cs b/LanMountainDesktop/Services/GlassEffectService.cs index 0dd03d7..7ee9fb5 100644 --- a/LanMountainDesktop/Services/GlassEffectService.cs +++ b/LanMountainDesktop/Services/GlassEffectService.cs @@ -7,9 +7,10 @@ namespace LanMountainDesktop.Services; public static class GlassEffectService { + private static readonly IMaterialSurfaceService MaterialSurfaceService = new MaterialSurfaceService(); + 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 = context.UseNeutralSurfaces @@ -48,13 +49,13 @@ public static class GlassEffectService ColorMath.Blend(buttonBackground, primary, context.IsNightMode ? 0.24 : 0.16), context.IsNightMode ? (byte)0xF8 : (byte)0xFF)); - 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 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, diff --git a/LanMountainDesktop/Services/MaterialColorService.cs b/LanMountainDesktop/Services/MaterialColorService.cs new file mode 100644 index 0000000..46ae6ac --- /dev/null +++ b/LanMountainDesktop/Services/MaterialColorService.cs @@ -0,0 +1,645 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Versioning; +using System.Threading; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Styling; +using Avalonia.Threading; +using LanMountainDesktop.Appearance; +using LanMountainDesktop.Models; +using LanMountainDesktop.PluginSdk; +using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Settings.Core; +using LanMountainDesktop.Shared.Contracts; +using LanMountainDesktop.Theme; +using Microsoft.Win32; + +namespace LanMountainDesktop.Services; + +internal sealed class MaterialColorService : IMaterialColorService, IDisposable +{ + private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6"); + private readonly ISettingsFacadeService _settingsFacade; + private readonly IWindowMaterialService _windowMaterialService; + private readonly IMaterialSurfaceService _materialSurfaceService; + private readonly MonetColorService _monetColorService = new(); + private readonly WallpaperColorPipeline _wallpaperColorPipeline; + private string _liveThemeColorMode; + private string _liveSystemMaterialMode; + private string? _liveSelectedWallpaperSeed; + private string _liveThemeWallpaperColorSource; + private bool _liveUseNativeWallpaperChangeEvents; + private Timer? _systemWallpaperPollTimer; + private string? _lastObservedWallpaperSourceKey; + private bool _nativeWallpaperEventsActive; + private bool _wallpaperPollingActive; + + public MaterialColorService( + ISettingsFacadeService settingsFacade, + ISystemWallpaperProvider systemWallpaperProvider, + IWindowMaterialService windowMaterialService, + IMaterialSurfaceService materialSurfaceService) + { + _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); + _windowMaterialService = windowMaterialService ?? throw new ArgumentNullException(nameof(windowMaterialService)); + _materialSurfaceService = materialSurfaceService ?? throw new ArgumentNullException(nameof(materialSurfaceService)); + _wallpaperColorPipeline = new WallpaperColorPipeline( + _settingsFacade, + systemWallpaperProvider ?? throw new ArgumentNullException(nameof(systemWallpaperProvider)), + _monetColorService, + RaiseChanged); + var initialThemeState = _settingsFacade.Theme.Get(); + _liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode( + initialThemeState.ThemeColorMode, + initialThemeState.ThemeColor); + _liveSystemMaterialMode = ResolveSupportedMaterialMode(initialThemeState.SystemMaterialMode); + _liveSelectedWallpaperSeed = initialThemeState.SelectedWallpaperSeed; + _liveThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(initialThemeState.ThemeWallpaperColorSource); + _liveUseNativeWallpaperChangeEvents = initialThemeState.UseNativeWallpaperChangeEvents; + _settingsFacade.Settings.Changed += OnSettingsChanged; + ConfigureSystemWallpaperMonitoring(initialThemeState); + } + + internal event EventHandler? AppearanceThemeChanged; + + public event EventHandler? MaterialColorChanged; + + 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 MaterialColorSnapshot GetMaterialColorSnapshot() + { + return CreateMaterialColorSnapshot(GetCurrent()); + } + + public MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState) + { + return CreateMaterialColorSnapshot(BuildPreview(pendingState)); + } + + public MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role) + { + var surface = GetMaterialSurface(role); + return new MaterialSurfaceSnapshot( + role, + surface.BackgroundColor, + surface.BorderColor, + surface.BlurRadius, + surface.Opacity); + } + + public void RefreshWallpaperColors() + { + _wallpaperColorPipeline.Clear(); + _lastObservedWallpaperSourceKey = null; + RaiseChanged(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); + resources["DesignCornerRadiusMicro"] = snapshot.CornerRadiusTokens.Micro; + resources["DesignCornerRadiusXs"] = snapshot.CornerRadiusTokens.Xs; + resources["DesignCornerRadiusSm"] = snapshot.CornerRadiusTokens.Sm; + resources["DesignCornerRadiusMd"] = snapshot.CornerRadiusTokens.Md; + resources["DesignCornerRadiusLg"] = snapshot.CornerRadiusTokens.Lg; + resources["DesignCornerRadiusXl"] = snapshot.CornerRadiusTokens.Xl; + resources["DesignCornerRadiusIsland"] = snapshot.CornerRadiusTokens.Island; + resources["DesignCornerRadiusComponent"] = snapshot.CornerRadiusTokens.Component; + } + + 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. + // SettingsWindowBackground 是唯一需要材质与资源同步热切换的宿主角色;其它窗口仍保持「仅创建时」应用以降低风险。 + if (window.IsVisible && role != MaterialSurfaceRole.SettingsWindowBackground) + { + 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; + StopSystemWallpaperMonitoring(); + _systemWallpaperPollTimer?.Dispose(); + _systemWallpaperPollTimer = null; + } + + 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) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColorMode), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.SystemMaterialMode), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.SelectedWallpaperSeed), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeWallpaperColorSource), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.UseNativeWallpaperChangeEvents), StringComparer.OrdinalIgnoreCase) && + !changedKeys.Contains(nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds), 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; + } + + var latestThemeState = _settingsFacade.Theme.Get(); + _liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode( + latestThemeState.ThemeColorMode, + latestThemeState.ThemeColor); + _liveSystemMaterialMode = ResolveSupportedMaterialMode(latestThemeState.SystemMaterialMode); + _liveSelectedWallpaperSeed = latestThemeState.SelectedWallpaperSeed; + _liveThemeWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(latestThemeState.ThemeWallpaperColorSource); + _liveUseNativeWallpaperChangeEvents = latestThemeState.UseNativeWallpaperChangeEvents; + ConfigureSystemWallpaperMonitoring(latestThemeState); + RaiseChanged(queueWallpaperPaletteBuild: true); + } + + private AppearanceThemeSnapshot BuildSnapshot( + ThemeAppearanceSettingsState themeState, + string themeColorMode, + string systemMaterialMode, + string? selectedWallpaperSeed, + bool queueWallpaperPaletteBuild) + { + var availableModes = _windowMaterialService.GetAvailableModes(); + var cornerRadiusStyle = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(themeState.CornerRadiusStyle); + var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(cornerRadiusStyle); + 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 = _wallpaperColorPipeline.Resolve( + themeState.IsNightMode, + wallpaperState, + ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource), + 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, + cornerRadiusStyle, + cornerRadiusTokens, + resolvedSeedSource, + palette, + ResolveAccentColor(themeColorMode, themeState.ThemeColor, palette), + effectiveSeedColor, + wallpaperSeedCandidates, + systemMaterialMode, + availableModes, + _windowMaterialService.CanChangeMode, + themeState.UseSystemChrome, + resolvedWallpaperPath, + ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource), + themeState.UseNativeWallpaperChangeEvents); + } + + 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 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 void RaiseChanged(bool queueWallpaperPaletteBuild) + { + var snapshot = BuildCurrentSnapshot(queueWallpaperPaletteBuild); + var materialSnapshot = CreateMaterialColorSnapshot(snapshot); + if (Dispatcher.UIThread.CheckAccess()) + { + AppearanceThemeChanged?.Invoke(this, snapshot); + MaterialColorChanged?.Invoke(this, materialSnapshot); + return; + } + + Dispatcher.UIThread.Post(() => + { + AppearanceThemeChanged?.Invoke(this, snapshot); + MaterialColorChanged?.Invoke(this, materialSnapshot); + }, DispatcherPriority.Background); + } + + private MaterialColorSnapshot CreateMaterialColorSnapshot(AppearanceThemeSnapshot snapshot) + { + var context = CreateThemeContext(snapshot); + var appPalette = ThemeColorSystemService.BuildPalette(context); + var palette = new LanMountainDesktop.Models.MaterialColorPalette( + appPalette.Primary, + appPalette.Secondary, + appPalette.Accent, + appPalette.OnAccent, + appPalette.AccentLight1, + appPalette.AccentLight2, + appPalette.AccentLight3, + appPalette.AccentDark1, + appPalette.AccentDark2, + appPalette.AccentDark3, + appPalette.SurfaceBase, + appPalette.SurfaceRaised, + appPalette.SurfaceOverlay, + appPalette.TextPrimary, + appPalette.TextSecondary, + appPalette.TextMuted, + appPalette.TextAccent, + appPalette.NavText, + appPalette.NavSelectedText, + appPalette.NavSelectionIndicator, + appPalette.NavItemBackground, + appPalette.NavItemHoverBackground, + appPalette.NavItemSelectedBackground, + appPalette.ToggleOn, + appPalette.ToggleOff, + appPalette.ToggleBorder); + var surfaces = Enum.GetValues() + .Select(role => + { + var surface = _materialSurfaceService.GetSurface(context, role); + return new MaterialSurfaceSnapshot( + role, + surface.BackgroundColor, + surface.BorderColor, + surface.BlurRadius, + surface.Opacity); + }) + .ToDictionary(surface => surface.Role); + + return new MaterialColorSnapshot( + snapshot.IsNightMode, + snapshot.ThemeColorMode, + snapshot.ThemeWallpaperColorSource, + ResolveMaterialColorSourceKind(snapshot), + snapshot.ResolvedSeedSource, + snapshot.CornerRadiusTokens, + snapshot.UserThemeColor, + snapshot.SelectedWallpaperSeed, + snapshot.EffectiveSeedColor, + snapshot.AccentColor, + snapshot.MonetPalette, + palette, + snapshot.WallpaperSeedCandidates, + snapshot.SystemMaterialMode, + snapshot.AvailableSystemMaterialModes, + snapshot.CanChangeSystemMaterial, + snapshot.UseSystemChrome, + snapshot.ResolvedWallpaperPath, + snapshot.UseNativeWallpaperChangeEvents, + _nativeWallpaperEventsActive, + _wallpaperPollingActive, + surfaces); + } + + private static MaterialColorSourceKind ResolveMaterialColorSourceKind(AppearanceThemeSnapshot snapshot) + { + if (string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase)) + { + return MaterialColorSourceKind.Neutral; + } + + if (string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase)) + { + return MaterialColorSourceKind.CustomSeed; + } + + if (!string.Equals(snapshot.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase)) + { + return MaterialColorSourceKind.Fallback; + } + + if (string.Equals(snapshot.ResolvedSeedSource, "app_wallpaper", StringComparison.OrdinalIgnoreCase) || + string.Equals(snapshot.ResolvedSeedSource, "app_solid", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(snapshot.ThemeWallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase) + ? MaterialColorSourceKind.AppWallpaper + : MaterialColorSourceKind.WallpaperAuto; + } + + if (string.Equals(snapshot.ResolvedSeedSource, "system_wallpaper", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(snapshot.ThemeWallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceSystem, StringComparison.OrdinalIgnoreCase) + ? MaterialColorSourceKind.SystemWallpaper + : MaterialColorSourceKind.WallpaperAuto; + } + + return MaterialColorSourceKind.Fallback; + } + + private void ConfigureSystemWallpaperMonitoring(ThemeAppearanceSettingsState themeState) + { + var colorMode = ThemeAppearanceValues.NormalizeThemeColorMode(themeState.ThemeColorMode, themeState.ThemeColor); + var wallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(themeState.ThemeWallpaperColorSource); + var shouldMonitor = + string.Equals(colorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) && + !string.Equals(wallpaperColorSource, ThemeAppearanceValues.WallpaperColorSourceApp, StringComparison.OrdinalIgnoreCase); + + if (!shouldMonitor) + { + StopSystemWallpaperMonitoring(); + return; + } + + ConfigureNativeWallpaperEvents(themeState.UseNativeWallpaperChangeEvents); + ConfigureWallpaperPolling(_settingsFacade.Wallpaper.Get().SystemWallpaperRefreshIntervalSeconds); + UpdateObservedWallpaperSourceKey(); + } + + private void ConfigureNativeWallpaperEvents(bool enabled) + { + if (!enabled || !OperatingSystem.IsWindows()) + { + UnregisterNativeWallpaperEvents(); + return; + } + + if (_nativeWallpaperEventsActive) + { + return; + } + + RegisterNativeWallpaperEvents(); + } + + private void UnregisterNativeWallpaperEvents() + { + if (!_nativeWallpaperEventsActive) + { + return; + } + + if (OperatingSystem.IsWindows()) + { + UnregisterNativeWallpaperEventsCore(); + } + + _nativeWallpaperEventsActive = false; + } + + [SupportedOSPlatform("windows")] + private void RegisterNativeWallpaperEvents() + { + try + { + SystemEvents.UserPreferenceChanged += OnNativeWallpaperPreferenceChanged; + _nativeWallpaperEventsActive = true; + } + catch (Exception ex) + { + _nativeWallpaperEventsActive = false; + AppLogger.Warn("Appearance.WallpaperMonitor", "Failed to subscribe to native wallpaper change events; polling will remain active.", ex); + } + } + + [SupportedOSPlatform("windows")] + private void UnregisterNativeWallpaperEventsCore() + { + try + { + SystemEvents.UserPreferenceChanged -= OnNativeWallpaperPreferenceChanged; + } + catch + { + // Ignore shutdown-time native event cleanup failures. + } + } + + private void ConfigureWallpaperPolling(int intervalSeconds) + { + var normalizedInterval = Math.Clamp(intervalSeconds <= 0 ? 300 : intervalSeconds, 30, 86400); + var interval = TimeSpan.FromSeconds(normalizedInterval); + _systemWallpaperPollTimer ??= new Timer(OnSystemWallpaperPollTimer); + _systemWallpaperPollTimer.Change(interval, interval); + _wallpaperPollingActive = true; + } + + private void StopSystemWallpaperMonitoring() + { + UnregisterNativeWallpaperEvents(); + _systemWallpaperPollTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + _wallpaperPollingActive = false; + _lastObservedWallpaperSourceKey = null; + } + + private void OnNativeWallpaperPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) + { + _ = sender; + + if (!OperatingSystem.IsWindows()) + { + return; + } + + if (e.Category is UserPreferenceCategory.Desktop or UserPreferenceCategory.General) + { + RefreshWallpaperColors(); + } + } + + private void OnSystemWallpaperPollTimer(object? state) + { + _ = state; + + try + { + var source = _wallpaperColorPipeline.ResolveSource(_settingsFacade.Wallpaper.Get(), _liveThemeWallpaperColorSource); + var sourceKey = source.SourceKey; + if (string.Equals(_lastObservedWallpaperSourceKey, sourceKey, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _lastObservedWallpaperSourceKey = sourceKey; + RefreshWallpaperColors(); + } + catch (Exception ex) + { + AppLogger.Warn("Appearance.WallpaperMonitor", "Failed to poll wallpaper color source.", ex); + } + } + + private void UpdateObservedWallpaperSourceKey() + { + try + { + _lastObservedWallpaperSourceKey = _wallpaperColorPipeline.ResolveSource( + _settingsFacade.Wallpaper.Get(), + _liveThemeWallpaperColorSource).SourceKey; + } + catch + { + _lastObservedWallpaperSourceKey = null; + } + } + + +} diff --git a/LanMountainDesktop/Services/MaterialSurfaceService.cs b/LanMountainDesktop/Services/MaterialSurfaceService.cs new file mode 100644 index 0000000..814ad94 --- /dev/null +++ b/LanMountainDesktop/Services/MaterialSurfaceService.cs @@ -0,0 +1,144 @@ +using System.Linq; +using Avalonia.Media; +using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Theme; + +namespace LanMountainDesktop.Services; + +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.ResolveEffectiveSystemMaterialMode(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) + { + if (role == MaterialSurfaceRole.SettingsWindowBackground) + { + return materialMode switch + { + ThemeAppearanceValues.MaterialAcrylic => ( + 0.20, + 0.14, + isNightMode ? (byte)0x8E : (byte)0x96, + 0), + ThemeAppearanceValues.MaterialMica => ( + 0.14, + 0.08, + isNightMode ? (byte)0x9E : (byte)0xA6, + 0), + _ => (0.08, 0.05, (byte)0xFF, 0) + }; + } + + var isOverlay = role is MaterialSurfaceRole.DockBackground or MaterialSurfaceRole.StatusBarBackground or MaterialSurfaceRole.OverlayPanel; + return materialMode switch + { + 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") + }; + } +} diff --git a/LanMountainDesktop/Services/NotificationService.cs b/LanMountainDesktop/Services/NotificationService.cs index 500c861..1fdb324 100644 --- a/LanMountainDesktop/Services/NotificationService.cs +++ b/LanMountainDesktop/Services/NotificationService.cs @@ -94,13 +94,18 @@ public interface INotificationService internal sealed class NotificationService : INotificationService { - private readonly IAppearanceThemeService? _appearanceThemeService; + private readonly IMaterialColorService _materialColorService; private readonly NotificationWindowManager _windowManager; - public NotificationService(IAppearanceThemeService? appearanceThemeService = null) + public NotificationService( + IAppearanceThemeService? appearanceThemeService = null, + IMaterialColorService? materialColorService = null) { - _appearanceThemeService = appearanceThemeService; + _materialColorService = materialColorService + ?? appearanceThemeService as IMaterialColorService + ?? HostMaterialColorProvider.GetOrCreate(); _windowManager = NotificationWindowManager.Instance; + _materialColorService.MaterialColorChanged += OnMaterialColorChanged; } public void Show(NotificationContent content) @@ -122,7 +127,7 @@ internal sealed class NotificationService : INotificationService private void ShowDialogWindow(NotificationContent content) { var window = new NotificationDialogWindow(); - window.Initialize(content, _appearanceThemeService); + window.Initialize(content, _materialColorService.GetMaterialColorSnapshot()); Screen? screen = null; if (Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) @@ -223,6 +228,7 @@ internal sealed class NotificationService : INotificationService private void ShowCore(NotificationContent content) { + var materialColorSnapshot = _materialColorService.GetMaterialColorSnapshot(); var viewModel = new NotificationViewModel { Title = content.Title, @@ -253,7 +259,13 @@ internal sealed class NotificationService : INotificationService } } - _windowManager.ShowNotification(viewModel, _appearanceThemeService); + _windowManager.ShowNotification(viewModel, materialColorSnapshot); + } + + private void OnMaterialColorChanged(object? sender, MaterialColorSnapshot snapshot) + { + _ = sender; + _windowManager.ApplyMaterialColorToAllWindows(snapshot); } public void ShowInfo(string title, string? message = null, @@ -346,7 +358,7 @@ internal sealed class NotificationWindowManager } } - public void ShowNotification(NotificationViewModel viewModel, IAppearanceThemeService? themeService) + public void ShowNotification(NotificationViewModel viewModel, MaterialColorSnapshot materialColorSnapshot) { var position = viewModel.Position; var windows = _windowsByPosition[position]; @@ -362,7 +374,7 @@ internal sealed class NotificationWindowManager } var window = new NotificationWindow(); - window.Initialize(viewModel, themeService); + window.Initialize(viewModel, materialColorSnapshot); window.Closed += OnWindowClosed; windows.Add(window); @@ -371,6 +383,23 @@ internal sealed class NotificationWindowManager window.ShowWithAnimationAsync(); } + public void ApplyMaterialColorToAllWindows(MaterialColorSnapshot snapshot) + { + foreach (var windows in _windowsByPosition.Values) + { + foreach (var window in windows.ToList()) + { + try + { + window.ApplyMaterialSnapshot(snapshot); + } + catch + { + } + } + } + } + private void OnWindowClosed(object? sender, EventArgs e) { if (sender is not NotificationWindow window) return; @@ -484,20 +513,4 @@ internal sealed class NotificationWindowManager return null; } - public void ApplyThemeToAllWindows(AppearanceThemeSnapshot snapshot) - { - foreach (var windows in _windowsByPosition.Values) - { - foreach (var window in windows.ToList()) - { - try - { - window.RequestedThemeVariant = snapshot.IsNightMode ? Avalonia.Styling.ThemeVariant.Dark : Avalonia.Styling.ThemeVariant.Light; - } - catch - { - } - } - } - } } diff --git a/LanMountainDesktop/Services/PluginAppearanceSnapshotMapper.cs b/LanMountainDesktop/Services/PluginAppearanceSnapshotMapper.cs index d19397b..3936c01 100644 --- a/LanMountainDesktop/Services/PluginAppearanceSnapshotMapper.cs +++ b/LanMountainDesktop/Services/PluginAppearanceSnapshotMapper.cs @@ -9,6 +9,9 @@ namespace LanMountainDesktop.Services; internal static class PluginAppearanceSnapshotMapper { + /// + /// Normal host-to-plugin appearance mapping for the live material color pipeline. + /// public static PluginAppearanceSnapshot FromMaterialColorSnapshot(MaterialColorSnapshot snapshot) { ArgumentNullException.ThrowIfNull(snapshot); @@ -32,7 +35,11 @@ internal static class PluginAppearanceSnapshotMapper snapshot.WallpaperSeedCandidates.Select(ToText).ToArray()); } - public static PluginAppearanceSnapshot FromAppearanceSnapshot(AppearanceThemeSnapshot snapshot) + /// + /// Compatibility-only mapper for older hosts that still expose + /// instead of the material color pipeline. + /// + public static PluginAppearanceSnapshot FromCompatibilityAppearanceSnapshot(AppearanceThemeSnapshot snapshot) { ArgumentNullException.ThrowIfNull(snapshot); @@ -56,6 +63,15 @@ internal static class PluginAppearanceSnapshotMapper snapshot.WallpaperSeedCandidates.Select(ToText).ToArray()); } + /// + /// Backward-compatible alias for older call sites. Prefer . + /// + [Obsolete("Use FromCompatibilityAppearanceSnapshot instead.")] + public static PluginAppearanceSnapshot FromAppearanceSnapshot(AppearanceThemeSnapshot snapshot) + { + return FromCompatibilityAppearanceSnapshot(snapshot); + } + private static IReadOnlyDictionary BuildColorRoles(MaterialColorSnapshot snapshot) { return new Dictionary(StringComparer.OrdinalIgnoreCase) diff --git a/LanMountainDesktop/Services/Settings/SettingsCatalogService.cs b/LanMountainDesktop/Services/Settings/SettingsCatalogService.cs index 2e2dada..3d0e289 100644 --- a/LanMountainDesktop/Services/Settings/SettingsCatalogService.cs +++ b/LanMountainDesktop/Services/Settings/SettingsCatalogService.cs @@ -16,7 +16,9 @@ internal sealed class SettingsCatalogService : ISettingsCatalog _sections.AddRange( [ new SettingsSectionDefinition("general", SettingsCategories.General, SettingsScope.App, "settings.general.title", iconKey: "Settings", sortOrder: 0), + new SettingsSectionDefinition("material-color", SettingsCategories.Appearance, SettingsScope.App, "settings.material_color.title", iconKey: "Color", sortOrder: 8), new SettingsSectionDefinition("appearance", SettingsCategories.Appearance, SettingsScope.App, "settings.appearance.title", iconKey: "DesignIdeas", sortOrder: 10), + new SettingsSectionDefinition("wallpaper", SettingsCategories.Appearance, SettingsScope.App, "settings.wallpaper.title", iconKey: "Image", sortOrder: 15), new SettingsSectionDefinition("components", SettingsCategories.Components, SettingsScope.ComponentInstance, "settings.components.title", iconKey: "Apps", sortOrder: 20), new SettingsSectionDefinition("plugins", SettingsCategories.Plugins, SettingsScope.Plugin, "settings.plugins.title", iconKey: "PuzzlePiece", sortOrder: 30), new SettingsSectionDefinition("about", SettingsCategories.About, SettingsScope.App, "settings.about.title", iconKey: "Info", sortOrder: 40) diff --git a/LanMountainDesktop/Services/Settings/SettingsContracts.cs b/LanMountainDesktop/Services/Settings/SettingsContracts.cs index 97c4bb3..4374912 100644 --- a/LanMountainDesktop/Services/Settings/SettingsContracts.cs +++ b/LanMountainDesktop/Services/Settings/SettingsContracts.cs @@ -20,11 +20,10 @@ 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, - string? CustomColor = null, + string? WallpaperPath, + string Type, + string? Color, + string Placement, int SystemWallpaperRefreshIntervalSeconds = 300); public sealed record ThemeAppearanceSettingsState( bool IsNightMode, diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs index f5916ea..7aeba68 100644 --- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs +++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs @@ -102,7 +102,6 @@ internal sealed class WallpaperSettingsService : IWallpaperSettingsService normalizedType, snapshot.WallpaperColor, snapshot.WallpaperPlacement, - CustomColor: null, SystemWallpaperRefreshIntervalSeconds: NormalizeRefreshInterval(snapshot.SystemWallpaperRefreshIntervalSeconds)); } diff --git a/LanMountainDesktop/Services/WallpaperColorPipeline.cs b/LanMountainDesktop/Services/WallpaperColorPipeline.cs new file mode 100644 index 0000000..08df2eb --- /dev/null +++ b/LanMountainDesktop/Services/WallpaperColorPipeline.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.Services; + +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 WallpaperColorPipeline +{ + private static readonly Color NeutralFallbackSeedColor = Color.Parse("#FF8A8A8A"); + + private readonly ISettingsFacadeService _settingsFacade; + private readonly ISystemWallpaperProvider _systemWallpaperProvider; + private readonly MonetColorService _monetColorService; + private readonly Action _notifyChanged; + private readonly object _gate = new(); + private readonly Dictionary _seedCache = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _pendingSeedKeys = new(StringComparer.OrdinalIgnoreCase); + + public WallpaperColorPipeline( + ISettingsFacadeService settingsFacade, + ISystemWallpaperProvider systemWallpaperProvider, + MonetColorService monetColorService, + Action notifyChanged) + { + _settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade)); + _systemWallpaperProvider = systemWallpaperProvider ?? throw new ArgumentNullException(nameof(systemWallpaperProvider)); + _monetColorService = monetColorService ?? throw new ArgumentNullException(nameof(monetColorService)); + _notifyChanged = notifyChanged ?? throw new ArgumentNullException(nameof(notifyChanged)); + } + + public void Clear() + { + lock (_gate) + { + _seedCache.Clear(); + _pendingSeedKeys.Clear(); + } + } + + public WallpaperPaletteResolution Resolve( + bool nightMode, + WallpaperSettingsState wallpaperState, + string wallpaperColorSource, + string? selectedWallpaperSeed, + bool queueWallpaperPaletteBuild) + { + var source = ResolveSource(wallpaperState, wallpaperColorSource); + if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase)) + { + return BuildFallbackResolution(nightMode, source.ResolvedWallpaperPath); + } + + if (string.Equals(source.SourceKind, "app_solid", StringComparison.OrdinalIgnoreCase)) + { + var candidates = source.SolidColor is { } solidColor + ? new[] { solidColor } + : []; + return BuildResolution(nightMode, source, candidates, selectedWallpaperSeed); + } + + lock (_gate) + { + if (_seedCache.TryGetValue(source.SourceKey, out var cachedSeedResult)) + { + if (cachedSeedResult.SeedCandidates.Count > 0) + { + return BuildResolution( + nightMode, + source with + { + SourceKind = cachedSeedResult.SourceKind, + ResolvedWallpaperPath = cachedSeedResult.ResolvedWallpaperPath + }, + cachedSeedResult.SeedCandidates, + selectedWallpaperSeed); + } + + return BuildFallbackResolution(nightMode, cachedSeedResult.ResolvedWallpaperPath); + } + } + + if (queueWallpaperPaletteBuild) + { + QueueSeedExtraction(source); + } + + return BuildFallbackResolution(nightMode, source.ResolvedWallpaperPath); + } + + public WallpaperSeedSourceDescriptor ResolveSource( + WallpaperSettingsState wallpaperState, + string wallpaperColorSource) + { + var normalizedWallpaperColorSource = ThemeAppearanceValues.NormalizeWallpaperColorSource(wallpaperColorSource); + + if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceSystem && + 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 (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceSystem && + !string.IsNullOrWhiteSpace(wallpaperPath) && + File.Exists(wallpaperPath) && + appWallpaperMediaType == WallpaperMediaType.Image) + { + return new WallpaperSeedSourceDescriptor( + "app_wallpaper", + CreateWallpaperSourceKey("app_wallpaper", wallpaperPath), + wallpaperPath, + wallpaperPath, + null); + } + + if (normalizedWallpaperColorSource == ThemeAppearanceValues.WallpaperColorSourceApp) + { + return new WallpaperSeedSourceDescriptor( + "fallback", + "fallback", + null, + null, + null); + } + + var systemWallpaper = _systemWallpaperProvider.GetWallpaperPath(); + if (normalizedWallpaperColorSource != ThemeAppearanceValues.WallpaperColorSourceApp && + !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 QueueSeedExtraction(WallpaperSeedSourceDescriptor source) + { + if (string.Equals(source.SourceKind, "fallback", StringComparison.OrdinalIgnoreCase) || + string.Equals(source.SourceKind, "app_solid", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + lock (_gate) + { + if (_pendingSeedKeys.Contains(source.SourceKey)) + { + return; + } + + _pendingSeedKeys.Add(source.SourceKey); + } + + _ = Task.Run(() => + { + WallpaperSeedExtractionResult? extractionResult = null; + + try + { + extractionResult = ExtractSeedCandidates(source); + } + catch (Exception ex) + { + AppLogger.Warn( + "Appearance.WallpaperSeed", + $"Failed to build wallpaper seed candidates asynchronously. Source='{source.SourceKind}'; Path='{source.FilePath}'.", + ex); + } + finally + { + lock (_gate) + { + _pendingSeedKeys.Remove(source.SourceKey); + if (extractionResult is not null) + { + _seedCache[source.SourceKey] = extractionResult; + } + } + } + + if (extractionResult is not null) + { + _notifyChanged(false); + } + }); + } + + private WallpaperSeedExtractionResult ExtractSeedCandidates(WallpaperSeedSourceDescriptor source) + { + IReadOnlyList seedCandidates = source.SourceKind switch + { + "app_wallpaper" or "system_wallpaper" => ExtractImageSeedCandidates(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 WallpaperPaletteResolution BuildResolution( + 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 BuildFallbackResolution(bool nightMode, string? resolvedWallpaperPath) + { + var palette = _monetColorService.BuildPaletteFromSeedCandidates([], nightMode, NeutralFallbackSeedColor); + return new WallpaperPaletteResolution( + palette, + [], + "fallback", + palette.Seed, + resolvedWallpaperPath); + } + + 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()); + } +} diff --git a/LanMountainDesktop/Services/WindowMaterialService.cs b/LanMountainDesktop/Services/WindowMaterialService.cs new file mode 100644 index 0000000..b98fe91 --- /dev/null +++ b/LanMountainDesktop/Services/WindowMaterialService.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using Avalonia.Media; +using LanMountainDesktop.Services.Settings; +using Microsoft.Win32; + +namespace LanMountainDesktop.Services; + +internal sealed class WindowMaterialService : IWindowMaterialService +{ + private const int Windows11Build = 22000; + private const int Windows11_24H2Build = 26100; + + public bool CanChangeMode => GetAvailableModes().Count > 1; + + public IReadOnlyList GetAvailableModes() + { + return GetSupportProfile() switch + { + WindowMaterialSupportProfile.FullSwitching => + [ + ThemeAppearanceValues.MaterialAuto, + ThemeAppearanceValues.MaterialNone, + ThemeAppearanceValues.MaterialMica, + ThemeAppearanceValues.MaterialAcrylic + ], + WindowMaterialSupportProfile.FixedMica => + [ + ThemeAppearanceValues.MaterialAuto, + ThemeAppearanceValues.MaterialNone, + ThemeAppearanceValues.MaterialMica + ], + WindowMaterialSupportProfile.FixedAcrylic => + [ + ThemeAppearanceValues.MaterialAuto, + ThemeAppearanceValues.MaterialNone, + ThemeAppearanceValues.MaterialAcrylic + ], + _ => + [ + ThemeAppearanceValues.MaterialAuto, + ThemeAppearanceValues.MaterialNone + ] + }; + } + + public void Apply(Window window, string materialMode) + { + ArgumentNullException.ThrowIfNull(window); + + var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode); + var supportProfile = GetSupportProfile(); + var effectiveMode = normalizedMode == ThemeAppearanceValues.MaterialAuto + ? ResolveAutoMaterialMode(supportProfile) + : normalizedMode; + + if (effectiveMode == ThemeAppearanceValues.MaterialNone) + { + window.Background = Brushes.White; + window.TransparencyLevelHint = [WindowTransparencyLevel.None]; + return; + } + + window.Background = Brushes.Transparent; + + if (supportProfile == WindowMaterialSupportProfile.NoneOnly) + { + window.TransparencyLevelHint = + [ + WindowTransparencyLevel.None + ]; + return; + } + + window.TransparencyLevelHint = normalizedMode == ThemeAppearanceValues.MaterialAuto + ? ResolveAutoTransparencyLevels(supportProfile) + : effectiveMode switch + { + ThemeAppearanceValues.MaterialMica => + [ + WindowTransparencyLevel.Mica, + WindowTransparencyLevel.Blur, + WindowTransparencyLevel.None + ], + ThemeAppearanceValues.MaterialAcrylic => + [ + WindowTransparencyLevel.AcrylicBlur, + WindowTransparencyLevel.Blur, + WindowTransparencyLevel.None + ], + _ => + [ + WindowTransparencyLevel.None + ] + }; + } + + private static string ResolveAutoMaterialMode(WindowMaterialSupportProfile supportProfile) + { + return supportProfile switch + { + WindowMaterialSupportProfile.FullSwitching or WindowMaterialSupportProfile.FixedMica => + ThemeAppearanceValues.MaterialMica, + WindowMaterialSupportProfile.FixedAcrylic => + ThemeAppearanceValues.MaterialAcrylic, + _ => ThemeAppearanceValues.MaterialNone + }; + } + + private static IReadOnlyList ResolveAutoTransparencyLevels(WindowMaterialSupportProfile supportProfile) + { + return supportProfile switch + { + WindowMaterialSupportProfile.FullSwitching or WindowMaterialSupportProfile.FixedMica => + [ + WindowTransparencyLevel.Mica, + WindowTransparencyLevel.AcrylicBlur, + WindowTransparencyLevel.Blur, + WindowTransparencyLevel.None + ], + WindowMaterialSupportProfile.FixedAcrylic => + [ + WindowTransparencyLevel.AcrylicBlur, + WindowTransparencyLevel.Blur, + WindowTransparencyLevel.None + ], + _ => + [ + WindowTransparencyLevel.None + ] + }; + } + + private static bool IsTransparencyEnabled() + { + if (!OperatingSystem.IsWindows()) + { + 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 + } +} diff --git a/LanMountainDesktop/ViewModels/MaterialColorSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/MaterialColorSettingsPageViewModel.cs index c214cb0..1a45ec7 100644 --- a/LanMountainDesktop/ViewModels/MaterialColorSettingsPageViewModel.cs +++ b/LanMountainDesktop/ViewModels/MaterialColorSettingsPageViewModel.cs @@ -542,17 +542,17 @@ public sealed partial class MaterialColorSettingsPageViewModel : ViewModelBase SourceStatusHeader = L("settings.material_color.source_status.header", "Resolved source"); SemanticColorsHeader = L("settings.material_color.semantic.header", "Semantic colors"); SurfacesHeader = L("settings.material_color.surfaces.header", "Material surfaces"); - WallpaperSeedCurrentText = L("settings.appearance.preview.wallpaper_current", "Current"); - ModeNeutralText = L("settings.appearance.theme_color_mode.neutral", "Default neutral"); - ModeCustomText = L("settings.appearance.theme_color_mode.user", "User theme color Monet"); - ModeWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet"); + WallpaperSeedCurrentText = L("settings.material_color.preview.wallpaper_current", "Current"); + ModeNeutralText = L("settings.material_color.theme_color_mode.neutral", "Default neutral"); + ModeCustomText = L("settings.material_color.theme_color_mode.user", "User theme color Monet"); + ModeWallpaperText = L("settings.material_color.theme_color_mode.wallpaper", "Wallpaper Monet"); WallpaperSourceAutoText = L("settings.material_color.wallpaper_source.auto", "Auto"); WallpaperSourceAppText = L("settings.material_color.wallpaper_source.app", "App wallpaper"); WallpaperSourceSystemText = L("settings.material_color.wallpaper_source.system", "System wallpaper"); - MaterialNoneText = L("settings.appearance.system_material.none", "None"); - MaterialAutoText = L("settings.appearance.system_material.auto", "Auto"); - MaterialMicaText = L("settings.appearance.system_material.mica", "Mica"); - MaterialAcrylicText = L("settings.appearance.system_material.acrylic", "Acrylic"); + MaterialNoneText = L("settings.material_color.system_material.none", "None"); + MaterialAutoText = L("settings.material_color.system_material.auto", "Auto"); + MaterialMicaText = L("settings.material_color.system_material.mica", "Mica"); + MaterialAcrylicText = L("settings.material_color.system_material.acrylic", "Acrylic"); } private string L(string key, string fallback) diff --git a/LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs index d4a122f..96a7047 100644 --- a/LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs +++ b/LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs @@ -20,6 +20,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase private readonly LocalizationService _localizationService = new(); private readonly string _languageCode; private bool _isInitializing; + private int _systemWallpaperRefreshIntervalSeconds = 300; public WallpaperSettingsPageViewModel(ISettingsFacadeService settingsFacade) { @@ -28,7 +29,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); WallpaperPlacements = CreateWallpaperPlacements(); WallpaperTypes = CreateWallpaperTypes(); - RefreshIntervals = CreateRefreshIntervals(); PresetColors = CreatePresetColors(); RefreshLocalizedText(); @@ -39,7 +39,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase public IReadOnlyList WallpaperPlacements { get; } public IReadOnlyList WallpaperTypes { get; } - public IReadOnlyList RefreshIntervals { get; } public IReadOnlyList PresetColors { get; } public bool IsSystemWallpaperSupported => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); @@ -56,9 +55,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase [ObservableProperty] private SelectionOption _selectedWallpaperPlacement = null!; - [ObservableProperty] - private SelectionOption _selectedRefreshInterval = null!; - [ObservableProperty] private string _wallpaperHeader = string.Empty; @@ -83,18 +79,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _filePickerTitle = string.Empty; - [ObservableProperty] - private string _systemWallpaperLabel = string.Empty; - - [ObservableProperty] - private string _refreshIntervalLabel = string.Empty; - - [ObservableProperty] - private string _refreshButtonTooltip = string.Empty; - - [ObservableProperty] - private string _systemWallpaperStatus = string.Empty; - [ObservableProperty] private bool _isImageOrVideo; @@ -126,6 +110,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase SelectedWallpaperType = WallpaperTypes.FirstOrDefault(t => t.Value == wallpaper.Type) ?? WallpaperTypes[0]; SelectedColor = wallpaper.Color ?? PresetColors[0]; + UpdateCustomColorPreview(SelectedColor); var wallpaperPlacement = string.IsNullOrWhiteSpace(wallpaper.Placement) ? "Fill" @@ -134,20 +119,10 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase string.Equals(option.Value, wallpaperPlacement, StringComparison.OrdinalIgnoreCase)) ?? WallpaperPlacements[0]; - var refreshIntervalSeconds = wallpaper.SystemWallpaperRefreshIntervalSeconds; - SelectedRefreshInterval = RefreshIntervals.FirstOrDefault(option => - GetIntervalSeconds(option.Value) == refreshIntervalSeconds) - ?? RefreshIntervals[2]; - - if (!string.IsNullOrWhiteSpace(wallpaper.CustomColor) && Color.TryParse(wallpaper.CustomColor, out var customColor)) - { - CustomColor = customColor; - CustomColorBrush = new SolidColorBrush(customColor); - } + _systemWallpaperRefreshIntervalSeconds = wallpaper.SystemWallpaperRefreshIntervalSeconds; UpdateVisibility(); UpdatePreviewFromCurrentSelection(); - UpdateSystemWallpaperStatus(); } partial void OnSelectedWallpaperTypeChanged(SelectionOption value) @@ -168,6 +143,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase partial void OnSelectedColorChanged(string? value) { + UpdateCustomColorPreview(value); if (_isInitializing) return; SaveWallpaper(); } @@ -181,12 +157,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase SaveWallpaper(); } - partial void OnSelectedRefreshIntervalChanged(SelectionOption value) - { - if (_isInitializing) return; - SaveWallpaper(); - } - public async Task ImportWallpaperAsync(string sourcePath) { var importedPath = await _settingsFacade.WallpaperMedia.ImportAssetAsync(sourcePath); @@ -227,11 +197,9 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase if (string.IsNullOrWhiteSpace(systemPath)) { ClearPreviewImage(); - SystemWallpaperStatus = L("settings.wallpaper.system.unavailable", "Unable to read system wallpaper"); return; } - SystemWallpaperStatus = systemPath; UpdatePreviewImage(systemPath); } @@ -270,16 +238,13 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase previousPreview?.Dispose(); } - private void UpdateSystemWallpaperStatus() + private void UpdateCustomColorPreview(string? value) { - if (!IsSystemWallpaper) return; - UpdateSystemWallpaperPreview(); - } - - [RelayCommand] - private void RefreshSystemWallpaper() - { - UpdateSystemWallpaperPreview(); + if (!string.IsNullOrWhiteSpace(value) && Color.TryParse(value, out var parsed)) + { + CustomColor = parsed; + CustomColorBrush = new SolidColorBrush(parsed); + } } partial void OnSelectedWallpaperPlacementChanged(SelectionOption value) @@ -303,8 +268,10 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase { var selectedType = SelectedWallpaperType?.Value ?? "Image"; var selectedPlacement = SelectedWallpaperPlacement?.Value ?? WallpaperImageBrushFactory.Fill; - var refreshIntervalSeconds = GetIntervalSeconds(SelectedRefreshInterval?.Value); - + var selectedColor = string.IsNullOrWhiteSpace(SelectedColor) + ? PresetColors[0] + : SelectedColor.Trim(); + string? normalizedPath; if (selectedType == "SolidColor" || selectedType == "SystemWallpaper") { @@ -315,34 +282,12 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase normalizedPath = string.IsNullOrWhiteSpace(WallpaperPath) ? null : WallpaperPath; } - var customColorHex = $"#{CustomColor.A:X2}{CustomColor.R:X2}{CustomColor.G:X2}{CustomColor.B:X2}"; _settingsFacade.Wallpaper.Save(new WallpaperSettingsState( normalizedPath, selectedType, - SelectedColor, + selectedColor, selectedPlacement, - customColorHex, - refreshIntervalSeconds)); - } - - private static int GetIntervalSeconds(string? value) - { - return value switch - { - "30s" => 30, - "1m" => 60, - "5m" => 300, - "10m" => 600, - "15m" => 900, - "30m" => 1800, - "1h" => 3600, - "2h" => 7200, - "4h" => 14400, - "8h" => 28800, - "12h" => 43200, - "24h" => 86400, - _ => 300 - }; + _systemWallpaperRefreshIntervalSeconds)); } private IReadOnlyList CreateWallpaperPlacements() @@ -373,25 +318,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase return types; } - private IReadOnlyList CreateRefreshIntervals() - { - return - [ - new SelectionOption("30s", L("settings.wallpaper.refresh.30s", "30 seconds")), - new SelectionOption("1m", L("settings.wallpaper.refresh.1m", "1 minute")), - new SelectionOption("5m", L("settings.wallpaper.refresh.5m", "5 minutes")), - new SelectionOption("10m", L("settings.wallpaper.refresh.10m", "10 minutes")), - new SelectionOption("15m", L("settings.wallpaper.refresh.15m", "15 minutes")), - new SelectionOption("30m", L("settings.wallpaper.refresh.30m", "30 minutes")), - new SelectionOption("1h", L("settings.wallpaper.refresh.1h", "1 hour")), - new SelectionOption("2h", L("settings.wallpaper.refresh.2h", "2 hours")), - new SelectionOption("4h", L("settings.wallpaper.refresh.4h", "4 hours")), - new SelectionOption("8h", L("settings.wallpaper.refresh.8h", "8 hours")), - new SelectionOption("12h", L("settings.wallpaper.refresh.12h", "12 hours")), - new SelectionOption("24h", L("settings.wallpaper.refresh.24h", "24 hours")) - ]; - } - private IReadOnlyList CreatePresetColors() { return @@ -412,9 +338,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase WallpaperPlacementDescription = L("settings.wallpaper.placement_desc", "Adjust how the image fills the desktop."); ImportWallpaperButtonText = L("settings.wallpaper.pick_button", "Import Wallpaper"); FilePickerTitle = L("filepicker.title", "Select wallpaper"); - SystemWallpaperLabel = L("settings.wallpaper.system.label", "System Wallpaper"); - RefreshIntervalLabel = L("settings.wallpaper.refresh_interval", "Refresh Interval"); - RefreshButtonTooltip = L("settings.wallpaper.refresh_now", "Refresh Now"); } private string L(string key, string fallback) diff --git a/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs b/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs index 62471ea..fa86bd8 100644 --- a/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs +++ b/LanMountainDesktop/Views/NotificationDialogWindow.axaml.cs @@ -9,6 +9,7 @@ using Avalonia.Styling; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using FluentIcons.Avalonia; +using LanMountainDesktop.Models; using LanMountainDesktop.Services; namespace LanMountainDesktop.Views; @@ -27,25 +28,32 @@ public partial class NotificationDialogWindow : Window } public void Initialize(NotificationContent content, IAppearanceThemeService? themeService = null) + { + Initialize(content, themeService is IMaterialColorService materialColorService + ? materialColorService.GetMaterialColorSnapshot() + : themeService is null + ? null + : HostMaterialColorProvider.GetOrCreate().GetMaterialColorSnapshot()); + } + + public void Initialize(NotificationContent content, MaterialColorSnapshot? materialColorSnapshot) { _viewModel = new NotificationDialogViewModel(content, this); DataContext = _viewModel; CompletionSource = new TaskCompletionSource(); - bool isNightMode = false; - if (themeService is not null) + if (materialColorSnapshot is not null) { - var snapshot = themeService.GetCurrent(); - isNightMode = snapshot.IsNightMode; - RequestedThemeVariant = isNightMode ? ThemeVariant.Dark : ThemeVariant.Light; + RequestedThemeVariant = materialColorSnapshot.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; } - if (DialogCard is not null) + if (DialogCard is not null && materialColorSnapshot is not null) { - DialogCard.Background = isNightMode - ? new SolidColorBrush(Color.Parse("#FF2D2D2D")) - : new SolidColorBrush(Color.Parse("#FFF8F9FA")); + var cardSurface = GetDialogSurface(materialColorSnapshot); + DialogCard.Background = new SolidColorBrush(cardSurface.BackgroundColor); + DialogCard.BorderBrush = new SolidColorBrush(cardSurface.BorderColor); + DialogCard.BorderThickness = new Thickness(1); } if (!HasButtons(content) && content.Duration.HasValue) @@ -59,6 +67,20 @@ public partial class NotificationDialogWindow : Window } } + private static MaterialSurfaceSnapshot GetDialogSurface(MaterialColorSnapshot materialColorSnapshot) + { + return materialColorSnapshot.Surfaces.TryGetValue(MaterialSurfaceRole.OverlayPanel, out var overlaySurface) + ? overlaySurface + : materialColorSnapshot.Surfaces.TryGetValue(MaterialSurfaceRole.WindowBackground, out var windowSurface) + ? windowSurface + : new MaterialSurfaceSnapshot( + MaterialSurfaceRole.WindowBackground, + Color.Parse("#FFF8F9FA"), + Color.Parse("#22000000"), + 0, + 1); + } + private static bool HasButtons(NotificationContent content) { return !string.IsNullOrEmpty(content.PrimaryButtonText) || diff --git a/LanMountainDesktop/Views/NotificationWindow.axaml.cs b/LanMountainDesktop/Views/NotificationWindow.axaml.cs index 2e0ec64..ab8badd 100644 --- a/LanMountainDesktop/Views/NotificationWindow.axaml.cs +++ b/LanMountainDesktop/Views/NotificationWindow.axaml.cs @@ -8,6 +8,7 @@ using Avalonia.Input; using Avalonia.Media; using Avalonia.Styling; using Avalonia.Threading; +using LanMountainDesktop.Models; using LanMountainDesktop.Services; using LanMountainDesktop.Theme; using LanMountainDesktop.ViewModels; @@ -31,42 +32,58 @@ public partial class NotificationWindow : Window } public void Initialize(NotificationViewModel viewModel, IAppearanceThemeService? themeService = null) + { + Initialize(viewModel, themeService is IMaterialColorService materialColorService + ? materialColorService.GetMaterialColorSnapshot() + : themeService is null + ? null + : HostMaterialColorProvider.GetOrCreate().GetMaterialColorSnapshot()); + } + + public void Initialize(NotificationViewModel viewModel, MaterialColorSnapshot? materialColorSnapshot) { _viewModel = viewModel; DataContext = viewModel; - + _remainingDuration = viewModel.Duration; - - ApplyTheme(themeService); + + ApplyTheme(materialColorSnapshot); ApplySeverityColor(); } - private void ApplyTheme(IAppearanceThemeService? themeService) + public void ApplyMaterialSnapshot(MaterialColorSnapshot materialColorSnapshot) { - if (themeService is null) return; + ApplyTheme(materialColorSnapshot); + ApplySeverityColor(); + } - var snapshot = themeService.GetCurrent(); - RequestedThemeVariant = snapshot.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; + private void ApplyTheme(MaterialColorSnapshot? materialColorSnapshot) + { + // Notification windows must always stay transparent, regardless of whether + // we have a live material snapshot. + Background = Brushes.Transparent; + TransparencyLevelHint = [WindowTransparencyLevel.Transparent]; + + if (materialColorSnapshot is null) return; + + RequestedThemeVariant = materialColorSnapshot.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; // Apply glass effect resources directly to window resources // This ensures the notification card has proper background/border colors - var context = CreateThemeContext(snapshot); + var context = CreateThemeContext(materialColorSnapshot); GlassEffectService.ApplyGlassResources(Resources, context); // IMPORTANT: Do NOT call ApplyWindowMaterial for notification windows! // ApplyWindowMaterial sets Background to White when MaterialMode is "None", // which causes the white border around the notification card. - // Notification windows must always have transparent background. - Background = Brushes.Transparent; - TransparencyLevelHint = [WindowTransparencyLevel.Transparent]; } - private ThemeColorContext CreateThemeContext(AppearanceThemeSnapshot snapshot) + private ThemeColorContext CreateThemeContext(MaterialColorSnapshot snapshot) { // Create theme context for glass effect resources // Note: IsLightBackground and IsLightNavBackground are derived from IsNightMode // UseNeutralSurfaces is determined by ThemeColorMode - var useNeutralSurfaces = snapshot.ThemeColorMode == "Neutral"; + var useNeutralSurfaces = snapshot.ThemeColorMode == ThemeAppearanceValues.ColorModeDefaultNeutral; var monetColors = snapshot.WallpaperSeedCandidates; return new ThemeColorContext( diff --git a/LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml index f76fe93..a3d0723 100644 --- a/LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml @@ -3,7 +3,6 @@ xmlns:vm="using:LanMountainDesktop.ViewModels" xmlns:controls="using:LanMountainDesktop.Controls" xmlns:ui="using:FluentAvalonia.UI.Controls" - xmlns:fi="using:FluentIcons.Avalonia" x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage" x:DataType="vm:WallpaperSettingsPageViewModel"> @@ -180,21 +179,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LanMountainDesktop/plugins/PluginLoader.cs b/LanMountainDesktop/plugins/PluginLoader.cs index 74bf1de..503cbed 100644 --- a/LanMountainDesktop/plugins/PluginLoader.cs +++ b/LanMountainDesktop/plugins/PluginLoader.cs @@ -335,6 +335,7 @@ public sealed class PluginLoader RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); + // Legacy compatibility only. Normal plugin appearance snapshots come from IMaterialColorService. RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); RegisterHostService(services, hostServices); @@ -344,20 +345,18 @@ public sealed class PluginLoader private static PluginAppearanceSnapshot BuildAppearanceSnapshot(IServiceProvider? hostServices) { - var defaultSnapshot = new PluginAppearanceSnapshot( - CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24), - ThemeVariant: "Unknown"); + var defaultSnapshot = CreateDefaultAppearanceSnapshot(); try { - if (hostServices?.GetService(typeof(IMaterialColorService)) is IMaterialColorService materialColorService) + if (TryBuildAppearanceSnapshotFromMaterialColorService(hostServices, out var snapshot)) { - return PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(materialColorService.GetMaterialColorSnapshot()); + return snapshot; } - if (hostServices?.GetService(typeof(IAppearanceThemeService)) is IAppearanceThemeService appearanceThemeService) + if (TryBuildCompatibilityAppearanceSnapshotFromAppearanceThemeService(hostServices, out snapshot)) { - return PluginAppearanceSnapshotMapper.FromAppearanceSnapshot(appearanceThemeService.GetCurrent()); + return snapshot; } return defaultSnapshot; @@ -369,6 +368,41 @@ public sealed class PluginLoader } } + private static bool TryBuildAppearanceSnapshotFromMaterialColorService( + IServiceProvider? hostServices, + out PluginAppearanceSnapshot snapshot) + { + snapshot = default!; + if (hostServices?.GetService(typeof(IMaterialColorService)) is not IMaterialColorService materialColorService) + { + return false; + } + + snapshot = PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(materialColorService.GetMaterialColorSnapshot()); + return true; + } + + private static bool TryBuildCompatibilityAppearanceSnapshotFromAppearanceThemeService( + IServiceProvider? hostServices, + out PluginAppearanceSnapshot snapshot) + { + snapshot = default!; + if (hostServices?.GetService(typeof(IAppearanceThemeService)) is not IAppearanceThemeService appearanceThemeService) + { + return false; + } + + snapshot = PluginAppearanceSnapshotMapper.FromCompatibilityAppearanceSnapshot(appearanceThemeService.GetCurrent()); + return true; + } + + private static PluginAppearanceSnapshot CreateDefaultAppearanceSnapshot() + { + return new PluginAppearanceSnapshot( + CornerRadiusTokens: new PluginCornerRadiusTokens(6, 12, 14, 20, 28, 32, 36, 24), + ThemeVariant: "Unknown"); + } + private static void RegisterHostService(IServiceCollection services, IServiceProvider? hostServices) where TService : class { diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs index d73c867..397fa74 100644 --- a/LanMountainDesktop/plugins/PluginRuntimeService.cs +++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs @@ -1103,6 +1103,7 @@ public sealed class PluginRuntimeService : IDisposable if (serviceType == typeof(IAppearanceThemeService)) { + // Compatibility-only. Plugin appearance snapshots are still sourced from the material pipeline. return _appearanceThemeService; }