From d146fd442ad3eee1c6791eaeee42c54a799c8a3d Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Sat, 8 Aug 2026 21:48:16 +0500 Subject: [PATCH] added multilang and settings --- CursorLang/App.xaml.cs | 16 +- CursorLang/Interop/PopupWindowNative.cs | 50 +++- CursorLang/Models/AppSettings.cs | 50 ++++ CursorLang/Models/PopupPlacement.cs | 36 +++ CursorLang/Resources/Strings.resx | 145 ++++++++++ CursorLang/Resources/Strings.ru.resx | 145 ++++++++++ CursorLang/Services/ILocalizationService.cs | 21 ++ .../LayoutNotificationCoordinator.cs} | 32 +-- CursorLang/Services/LayoutPopupService.cs | 25 +- CursorLang/Services/LocalizationService.cs | 46 ++++ CursorLang/Services/SettingsService.cs | 143 ++++++++++ CursorLang/ViewModels/LayoutPopupViewModel.cs | 8 +- CursorLang/ViewModels/SettingsViewModel.cs | 93 +++++++ CursorLang/Views/Converters.cs | 44 ++++ CursorLang/Views/LayoutPopupWindow.xaml | 11 +- CursorLang/Views/LayoutPopupWindow.xaml.cs | 70 +++-- CursorLang/Views/MainWindow.xaml | 248 +++++++++++++++++- CursorLang/Views/MainWindow.xaml.cs | 4 +- 18 files changed, 1115 insertions(+), 72 deletions(-) create mode 100644 CursorLang/Models/AppSettings.cs create mode 100644 CursorLang/Models/PopupPlacement.cs create mode 100644 CursorLang/Resources/Strings.resx create mode 100644 CursorLang/Resources/Strings.ru.resx create mode 100644 CursorLang/Services/ILocalizationService.cs rename CursorLang/{ViewModels/MainViewModel.cs => Services/LayoutNotificationCoordinator.cs} (50%) create mode 100644 CursorLang/Services/LocalizationService.cs create mode 100644 CursorLang/Services/SettingsService.cs create mode 100644 CursorLang/ViewModels/SettingsViewModel.cs create mode 100644 CursorLang/Views/Converters.cs diff --git a/CursorLang/App.xaml.cs b/CursorLang/App.xaml.cs index d63998c..44a0fca 100644 --- a/CursorLang/App.xaml.cs +++ b/CursorLang/App.xaml.cs @@ -1,4 +1,5 @@ using System.Windows; +using CursorLang.Models; using CursorLang.Services; using CursorLang.ViewModels; using CursorLang.Views; @@ -7,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection; namespace CursorLang; /// -/// Композиционный корень: собирает контейнер и запускает главное окно. +/// Композиционный корень: собирает контейнер и запускает окно настроек. /// public partial class App : Application { @@ -24,12 +25,13 @@ public partial class App : Application MainWindow = _services.GetRequiredService(); MainWindow.Show(); - _services.GetRequiredService().Start(); + _services.GetRequiredService().Start(); } protected override void OnExit(ExitEventArgs e) { - // Контейнер сам остановит таймеры и закроет окно подсказки + // Контейнер сам остановит таймеры, сохранит настройки + // и закроет окно подсказки _services?.Dispose(); base.OnExit(e); } @@ -37,13 +39,17 @@ public partial class App : Application private static void ConfigureServices(IServiceCollection services) { services.AddSingleton(new KeyboardLayoutOptions()); - services.AddSingleton(new PopupOptions()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService().Load()); + + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/CursorLang/Interop/PopupWindowNative.cs b/CursorLang/Interop/PopupWindowNative.cs index ac24b4d..5cf69d0 100644 --- a/CursorLang/Interop/PopupWindowNative.cs +++ b/CursorLang/Interop/PopupWindowNative.cs @@ -18,6 +18,9 @@ internal static class PopupWindowNative [DllImport("user32.dll")] private static extern bool GetCursorPos(out Point lpPoint); + [DllImport("user32.dll")] + private static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll", SetLastError = true)] private static extern int GetWindowLong(IntPtr hWnd, int nIndex); @@ -34,6 +37,30 @@ internal static class PopupWindowNative [DllImport("shcore.dll")] private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY); + [DllImport("user32.dll")] + private static extern IntPtr MonitorFromWindow(IntPtr hWnd, uint dwFlags); + + [DllImport("user32.dll")] + private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfo lpmi); + + [StructLayout(LayoutKind.Sequential)] + internal struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MonitorInfo + { + public int cbSize; + public Rect rcMonitor; + public Rect rcWork; + public uint dwFlags; + } + private const int GWL_EXSTYLE = -20; // Окно не забирает фокус у активного приложения private const int WS_EX_NOACTIVATE = 0x08000000; @@ -75,9 +102,28 @@ internal static class PopupWindowNative } /// Масштаб монитора, на котором находится точка (1.0 при 96 DPI). - internal static double GetScaleAt(Point point) + internal static double GetScaleAt(Point point) => + GetScaleOf(MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST)); + + /// + /// Рабочая область монитора с активным окном — без панели задач — и его масштаб. + /// Именно на этом мониторе пользователь сейчас работает. + /// + internal static (Rect WorkArea, double Scale) GetActiveMonitorWorkArea() + { + IntPtr monitor = MonitorFromWindow(GetForegroundWindow(), MONITOR_DEFAULTTONEAREST); + + var info = new MonitorInfo { cbSize = Marshal.SizeOf() }; + if (!GetMonitorInfo(monitor, ref info)) + { + return (new Rect(), 1.0); + } + + return (info.rcWork, GetScaleOf(monitor)); + } + + private static double GetScaleOf(IntPtr monitor) { - IntPtr monitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST); if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) < 0) { return 1.0; diff --git a/CursorLang/Models/AppSettings.cs b/CursorLang/Models/AppSettings.cs new file mode 100644 index 0000000..6fc73ef --- /dev/null +++ b/CursorLang/Models/AppSettings.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; +using System.Windows.Media; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CursorLang.Models; + +/// +/// Настройки приложения. Все изменения применяются на лету: подсказка и окно +/// настроек привязаны к этим свойствам, а SettingsService сохраняет их на диск. +/// +public sealed partial class AppSettings : ObservableObject +{ + /// Язык интерфейса в виде кода культуры: «ru», «en». + [ObservableProperty] + private string _language = "en"; + + [ObservableProperty] + private PopupPlacementMode _placementMode = PopupPlacementMode.AtCursor; + + [ObservableProperty] + private CursorCorner _cursorCorner = CursorCorner.BottomRight; + + /// Отступ от курсора в единицах WPF. + [ObservableProperty] + private double _cursorOffset = 16; + + [ObservableProperty] + private ScreenPosition _screenPosition = ScreenPosition.BottomRight; + + /// Отступ от края монитора в единицах WPF. + [ObservableProperty] + private double _screenMargin = 24; + + [ObservableProperty] + private double _fontSize = 20; + + /// Непрозрачность подсказки: 1.0 — полностью непрозрачная. + [ObservableProperty] + private double _opacity = 0.9; + + /// Сколько подсказка держится на экране, в миллисекундах. + [ObservableProperty] + private double _durationMilliseconds = 500; + + [ObservableProperty] + private Color _backgroundColor = Color.FromRgb(0x20, 0x20, 0x20); + + [JsonIgnore] + public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMilliseconds); +} diff --git a/CursorLang/Models/PopupPlacement.cs b/CursorLang/Models/PopupPlacement.cs new file mode 100644 index 0000000..0364a1b --- /dev/null +++ b/CursorLang/Models/PopupPlacement.cs @@ -0,0 +1,36 @@ +namespace CursorLang.Models; + +/// +/// Способ выбора места для подсказки. +/// +public enum PopupPlacementMode +{ + /// Рядом с курсором мыши. + AtCursor, + + /// В заданной точке монитора с активным окном. + FixedPoint, +} + +/// +/// С какой стороны от курсора показывать подсказку. +/// +public enum CursorCorner +{ + BottomRight, + BottomLeft, + TopRight, + TopLeft, +} + +/// +/// Место на мониторе для режима . +/// +public enum ScreenPosition +{ + TopLeft, + TopRight, + BottomLeft, + BottomRight, + Center, +} diff --git a/CursorLang/Resources/Strings.resx b/CursorLang/Resources/Strings.resx new file mode 100644 index 0000000..847dbc5 --- /dev/null +++ b/CursorLang/Resources/Strings.resx @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + CursorLang — Settings + + + Interface + + + Interface language + + + Placement + + + Mode + + + Near the cursor + + + Fixed point on screen + + + Side of the cursor + + + Bottom right + + + Bottom left + + + Top right + + + Top left + + + Offset from cursor + + + Position on screen + + + Top left + + + Top right + + + Bottom left + + + Bottom right + + + Center + + + Margin from screen edge + + + Appearance + + + Font size + + + Opacity + + + Background color + + + Behavior + + + Display time + + + Preview + + + ms + + diff --git a/CursorLang/Resources/Strings.ru.resx b/CursorLang/Resources/Strings.ru.resx new file mode 100644 index 0000000..fe1e88f --- /dev/null +++ b/CursorLang/Resources/Strings.ru.resx @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + CursorLang — Настройки + + + Интерфейс + + + Язык интерфейса + + + Расположение + + + Режим + + + Рядом с курсором + + + В заданной точке экрана + + + Сторона от курсора + + + Справа снизу + + + Слева снизу + + + Справа сверху + + + Слева сверху + + + Отступ от курсора + + + Позиция на экране + + + Слева сверху + + + Справа сверху + + + Слева снизу + + + Справа снизу + + + По центру + + + Отступ от края экрана + + + Внешний вид + + + Размер шрифта + + + Прозрачность + + + Цвет фона + + + Поведение + + + Время отображения + + + Предпросмотр + + + мс + + diff --git a/CursorLang/Services/ILocalizationService.cs b/CursorLang/Services/ILocalizationService.cs new file mode 100644 index 0000000..16efc3f --- /dev/null +++ b/CursorLang/Services/ILocalizationService.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace CursorLang.Services; + +/// Язык интерфейса для выбора в настройках. +/// Код культуры: «ru», «en». +/// Название на самом этом языке. +public sealed record LanguageOption(string Code, string DisplayName); + +/// +/// Даёт строки интерфейса и умеет менять язык без перезапуска. +/// +public interface ILocalizationService : INotifyPropertyChanged +{ + /// Строка по ключу ресурса. Привязки обновляются при смене языка. + string this[string key] { get; } + + IReadOnlyList AvailableLanguages { get; } + + string CurrentLanguage { get; set; } +} diff --git a/CursorLang/ViewModels/MainViewModel.cs b/CursorLang/Services/LayoutNotificationCoordinator.cs similarity index 50% rename from CursorLang/ViewModels/MainViewModel.cs rename to CursorLang/Services/LayoutNotificationCoordinator.cs index 78c5cb5..514dd1e 100644 --- a/CursorLang/ViewModels/MainViewModel.cs +++ b/CursorLang/Services/LayoutNotificationCoordinator.cs @@ -1,36 +1,36 @@ -using CommunityToolkit.Mvvm.ComponentModel; using CursorLang.Models; -using CursorLang.Services; -namespace CursorLang.ViewModels; +namespace CursorLang.Services; /// -/// Показывает текущую раскладку в главном окне и просит показать подсказку -/// у курсора, когда пользователь переключил раскладку сам. +/// Связывает слежение за раскладкой с показом подсказки. +/// Живёт всё время работы приложения независимо от открытых окон. /// -public sealed partial class MainViewModel : ObservableObject, IDisposable +public sealed class LayoutNotificationCoordinator : IDisposable { private readonly IKeyboardLayoutService _layoutService; private readonly ILayoutPopupService _popupService; - [ObservableProperty] - private string _currentLayout; - - public MainViewModel(IKeyboardLayoutService layoutService, ILayoutPopupService popupService) + public LayoutNotificationCoordinator(IKeyboardLayoutService layoutService, ILayoutPopupService popupService) { _layoutService = layoutService; _popupService = popupService; - - _currentLayout = layoutService.Current.DisplayName; - _layoutService.LayoutChanged += OnLayoutChanged; } - public void Dispose() => _layoutService.LayoutChanged -= OnLayoutChanged; + public void Start() + { + _layoutService.LayoutChanged += OnLayoutChanged; + _layoutService.Start(); + } + + public void Dispose() + { + _layoutService.LayoutChanged -= OnLayoutChanged; + _layoutService.Stop(); + } private void OnLayoutChanged(object? sender, LayoutChangedEventArgs e) { - CurrentLayout = e.Layout.DisplayName; - // При переходе в другое приложение раскладка меняется без участия // пользователя, и всплывающая подсказка была бы навязчивой if (e.Reason == LayoutChangeReason.UserSwitched) diff --git a/CursorLang/Services/LayoutPopupService.cs b/CursorLang/Services/LayoutPopupService.cs index 144390e..621b604 100644 --- a/CursorLang/Services/LayoutPopupService.cs +++ b/CursorLang/Services/LayoutPopupService.cs @@ -5,18 +5,6 @@ using CursorLang.Views; namespace CursorLang.Services; -/// -/// Настройки подсказки у курсора. -/// -public sealed class PopupOptions -{ - /// Сколько подсказка держится на экране после последнего переключения. - public TimeSpan Duration { get; init; } = TimeSpan.FromMilliseconds(500); - - /// Отступ от курсора в единицах WPF, чтобы подсказка не оказалась под ним. - public double CursorOffset { get; init; } = 16; -} - /// /// Управляет временем жизни подсказки: окно отвечает только за показ, /// а решение «когда показать и когда убрать» принимается здесь. @@ -25,24 +13,27 @@ public sealed class LayoutPopupService : ILayoutPopupService, IDisposable { private readonly LayoutPopupWindow _window; private readonly LayoutPopupViewModel _viewModel; - private readonly DispatcherTimer _hideTimer; + private readonly AppSettings _settings; + private readonly DispatcherTimer _hideTimer = new(); - public LayoutPopupService(LayoutPopupWindow window, LayoutPopupViewModel viewModel, PopupOptions options) + public LayoutPopupService(LayoutPopupWindow window, LayoutPopupViewModel viewModel, AppSettings settings) { _window = window; _viewModel = viewModel; + _settings = settings; - _hideTimer = new DispatcherTimer { Interval = options.Duration }; _hideTimer.Tick += OnHideTimerTick; } public void Show(KeyboardLayout layout) { _viewModel.ShortName = layout.ShortName; - _window.ShowAtCursor(); + _window.ShowPopup(); - // Перезапускаем таймер, чтобы быстрые переключения продлевали показ + // Длительность читаем при каждом показе: её меняют в настройках на лету. + // Перезапуск таймера заодно продлевает показ при быстрых переключениях _hideTimer.Stop(); + _hideTimer.Interval = _settings.Duration; _hideTimer.Start(); } diff --git a/CursorLang/Services/LocalizationService.cs b/CursorLang/Services/LocalizationService.cs new file mode 100644 index 0000000..d7697ba --- /dev/null +++ b/CursorLang/Services/LocalizationService.cs @@ -0,0 +1,46 @@ +using System.Globalization; +using System.Resources; +using System.Windows.Data; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CursorLang.Services; + +/// +/// Берёт строки из ресурсов и при смене языка просит WPF перечитать все привязки. +/// +public sealed class LocalizationService : ObservableObject, ILocalizationService +{ + private static readonly ResourceManager Resources = + new("CursorLang.Resources.Strings", typeof(App).Assembly); + + private CultureInfo _culture = CultureInfo.GetCultureInfo("en"); + + public string this[string key] => Resources.GetString(key, _culture) ?? key; + + public IReadOnlyList AvailableLanguages { get; } = + [ + new LanguageOption("en", "English"), + new LanguageOption("ru", "Русский"), + ]; + + public string CurrentLanguage + { + get => _culture.TwoLetterISOLanguageName; + set + { + if (string.IsNullOrWhiteSpace(value) || value == CurrentLanguage) + { + return; + } + + _culture = CultureInfo.GetCultureInfo(value); + CultureInfo.CurrentUICulture = _culture; + + OnPropertyChanged(nameof(CurrentLanguage)); + + // Сообщаем об изменении индексатора: так обновляются все привязки + // вида {Binding Localization[Key]}, то есть весь текст интерфейса + OnPropertyChanged(Binding.IndexerName); + } + } +} diff --git a/CursorLang/Services/SettingsService.cs b/CursorLang/Services/SettingsService.cs new file mode 100644 index 0000000..89a401d --- /dev/null +++ b/CursorLang/Services/SettingsService.cs @@ -0,0 +1,143 @@ +using System.ComponentModel; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Windows.Media; +using System.Windows.Threading; +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// Хранит настройки в %APPDATA%\CursorLang\settings.json. +/// +public sealed class SettingsService : IDisposable +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = true, + Converters = { new ColorJsonConverter(), new JsonStringEnumConverter() }, + }; + + private readonly string _filePath; + private readonly DispatcherTimer _saveTimer; + private AppSettings? _settings; + + public SettingsService() + { + string folder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "CursorLang"); + _filePath = Path.Combine(folder, "settings.json"); + + // Ползунки меняют значения непрерывно, поэтому запись на диск + // откладывается до паузы в изменениях + _saveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; + _saveTimer.Tick += OnSaveTimerTick; + } + + /// + /// Читает настройки с диска либо отдаёт значения по умолчанию, + /// и дальше сам сохраняет любые изменения. + /// + public AppSettings Load() + { + _settings = ReadFile() ?? CreateDefault(); + _settings.PropertyChanged += OnSettingsChanged; + return _settings; + } + + public void Save() + { + if (_settings is null) + { + return; + } + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_filePath)!); + File.WriteAllText(_filePath, JsonSerializer.Serialize(_settings, SerializerOptions)); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + // Настройки — не тот случай, ради которого стоит ронять приложение + } + } + + public void Dispose() + { + _saveTimer.Stop(); + _saveTimer.Tick -= OnSaveTimerTick; + + if (_settings is not null) + { + _settings.PropertyChanged -= OnSettingsChanged; + Save(); + } + } + + private AppSettings? ReadFile() + { + try + { + if (!File.Exists(_filePath)) + { + return null; + } + + return JsonSerializer.Deserialize(File.ReadAllText(_filePath), SerializerOptions); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or JsonException) + { + // Испорченный файл не должен мешать запуску: начнём с умолчаний + return null; + } + } + + // Язык по умолчанию берём системный, если он поддерживается + private static AppSettings CreateDefault() + { + string uiLanguage = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName; + return new AppSettings { Language = uiLanguage == "ru" ? "ru" : "en" }; + } + + private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e) + { + _saveTimer.Stop(); + _saveTimer.Start(); + } + + private void OnSaveTimerTick(object? sender, EventArgs e) + { + _saveTimer.Stop(); + Save(); + } + + // Color не сериализуется штатно, а хранить его читаемым в файле удобно + private sealed class ColorJsonConverter : JsonConverter + { + public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? value = reader.GetString(); + if (string.IsNullOrWhiteSpace(value)) + { + return Colors.Black; + } + + try + { + return (Color)ColorConverter.ConvertFromString(value)!; + } + catch (FormatException) + { + return Colors.Black; + } + } + + public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } +} diff --git a/CursorLang/ViewModels/LayoutPopupViewModel.cs b/CursorLang/ViewModels/LayoutPopupViewModel.cs index 99da121..990e113 100644 --- a/CursorLang/ViewModels/LayoutPopupViewModel.cs +++ b/CursorLang/ViewModels/LayoutPopupViewModel.cs @@ -1,12 +1,18 @@ using CommunityToolkit.Mvvm.ComponentModel; +using CursorLang.Models; namespace CursorLang.ViewModels; /// -/// Содержимое подсказки у курсора. +/// Содержимое подсказки у курсора. Внешний вид берётся прямо из настроек, +/// поэтому их правка применяется без перезапуска. /// public sealed partial class LayoutPopupViewModel : ObservableObject { [ObservableProperty] private string _shortName = "—"; + + public LayoutPopupViewModel(AppSettings settings) => Settings = settings; + + public AppSettings Settings { get; } } diff --git a/CursorLang/ViewModels/SettingsViewModel.cs b/CursorLang/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..3b55be6 --- /dev/null +++ b/CursorLang/ViewModels/SettingsViewModel.cs @@ -0,0 +1,93 @@ +using System.ComponentModel; +using System.Windows.Data; +using System.Windows.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using CursorLang.Models; +using CursorLang.Services; + +namespace CursorLang.ViewModels; + +/// Вариант выбора в списке: значение и его подпись на текущем языке. +public sealed record EnumOption(T Value, string Display); + +/// +/// Окно настроек. Значения правятся прямо в , +/// поэтому подсказка подхватывает их сразу, без кнопки «Применить». +/// +public sealed partial class SettingsViewModel : ObservableObject, IDisposable +{ + private static readonly Color[] Palette = + [ + Color.FromRgb(0x20, 0x20, 0x20), + Color.FromRgb(0x00, 0x00, 0x00), + Color.FromRgb(0x1E, 0x3A, 0x8A), + Color.FromRgb(0x0F, 0x76, 0x6E), + Color.FromRgb(0x7C, 0x2D, 0x12), + Color.FromRgb(0x86, 0x19, 0x8F), + Color.FromRgb(0xB9, 0x1C, 0x1C), + Color.FromRgb(0xF5, 0xF5, 0xF5), + ]; + + public SettingsViewModel(AppSettings settings, ILocalizationService localization) + { + Settings = settings; + Localization = localization; + + // Язык интерфейса — такая же настройка, как остальные, и хранится там же + Localization.CurrentLanguage = settings.Language; + Settings.PropertyChanged += OnSettingsChanged; + Localization.PropertyChanged += OnLocalizationChanged; + + BuildLocalizedOptions(); + } + + public AppSettings Settings { get; } + + public ILocalizationService Localization { get; } + + public IReadOnlyList BackgroundPalette { get; } = Palette; + + public IReadOnlyList> PlacementModes { get; private set; } = []; + + public IReadOnlyList> CursorCorners { get; private set; } = []; + + public IReadOnlyList> ScreenPositions { get; private set; } = []; + + public void Dispose() + { + Settings.PropertyChanged -= OnSettingsChanged; + Localization.PropertyChanged -= OnLocalizationChanged; + } + + private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AppSettings.Language)) + { + Localization.CurrentLanguage = Settings.Language; + } + } + + // Подписи вариантов приходят из ресурсов, поэтому при смене языка + // списки нужно собрать заново — привязки сами перечитают их + private void OnLocalizationChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == Binding.IndexerName) + { + BuildLocalizedOptions(); + } + } + + private void BuildLocalizedOptions() + { + PlacementModes = BuildOptions(nameof(PopupPlacementMode)); + CursorCorners = BuildOptions(nameof(CursorCorner)); + ScreenPositions = BuildOptions(nameof(ScreenPosition)); + + OnPropertyChanged(nameof(PlacementModes)); + OnPropertyChanged(nameof(CursorCorners)); + OnPropertyChanged(nameof(ScreenPositions)); + } + + private EnumOption[] BuildOptions(string resourcePrefix) where T : struct, Enum => + [.. Enum.GetValues().Select(value => new EnumOption(value, Localization[$"{resourcePrefix}_{value}"]))]; +} diff --git a/CursorLang/Views/Converters.cs b/CursorLang/Views/Converters.cs new file mode 100644 index 0000000..a382455 --- /dev/null +++ b/CursorLang/Views/Converters.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using System.Windows.Media; + +namespace CursorLang.Views; + +/// +/// Показывает элемент, только если значение совпадает с параметром. +/// Нужен, чтобы настройки курсора и экрана не показывались одновременно. +/// +public sealed class EnumToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value?.ToString() == parameter?.ToString() ? Visibility.Visible : Visibility.Collapsed; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + Binding.DoNothing; +} + +/// +/// Цвет в запись вида «#RRGGBB»: альфа не показывается, потому что +/// за прозрачность отвечает отдельная настройка. +/// +public sealed class ColorToHexConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is Color color ? $"#{color.R:X2}{color.G:X2}{color.B:X2}" : string.Empty; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + Binding.DoNothing; +} + +/// +/// Цвет в кисть — для образцов палитры в списке. +/// +public sealed class ColorToBrushConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is Color color ? new SolidColorBrush(color) : Brushes.Transparent; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is SolidColorBrush brush ? brush.Color : Binding.DoNothing; +} diff --git a/CursorLang/Views/LayoutPopupWindow.xaml b/CursorLang/Views/LayoutPopupWindow.xaml index 0ce3a2e..f220bbf 100644 --- a/CursorLang/Views/LayoutPopupWindow.xaml +++ b/CursorLang/Views/LayoutPopupWindow.xaml @@ -14,13 +14,16 @@ ShowActivated="False" Topmost="True" Focusable="False" - IsHitTestVisible="False"> - + + + + diff --git a/CursorLang/Views/LayoutPopupWindow.xaml.cs b/CursorLang/Views/LayoutPopupWindow.xaml.cs index b3e07cf..2f5b764 100644 --- a/CursorLang/Views/LayoutPopupWindow.xaml.cs +++ b/CursorLang/Views/LayoutPopupWindow.xaml.cs @@ -1,39 +1,38 @@ using System.Windows; using System.Windows.Interop; using CursorLang.Interop; -using CursorLang.Services; +using CursorLang.Models; using CursorLang.ViewModels; namespace CursorLang.Views; /// -/// Всплывающая подсказка у курсора с коротким именем раскладки. -/// Отвечает только за показ: когда её убрать, решает . +/// Всплывающая подсказка с коротким именем раскладки. +/// Отвечает только за показ и место на экране: когда её убрать, +/// решает . /// public partial class LayoutPopupWindow : Window { - private readonly double _cursorOffset; - private PopupWindowNative.Point _cursor; + private readonly AppSettings _settings; - public LayoutPopupWindow(LayoutPopupViewModel viewModel, PopupOptions options) + public LayoutPopupWindow(LayoutPopupViewModel viewModel, AppSettings settings) { InitializeComponent(); DataContext = viewModel; - _cursorOffset = options.CursorOffset; + _settings = settings; } /// - /// Показывает подсказку у текущего положения курсора. + /// Показывает подсказку в месте, заданном настройками. /// - public void ShowAtCursor() + public void ShowPopup() { ResizeToContent(); - _cursor = PopupWindowNative.GetCursorPosition(); // Для уже созданного окна двигаем до показа; при самом первом вызове // хэндла ещё нет, и позиционирование выполнит OnSourceInitialized - MoveToCursor(); + MoveToTargetPosition(); Show(); } @@ -44,7 +43,7 @@ public partial class LayoutPopupWindow : Window base.OnSourceInitialized(e); PopupWindowNative.MakePassive(new WindowInteropHelper(this).Handle); - MoveToCursor(); + MoveToTargetPosition(); } // Размер считаем сами, а не через SizeToContent: тот вычисляет его при создании @@ -59,7 +58,7 @@ public partial class LayoutPopupWindow : Window Height = content.DesiredSize.Height; } - private void MoveToCursor() + private void MoveToTargetPosition() { IntPtr handle = new WindowInteropHelper(this).Handle; if (handle == IntPtr.Zero) @@ -67,9 +66,46 @@ public partial class LayoutPopupWindow : Window return; } - // Отступ задан в единицах WPF, а двигаем окно в пикселях: пересчитываем - // его по масштабу монитора, на котором сейчас курсор - int offset = (int)Math.Round(_cursorOffset * PopupWindowNative.GetScaleAt(_cursor)); - PopupWindowNative.MoveTo(handle, _cursor.X + offset, _cursor.Y + offset); + (int x, int y) = _settings.PlacementMode == PopupPlacementMode.AtCursor + ? GetCursorPosition() + : GetScreenPosition(); + + PopupWindowNative.MoveTo(handle, x, y); } + + // Отступы и размеры заданы в единицах WPF, а окно двигаем в пикселях, + // поэтому всё пересчитывается по масштабу нужного монитора + private (int X, int Y) GetCursorPosition() + { + PopupWindowNative.Point cursor = PopupWindowNative.GetCursorPosition(); + double scale = PopupWindowNative.GetScaleAt(cursor); + int offset = (int)Math.Round(_settings.CursorOffset * scale); + + bool toRight = _settings.CursorCorner is CursorCorner.BottomRight or CursorCorner.TopRight; + bool toBottom = _settings.CursorCorner is CursorCorner.BottomRight or CursorCorner.BottomLeft; + + int x = toRight ? cursor.X + offset : cursor.X - offset - ToPixels(Width, scale); + int y = toBottom ? cursor.Y + offset : cursor.Y - offset - ToPixels(Height, scale); + return (x, y); + } + + private (int X, int Y) GetScreenPosition() + { + (PopupWindowNative.Rect work, double scale) = PopupWindowNative.GetActiveMonitorWorkArea(); + int margin = (int)Math.Round(_settings.ScreenMargin * scale); + int width = ToPixels(Width, scale); + int height = ToPixels(Height, scale); + + return _settings.ScreenPosition switch + { + ScreenPosition.TopLeft => (work.Left + margin, work.Top + margin), + ScreenPosition.TopRight => (work.Right - margin - width, work.Top + margin), + ScreenPosition.BottomLeft => (work.Left + margin, work.Bottom - margin - height), + ScreenPosition.BottomRight => (work.Right - margin - width, work.Bottom - margin - height), + _ => (work.Left + ((work.Right - work.Left - width) / 2), + work.Top + ((work.Bottom - work.Top - height) / 2)), + }; + } + + private static int ToPixels(double wpfUnits, double scale) => (int)Math.Round(wpfUnits * scale); } diff --git a/CursorLang/Views/MainWindow.xaml b/CursorLang/Views/MainWindow.xaml index 9d2c61f..2874091 100644 --- a/CursorLang/Views/MainWindow.xaml +++ b/CursorLang/Views/MainWindow.xaml @@ -3,14 +3,246 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:local="clr-namespace:CursorLang.Views" + xmlns:models="clr-namespace:CursorLang.Models" xmlns:vm="clr-namespace:CursorLang.ViewModels" mc:Ignorable="d" - d:DataContext="{d:DesignInstance Type=vm:MainViewModel}" - Title="MainWindow" Height="450" Width="800"> - - + d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}" + Title="{Binding Localization[SettingsTitle]}" + Width="560" SizeToContent="Height" MaxHeight="900" + ResizeMode="CanMinimize"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CursorLang/Views/MainWindow.xaml.cs b/CursorLang/Views/MainWindow.xaml.cs index e48bb74..aef0e0e 100644 --- a/CursorLang/Views/MainWindow.xaml.cs +++ b/CursorLang/Views/MainWindow.xaml.cs @@ -4,11 +4,11 @@ using CursorLang.ViewModels; namespace CursorLang.Views; /// -/// Главное окно: показывает текущую раскладку. +/// Окно настроек приложения. /// public partial class MainWindow : Window { - public MainWindow(MainViewModel viewModel) + public MainWindow(SettingsViewModel viewModel) { InitializeComponent(); DataContext = viewModel;