diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index 3764f2a..2e4f890 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -8,6 +8,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Data.Core; using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; +using Avalonia.Media; using Avalonia.Platform; using Avalonia.Styling; using Avalonia.Threading; @@ -17,6 +18,7 @@ using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Settings; +using LanMountainDesktop.Theme; using LanMountainDesktop.ViewModels; using LanMountainDesktop.Views; @@ -24,6 +26,7 @@ namespace LanMountainDesktop; public partial class App : Application { + private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6"); private enum DesktopShellState { ForegroundDesktop = 0, @@ -75,13 +78,18 @@ public partial class App : Application PageId: pageTag)); } + public App() + { + _settingsFacade.Settings.Changed += OnSettingsChanged; + } + public override void Initialize() { AppLogger.Info("App", "Initializing application resources."); ConfigureWebViewUserDataFolder(); AvaloniaWebViewBuilder.Initialize(default); AvaloniaXamlLoader.Load(this); - ApplyInitialThemeVariantFromSettings(); + ApplyThemeFromSettings(); ApplyCurrentCultureFromSettings(); EnsureSettingsWindowService(); } @@ -292,12 +300,13 @@ public partial class App : Application _settingsFacade); } - private void ApplyInitialThemeVariantFromSettings() + private void ApplyThemeFromSettings() { var themeState = _settingsFacade.Theme.Get(); RequestedThemeVariant = themeState.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; + ApplyAdaptiveThemeResources(themeState); } private void ApplyCurrentCultureFromSettings() @@ -424,7 +433,7 @@ public partial class App : Application { Dispatcher.UIThread.Post(() => { - ApplyInitialThemeVariantFromSettings(); + ApplyThemeFromSettings(); ApplyCurrentCultureFromSettings(); if (_trayIcons is not null) { @@ -433,6 +442,71 @@ public partial class App : Application }, DispatcherPriority.Background); } + private void OnSettingsChanged(object? sender, SettingsChangedEvent e) + { + _ = sender; + + if (e.Scope != SettingsScope.App) + { + return; + } + + Dispatcher.UIThread.Post(() => + { + var changedKeys = e.ChangedKeys?.ToArray(); + var refreshAll = changedKeys is null || changedKeys.Length == 0; + var themeChanged = + refreshAll || + changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase); + var languageChanged = + refreshAll || + changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase); + + if (themeChanged) + { + ApplyThemeFromSettings(); + } + + if (languageChanged) + { + ApplyCurrentCultureFromSettings(); + if (_trayIcons is not null) + { + InitializeTrayIcon(); + } + } + }, DispatcherPriority.Background); + } + + private void ApplyAdaptiveThemeResources(ThemeAppearanceSettingsState themeState) + { + var accentColor = TryParseThemeColor(themeState.ThemeColor); + var context = new ThemeColorContext( + accentColor, + IsLightBackground: !themeState.IsNightMode, + IsLightNavBackground: !themeState.IsNightMode, + IsNightMode: themeState.IsNightMode); + ThemeColorSystemService.ApplyThemeResources(Resources, context); + GlassEffectService.ApplyGlassResources(Resources, context); + } + + private static Color TryParseThemeColor(string? colorText) + { + if (!string.IsNullOrWhiteSpace(colorText)) + { + try + { + return Color.Parse(colorText); + } + catch + { + } + } + + return DefaultAccentColor; + } + private void RegisterUiUnhandledExceptionGuard() { if (_uiUnhandledExceptionHooked) @@ -479,6 +553,7 @@ public partial class App : Application _exitCleanupCompleted = true; AppSettingsService.SettingsSaved -= OnAppSettingsSaved; + _settingsFacade.Settings.Changed -= OnSettingsChanged; try { diff --git a/LanMountainDesktop/Controls/IconText.axaml b/LanMountainDesktop/Controls/IconText.axaml new file mode 100644 index 0000000..da69f8e --- /dev/null +++ b/LanMountainDesktop/Controls/IconText.axaml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/LanMountainDesktop/Controls/IconText.axaml.cs b/LanMountainDesktop/Controls/IconText.axaml.cs new file mode 100644 index 0000000..92b95b5 --- /dev/null +++ b/LanMountainDesktop/Controls/IconText.axaml.cs @@ -0,0 +1,52 @@ +using Avalonia; +using Avalonia.Controls; +using FluentIcons.Avalonia; +using FluentIcons.Common; + +namespace LanMountainDesktop.Controls; + +public partial class IconText : UserControl +{ + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon), Icon.Info); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text), string.Empty); + + public IconText() + { + InitializeComponent(); + } + + public Icon Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == IconProperty) + { + if (IconElement is not null) + { + IconElement.Icon = change.GetNewValue(); + } + } + else if (change.Property == TextProperty) + { + if (TextElement is not null) + { + TextElement.Text = change.GetNewValue(); + } + } + } +} diff --git a/LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs b/LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs index 984cfe7..60b02ea 100644 --- a/LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs +++ b/LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs @@ -102,6 +102,7 @@ public partial class SettingsOptionCard : UserControl return iconKey?.Trim() switch { "DesignIdeas" => Symbol.Color, + "Image" => Symbol.Image, "GridDots" => Symbol.GridDots, "PuzzlePiece" => Symbol.PuzzlePiece, "Info" => Symbol.Info, diff --git a/LanMountainDesktop/Controls/SettingsSectionCard.axaml.cs b/LanMountainDesktop/Controls/SettingsSectionCard.axaml.cs index 813f0b8..baee282 100644 --- a/LanMountainDesktop/Controls/SettingsSectionCard.axaml.cs +++ b/LanMountainDesktop/Controls/SettingsSectionCard.axaml.cs @@ -87,6 +87,7 @@ public partial class SettingsSectionCard : UserControl return iconKey?.Trim() switch { "DesignIdeas" => Symbol.Color, + "Image" => Symbol.Image, "GridDots" => Symbol.GridDots, "PuzzlePiece" => Symbol.PuzzlePiece, "Info" => Symbol.Info, diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 6e30881..f366cc0 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -232,7 +232,7 @@ "settings.general.preview_date_label": "Date", "settings.general.render_mode_restart_message": "Rendering mode changes require restarting the app.", "settings.appearance.title": "Appearance", - "settings.appearance.description": "Adjust theme, wallpaper, and status bar presentation.", + "settings.appearance.description": "Adjust theme and status bar presentation.", "settings.appearance.theme_header": "Theme", "settings.color.enable_night_mode_toggle": "Enable night mode", "settings.color.use_system_chrome_toggle": "Use system window chrome", @@ -802,4 +802,3 @@ "single_instance.notice.button": "OK" } - diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 5b54ad7..f037590 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -232,7 +232,7 @@ "settings.general.preview_date_label": "日期", "settings.general.render_mode_restart_message": "渲染模式变更需要重启应用。", "settings.appearance.title": "外观", - "settings.appearance.description": "切换主题、壁纸和状态栏展示。", + "settings.appearance.description": "切换主题与状态栏展示。", "settings.appearance.theme_header": "主题", "settings.color.enable_night_mode_toggle": "启用夜间模式", "settings.color.use_system_chrome_toggle": "使用系统窗口标题栏", @@ -802,4 +802,3 @@ "single_instance.notice.button": "确定" } - diff --git a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs index decaef0..8185a82 100644 --- a/LanMountainDesktop/Services/Settings/SettingsWindowService.cs +++ b/LanMountainDesktop/Services/Settings/SettingsWindowService.cs @@ -2,11 +2,13 @@ using System; using System.Linq; using Avalonia; using Avalonia.Controls; +using Avalonia.Media; using Avalonia.Styling; using Avalonia.Threading; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; +using LanMountainDesktop.Theme; using LanMountainDesktop.ViewModels; using LanMountainDesktop.Views; @@ -50,6 +52,7 @@ public interface ISettingsWindowService internal sealed class SettingsWindowService : ISettingsWindowService { + private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6"); private readonly ISettingsPageRegistry _pageRegistry; private readonly IHostApplicationLifecycle _hostApplicationLifecycle; private readonly ISettingsFacadeService _settingsFacade; @@ -85,7 +88,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService _window ??= CreateWindow(); var themeState = _settingsFacade.Theme.Get(); _window.ApplyChromeMode(themeState.UseSystemChrome); - ApplyTheme(_window, themeState.IsNightMode); + ApplyTheme(_window, themeState); _window.ReloadPages(request.PageId); PositionWindow(_window, request); @@ -140,7 +143,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService _pageRegistry, _hostApplicationLifecycle, useSystemChrome); - ApplyTheme(window, themeState.IsNightMode); + ApplyTheme(window, themeState); window.ShowInTaskbar = false; window.Closed += (_, _) => { @@ -277,6 +280,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService var themeChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) || + changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase) || changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase); if (languageChanged) @@ -292,15 +296,40 @@ internal sealed class SettingsWindowService : ISettingsWindowService { var themeState = _settingsFacade.Theme.Get(); _window.ApplyChromeMode(themeState.UseSystemChrome); - ApplyTheme(_window, themeState.IsNightMode); + ApplyTheme(_window, themeState); } }, DispatcherPriority.Background); } - private static void ApplyTheme(SettingsWindow window, bool isNightMode) + private static void ApplyTheme(SettingsWindow window, ThemeAppearanceSettingsState themeState) { - window.RequestedThemeVariant = isNightMode + window.RequestedThemeVariant = themeState.IsNightMode ? ThemeVariant.Dark : ThemeVariant.Light; + + var accentColor = TryParseThemeColor(themeState.ThemeColor); + var context = new ThemeColorContext( + accentColor, + IsLightBackground: !themeState.IsNightMode, + IsLightNavBackground: !themeState.IsNightMode, + IsNightMode: themeState.IsNightMode); + ThemeColorSystemService.ApplyThemeResources(window.Resources, context); + GlassEffectService.ApplyGlassResources(window.Resources, context); + } + + private static Color TryParseThemeColor(string? colorText) + { + if (!string.IsNullOrWhiteSpace(colorText)) + { + try + { + return Color.Parse(colorText); + } + catch + { + } + } + + return DefaultAccentColor; } } diff --git a/LanMountainDesktop/Styles/GlassModule.axaml b/LanMountainDesktop/Styles/GlassModule.axaml index 4e20c87..9fd498e 100644 --- a/LanMountainDesktop/Styles/GlassModule.axaml +++ b/LanMountainDesktop/Styles/GlassModule.axaml @@ -107,11 +107,21 @@ + + + + + + @@ -33,6 +32,7 @@ + + diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index d1dbe9b..d80c552 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -41,6 +41,9 @@ public sealed partial class SettingsWindowViewModel : ViewModelBase [ObservableProperty] private string _currentPageTitle = string.Empty; + [ObservableProperty] + private bool _isPageTitleVisible = true; + [ObservableProperty] private string? _currentPageDescription; @@ -636,7 +639,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase private void RefreshLocalizedText() { PageTitle = L("settings.appearance.title", "Appearance"); - PageDescription = L("settings.appearance.description", "Theme, wallpaper, and status bar presentation."); + PageDescription = L("settings.appearance.description", "Theme and status bar presentation."); ThemeHeader = L("settings.appearance.theme_header", "Theme"); NightModeLabel = L("settings.color.enable_night_mode_toggle", "Enable night mode"); UseSystemChromeLabel = L("settings.color.use_system_chrome_toggle", "Use system window chrome"); diff --git a/LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs b/LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs new file mode 100644 index 0000000..c7696b4 --- /dev/null +++ b/LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using LanMountainDesktop.Services; +using LanMountainDesktop.Services.Settings; + +namespace LanMountainDesktop.ViewModels; + +public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase +{ + private readonly ISettingsFacadeService _settingsFacade; + private readonly LocalizationService _localizationService = new(); + private readonly string _languageCode; + private bool _isInitializing; + + public WallpaperSettingsPageViewModel(ISettingsFacadeService settingsFacade) + { + _settingsFacade = settingsFacade; + _languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode); + WallpaperPlacements = CreateWallpaperPlacements(); + RefreshLocalizedText(); + + _isInitializing = true; + Load(); + _isInitializing = false; + } + + public IReadOnlyList WallpaperPlacements { get; } + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private string _wallpaperPath = string.Empty; + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private SelectionOption _selectedWallpaperPlacement = new("Fill", "Fill"); + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private string _wallpaperHeader = string.Empty; + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private string _wallpaperPathLabel = string.Empty; + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private string _wallpaperPlacementLabel = string.Empty; + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private string _wallpaperPlacementDescription = string.Empty; + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private string _importWallpaperButtonText = string.Empty; + + [CommunityToolkit.Mvvm.ComponentModel.ObservableProperty] + private string _filePickerTitle = string.Empty; + + public void Load() + { + var wallpaper = _settingsFacade.Wallpaper.Get(); + WallpaperPath = wallpaper.WallpaperPath ?? string.Empty; + var wallpaperPlacement = string.IsNullOrWhiteSpace(wallpaper.Placement) + ? "Fill" + : wallpaper.Placement; + SelectedWallpaperPlacement = WallpaperPlacements.FirstOrDefault(option => + string.Equals(option.Value, wallpaperPlacement, StringComparison.OrdinalIgnoreCase)) + ?? WallpaperPlacements[0]; + } + + public async Task ImportWallpaperAsync(string sourcePath) + { + var importedPath = await _settingsFacade.WallpaperMedia.ImportAssetAsync(sourcePath); + if (!string.IsNullOrWhiteSpace(importedPath)) + { + WallpaperPath = importedPath; + } + } + + partial void OnWallpaperPathChanged(string value) + { + if (_isInitializing) + { + return; + } + + SaveWallpaper(); + } + + partial void OnSelectedWallpaperPlacementChanged(SelectionOption value) + { + if (_isInitializing || value is null) + { + return; + } + + SaveWallpaper(); + } + + private void SaveWallpaper() + { + _settingsFacade.Wallpaper.Save(new WallpaperSettingsState( + string.IsNullOrWhiteSpace(WallpaperPath) ? null : WallpaperPath, + SelectedWallpaperPlacement.Value)); + } + + private IReadOnlyList CreateWallpaperPlacements() + { + return + [ + new SelectionOption("Fill", L("settings.wallpaper.placement.fill", "Fill")), + new SelectionOption("Fit", L("settings.wallpaper.placement.fit", "Fit")), + new SelectionOption("Stretch", L("settings.wallpaper.placement.stretch", "Stretch")), + new SelectionOption("Center", L("settings.wallpaper.placement.center", "Center")), + new SelectionOption("Tile", L("settings.wallpaper.placement.tile", "Tile")) + ]; + } + + private void RefreshLocalizedText() + { + WallpaperHeader = L("settings.wallpaper.title", "Wallpaper"); + WallpaperPathLabel = L("settings.wallpaper.current_label", "Current Wallpaper"); + WallpaperPlacementLabel = L("settings.wallpaper.placement_label", "Placement"); + 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"); + } + + private string L(string key, string fallback) + => _localizationService.GetString(_languageCode, key, fallback); +} diff --git a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs index b4db42a..d237dbe 100644 --- a/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs +++ b/LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs @@ -13,6 +13,7 @@ using FluentAvalonia.UI.Controls; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; +using LanMountainDesktop.Theme; using LanMountainDesktop.Views.Components; namespace LanMountainDesktop.Views; @@ -193,6 +194,30 @@ public partial class MainWindow return false; } + private ThemeColorContext BuildAdaptiveThemeContext() + { + return new ThemeColorContext( + _selectedThemeColor, + IsLightBackground: !_isNightMode, + IsLightNavBackground: !_isNightMode, + IsNightMode: _isNightMode); + } + + private void ApplyAdaptiveThemeResources() + { + var context = BuildAdaptiveThemeContext(); + ThemeColorSystemService.ApplyThemeResources(Resources, context); + GlassEffectService.ApplyGlassResources(Resources, context); + + if (Application.Current?.Resources is { } applicationResources) + { + ThemeColorSystemService.ApplyThemeResources(applicationResources, context); + GlassEffectService.ApplyGlassResources(applicationResources, context); + } + + _defaultDesktopBackground = GetThemeBrush("AdaptiveSurfaceBaseBrush"); + } + private void TryRestoreWallpaper(string? savedWallpaperPath) { _wallpaperPath = string.IsNullOrWhiteSpace(savedWallpaperPath) ? null : savedWallpaperPath; @@ -285,6 +310,9 @@ public partial class MainWindow Application.Current.RequestedThemeVariant = requestedThemeVariant; } + ApplyAdaptiveThemeResources(); + ApplyWallpaperBrush(); + if (!refreshPalettes) { return; diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index 7817c5b..f75eed1 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -25,43 +25,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 22 - 28 - 0.92 - 0.95 - - - - - - - - - - - + + - - - - - - - + + + + + + + + + + + + + + + + + - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + +