Add material color services, plugin DTOs, and tests

Introduce IPC wire-format appearance DTOs (PluginIsolation.Contracts) and clarify they are distinct from the runtime PluginSdk snapshot. Update PluginSdk comments to document the runtime-facing snapshot shape. Change ComponentColorSchemeHelper to use the HostMaterialColorProvider and add an overload that accepts a MaterialColorSnapshot. Add new services and pipelines (MaterialColorService, MaterialSurfaceService, WindowMaterialService, WallpaperColorPipeline) and refactor AppearanceThemeService to depend on MaterialColorService while removing legacy internal implementations. Add multiple unit tests (ComponentColorSchemeHelper, PluginAppearanceBoundary, SettingsCatalogService, WallpaperSettingsPageViewModel) and update localization resources with new material_color and wallpaper keys.
This commit is contained in:
lincube
2026-05-06 19:33:08 +08:00
parent f8a4bb888c
commit 6b1c738d8c
30 changed files with 2402 additions and 1577 deletions

View File

@@ -1,5 +1,9 @@
namespace LanMountainDesktop.PluginIsolation.Contracts;
/// <summary>
/// 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.
/// </summary>
public sealed record PluginAppearanceSnapshotRequest(string SessionId);
public sealed record PluginMaterialSurfaceSnapshot(
@@ -8,6 +12,10 @@ public sealed record PluginMaterialSurfaceSnapshot(
double BlurRadius,
double Opacity);
/// <summary>
/// Wire-format appearance snapshot exchanged over IPC.
/// Do not treat this as the same type as <c>LanMountainDesktop.PluginSdk.PluginAppearanceSnapshot</c>.
/// </summary>
public sealed record PluginAppearanceSnapshot(
string ThemeVariant,
string? AccentColor = null,
@@ -21,4 +29,7 @@ public sealed record PluginAppearanceSnapshot(
IReadOnlyDictionary<string, PluginMaterialSurfaceSnapshot>? MaterialSurfaces = null,
IReadOnlyList<string>? WallpaperSeedCandidates = null);
/// <summary>
/// Wire notification carrying the IPC appearance snapshot.
/// </summary>
public sealed record PluginAppearanceChangedNotification(PluginAppearanceSnapshot Snapshot);

View File

@@ -1,11 +1,21 @@
namespace LanMountainDesktop.PluginSdk;
/// <remarks>
/// 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
/// <c>LanMountainDesktop.PluginIsolation.Contracts.PluginAppearanceSnapshot</c>.
/// </remarks>
public sealed record PluginMaterialSurfaceSnapshot(
string BackgroundColor,
string BorderColor,
double BlurRadius,
double Opacity);
/// <remarks>
/// Runtime-facing appearance snapshot for plugins. This is not the same contract as the
/// wire-format snapshot in <c>LanMountainDesktop.PluginIsolation.Contracts</c>, even though the
/// type name matches.
/// </remarks>
public sealed record PluginAppearanceSnapshot(
PluginCornerRadiusTokens CornerRadiusTokens,
string ThemeVariant,

View File

@@ -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, MaterialSurfaceSnapshot>
{
[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);
}
}

View File

@@ -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<string, double>(StringComparer.OrdinalIgnoreCase)
{
["component"] = 24,
["sm"] = 8
},
ResourceAliases: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["surface-base"] = "DesignSurfaceBase"
},
SeedColor: "#FF123456",
ColorSource: "custom_seed",
SystemMaterialMode: ThemeAppearanceValues.MaterialMica,
ColorRoles: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["accent"] = "#FF214365",
["primary"] = "#FF315577"
},
MaterialSurfaces: new Dictionary<string, PluginIsolationMaterialSurfaceSnapshot>(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<PluginSdkAppearanceSnapshot>(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<TKey, TValue>(
IReadOnlyDictionary<TKey, TValue>? expected,
IReadOnlyDictionary<TKey, TValue>? 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, MaterialSurfaceSnapshot>
{
[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<Type, object> _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<MaterialColorSnapshot>? 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<AppearanceThemeSnapshot>? 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();
}
}
}

View File

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

View File

@@ -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<string?> ImportAssetAsync(string sourcePath, CancellationToken cancellationToken = default)
{
_ = sourcePath;
_ = cancellationToken;
return Task.FromResult<string?>(null);
}
}
}

View File

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

View File

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

View File

@@ -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": "中央",

View File

@@ -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": "가운데",

View File

@@ -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": "居中",

File diff suppressed because it is too large Load Diff

View File

@@ -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<string>(
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,

View File

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

View File

@@ -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<AppearanceThemeSnapshot>? AppearanceThemeChanged;
public event EventHandler<MaterialColorSnapshot>? 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<Color> 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<MaterialSurfaceRole>()
.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;
}
}
}

View File

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

View File

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

View File

@@ -9,6 +9,9 @@ namespace LanMountainDesktop.Services;
internal static class PluginAppearanceSnapshotMapper
{
/// <summary>
/// Normal host-to-plugin appearance mapping for the live material color pipeline.
/// </summary>
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)
/// <summary>
/// Compatibility-only mapper for older hosts that still expose <see cref="IAppearanceThemeService"/>
/// instead of the material color pipeline.
/// </summary>
public static PluginAppearanceSnapshot FromCompatibilityAppearanceSnapshot(AppearanceThemeSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
@@ -56,6 +63,15 @@ internal static class PluginAppearanceSnapshotMapper
snapshot.WallpaperSeedCandidates.Select(ToText).ToArray());
}
/// <summary>
/// Backward-compatible alias for older call sites. Prefer <see cref="FromCompatibilityAppearanceSnapshot"/>.
/// </summary>
[Obsolete("Use FromCompatibilityAppearanceSnapshot instead.")]
public static PluginAppearanceSnapshot FromAppearanceSnapshot(AppearanceThemeSnapshot snapshot)
{
return FromCompatibilityAppearanceSnapshot(snapshot);
}
private static IReadOnlyDictionary<string, string> BuildColorRoles(MaterialColorSnapshot snapshot)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)

View File

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

View File

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

View File

@@ -102,7 +102,6 @@ internal sealed class WallpaperSettingsService : IWallpaperSettingsService
normalizedType,
snapshot.WallpaperColor,
snapshot.WallpaperPlacement,
CustomColor: null,
SystemWallpaperRefreshIntervalSeconds: NormalizeRefreshInterval(snapshot.SystemWallpaperRefreshIntervalSeconds));
}

View File

@@ -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<Color> SeedCandidates);
internal readonly record struct WallpaperPaletteResolution(
MonetPalette Palette,
IReadOnlyList<Color> 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<bool> _notifyChanged;
private readonly object _gate = new();
private readonly Dictionary<string, WallpaperSeedExtractionResult> _seedCache = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _pendingSeedKeys = new(StringComparer.OrdinalIgnoreCase);
public WallpaperColorPipeline(
ISettingsFacadeService settingsFacade,
ISystemWallpaperProvider systemWallpaperProvider,
MonetColorService monetColorService,
Action<bool> 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<Color> 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<Color> 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<Color> 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<Color> 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());
}
}

View File

@@ -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<string> 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<WindowTransparencyLevel> ResolveAutoTransparencyLevels(WindowMaterialSupportProfile supportProfile)
{
return supportProfile switch
{
WindowMaterialSupportProfile.FullSwitching or WindowMaterialSupportProfile.FixedMica =>
[
WindowTransparencyLevel.Mica,
WindowTransparencyLevel.AcrylicBlur,
WindowTransparencyLevel.Blur,
WindowTransparencyLevel.None
],
WindowMaterialSupportProfile.FixedAcrylic =>
[
WindowTransparencyLevel.AcrylicBlur,
WindowTransparencyLevel.Blur,
WindowTransparencyLevel.None
],
_ =>
[
WindowTransparencyLevel.None
]
};
}
private static bool IsTransparencyEnabled()
{
if (!OperatingSystem.IsWindows())
{
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
}
}

View File

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

View File

@@ -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<SelectionOption> WallpaperPlacements { get; }
public IReadOnlyList<SelectionOption> WallpaperTypes { get; }
public IReadOnlyList<SelectionOption> RefreshIntervals { get; }
public IReadOnlyList<string> 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<SelectionOption> CreateWallpaperPlacements()
@@ -373,25 +318,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
return types;
}
private IReadOnlyList<SelectionOption> 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<string> 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)

View File

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

View File

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

View File

@@ -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">
<ScrollViewer VerticalScrollBarVisibility="Auto">
@@ -180,21 +179,6 @@
</Button>
</UniformGrid>
</StackPanel>
<StackPanel Grid.Column="1"
VerticalAlignment="Center"
Spacing="12"
IsVisible="{Binding IsSystemWallpaper}">
<TextBlock Text="{Binding SystemWallpaperLabel}"
FontSize="14"
FontWeight="SemiBold"
Opacity="0.8" />
<TextBlock Text="{Binding SystemWallpaperStatus}"
FontSize="12"
Opacity="0.7"
TextWrapping="Wrap"
MaxWidth="280" />
</StackPanel>
</Grid>
<Separator Classes="settings-separator"
@@ -242,36 +226,6 @@
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding RefreshIntervalLabel}"
IsVisible="{Binding IsSystemWallpaper}"
Margin="0,4,0,0">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF0168;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<StackPanel Orientation="Horizontal"
Spacing="8">
<ComboBox Width="140"
ItemsSource="{Binding RefreshIntervals}"
SelectedItem="{Binding SelectedRefreshInterval}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Classes="settings-accent-button"
Command="{Binding RefreshSystemWallpaperCommand}"
ToolTip.Tip="{Binding RefreshButtonTooltip}"
VerticalAlignment="Center"
Padding="12,8">
<fi:SymbolIcon Symbol="ArrowSync"
IconVariant="Regular" />
</Button>
</StackPanel>
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding WallpaperPlacementLabel}"
Description="{Binding WallpaperPlacementDescription}"
IsVisible="{Binding IsImage}"
@@ -291,26 +245,6 @@
</ComboBox>
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="{Binding WallpaperPlacementLabel}"
Description="{Binding WallpaperPlacementDescription}"
IsVisible="{Binding IsSystemWallpaper}"
Margin="0,4,0,0">
<ui:FASettingsExpander.IconSource>
<ui:FAFontIconSource Glyph="&#xF01A8;" FontFamily="avares://fluenticons.resources.avalonia/Assets#Seagull Fluent Icons" />
</ui:FASettingsExpander.IconSource>
<ui:FASettingsExpander.Footer>
<ComboBox Width="200"
ItemsSource="{Binding WallpaperPlacements}"
SelectedItem="{Binding SelectedWallpaperPlacement}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:FASettingsExpander.Footer>
</ui:FASettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -335,6 +335,7 @@ public sealed class PluginLoader
RegisterHostService<ISettingsFacadeService>(services, hostServices);
RegisterHostService<ISettingsService>(services, hostServices);
RegisterHostService<ISettingsCatalog>(services, hostServices);
// Legacy compatibility only. Normal plugin appearance snapshots come from IMaterialColorService.
RegisterHostService<IAppearanceThemeService>(services, hostServices);
RegisterHostService<IMaterialColorService>(services, hostServices);
RegisterHostService<IExternalIpcNotificationPublisher>(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<TService>(IServiceCollection services, IServiceProvider? hostServices)
where TService : class
{

View File

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