diff --git a/CursorLang/App.xaml b/CursorLang/App.xaml index 5c8ac19..ce2d06e 100644 --- a/CursorLang/App.xaml +++ b/CursorLang/App.xaml @@ -3,6 +3,10 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" ShutdownMode="OnMainWindowClose"> - + + + + + diff --git a/CursorLang/App.xaml.cs b/CursorLang/App.xaml.cs index 44a0fca..f87db0c 100644 --- a/CursorLang/App.xaml.cs +++ b/CursorLang/App.xaml.cs @@ -22,6 +22,8 @@ public partial class App : Application ConfigureServices(services); _services = services.BuildServiceProvider(); + _services.GetRequiredService(); + MainWindow = _services.GetRequiredService(); MainWindow.Show(); @@ -43,6 +45,9 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService().Load()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/CursorLang/Interop/WindowThemeNative.cs b/CursorLang/Interop/WindowThemeNative.cs new file mode 100644 index 0000000..ff457fd --- /dev/null +++ b/CursorLang/Interop/WindowThemeNative.cs @@ -0,0 +1,30 @@ +using System.Runtime.InteropServices; + +namespace CursorLang.Interop; + +/// +/// Оформление рамки окна средствами системы: заголовок рисует Windows, +/// и в тёмной теме его нужно переключать отдельно от содержимого окна. +/// +internal static class WindowThemeNative +{ + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hWnd, int attribute, ref int value, int size); + + private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + + /// + /// Перекрашивает заголовок окна. На сборках Windows 10 до 2004 атрибут + /// не поддерживается — заголовок просто останется светлым. + /// + internal static void SetDarkTitleBar(IntPtr hWnd, bool isDark) + { + if (hWnd == IntPtr.Zero) + { + return; + } + + int value = isDark ? 1 : 0; + DwmSetWindowAttribute(hWnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref value, sizeof(int)); + } +} diff --git a/CursorLang/Models/AppSettings.cs b/CursorLang/Models/AppSettings.cs index 97d02eb..1ea74b3 100644 --- a/CursorLang/Models/AppSettings.cs +++ b/CursorLang/Models/AppSettings.cs @@ -14,6 +14,10 @@ public sealed partial class AppSettings : ObservableObject [ObservableProperty] private string _language = "en"; + /// Оформление окна настроек. + [ObservableProperty] + private AppTheme _theme = AppTheme.System; + [ObservableProperty] private PopupPlacementMode _placementMode = PopupPlacementMode.AtCursor; diff --git a/CursorLang/Models/AppTheme.cs b/CursorLang/Models/AppTheme.cs new file mode 100644 index 0000000..6710f6d --- /dev/null +++ b/CursorLang/Models/AppTheme.cs @@ -0,0 +1,12 @@ +namespace CursorLang.Models; + +/// +/// Оформление окна настроек. По умолчанию приложение следует теме Windows, +/// но пользователь может закрепить светлую или тёмную. +/// +public enum AppTheme +{ + System, + Light, + Dark +} diff --git a/CursorLang/Resources/Strings.resx b/CursorLang/Resources/Strings.resx index b0f7173..45aeb0e 100644 --- a/CursorLang/Resources/Strings.resx +++ b/CursorLang/Resources/Strings.resx @@ -67,6 +67,18 @@ Interface language + + Theme + + + Same as Windows + + + Light + + + Dark + Placement diff --git a/CursorLang/Resources/Strings.ru.resx b/CursorLang/Resources/Strings.ru.resx index 1ef9918..21a8c36 100644 --- a/CursorLang/Resources/Strings.ru.resx +++ b/CursorLang/Resources/Strings.ru.resx @@ -67,6 +67,18 @@ Язык интерфейса + + Тема + + + Как в Windows + + + Светлая + + + Тёмная + Расположение diff --git a/CursorLang/Services/IThemeService.cs b/CursorLang/Services/IThemeService.cs new file mode 100644 index 0000000..4712949 --- /dev/null +++ b/CursorLang/Services/IThemeService.cs @@ -0,0 +1,19 @@ +using System.Windows; +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// Применяет светлое или тёмное оформление к окнам приложения. +/// +public interface IThemeService +{ + /// Тема, действующая сейчас. + AppTheme CurrentTheme { get; } + + /// + /// Подключает окно к смене темы: заголовок окна рисует Windows, + /// и его цвет приходится переключать для каждого окна отдельно. + /// + void Register(Window window); +} diff --git a/CursorLang/Services/SettingsService.cs b/CursorLang/Services/SettingsService.cs index 89a401d..792077d 100644 --- a/CursorLang/Services/SettingsService.cs +++ b/CursorLang/Services/SettingsService.cs @@ -90,12 +90,10 @@ public sealed class SettingsService : IDisposable } catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException) { - // Испорченный файл не должен мешать запуску: начнём с умолчаний return null; } } - // Язык по умолчанию берём системный, если он поддерживается private static AppSettings CreateDefault() { string uiLanguage = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; diff --git a/CursorLang/Services/ThemeService.cs b/CursorLang/Services/ThemeService.cs new file mode 100644 index 0000000..b8339a9 --- /dev/null +++ b/CursorLang/Services/ThemeService.cs @@ -0,0 +1,144 @@ +using System.ComponentModel; +using System.Windows; +using System.Windows.Interop; +using CursorLang.Interop; +using CursorLang.Models; +using Microsoft.Win32; + +namespace CursorLang.Services; + +/// +/// Держит в ресурсах приложения палитру выбранной темы и подменяет её +/// при смене настройки — окна перекрашиваются без перезапуска. +/// +public sealed class ThemeService : IThemeService, IDisposable +{ + private const string PersonalizeKey = + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; + + private readonly AppSettings _settings; + private readonly List _windows = []; + private ResourceDictionary? _palette; + private AppTheme _current; + + public ThemeService(AppSettings settings) + { + _settings = settings; + _settings.PropertyChanged += OnSettingsChanged; + SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged; + Apply(); + } + + /// Тема, которую видит пользователь: System здесь уже разрешён. + public AppTheme CurrentTheme => _current; + + public void Register(Window window) + { + _windows.Add(window); + window.Closed += OnWindowClosed; + + if (new WindowInteropHelper(window).Handle == IntPtr.Zero) + { + window.SourceInitialized += OnWindowSourceInitialized; + return; + } + + ApplyTitleBar(window); + } + + public void Dispose() + { + _settings.PropertyChanged -= OnSettingsChanged; + SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged; + + foreach (Window window in _windows) + { + window.Closed -= OnWindowClosed; + window.SourceInitialized -= OnWindowSourceInitialized; + } + + _windows.Clear(); + } + + /// Тема приложений в настройках Windows. + private static AppTheme DetectSystemTheme() + { + try + { + using RegistryKey? key = Registry.CurrentUser.OpenSubKey(PersonalizeKey); + return key?.GetValue("AppsUseLightTheme") is int light && light == 0 + ? AppTheme.Dark + : AppTheme.Light; + } + catch (Exception e) when (e is System.Security.SecurityException or UnauthorizedAccessException) + { + return AppTheme.Light; + } + } + + private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AppSettings.Theme)) + { + Apply(); + } + } + + // Windows сообщает о смене оформления не из потока интерфейса + private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) => + Application.Current?.Dispatcher.InvokeAsync(Apply); + + private void Apply() + { + AppTheme theme = _settings.Theme == AppTheme.System ? DetectSystemTheme() : _settings.Theme; + if (_palette is not null && theme == _current) + { + return; + } + + _current = theme; + + var next = new ResourceDictionary + { + Source = new Uri($"pack://application:,,,/Themes/{theme}.xaml", UriKind.Absolute), + }; + + ICollection dictionaries = Application.Current.Resources.MergedDictionaries; + dictionaries.Add(next); + + if (_palette is not null) + { + dictionaries.Remove(_palette); + } + + _palette = next; + + foreach (Window window in _windows) + { + ApplyTitleBar(window); + } + } + + private void ApplyTitleBar(Window window) => + WindowThemeNative.SetDarkTitleBar( + new WindowInteropHelper(window).Handle, + _current == AppTheme.Dark); + + private void OnWindowSourceInitialized(object? sender, EventArgs e) + { + if (sender is Window window) + { + window.SourceInitialized -= OnWindowSourceInitialized; + ApplyTitleBar(window); + } + } + + private void OnWindowClosed(object? sender, EventArgs e) + { + if (sender is Window window) + { + window.Closed -= OnWindowClosed; + _windows.Remove(window); + } + } +} diff --git a/CursorLang/Themes/Controls.xaml b/CursorLang/Themes/Controls.xaml new file mode 100644 index 0000000..b457c99 --- /dev/null +++ b/CursorLang/Themes/Controls.xaml @@ -0,0 +1,294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CursorLang/Themes/Dark.xaml b/CursorLang/Themes/Dark.xaml new file mode 100644 index 0000000..cd831b3 --- /dev/null +++ b/CursorLang/Themes/Dark.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/CursorLang/Themes/Light.xaml b/CursorLang/Themes/Light.xaml new file mode 100644 index 0000000..1c0d4a6 --- /dev/null +++ b/CursorLang/Themes/Light.xaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/CursorLang/ViewModels/SettingsViewModel.cs b/CursorLang/ViewModels/SettingsViewModel.cs index 9aff458..eb9fcf7 100644 --- a/CursorLang/ViewModels/SettingsViewModel.cs +++ b/CursorLang/ViewModels/SettingsViewModel.cs @@ -62,6 +62,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable Settings.PropertyChanged += OnSettingsChanged; Localization.PropertyChanged += OnLocalizationChanged; + Themes = CreateOptions(); PlacementModes = CreateOptions(); AnchorSides = CreateOptions(); ScreenPositions = CreateOptions(); @@ -73,6 +74,8 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable public IReadOnlyList BackgroundPalette { get; } = Palette; + public IReadOnlyList> Themes { get; } + public IReadOnlyList> PlacementModes { get; } public IReadOnlyList> AnchorSides { get; } @@ -102,6 +105,7 @@ public sealed partial class SettingsViewModel : ObservableObject, IDisposable return; } + Translate(Themes); Translate(PlacementModes); Translate(AnchorSides); Translate(ScreenPositions); diff --git a/CursorLang/Views/MainWindow.xaml b/CursorLang/Views/MainWindow.xaml index 5426081..353a1ea 100644 --- a/CursorLang/Views/MainWindow.xaml +++ b/CursorLang/Views/MainWindow.xaml @@ -9,7 +9,9 @@ d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}" Title="{Binding Localization[SettingsTitle]}" Width="560" SizeToContent="Height" MaxHeight="900" - ResizeMode="CanMinimize"> + ResizeMode="CanMinimize" + Background="{DynamicResource Theme.WindowBackground}" + Foreground="{DynamicResource Theme.Foreground}"> @@ -24,18 +26,7 @@ - - - - - @@ -48,6 +39,11 @@ + + + + + + + + @@ -245,7 +250,7 @@ Text="{Binding Localization[PreviewLabel]}" /> - diff --git a/CursorLang/Views/MainWindow.xaml.cs b/CursorLang/Views/MainWindow.xaml.cs index aef0e0e..fd3b8fb 100644 --- a/CursorLang/Views/MainWindow.xaml.cs +++ b/CursorLang/Views/MainWindow.xaml.cs @@ -1,4 +1,5 @@ using System.Windows; +using CursorLang.Services; using CursorLang.ViewModels; namespace CursorLang.Views; @@ -8,9 +9,10 @@ namespace CursorLang.Views; /// public partial class MainWindow : Window { - public MainWindow(SettingsViewModel viewModel) + public MainWindow(SettingsViewModel viewModel, IThemeService theme) { InitializeComponent(); DataContext = viewModel; + theme.Register(this); } }