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