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;