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