From 4ded1c1f2060a7bbca3eabd414b8968184a18a7f Mon Sep 17 00:00:00 2001 From: lincube Date: Fri, 27 Feb 2026 19:27:38 +0800 Subject: [PATCH] 0.1.4 --- LanMontainDesktop/Models/MonetPalette.cs | 8 + .../Services/GlassEffectService.cs | 32 + .../Services/MonetColorService.cs | 250 +++++ .../Services/ThemeColorSystemService.cs | 80 ++ LanMontainDesktop/Styles/GlassModule.axaml | 54 + LanMontainDesktop/Theme/AppThemePalette.cs | 14 + LanMontainDesktop/Theme/ThemeColorContext.cs | 9 + .../Views/Components/ClockWidget.axaml | 10 +- LanMontainDesktop/Views/MainWindow.axaml | 615 ++++++++++-- LanMontainDesktop/Views/MainWindow.axaml.cs | 925 +++++++++++++++++- 10 files changed, 1891 insertions(+), 106 deletions(-) create mode 100644 LanMontainDesktop/Models/MonetPalette.cs create mode 100644 LanMontainDesktop/Services/GlassEffectService.cs create mode 100644 LanMontainDesktop/Services/MonetColorService.cs create mode 100644 LanMontainDesktop/Services/ThemeColorSystemService.cs create mode 100644 LanMontainDesktop/Styles/GlassModule.axaml create mode 100644 LanMontainDesktop/Theme/AppThemePalette.cs create mode 100644 LanMontainDesktop/Theme/ThemeColorContext.cs diff --git a/LanMontainDesktop/Models/MonetPalette.cs b/LanMontainDesktop/Models/MonetPalette.cs new file mode 100644 index 0000000..6bdb2d5 --- /dev/null +++ b/LanMontainDesktop/Models/MonetPalette.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; +using Avalonia.Media; + +namespace LanMontainDesktop.Models; + +public sealed record MonetPalette( + IReadOnlyList RecommendedColors, + IReadOnlyList MonetColors); diff --git a/LanMontainDesktop/Services/GlassEffectService.cs b/LanMontainDesktop/Services/GlassEffectService.cs new file mode 100644 index 0000000..6403893 --- /dev/null +++ b/LanMontainDesktop/Services/GlassEffectService.cs @@ -0,0 +1,32 @@ +using Avalonia.Controls; +using Avalonia.Media; + +namespace LanMontainDesktop.Services; + +public static class GlassEffectService +{ + public static void ApplyGlassResources(IResourceDictionary resources, bool isLightBackground) + { + if (isLightBackground) + { + resources["AdaptiveButtonBackgroundBrush"] = new SolidColorBrush(Color.Parse("#80FFFFFF")); + resources["AdaptiveButtonBorderBrush"] = new SolidColorBrush(Color.Parse("#80475569")); + resources["AdaptiveButtonHoverBackgroundBrush"] = new SolidColorBrush(Color.Parse("#B3FFFFFF")); + resources["AdaptiveButtonPressedBackgroundBrush"] = new SolidColorBrush(Color.Parse("#D9F8FAFC")); + resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush(Color.Parse("#A6FFFFFF")); + resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush(Color.Parse("#80475569")); + resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush(Color.Parse("#CCFFFFFF")); + resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush(Color.Parse("#80475569")); + return; + } + + resources["AdaptiveButtonBackgroundBrush"] = new SolidColorBrush(Color.Parse("#66334155")); + resources["AdaptiveButtonBorderBrush"] = new SolidColorBrush(Color.Parse("#80E2E8F0")); + resources["AdaptiveButtonHoverBackgroundBrush"] = new SolidColorBrush(Color.Parse("#88475A74")); + resources["AdaptiveButtonPressedBackgroundBrush"] = new SolidColorBrush(Color.Parse("#AA2A3B55")); + resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush(Color.Parse("#70233448")); + resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush(Color.Parse("#70475569")); + resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush(Color.Parse("#A01E293B")); + resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush(Color.Parse("#80475569")); + } +} diff --git a/LanMontainDesktop/Services/MonetColorService.cs b/LanMontainDesktop/Services/MonetColorService.cs new file mode 100644 index 0000000..294a2c7 --- /dev/null +++ b/LanMontainDesktop/Services/MonetColorService.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using LanMontainDesktop.Models; +using Microsoft.Win32; + +namespace LanMontainDesktop.Services; + +public sealed class MonetColorService +{ + public MonetPalette BuildPalette(Bitmap? wallpaper, bool nightMode) + { + var recommended = BuildRecommendedPalette(nightMode); + var seed = TryExtractSeedColor(wallpaper) ?? TryGetSystemMonetSeedColor() ?? Color.Parse("#FF3B82F6"); + var monet = BuildMonetPalette(seed, nightMode); + return new MonetPalette(recommended, monet); + } + + private static IReadOnlyList BuildRecommendedPalette(bool nightMode) + { + if (nightMode) + { + return + [ + Color.Parse("#FF3B82F6"), + Color.Parse("#FF22C55E"), + Color.Parse("#FFF59E0B"), + Color.Parse("#FFF97316"), + Color.Parse("#FFA855F7"), + Color.Parse("#FFEF4444") + ]; + } + + return + [ + Color.Parse("#FF1D4ED8"), + Color.Parse("#FF15803D"), + Color.Parse("#FFB45309"), + Color.Parse("#FFC2410C"), + Color.Parse("#FF7E22CE"), + Color.Parse("#FFB91C1C") + ]; + } + + private static IReadOnlyList BuildMonetPalette(Color seed, bool nightMode) + { + var (hue, saturation, value) = ToHsv(seed); + var valueBase = nightMode ? Math.Max(0.70, value) : Math.Min(0.72, Math.Max(0.35, value)); + var saturationBase = Math.Clamp(saturation, 0.22, 0.74); + var offsets = new[] { 0d, 16d, -16d, 36d, -36d, 180d }; + var palette = new List(offsets.Length); + + for (var i = 0; i < offsets.Length; i++) + { + var hueShift = NormalizeHue(hue + offsets[i]); + var sat = Math.Clamp(saturationBase + ((i % 2 == 0) ? 0.05 : -0.05), 0.18, 0.86); + var val = Math.Clamp(valueBase + ((i < 3) ? 0.06 : -0.04), 0.32, 0.92); + palette.Add(FromHsv(hueShift, sat, val)); + } + + return palette; + } + + private static Color? TryExtractSeedColor(Bitmap? wallpaper) + { + if (wallpaper is null) + { + return null; + } + + try + { + var sampleWidth = Math.Clamp(wallpaper.PixelSize.Width, 1, 48); + var sampleHeight = Math.Clamp(wallpaper.PixelSize.Height, 1, 48); + + using var scaledBitmap = wallpaper.CreateScaledBitmap( + new PixelSize(sampleWidth, sampleHeight), + BitmapInterpolationMode.MediumQuality); + using var writeable = new WriteableBitmap( + scaledBitmap.PixelSize, + new Vector(96, 96), + PixelFormat.Bgra8888, + AlphaFormat.Premul); + using var framebuffer = writeable.Lock(); + scaledBitmap.CopyPixels(framebuffer, AlphaFormat.Premul); + + var byteCount = framebuffer.RowBytes * framebuffer.Size.Height; + if (byteCount <= 0 || framebuffer.Address == IntPtr.Zero) + { + return null; + } + + var pixelBuffer = new byte[byteCount]; + Marshal.Copy(framebuffer.Address, pixelBuffer, 0, byteCount); + + double bestScore = double.MinValue; + Color? bestColor = null; + + for (var y = 0; y < framebuffer.Size.Height; y++) + { + var rowOffset = y * framebuffer.RowBytes; + for (var x = 0; x < framebuffer.Size.Width; x++) + { + var index = rowOffset + (x * 4); + var alpha = pixelBuffer[index + 3] / 255d; + if (alpha <= 0.15) + { + continue; + } + + var blue = (pixelBuffer[index] / 255d) / alpha; + var green = (pixelBuffer[index + 1] / 255d) / alpha; + var red = (pixelBuffer[index + 2] / 255d) / alpha; + red = Math.Clamp(red, 0, 1); + green = Math.Clamp(green, 0, 1); + blue = Math.Clamp(blue, 0, 1); + + var color = Color.FromRgb( + (byte)Math.Round(red * 255), + (byte)Math.Round(green * 255), + (byte)Math.Round(blue * 255)); + var (_, saturation, value) = ToHsv(color); + var score = (saturation * 1.8) + (value * 0.6); + if (score <= bestScore) + { + continue; + } + + bestScore = score; + bestColor = color; + } + } + + return bestColor; + } + catch + { + return null; + } + } + + private static Color? TryGetSystemMonetSeedColor() + { + if (!OperatingSystem.IsWindows()) + { + return null; + } + + try + { + var value = Registry.GetValue( + @"HKEY_CURRENT_USER\Software\Microsoft\Windows\DWM", + "AccentColor", + null); + if (value is not int accentDword) + { + return null; + } + + var bytes = BitConverter.GetBytes(accentDword); + var blue = bytes[0]; + var green = bytes[1]; + var red = bytes[2]; + return Color.FromRgb(red, green, blue); + } + catch + { + return null; + } + } + + private static (double Hue, double Saturation, double Value) ToHsv(Color color) + { + var red = color.R / 255d; + var green = color.G / 255d; + var blue = color.B / 255d; + var max = Math.Max(red, Math.Max(green, blue)); + var min = Math.Min(red, Math.Min(green, blue)); + var delta = max - min; + + double hue; + if (delta < 0.0001) + { + hue = 0; + } + else if (Math.Abs(max - red) < 0.0001) + { + hue = 60 * (((green - blue) / delta) % 6); + } + else if (Math.Abs(max - green) < 0.0001) + { + hue = 60 * (((blue - red) / delta) + 2); + } + else + { + hue = 60 * (((red - green) / delta) + 4); + } + + hue = NormalizeHue(hue); + var saturation = max <= 0.0001 ? 0 : delta / max; + return (hue, saturation, max); + } + + private static Color FromHsv(double hue, double saturation, double value) + { + hue = NormalizeHue(hue); + saturation = Math.Clamp(saturation, 0, 1); + value = Math.Clamp(value, 0, 1); + + if (saturation <= 0.0001) + { + var gray = (byte)Math.Round(value * 255); + return Color.FromRgb(gray, gray, gray); + } + + var chroma = value * saturation; + var x = chroma * (1 - Math.Abs(((hue / 60d) % 2) - 1)); + var m = value - chroma; + + (double r, double g, double b) = hue switch + { + >= 0 and < 60 => (chroma, x, 0d), + >= 60 and < 120 => (x, chroma, 0d), + >= 120 and < 180 => (0d, chroma, x), + >= 180 and < 240 => (0d, x, chroma), + >= 240 and < 300 => (x, 0d, chroma), + _ => (chroma, 0d, x) + }; + + var red = (byte)Math.Round((r + m) * 255); + var green = (byte)Math.Round((g + m) * 255); + var blue = (byte)Math.Round((b + m) * 255); + return Color.FromRgb(red, green, blue); + } + + private static double NormalizeHue(double hue) + { + hue %= 360; + if (hue < 0) + { + hue += 360; + } + + return hue; + } +} diff --git a/LanMontainDesktop/Services/ThemeColorSystemService.cs b/LanMontainDesktop/Services/ThemeColorSystemService.cs new file mode 100644 index 0000000..cd5c5a0 --- /dev/null +++ b/LanMontainDesktop/Services/ThemeColorSystemService.cs @@ -0,0 +1,80 @@ +using System; +using Avalonia.Controls; +using Avalonia.Media; +using LanMontainDesktop.Theme; + +namespace LanMontainDesktop.Services; + +public static class ThemeColorSystemService +{ + public static void ApplyThemeResources( + IResourceDictionary resources, + ThemeColorContext context) + { + var palette = BuildPalette(context); + + resources["AdaptiveTextPrimaryBrush"] = new SolidColorBrush(palette.TextPrimary); + resources["AdaptiveTextSecondaryBrush"] = new SolidColorBrush(palette.TextSecondary); + resources["AdaptiveTextMutedBrush"] = new SolidColorBrush(palette.TextMuted); + resources["AdaptiveTextAccentBrush"] = new SolidColorBrush(palette.TextAccent); + resources["AdaptiveNavTextBrush"] = new SolidColorBrush(palette.NavText); + resources["AdaptiveNavSelectedTextBrush"] = new SolidColorBrush(palette.NavSelectedText); + resources["AdaptiveNavItemBackgroundBrush"] = new SolidColorBrush(palette.NavItemBackground); + resources["AdaptiveNavItemHoverBackgroundBrush"] = new SolidColorBrush(palette.NavItemHoverBackground); + resources["AdaptiveNavItemSelectedBackgroundBrush"] = new SolidColorBrush(palette.NavItemSelectedBackground); + } + + public static void ApplyThemeResources( + IResourceDictionary resources, + Color accentColor, + bool isLightBackground, + bool isLightNavBackground) + { + ApplyThemeResources(resources, new ThemeColorContext( + accentColor, + isLightBackground, + isLightNavBackground, + !isLightBackground)); + } + + private static AppThemePalette BuildPalette(ThemeColorContext context) + { + var textPrimary = context.IsLightBackground ? Color.Parse("#FF0B1220") : Color.Parse("#FFF8FAFC"); + var textSecondary = context.IsLightBackground ? Color.Parse("#FF1E293B") : Color.Parse("#FFE2E8F0"); + var textMuted = context.IsLightBackground ? Color.Parse("#FF475569") : Color.Parse("#FF94A3B8"); + var textAccent = context.IsLightBackground + ? BlendColor(context.AccentColor, Color.Parse("#FF0B1220"), 0.20) + : BlendColor(context.AccentColor, Color.Parse("#FFFFFFFF"), 0.16); + + var navText = context.IsLightNavBackground ? Color.Parse("#FF0B1220") : Color.Parse("#FFF8FAFC"); + var navSelectedText = Color.Parse("#FFFFFFFF"); + var navItemBackground = context.IsLightNavBackground ? Color.Parse("#40FFFFFF") : Color.Parse("#220F172A"); + var navItemHoverBackground = context.IsLightNavBackground ? Color.Parse("#66E2E8F0") : Color.Parse("#40334155"); + var navItemSelectedBackground = Color.FromArgb( + 0xCC, + context.AccentColor.R, + context.AccentColor.G, + context.AccentColor.B); + + return new AppThemePalette( + textPrimary, + textSecondary, + textMuted, + textAccent, + navText, + navSelectedText, + navItemBackground, + navItemHoverBackground, + navItemSelectedBackground); + } + + private static Color BlendColor(Color from, Color to, double ratio) + { + ratio = Math.Clamp(ratio, 0, 1); + var inverse = 1 - ratio; + var red = (byte)Math.Round((from.R * inverse) + (to.R * ratio)); + var green = (byte)Math.Round((from.G * inverse) + (to.G * ratio)); + var blue = (byte)Math.Round((from.B * inverse) + (to.B * ratio)); + return Color.FromRgb(red, green, blue); + } +} diff --git a/LanMontainDesktop/Styles/GlassModule.axaml b/LanMontainDesktop/Styles/GlassModule.axaml new file mode 100644 index 0000000..494d295 --- /dev/null +++ b/LanMontainDesktop/Styles/GlassModule.axaml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Theme/AppThemePalette.cs b/LanMontainDesktop/Theme/AppThemePalette.cs new file mode 100644 index 0000000..5a64f28 --- /dev/null +++ b/LanMontainDesktop/Theme/AppThemePalette.cs @@ -0,0 +1,14 @@ +using Avalonia.Media; + +namespace LanMontainDesktop.Theme; + +public sealed record AppThemePalette( + Color TextPrimary, + Color TextSecondary, + Color TextMuted, + Color TextAccent, + Color NavText, + Color NavSelectedText, + Color NavItemBackground, + Color NavItemHoverBackground, + Color NavItemSelectedBackground); diff --git a/LanMontainDesktop/Theme/ThemeColorContext.cs b/LanMontainDesktop/Theme/ThemeColorContext.cs new file mode 100644 index 0000000..083334c --- /dev/null +++ b/LanMontainDesktop/Theme/ThemeColorContext.cs @@ -0,0 +1,9 @@ +using Avalonia.Media; + +namespace LanMontainDesktop.Theme; + +public sealed record ThemeColorContext( + Color AccentColor, + bool IsLightBackground, + bool IsLightNavBackground, + bool IsNightMode); diff --git a/LanMontainDesktop/Views/Components/ClockWidget.axaml b/LanMontainDesktop/Views/Components/ClockWidget.axaml index 9666856..a41f36a 100644 --- a/LanMontainDesktop/Views/Components/ClockWidget.axaml +++ b/LanMontainDesktop/Views/Components/ClockWidget.axaml @@ -9,17 +9,17 @@ + CornerRadius="0" + BorderBrush="Transparent" + BorderThickness="0" + Background="Transparent"> + Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" /> diff --git a/LanMontainDesktop/Views/MainWindow.axaml b/LanMontainDesktop/Views/MainWindow.axaml index f9623b5..7883cd8 100644 --- a/LanMontainDesktop/Views/MainWindow.axaml +++ b/LanMontainDesktop/Views/MainWindow.axaml @@ -14,6 +14,7 @@ WindowState="FullScreen" SystemDecorations="None" CanResize="False" + Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Background="#FF020617" Title="LanMontainDesktop"> @@ -21,71 +22,563 @@ + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMontainDesktop/Views/MainWindow.axaml.cs b/LanMontainDesktop/Views/MainWindow.axaml.cs index f890df2..dd38747 100644 --- a/LanMontainDesktop/Views/MainWindow.axaml.cs +++ b/LanMontainDesktop/Views/MainWindow.axaml.cs @@ -1,16 +1,54 @@ using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Platform.Storage; +using Avalonia.Styling; using Avalonia.Threading; +using LanMontainDesktop.Models; +using LanMontainDesktop.Services; +using LanMontainDesktop.Theme; namespace LanMontainDesktop.Views; public partial class MainWindow : Window { + private enum WallpaperPlacement + { + Fill, + Fit, + Stretch, + Center, + Tile + } + + private const int StatusBarRowIndex = 0; private const int MinShortSideCells = 6; private const int MaxShortSideCells = 96; + private const int SettingsTransitionDurationMs = 240; + private const double LightBackgroundLuminanceThreshold = 0.57; + private const double WallpaperPreviewMinWidth = 220; + private const double WallpaperPreviewMinHeight = 140; + private const double WallpaperPreviewMaxHeight = 280; + private readonly record struct GridMetrics(int ColumnCount, int RowCount, double CellSize); + private readonly MonetColorService _monetColorService = new(); private int _targetShortSideCells; + private bool _isSettingsOpen; + private bool _isNightMode; + private bool _suppressThemeToggleEvents; + private TranslateTransform? _settingsContentPanelTransform; + private IBrush? _defaultDesktopBackground; + private Bitmap? _wallpaperBitmap; + private string? _wallpaperPath; + private string _wallpaperStatus = "Current background uses solid color."; + private IReadOnlyList _recommendedColors = Array.Empty(); + private IReadOnlyList _monetColors = Array.Empty(); + private Color _selectedThemeColor = Color.Parse("#FF3B82F6"); public MainWindow() { @@ -24,14 +62,31 @@ public partial class MainWindow : Window _targetShortSideCells = CalculateDefaultShortSideCellCountFromDpi(); GridSizeNumberBox.Value = _targetShortSideCells; + SettingsNavListBox.SelectedIndex = 0; + UpdateSettingsTabContent(); + WallpaperPlacementComboBox.SelectedIndex = 0; + _defaultDesktopBackground = DesktopHost.Background; + UpdateWallpaperDisplay(); + _isNightMode = CalculateCurrentBackgroundLuminance() < LightBackgroundLuminanceThreshold; + ApplyNightModeState(_isNightMode, refreshPalettes: false); + RefreshColorPalettes(); + EnsureSelectedThemeColor(); + UpdateThemeColorSelectionState(); + ThemeColorStatusTextBlock.Text = $"Theme color ready: {_selectedThemeColor}."; + UpdateAdaptiveTextSystem(); + _settingsContentPanelTransform = SettingsContentPanel.RenderTransform as TranslateTransform; DesktopHost.SizeChanged += OnDesktopHostSizeChanged; + WallpaperPreviewHost.SizeChanged += OnWallpaperPreviewHostSizeChanged; RebuildDesktopGrid(); } protected override void OnClosed(EventArgs e) { + _wallpaperBitmap?.Dispose(); + _wallpaperBitmap = null; PropertyChanged -= OnWindowPropertyChanged; DesktopHost.SizeChanged -= OnDesktopHostSizeChanged; + WallpaperPreviewHost.SizeChanged -= OnWallpaperPreviewHostSizeChanged; base.OnClosed(e); } @@ -47,6 +102,11 @@ public partial class MainWindow : Window RebuildDesktopGrid(); } + private void OnWallpaperPreviewHostSizeChanged(object? sender, SizeChangedEventArgs e) + { + UpdateWallpaperPreviewLayout(); + } + private void OnApplyGridSizeClick(object? sender, RoutedEventArgs e) { var requested = (int)Math.Round(GridSizeNumberBox.Value); @@ -67,60 +127,114 @@ public partial class MainWindow : Window private void RebuildDesktopGrid() { - var hostWidth = DesktopHost.Bounds.Width; - var hostHeight = DesktopHost.Bounds.Height; - if (hostWidth <= 1 || hostHeight <= 1) + var gridMetrics = CalculateGridMetrics( + DesktopHost.Bounds.Width, + DesktopHost.Bounds.Height, + _targetShortSideCells); + if (gridMetrics.CellSize <= 0) { return; } - var shortSideCells = Math.Max(1, _targetShortSideCells); - double cellSize; - int columnCount; - int rowCount; + DesktopGrid.RowDefinitions.Clear(); + DesktopGrid.ColumnDefinitions.Clear(); + DesktopGrid.Width = gridMetrics.ColumnCount * gridMetrics.CellSize; + DesktopGrid.Height = gridMetrics.RowCount * gridMetrics.CellSize; - if (hostWidth >= hostHeight) + for (var row = 0; row < gridMetrics.RowCount; row++) { - rowCount = shortSideCells; - cellSize = hostHeight / rowCount; - columnCount = Math.Max(1, (int)Math.Ceiling(hostWidth / cellSize)); + DesktopGrid.RowDefinitions.Add(new RowDefinition(new GridLength(gridMetrics.CellSize, GridUnitType.Pixel))); + } + + for (var col = 0; col < gridMetrics.ColumnCount; col++) + { + DesktopGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(gridMetrics.CellSize, GridUnitType.Pixel))); + } + + PlaceStatusBarComponent(ClockWidget, column: 0, requestedColumnSpan: 3, totalColumns: gridMetrics.ColumnCount); + + var firstDesktopRow = Math.Min(gridMetrics.RowCount - 1, StatusBarRowIndex + 1); + + var settingsColumnSpan = ClampComponentSpan(2, gridMetrics.ColumnCount); + var settingsRowSpan = ClampComponentSpan(1, gridMetrics.RowCount); + var settingsRow = Math.Max(firstDesktopRow, gridMetrics.RowCount - 1); + var settingsColumn = Math.Max(0, gridMetrics.ColumnCount - settingsColumnSpan); + + var backButtonRow = settingsRow; + var backButtonMaxColumnsWithoutOverlap = settingsColumn; + int backButtonColumnSpan; + if (backButtonMaxColumnsWithoutOverlap >= 1) + { + backButtonColumnSpan = ClampComponentSpan(Math.Min(4, backButtonMaxColumnsWithoutOverlap), gridMetrics.ColumnCount); } else { - columnCount = shortSideCells; - cellSize = hostWidth / columnCount; - rowCount = Math.Max(1, (int)Math.Ceiling(hostHeight / cellSize)); + backButtonRow = Math.Max(firstDesktopRow, gridMetrics.RowCount - 2); + backButtonColumnSpan = ClampComponentSpan(Math.Min(4, gridMetrics.ColumnCount), gridMetrics.ColumnCount); } - DesktopGrid.RowDefinitions.Clear(); - DesktopGrid.ColumnDefinitions.Clear(); - DesktopGrid.Width = columnCount * cellSize; - DesktopGrid.Height = rowCount * cellSize; + Grid.SetRow(BackToWindowsContainer, backButtonRow); + Grid.SetColumn(BackToWindowsContainer, 0); + Grid.SetRowSpan(BackToWindowsContainer, ClampComponentSpan(1, gridMetrics.RowCount)); + Grid.SetColumnSpan(BackToWindowsContainer, backButtonColumnSpan); - for (var row = 0; row < rowCount; row++) - { - DesktopGrid.RowDefinitions.Add(new RowDefinition(new GridLength(cellSize, GridUnitType.Pixel))); - } + Grid.SetRow(OpenSettingsButton, settingsRow); + Grid.SetColumn(OpenSettingsButton, settingsColumn); + Grid.SetRowSpan(OpenSettingsButton, settingsRowSpan); + Grid.SetColumnSpan(OpenSettingsButton, settingsColumnSpan); - for (var col = 0; col < columnCount; col++) - { - DesktopGrid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(cellSize, GridUnitType.Pixel))); - } - - Grid.SetRow(ClockWidget, 0); - Grid.SetColumn(ClockWidget, 0); - Grid.SetRowSpan(ClockWidget, 1); - Grid.SetColumnSpan(ClockWidget, Math.Min(3, columnCount)); - - Grid.SetRow(BackToWindowsButton, rowCount - 1); - Grid.SetColumn(BackToWindowsButton, 0); - Grid.SetRowSpan(BackToWindowsButton, 1); - Grid.SetColumnSpan(BackToWindowsButton, Math.Min(4, columnCount)); - - ApplyWidgetSizing(cellSize); + ApplyWidgetSizing(gridMetrics.CellSize); GridInfoTextBlock.Text = - $"Grid: {columnCount} cols x {rowCount} rows | cell {cellSize:F1}px (1:1)"; + $"Grid: {gridMetrics.ColumnCount} cols x {gridMetrics.RowCount} rows | cell {gridMetrics.CellSize:F1}px (1:1)"; + + UpdateWallpaperPreviewLayout(); + } + + private static GridMetrics CalculateGridMetrics(double hostWidth, double hostHeight, int targetShortSideCells) + { + if (hostWidth <= 1 || hostHeight <= 1) + { + return default; + } + + var shortSideCells = Math.Max(1, targetShortSideCells); + if (hostWidth >= hostHeight) + { + var rowCount = shortSideCells; + var cellSize = hostHeight / rowCount; + var columnCount = Math.Max(1, (int)Math.Floor(hostWidth / cellSize)); + return new GridMetrics(columnCount, rowCount, cellSize); + } + + var columns = shortSideCells; + var size = hostWidth / columns; + var rows = Math.Max(1, (int)Math.Floor(hostHeight / size)); + return new GridMetrics(columns, rows, size); + } + + private static int ClampComponentSpan(int requestedSpan, int axisCellCount) + { + return Math.Clamp(requestedSpan, 1, Math.Max(1, axisCellCount)); + } + + private static int ClampGridIndex(int requestedIndex, int axisCellCount) + { + return Math.Clamp(requestedIndex, 0, Math.Max(0, axisCellCount - 1)); + } + + private static void PlaceStatusBarComponent( + Control component, + int column, + int requestedColumnSpan, + int totalColumns) + { + var clampedColumn = ClampGridIndex(column, totalColumns); + var availableColumns = Math.Max(1, totalColumns - clampedColumn); + Grid.SetRow(component, StatusBarRowIndex); + Grid.SetColumn(component, clampedColumn); + Grid.SetRowSpan(component, 1); + Grid.SetColumnSpan(component, ClampComponentSpan(requestedColumnSpan, availableColumns)); } private void ApplyWidgetSizing(double cellSize) @@ -132,9 +246,129 @@ public partial class MainWindow : Window ClockWidget.Margin = new Thickness(margin); ClockWidget.ApplyCellSize(cellSize); - BackToWindowsButton.Margin = new Thickness(margin); + BackToWindowsContainer.Margin = new Thickness(margin); + BackToWindowsContainer.CornerRadius = new CornerRadius(Math.Clamp(cellSize * 0.12, 5, 14)); BackToWindowsButton.Padding = new Thickness(horizontalPadding, verticalPadding); BackToWindowsButton.FontSize = Math.Clamp(cellSize * 0.30, 8, 30); + + OpenSettingsButton.Margin = new Thickness(Math.Clamp(cellSize * 0.12, 6, 16)); + OpenSettingsButton.Padding = new Thickness( + Math.Clamp(horizontalPadding + 2, 8, 26), + Math.Clamp(verticalPadding, 4, 12)); + OpenSettingsButton.FontSize = Math.Clamp(cellSize * 0.22, 9, 22); + } + + private void UpdateWallpaperPreviewLayout() + { + if (WallpaperPreviewFrame is null || + WallpaperPreviewHost is null || + WallpaperPreviewGrid is null) + { + return; + } + + var desktopWidth = Math.Max(1, DesktopHost.Bounds.Width); + var desktopHeight = Math.Max(1, DesktopHost.Bounds.Height); + var aspectRatio = desktopWidth / desktopHeight; + var availableWidth = WallpaperPreviewHost.Bounds.Width - 24; + if (availableWidth <= 1) + { + availableWidth = WallpaperPreviewFrame.Width; + } + + var previewWidth = Math.Max(WallpaperPreviewMinWidth, availableWidth); + var previewHeight = previewWidth / aspectRatio; + + if (previewHeight > WallpaperPreviewMaxHeight) + { + previewHeight = WallpaperPreviewMaxHeight; + previewWidth = previewHeight * aspectRatio; + } + + if (previewHeight < WallpaperPreviewMinHeight) + { + previewHeight = WallpaperPreviewMinHeight; + previewWidth = previewHeight * aspectRatio; + } + + WallpaperPreviewFrame.Width = previewWidth; + WallpaperPreviewFrame.Height = previewHeight; + WallpaperPreviewClockTextBlock.Text = DateTime.Now.ToString("HH:mm"); + + var gridMetrics = CalculateGridMetrics(previewWidth, previewHeight, _targetShortSideCells); + if (gridMetrics.CellSize <= 0) + { + return; + } + + WallpaperPreviewGrid.RowDefinitions.Clear(); + WallpaperPreviewGrid.ColumnDefinitions.Clear(); + WallpaperPreviewGrid.Width = gridMetrics.ColumnCount * gridMetrics.CellSize; + WallpaperPreviewGrid.Height = gridMetrics.RowCount * gridMetrics.CellSize; + + for (var row = 0; row < gridMetrics.RowCount; row++) + { + WallpaperPreviewGrid.RowDefinitions.Add( + new RowDefinition(new GridLength(gridMetrics.CellSize, GridUnitType.Pixel))); + } + + for (var col = 0; col < gridMetrics.ColumnCount; col++) + { + WallpaperPreviewGrid.ColumnDefinitions.Add( + new ColumnDefinition(new GridLength(gridMetrics.CellSize, GridUnitType.Pixel))); + } + + PlaceStatusBarComponent( + WallpaperPreviewClockContainer, + column: 0, + requestedColumnSpan: 3, + totalColumns: gridMetrics.ColumnCount); + + var firstDesktopRow = Math.Min(gridMetrics.RowCount - 1, StatusBarRowIndex + 1); + var settingsColumnSpan = ClampComponentSpan(2, gridMetrics.ColumnCount); + var settingsRow = Math.Max(firstDesktopRow, gridMetrics.RowCount - 1); + var settingsColumn = Math.Max(0, gridMetrics.ColumnCount - settingsColumnSpan); + + var backButtonRow = settingsRow; + var backButtonMaxColumnsWithoutOverlap = settingsColumn; + int backButtonColumnSpan; + if (backButtonMaxColumnsWithoutOverlap >= 1) + { + backButtonColumnSpan = ClampComponentSpan(Math.Min(4, backButtonMaxColumnsWithoutOverlap), gridMetrics.ColumnCount); + } + else + { + backButtonRow = Math.Max(firstDesktopRow, gridMetrics.RowCount - 2); + backButtonColumnSpan = ClampComponentSpan(Math.Min(4, gridMetrics.ColumnCount), gridMetrics.ColumnCount); + } + + Grid.SetRow(WallpaperPreviewBackButtonContainer, backButtonRow); + Grid.SetColumn(WallpaperPreviewBackButtonContainer, 0); + Grid.SetRowSpan(WallpaperPreviewBackButtonContainer, 1); + Grid.SetColumnSpan(WallpaperPreviewBackButtonContainer, backButtonColumnSpan); + + Grid.SetRow(WallpaperPreviewSettingsButtonContainer, settingsRow); + Grid.SetColumn(WallpaperPreviewSettingsButtonContainer, settingsColumn); + Grid.SetRowSpan(WallpaperPreviewSettingsButtonContainer, 1); + Grid.SetColumnSpan(WallpaperPreviewSettingsButtonContainer, settingsColumnSpan); + + ApplyPreviewWidgetSizing(gridMetrics.CellSize); + } + + private void ApplyPreviewWidgetSizing(double cellSize) + { + var margin = Math.Clamp(cellSize * 0.08, 1, 6); + WallpaperPreviewClockContainer.Margin = new Thickness(margin); + WallpaperPreviewBackButtonContainer.Margin = new Thickness(margin); + WallpaperPreviewSettingsButtonContainer.Margin = new Thickness(margin); + + WallpaperPreviewClockTextBlock.FontSize = Math.Clamp(cellSize * 0.30, 6, 18); + WallpaperPreviewBackButtonTextBlock.FontSize = Math.Clamp(cellSize * 0.19, 5, 13); + WallpaperPreviewSettingsButtonTextBlock.FontSize = Math.Clamp(cellSize * 0.19, 5, 13); + + var cornerRadius = new CornerRadius(Math.Clamp(cellSize * 0.12, 3, 10)); + WallpaperPreviewBackButtonContainer.CornerRadius = cornerRadius; + WallpaperPreviewSettingsButtonContainer.CornerRadius = cornerRadius; } private void OnMinimizeClick(object? sender, RoutedEventArgs e) @@ -142,6 +376,617 @@ public partial class MainWindow : Window WindowState = WindowState.Minimized; } + private void OnOpenSettingsClick(object? sender, RoutedEventArgs e) + { + OpenSettingsPage(); + } + + private void OnCloseSettingsClick(object? sender, RoutedEventArgs e) + { + CloseSettingsPage(); + } + + private void OnSettingsNavSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + UpdateSettingsTabContent(); + } + + private void UpdateSettingsTabContent() + { + // SelectionChanged can fire during XAML initialization before all named controls are assigned. + if (SettingsNavListBox is null || + GridSettingsPanel is null || + WallpaperSettingsPanel is null || + ColorSettingsPanel is null) + { + return; + } + + var selectedIndex = SettingsNavListBox.SelectedIndex; + WallpaperSettingsPanel.IsVisible = selectedIndex == 0; + GridSettingsPanel.IsVisible = selectedIndex == 1; + ColorSettingsPanel.IsVisible = selectedIndex == 2; + } + + private void OnNightModeChecked(object? sender, RoutedEventArgs e) + { + if (_suppressThemeToggleEvents) + { + return; + } + + ApplyNightModeState(true, refreshPalettes: true); + } + + private void OnNightModeUnchecked(object? sender, RoutedEventArgs e) + { + if (_suppressThemeToggleEvents) + { + return; + } + + ApplyNightModeState(false, refreshPalettes: true); + } + + private void OnRecommendedColorClick(object? sender, RoutedEventArgs e) + { + ApplyThemeColorFromButton(sender as Button, "Recommended"); + } + + private void OnMonetColorClick(object? sender, RoutedEventArgs e) + { + ApplyThemeColorFromButton(sender as Button, "Monet"); + } + + private void OnRefreshMonetColorsClick(object? sender, RoutedEventArgs e) + { + RefreshColorPalettes(); + EnsureSelectedThemeColor(); + UpdateThemeColorSelectionState(); + ThemeColorStatusTextBlock.Text = "Monet colors refreshed."; + UpdateAdaptiveTextSystem(); + } + + private async void OnPickWallpaperClick(object? sender, RoutedEventArgs e) + { + if (StorageProvider is null) + { + _wallpaperStatus = "Storage provider is unavailable."; + UpdateWallpaperDisplay(); + return; + } + + var options = new FilePickerOpenOptions + { + Title = "Select wallpaper", + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType("Image files") + { + Patterns = ["*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif", "*.webp"] + } + ] + }; + + var files = await StorageProvider.OpenFilePickerAsync(options); + if (files.Count == 0) + { + return; + } + + var file = files[0]; + try + { + Bitmap bitmap; + var localPath = file.TryGetLocalPath(); + if (!string.IsNullOrWhiteSpace(localPath)) + { + bitmap = new Bitmap(localPath); + _wallpaperPath = localPath; + } + else + { + await using var stream = await file.OpenReadAsync(); + bitmap = new Bitmap(stream); + _wallpaperPath = file.Name; + } + + _wallpaperBitmap?.Dispose(); + _wallpaperBitmap = bitmap; + _wallpaperStatus = "Wallpaper applied."; + ApplyWallpaperBrush(); + UpdateWallpaperDisplay(); + RefreshColorPalettes(); + EnsureSelectedThemeColor(); + UpdateThemeColorSelectionState(); + ThemeColorStatusTextBlock.Text = "Wallpaper updated. Monet colors refreshed."; + } + catch (Exception ex) + { + _wallpaperStatus = $"Failed to apply wallpaper: {ex.Message}"; + UpdateWallpaperDisplay(); + } + } + + private void OnClearWallpaperClick(object? sender, RoutedEventArgs e) + { + _wallpaperBitmap?.Dispose(); + _wallpaperBitmap = null; + _wallpaperPath = null; + _wallpaperStatus = "Background reset to solid color."; + ApplyWallpaperBrush(); + UpdateWallpaperDisplay(); + RefreshColorPalettes(); + EnsureSelectedThemeColor(); + UpdateThemeColorSelectionState(); + ThemeColorStatusTextBlock.Text = "Wallpaper cleared. Monet colors refreshed."; + } + + private void OnWallpaperPlacementSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + ApplyWallpaperBrush(); + if (_wallpaperBitmap is not null) + { + _wallpaperStatus = $"Wallpaper mode: {GetPlacementDisplayName(GetSelectedWallpaperPlacement())}."; + } + + UpdateWallpaperDisplay(); + } + + private void ApplyWallpaperBrush() + { + if (_wallpaperBitmap is null) + { + DesktopHost.Background = _defaultDesktopBackground ?? new SolidColorBrush(Color.Parse("#FF020617")); + WallpaperPreviewViewport.Background = _defaultDesktopBackground ?? new SolidColorBrush(Color.Parse("#30111827")); + UpdateAdaptiveTextSystem(); + return; + } + + var placement = GetSelectedWallpaperPlacement(); + DesktopHost.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, false); + WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true); + UpdateAdaptiveTextSystem(); + } + + private void UpdateWallpaperDisplay() + { + if (WallpaperPathTextBlock is null || + WallpaperStatusTextBlock is null || + WallpaperPreviewViewport is null || + WallpaperPlacementComboBox is null) + { + return; + } + + WallpaperPathTextBlock.Text = string.IsNullOrWhiteSpace(_wallpaperPath) + ? "No wallpaper selected." + : _wallpaperPath; + WallpaperStatusTextBlock.Text = _wallpaperStatus; + + if (_wallpaperBitmap is null) + { + WallpaperPreviewViewport.Background = _defaultDesktopBackground ?? new SolidColorBrush(Color.Parse("#30111827")); + return; + } + + WallpaperPreviewViewport.Background = CreateWallpaperBrush( + _wallpaperBitmap, + GetSelectedWallpaperPlacement(), + true); + } + + private ImageBrush CreateWallpaperBrush(Bitmap bitmap, WallpaperPlacement placement, bool forPreview) + { + var brush = new ImageBrush + { + Source = bitmap, + Stretch = Stretch.UniformToFill, + AlignmentX = AlignmentX.Center, + AlignmentY = AlignmentY.Center, + TileMode = TileMode.None + }; + + switch (placement) + { + case WallpaperPlacement.Fill: + brush.Stretch = Stretch.UniformToFill; + break; + case WallpaperPlacement.Fit: + brush.Stretch = Stretch.Uniform; + break; + case WallpaperPlacement.Stretch: + brush.Stretch = Stretch.Fill; + break; + case WallpaperPlacement.Center: + brush.Stretch = Stretch.None; + break; + case WallpaperPlacement.Tile: + brush.Stretch = Stretch.None; + brush.TileMode = TileMode.Tile; + var tileSize = forPreview ? 96d : 220d; + brush.DestinationRect = new RelativeRect(0, 0, tileSize, tileSize, RelativeUnit.Absolute); + break; + } + + return brush; + } + + private WallpaperPlacement GetSelectedWallpaperPlacement() + { + return WallpaperPlacementComboBox?.SelectedIndex switch + { + 1 => WallpaperPlacement.Fit, + 2 => WallpaperPlacement.Stretch, + 3 => WallpaperPlacement.Center, + 4 => WallpaperPlacement.Tile, + _ => WallpaperPlacement.Fill + }; + } + + private static string GetPlacementDisplayName(WallpaperPlacement placement) + { + return placement switch + { + WallpaperPlacement.Fill => "Fill", + WallpaperPlacement.Fit => "Fit", + WallpaperPlacement.Stretch => "Stretch", + WallpaperPlacement.Center => "Center", + WallpaperPlacement.Tile => "Tile", + _ => "Fill" + }; + } + + private void UpdateAdaptiveTextSystem() + { + var luminance = CalculateCurrentBackgroundLuminance(); + var isLightBackground = luminance >= LightBackgroundLuminanceThreshold; + var navBackground = SettingsNavPanelBorder?.Background; + var isLightNavBackground = CalculateBrushLuminance(navBackground) >= LightBackgroundLuminanceThreshold; + var context = new ThemeColorContext( + _selectedThemeColor, + isLightBackground, + isLightNavBackground, + _isNightMode); + + ThemeColorSystemService.ApplyThemeResources(Resources, context); + GlassEffectService.ApplyGlassResources(Resources, context.IsLightBackground); + } + + private double CalculateCurrentBackgroundLuminance() + { + if (_wallpaperBitmap is not null) + { + return CalculateBitmapAverageLuminance(_wallpaperBitmap); + } + + return CalculateBrushLuminance(DesktopHost.Background ?? _defaultDesktopBackground); + } + + private void ApplyNightModeState(bool enabled, bool refreshPalettes) + { + _isNightMode = enabled; + RequestedThemeVariant = enabled ? ThemeVariant.Dark : ThemeVariant.Light; + + _suppressThemeToggleEvents = true; + NightModeToggleSwitch.IsChecked = enabled; + _suppressThemeToggleEvents = false; + ThemeModeStatusTextBlock.Text = enabled ? "Night mode enabled" : "Day mode enabled"; + + if (refreshPalettes) + { + RefreshColorPalettes(); + EnsureSelectedThemeColor(); + } + + UpdateThemeColorSelectionState(); + ThemeColorStatusTextBlock.Text = $"Theme mode: {(enabled ? "Night" : "Day")}."; + UpdateAdaptiveTextSystem(); + } + + private void RefreshColorPalettes() + { + var palette = _monetColorService.BuildPalette(_wallpaperBitmap, _isNightMode); + _recommendedColors = palette.RecommendedColors; + _monetColors = palette.MonetColors; + ApplyColorPaletteToButtons(_recommendedColors, GetRecommendedColorTargets()); + ApplyColorPaletteToButtons(_monetColors, GetMonetColorTargets()); + } + + private void ApplyColorPaletteToButtons( + IReadOnlyList colors, + IReadOnlyList<(Button Button, Border Swatch)> targets) + { + for (var i = 0; i < targets.Count; i++) + { + var color = i < colors.Count + ? colors[i] + : Color.Parse("#00000000"); + var (button, swatch) = targets[i]; + button.Tag = color.ToString(); + button.IsEnabled = i < colors.Count; + swatch.Background = i < colors.Count + ? new SolidColorBrush(color) + : new SolidColorBrush(Color.Parse("#00000000")); + } + } + + private IReadOnlyList<(Button Button, Border Swatch)> GetRecommendedColorTargets() + { + return + [ + (RecommendedColorButton1, RecommendedColorSwatch1), + (RecommendedColorButton2, RecommendedColorSwatch2), + (RecommendedColorButton3, RecommendedColorSwatch3), + (RecommendedColorButton4, RecommendedColorSwatch4), + (RecommendedColorButton5, RecommendedColorSwatch5), + (RecommendedColorButton6, RecommendedColorSwatch6) + ]; + } + + private IReadOnlyList<(Button Button, Border Swatch)> GetMonetColorTargets() + { + return + [ + (MonetColorButton1, MonetColorSwatch1), + (MonetColorButton2, MonetColorSwatch2), + (MonetColorButton3, MonetColorSwatch3), + (MonetColorButton4, MonetColorSwatch4), + (MonetColorButton5, MonetColorSwatch5), + (MonetColorButton6, MonetColorSwatch6) + ]; + } + + private void EnsureSelectedThemeColor() + { + if (ContainsColor(_recommendedColors, _selectedThemeColor) || + ContainsColor(_monetColors, _selectedThemeColor)) + { + return; + } + + if (_recommendedColors.Count > 0) + { + _selectedThemeColor = _recommendedColors[0]; + return; + } + + if (_monetColors.Count > 0) + { + _selectedThemeColor = _monetColors[0]; + } + } + + private void ApplyThemeColorFromButton(Button? button, string sourceLabel) + { + if (!TryGetButtonColor(button, out var color)) + { + return; + } + + _selectedThemeColor = color; + UpdateThemeColorSelectionState(); + ThemeColorStatusTextBlock.Text = $"{sourceLabel} color applied: {_selectedThemeColor}."; + UpdateAdaptiveTextSystem(); + } + + private void UpdateThemeColorSelectionState() + { + UpdateColorSelectionVisuals(GetRecommendedColorTargets()); + UpdateColorSelectionVisuals(GetMonetColorTargets()); + } + + private void UpdateColorSelectionVisuals(IReadOnlyList<(Button Button, Border Swatch)> targets) + { + foreach (var (button, swatch) in targets) + { + var isSelected = TryGetButtonColor(button, out var color) && AreSameColor(color, _selectedThemeColor); + swatch.BorderBrush = isSelected + ? new SolidColorBrush(Color.Parse("#FFFFFFFF")) + : new SolidColorBrush(Color.Parse("#A0FFFFFF")); + swatch.BorderThickness = new Thickness(isSelected ? 2 : 1); + } + } + + private static bool TryGetButtonColor(Button? button, out Color color) + { + color = default; + if (button?.Tag is not string colorText || string.IsNullOrWhiteSpace(colorText)) + { + return false; + } + + try + { + color = Color.Parse(colorText); + return true; + } + catch + { + return false; + } + } + + + private static bool ContainsColor(IReadOnlyList colors, Color target) + { + for (var i = 0; i < colors.Count; i++) + { + if (AreSameColor(colors[i], target)) + { + return true; + } + } + + return false; + } + + private static bool AreSameColor(Color left, Color right) + { + return left.R == right.R && left.G == right.G && left.B == right.B; + } + + + private static double CalculateBrushLuminance(IBrush? brush) + { + if (brush is ISolidColorBrush solidBrush) + { + return CalculateRelativeLuminance(solidBrush.Color); + } + + return CalculateRelativeLuminance(Color.Parse("#FF020617")); + } + + private static double CalculateBitmapAverageLuminance(Bitmap bitmap) + { + try + { + var sampleWidth = Math.Clamp(bitmap.PixelSize.Width, 1, 48); + var sampleHeight = Math.Clamp(bitmap.PixelSize.Height, 1, 48); + + using var scaledBitmap = bitmap.CreateScaledBitmap( + new PixelSize(sampleWidth, sampleHeight), + BitmapInterpolationMode.MediumQuality); + using var writeable = new WriteableBitmap( + scaledBitmap.PixelSize, + new Vector(96, 96), + PixelFormat.Bgra8888, + AlphaFormat.Premul); + using var framebuffer = writeable.Lock(); + + scaledBitmap.CopyPixels(framebuffer, AlphaFormat.Premul); + + var rowBytes = framebuffer.RowBytes; + var byteCount = rowBytes * framebuffer.Size.Height; + if (byteCount <= 0 || framebuffer.Address == IntPtr.Zero) + { + return CalculateRelativeLuminance(Color.Parse("#FF020617")); + } + + var pixelBuffer = new byte[byteCount]; + Marshal.Copy(framebuffer.Address, pixelBuffer, 0, byteCount); + + double luminanceSum = 0; + var pixelCount = 0; + for (var y = 0; y < framebuffer.Size.Height; y++) + { + var rowOffset = y * rowBytes; + for (var x = 0; x < framebuffer.Size.Width; x++) + { + var index = rowOffset + (x * 4); + var alpha = pixelBuffer[index + 3] / 255d; + if (alpha <= 0.01) + { + continue; + } + + var blue = (pixelBuffer[index] / 255d) / alpha; + var green = (pixelBuffer[index + 1] / 255d) / alpha; + var red = (pixelBuffer[index + 2] / 255d) / alpha; + + red = Math.Clamp(red, 0, 1); + green = Math.Clamp(green, 0, 1); + blue = Math.Clamp(blue, 0, 1); + + luminanceSum += CalculateRelativeLuminance(red, green, blue); + pixelCount++; + } + } + + return pixelCount > 0 + ? luminanceSum / pixelCount + : CalculateRelativeLuminance(Color.Parse("#FF020617")); + } + catch + { + return CalculateRelativeLuminance(Color.Parse("#FF020617")); + } + } + + private static double CalculateRelativeLuminance(Color color) + { + return CalculateRelativeLuminance(color.R / 255d, color.G / 255d, color.B / 255d); + } + + private static double CalculateRelativeLuminance(double red, double green, double blue) + { + var linearRed = ToLinearRgb(red); + var linearGreen = ToLinearRgb(green); + var linearBlue = ToLinearRgb(blue); + return (0.2126 * linearRed) + (0.7152 * linearGreen) + (0.0722 * linearBlue); + } + + private static double ToLinearRgb(double value) + { + return value <= 0.04045 + ? value / 12.92 + : Math.Pow((value + 0.055) / 1.055, 2.4); + } + + private void OpenSettingsPage() + { + if (_isSettingsOpen) + { + return; + } + + _isSettingsOpen = true; + UpdateAdaptiveTextSystem(); + SettingsPage.IsVisible = true; + SettingsPage.Opacity = 0; + if (_settingsContentPanelTransform is not null) + { + _settingsContentPanelTransform.Y = 30; + } + + DesktopPage.IsHitTestVisible = false; + UpdateWallpaperPreviewLayout(); + + Dispatcher.UIThread.Post(() => + { + if (!_isSettingsOpen) + { + return; + } + + SettingsPage.Opacity = 1; + if (_settingsContentPanelTransform is not null) + { + _settingsContentPanelTransform.Y = 0; + } + }, DispatcherPriority.Background); + } + + private void CloseSettingsPage() + { + if (!_isSettingsOpen) + { + return; + } + + _isSettingsOpen = false; + UpdateAdaptiveTextSystem(); + + DesktopPage.IsHitTestVisible = true; + + SettingsPage.Opacity = 0; + if (_settingsContentPanelTransform is not null) + { + _settingsContentPanelTransform.Y = 30; + } + + DispatcherTimer.RunOnce(() => + { + if (_isSettingsOpen) + { + return; + } + + SettingsPage.IsVisible = false; + }, TimeSpan.FromMilliseconds(SettingsTransitionDurationMs)); + } + private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) { if (e.Property != WindowStateProperty)