From 3d614255d84254e25bc7963ca704f8e237a16900 Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Sat, 8 Aug 2026 21:24:02 +0500 Subject: [PATCH] moved to MVVM architecture --- CursorLang/App.xaml | 7 +- CursorLang/App.xaml.cs | 47 ++++- CursorLang/CursorLang.csproj | 5 + CursorLang/Interop/KeyboardLayoutNative.cs | 28 +++ CursorLang/Interop/PopupWindowNative.cs | 88 +++++++++ CursorLang/LayoutPopupWindow.xaml.cs | 149 -------------- CursorLang/MainWindow.xaml.cs | 182 ------------------ CursorLang/Models/KeyboardLayout.cs | 41 ++++ CursorLang/Models/LayoutChangedEventArgs.cs | 23 +++ CursorLang/Services/IKeyboardLayoutService.cs | 18 ++ CursorLang/Services/ILayoutPopupService.cs | 11 ++ CursorLang/Services/KeyboardLayoutService.cs | 86 +++++++++ CursorLang/Services/LayoutPopupService.cs | 61 ++++++ CursorLang/ViewModels/LayoutPopupViewModel.cs | 12 ++ CursorLang/ViewModels/MainViewModel.cs | 41 ++++ CursorLang/{ => Views}/LayoutPopupWindow.xaml | 12 +- CursorLang/Views/LayoutPopupWindow.xaml.cs | 75 ++++++++ CursorLang/{ => Views}/MainWindow.xaml | 9 +- CursorLang/Views/MainWindow.xaml.cs | 16 ++ 19 files changed, 565 insertions(+), 346 deletions(-) create mode 100644 CursorLang/Interop/KeyboardLayoutNative.cs create mode 100644 CursorLang/Interop/PopupWindowNative.cs delete mode 100644 CursorLang/LayoutPopupWindow.xaml.cs delete mode 100644 CursorLang/MainWindow.xaml.cs create mode 100644 CursorLang/Models/KeyboardLayout.cs create mode 100644 CursorLang/Models/LayoutChangedEventArgs.cs create mode 100644 CursorLang/Services/IKeyboardLayoutService.cs create mode 100644 CursorLang/Services/ILayoutPopupService.cs create mode 100644 CursorLang/Services/KeyboardLayoutService.cs create mode 100644 CursorLang/Services/LayoutPopupService.cs create mode 100644 CursorLang/ViewModels/LayoutPopupViewModel.cs create mode 100644 CursorLang/ViewModels/MainViewModel.cs rename CursorLang/{ => Views}/LayoutPopupWindow.xaml (57%) create mode 100644 CursorLang/Views/LayoutPopupWindow.xaml.cs rename CursorLang/{ => Views}/MainWindow.xaml (64%) create mode 100644 CursorLang/Views/MainWindow.xaml.cs diff --git a/CursorLang/App.xaml b/CursorLang/App.xaml index 0868dcc..5c8ac19 100644 --- a/CursorLang/App.xaml +++ b/CursorLang/App.xaml @@ -1,9 +1,8 @@ - + ShutdownMode="OnMainWindowClose"> - + diff --git a/CursorLang/App.xaml.cs b/CursorLang/App.xaml.cs index 9582793..d63998c 100644 --- a/CursorLang/App.xaml.cs +++ b/CursorLang/App.xaml.cs @@ -1,10 +1,51 @@ -using System.Windows; +using System.Windows; +using CursorLang.Services; +using CursorLang.ViewModels; +using CursorLang.Views; +using Microsoft.Extensions.DependencyInjection; namespace CursorLang; /// -/// Interaction logic for App.xaml +/// Композиционный корень: собирает контейнер и запускает главное окно. /// public partial class App : Application { -} \ No newline at end of file + private ServiceProvider? _services; + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + var services = new ServiceCollection(); + ConfigureServices(services); + _services = services.BuildServiceProvider(); + + MainWindow = _services.GetRequiredService(); + MainWindow.Show(); + + _services.GetRequiredService().Start(); + } + + protected override void OnExit(ExitEventArgs e) + { + // Контейнер сам остановит таймеры и закроет окно подсказки + _services?.Dispose(); + base.OnExit(e); + } + + private static void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(new KeyboardLayoutOptions()); + services.AddSingleton(new PopupOptions()); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + } +} diff --git a/CursorLang/CursorLang.csproj b/CursorLang/CursorLang.csproj index 212659c..3e9d2d1 100644 --- a/CursorLang/CursorLang.csproj +++ b/CursorLang/CursorLang.csproj @@ -8,4 +8,9 @@ true + + + + + diff --git a/CursorLang/Interop/KeyboardLayoutNative.cs b/CursorLang/Interop/KeyboardLayoutNative.cs new file mode 100644 index 0000000..8bf1927 --- /dev/null +++ b/CursorLang/Interop/KeyboardLayoutNative.cs @@ -0,0 +1,28 @@ +using System.Runtime.InteropServices; + +namespace CursorLang.Interop; + +/// +/// Win32 API для определения раскладки активного окна. +/// +internal static class KeyboardLayoutNative +{ + [DllImport("user32.dll")] + internal static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId); + + [DllImport("user32.dll")] + private static extern IntPtr GetKeyboardLayout(uint idThread); + + /// + /// Идентификатор локали для окна. Раскладка в Windows привязана к потоку, + /// поэтому так её видно у любого приложения, а не только у своего. + /// + internal static int GetLocaleIdOf(IntPtr hWnd) + { + uint threadId = GetWindowThreadProcessId(hWnd, IntPtr.Zero); + return (int)GetKeyboardLayout(threadId).ToInt64() & 0xFFFF; + } +} diff --git a/CursorLang/Interop/PopupWindowNative.cs b/CursorLang/Interop/PopupWindowNative.cs new file mode 100644 index 0000000..ac24b4d --- /dev/null +++ b/CursorLang/Interop/PopupWindowNative.cs @@ -0,0 +1,88 @@ +using System.Runtime.InteropServices; + +namespace CursorLang.Interop; + +/// +/// Win32 API для окна-подсказки: стили, позиционирование у курсора +/// и масштаб монитора, на котором курсор находится. +/// +internal static class PopupWindowNative +{ + [StructLayout(LayoutKind.Sequential)] + internal struct Point + { + public int X; + public int Y; + } + + [DllImport("user32.dll")] + private static extern bool GetCursorPos(out Point lpPoint); + + [DllImport("user32.dll", SetLastError = true)] + private static extern int GetWindowLong(IntPtr hWnd, int nIndex); + + [DllImport("user32.dll")] + private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); + + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, + int X, int Y, int cx, int cy, uint uFlags); + + [DllImport("user32.dll")] + private static extern IntPtr MonitorFromPoint(Point pt, uint dwFlags); + + [DllImport("shcore.dll")] + private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY); + + private const int GWL_EXSTYLE = -20; + // Окно не забирает фокус у активного приложения + private const int WS_EX_NOACTIVATE = 0x08000000; + // И не попадает в Alt+Tab + private const int WS_EX_TOOLWINDOW = 0x00000080; + + private const uint SWP_NOSIZE = 0x0001; + private const uint SWP_NOZORDER = 0x0004; + private const uint SWP_NOACTIVATE = 0x0010; + + private const uint MONITOR_DEFAULTTONEAREST = 2; + private const int MDT_EFFECTIVE_DPI = 0; + + internal static Point GetCursorPosition() + { + GetCursorPos(out Point cursor); + return cursor; + } + + /// + /// Подсказка всплывает поверх чужих приложений, поэтому она не должна + /// ни активироваться сама, ни отбирать фокус ввода у активного окна. + /// + internal static void MakePassive(IntPtr hWnd) + { + int exStyle = GetWindowLong(hWnd, GWL_EXSTYLE); + SetWindowLong(hWnd, GWL_EXSTYLE, exStyle | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW); + } + + /// + /// Двигает окно в точку экрана, не меняя размер и порядок окон. + /// Координаты — физические пиксели: у мониторов разный масштаб, а + /// Window.Left/Top пересчитываются по DPI того монитора, где окно сейчас, + /// и на соседнем мониторе дают промах. + /// + internal static void MoveTo(IntPtr hWnd, int x, int y) + { + SetWindowPos(hWnd, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); + } + + /// Масштаб монитора, на котором находится точка (1.0 при 96 DPI). + internal static double GetScaleAt(Point point) + { + IntPtr monitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST); + if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) < 0) + { + return 1.0; + } + + return dpiX / 96.0; + } +} diff --git a/CursorLang/LayoutPopupWindow.xaml.cs b/CursorLang/LayoutPopupWindow.xaml.cs deleted file mode 100644 index 3c4a187..0000000 --- a/CursorLang/LayoutPopupWindow.xaml.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System.Runtime.InteropServices; -using System.Windows; -using System.Windows.Interop; -using System.Windows.Threading; - -namespace CursorLang; - -/// -/// Всплывающая подсказка у курсора с коротким именем раскладки. -/// -public partial class LayoutPopupWindow : Window -{ - [StructLayout(LayoutKind.Sequential)] - private struct Point - { - public int X; - public int Y; - } - - [DllImport("user32.dll")] - private static extern bool GetCursorPos(out Point lpPoint); - - [DllImport("user32.dll", SetLastError = true)] - private static extern int GetWindowLong(IntPtr hWnd, int nIndex); - - [DllImport("user32.dll")] - private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); - - [DllImport("user32.dll")] - private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, - int X, int Y, int cx, int cy, uint uFlags); - - [DllImport("user32.dll")] - private static extern IntPtr MonitorFromPoint(Point pt, uint dwFlags); - - [DllImport("shcore.dll")] - private static extern int GetDpiForMonitor(IntPtr hMonitor, int dpiType, out uint dpiX, out uint dpiY); - - private const int GWL_EXSTYLE = -20; - // Окно не забирает фокус у активного приложения - private const int WS_EX_NOACTIVATE = 0x08000000; - // И не попадает в Alt+Tab - private const int WS_EX_TOOLWINDOW = 0x00000080; - - private const uint SWP_NOSIZE = 0x0001; - private const uint SWP_NOZORDER = 0x0004; - private const uint SWP_NOACTIVATE = 0x0010; - - private const uint MONITOR_DEFAULTTONEAREST = 2; - private const int MDT_EFFECTIVE_DPI = 0; - - // Отступ от курсора, чтобы подсказка не оказалась под ним. - // Задан в единицах WPF и пересчитывается в пиксели того монитора, - // на котором сейчас курсор - private const double CursorOffset = 16; - - private readonly DispatcherTimer _hideTimer; - private Point _cursor; - - public LayoutPopupWindow() - { - InitializeComponent(); - - _hideTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) }; - _hideTimer.Tick += (_, _) => - { - _hideTimer.Stop(); - Hide(); - }; - } - - // Хэндл создан, но окно ещё не отрисовано: здесь и настраиваем стили, - // и ставим окно на место, чтобы первый показ прошёл уже на нужном мониторе - protected override void OnSourceInitialized(EventArgs e) - { - base.OnSourceInitialized(e); - - ApplyPassiveWindowStyles(); - MoveToCursor(); - } - - /// - /// Показывает подсказку у курсора и скрывает её по таймеру. - /// - public void ShowAt(string text) - { - LayoutText.Text = text; - ResizeToContent(); - GetCursorPos(out _cursor); - - // Для уже созданного окна двигаем до показа; при самом первом вызове - // хэндла ещё нет, и позиционирование выполнит OnSourceInitialized - MoveToCursor(); - Show(); - - // Перезапускаем таймер, чтобы быстрые переключения продлевали показ - _hideTimer.Stop(); - _hideTimer.Start(); - } - - // Размер считаем сами, а не через SizeToContent: тот вычисляет его при создании - // окна, когда содержимое ещё не измерено, и первый показ выходит шире текста. - // Рамок у окна нет, поэтому его размер равен размеру содержимого - private void ResizeToContent() - { - var content = (FrameworkElement)Content; - content.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); - - Width = content.DesiredSize.Width; - Height = content.DesiredSize.Height; - } - - // Подсказка всплывает поверх чужих приложений, поэтому она не должна - // ни активироваться сама, ни отбирать фокус ввода у активного окна - private void ApplyPassiveWindowStyles() - { - IntPtr handle = new WindowInteropHelper(this).Handle; - int exStyle = GetWindowLong(handle, GWL_EXSTYLE); - SetWindowLong(handle, GWL_EXSTYLE, exStyle | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW); - } - - // Позиционируем нативно, в физических пикселях: у мониторов разный масштаб, - // а Window.Left/Top пересчитываются по DPI того монитора, где окно сейчас, - // и на соседнем мониторе дают промах - private void MoveToCursor() - { - IntPtr handle = new WindowInteropHelper(this).Handle; - if (handle == IntPtr.Zero) - { - return; - } - - int offset = (int)Math.Round(CursorOffset * GetScaleAt(_cursor)); - SetWindowPos(handle, IntPtr.Zero, _cursor.X + offset, _cursor.Y + offset, 0, 0, - SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); - } - - // Масштаб монитора, на котором находится точка - private static double GetScaleAt(Point point) - { - IntPtr monitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST); - if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out uint dpiX, out _) < 0) - { - return 1.0; - } - - return dpiX / 96.0; - } -} diff --git a/CursorLang/MainWindow.xaml.cs b/CursorLang/MainWindow.xaml.cs deleted file mode 100644 index 3dac859..0000000 --- a/CursorLang/MainWindow.xaml.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System.Windows; -// ==== -using System.Windows.Interop; -using System.Windows.Threading; -using System.Runtime.InteropServices; - -namespace CursorLang; - -/// -/// Interaction logic for MainWindow.xaml -/// -public partial class MainWindow : Window -{ - // Импорт нужных Win32 API - [DllImport("user32.dll")] - private static extern int RegisterWindowMessage(string lpString); - - [DllImport("user32.dll")] - private static extern bool RegisterShellHookWindow(IntPtr hWnd); - - [DllImport("user32.dll")] - private static extern bool DeregisterShellHookWindow(IntPtr hWnd); - - [DllImport("user32.dll")] - private static extern IntPtr GetForegroundWindow(); - - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId); - - [DllImport("user32.dll")] - private static extern IntPtr GetKeyboardLayout(uint idThread); - - // Константы оболочки - private const int HShellLanguage = 8; - // Сообщение о смене языка ввода в самом окне - private const int WmInputLangChange = 0x0051; - private int _shellHookMessageId; - private IntPtr _windowHandle; - private LayoutPopupWindow? _popup; - - // Опрос активного окна: HShellLanguage в Windows 10/11 не приходит, а событийных - // способов узнать о смене раскладки в чужом процессе из managed-кода нет - private readonly DispatcherTimer _pollTimer = new() { Interval = TimeSpan.FromMilliseconds(150) }; - private int _lastLocaleId = -1; - private IntPtr _lastForegroundWindow; - - public MainWindow() - { - InitializeComponent(); - } - - protected override void OnSourceInitialized(EventArgs e) - { - base.OnSourceInitialized(e); - - // 1. Получаем хэндл нашего окна (для WinForms используйте this.Handle) - _windowHandle = new WindowInteropHelper(this).Handle; - - // 2. Регистрируем уникальное сообщение SHELLHOOK - _shellHookMessageId = RegisterWindowMessage("SHELLHOOK"); - - // 3. Подписываем наше окно на получение событий оболочки - RegisterShellHookWindow(_windowHandle); - - // 4. Добавляем обработчик сообщений окна (WndProc) - HwndSource? source = HwndSource.FromHwnd(_windowHandle); - if (source != null) source.AddHook(WndProc); - - // 5. Готовим всплывающее окно у курсора - _popup = new LayoutPopupWindow(); - - // 6. Показываем раскладку, активную на момент запуска (без всплывающего окна) - _lastForegroundWindow = GetForegroundWindow(); - HandleLayout(GetLayoutOf(_lastForegroundWindow), showPopup: false); - - // 7. Запускаем опрос активного окна - _pollTimer.Tick += PollForegroundLayout; - _pollTimer.Start(); - } - - // Раскладка потока, которому принадлежит окно - private static int GetLayoutOf(IntPtr hWnd) - { - uint threadId = GetWindowThreadProcessId(hWnd, IntPtr.Zero); - return (int)GetKeyboardLayout(threadId).ToInt64() & 0xFFFF; - } - - private void PollForegroundLayout(object? sender, EventArgs e) - { - IntPtr foreground = GetForegroundWindow(); - if (foreground == IntPtr.Zero) - { - return; - } - - // Смена активного приложения сама по себе не является переключением - // раскладки пользователем, поэтому подсказку в этом случае не показываем - bool appSwitched = foreground != _lastForegroundWindow; - _lastForegroundWindow = foreground; - - HandleLayout(GetLayoutOf(foreground), showPopup: !appSwitched); - } - - private static System.Globalization.CultureInfo? GetCulture(int localeId) - { - try - { - return new System.Globalization.CultureInfo(localeId); - } - catch (System.Globalization.CultureNotFoundException) - { - return null; - } - } - - // Обновление лейбла с текущей раскладкой - private void UpdateLabel(int localeId) - { - var culture = GetCulture(localeId); - LayoutLabel.Content = culture is null - ? $"0x{localeId:X4}" - : $"{culture.TwoLetterISOLanguageName.ToUpperInvariant()} — {culture.NativeName}"; - } - - // Единая точка обработки: лейбл в окне и подсказка у курсора. - // Повторные сообщения об одной и той же раскладке отсекаются, - // так как событие может прийти и от хука, и от таймера - private void HandleLayout(int localeId, bool showPopup) - { - if (localeId == _lastLocaleId) - { - return; - } - - _lastLocaleId = localeId; - UpdateLabel(localeId); - - if (showPopup) - { - var culture = GetCulture(localeId); - string shortName = culture?.TwoLetterISOLanguageName.ToUpperInvariant() ?? $"0x{localeId:X4}"; - _popup?.ShowAt(shortName); - } - } - - // Перехватчик сообщений Windows - private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) - { - // Если пришло сообщение от Shell - if (msg == _shellHookMessageId) - { - // Проверяем, что это именно событие смены языка - if (wParam.ToInt32() == HShellLanguage) - { - // Раскладка изменилась! - // lParam содержит дескриптор клавиатуры (HKL), можно извлечь язык напрямую, - // либо вызвать метод из прошлого ответа для активного окна - - int localeId = (int)lParam.ToInt64() & 0xFFFF; - - // Здесь показываем UI возле курсора - HandleLayout(localeId, showPopup: true); - } - } - // Смена языка, когда фокус ввода находится в нашем окне: - // shell hook в этом случае события не присылает - else if (msg == WmInputLangChange) - { - HandleLayout((int)lParam.ToInt64() & 0xFFFF, showPopup: true); - } - return IntPtr.Zero; - } - - protected override void OnClosed(EventArgs e) - { - // Обязательно отписываемся при закрытии - _pollTimer.Stop(); - DeregisterShellHookWindow(_windowHandle); - _popup?.Close(); - base.OnClosed(e); - } -} \ No newline at end of file diff --git a/CursorLang/Models/KeyboardLayout.cs b/CursorLang/Models/KeyboardLayout.cs new file mode 100644 index 0000000..e114260 --- /dev/null +++ b/CursorLang/Models/KeyboardLayout.cs @@ -0,0 +1,41 @@ +using System.Globalization; + +namespace CursorLang.Models; + +/// +/// Раскладка клавиатуры в удобном для отображения виде. +/// +/// Идентификатор локали (младшее слово HKL). +/// Короткое имя для подсказки у курсора, например «RU». +/// Полное имя, например «RU — русский (Россия)». +public sealed record KeyboardLayout(int LocaleId, string ShortName, string DisplayName) +{ + /// + /// Строит модель по идентификатору локали. Неизвестные локали не являются + /// ошибкой: для них показываем сам идентификатор. + /// + public static KeyboardLayout FromLocaleId(int localeId) + { + CultureInfo? culture = TryGetCulture(localeId); + if (culture is null) + { + string fallback = $"0x{localeId:X4}"; + return new KeyboardLayout(localeId, fallback, fallback); + } + + string shortName = culture.TwoLetterISOLanguageName.ToUpperInvariant(); + return new KeyboardLayout(localeId, shortName, $"{shortName} — {culture.NativeName}"); + } + + private static CultureInfo? TryGetCulture(int localeId) + { + try + { + return new CultureInfo(localeId); + } + catch (CultureNotFoundException) + { + return null; + } + } +} diff --git a/CursorLang/Models/LayoutChangedEventArgs.cs b/CursorLang/Models/LayoutChangedEventArgs.cs new file mode 100644 index 0000000..cbe08a2 --- /dev/null +++ b/CursorLang/Models/LayoutChangedEventArgs.cs @@ -0,0 +1,23 @@ +namespace CursorLang.Models; + +/// +/// Почему изменилась текущая раскладка. +/// +public enum LayoutChangeReason +{ + /// Пользователь переключил раскладку в активном приложении. + UserSwitched, + + /// Пользователь перешёл в другое приложение, где своя раскладка. + ApplicationSwitched, +} + +/// +/// Данные события смены раскладки. +/// +public sealed class LayoutChangedEventArgs(KeyboardLayout layout, LayoutChangeReason reason) : EventArgs +{ + public KeyboardLayout Layout { get; } = layout; + + public LayoutChangeReason Reason { get; } = reason; +} diff --git a/CursorLang/Services/IKeyboardLayoutService.cs b/CursorLang/Services/IKeyboardLayoutService.cs new file mode 100644 index 0000000..0a16570 --- /dev/null +++ b/CursorLang/Services/IKeyboardLayoutService.cs @@ -0,0 +1,18 @@ +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// Следит за раскладкой активного окна — в том числе в чужих приложениях. +/// +public interface IKeyboardLayoutService +{ + /// Раскладка активного окна на текущий момент. + KeyboardLayout Current { get; } + + event EventHandler? LayoutChanged; + + void Start(); + + void Stop(); +} diff --git a/CursorLang/Services/ILayoutPopupService.cs b/CursorLang/Services/ILayoutPopupService.cs new file mode 100644 index 0000000..5ea7494 --- /dev/null +++ b/CursorLang/Services/ILayoutPopupService.cs @@ -0,0 +1,11 @@ +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// Показывает подсказку с раскладкой у курсора. +/// +public interface ILayoutPopupService +{ + void Show(KeyboardLayout layout); +} diff --git a/CursorLang/Services/KeyboardLayoutService.cs b/CursorLang/Services/KeyboardLayoutService.cs new file mode 100644 index 0000000..1395d12 --- /dev/null +++ b/CursorLang/Services/KeyboardLayoutService.cs @@ -0,0 +1,86 @@ +using System.Windows.Threading; +using CursorLang.Interop; +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// Настройки слежения за раскладкой. +/// +public sealed class KeyboardLayoutOptions +{ + /// Как часто проверять раскладку активного окна. + public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(150); +} + +/// +/// Опрашивает активное окно по таймеру. +/// +/// +/// Опрос выбран не от простоты: событийных способов узнать о смене раскладки +/// в чужом процессе из managed-кода нет. HSHELL_LANGUAGE от RegisterShellHookWindow +/// в Windows 10/11 не приходит, а уведомления TSF (ITfLanguageProfileNotifySink) +/// сообщают только о смене языка внутри своего процесса — оба варианта проверены +/// и не сработали. Один тик — три вызова Win32, читающих данные из памяти ядра. +/// +public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable +{ + private readonly DispatcherTimer _pollTimer; + private int _lastLocaleId = -1; + private IntPtr _lastForegroundWindow; + + public KeyboardLayoutService(KeyboardLayoutOptions options) + { + _pollTimer = new DispatcherTimer { Interval = options.PollInterval }; + _pollTimer.Tick += OnTick; + } + + public event EventHandler? LayoutChanged; + + public KeyboardLayout Current => + KeyboardLayout.FromLocaleId(KeyboardLayoutNative.GetLocaleIdOf(KeyboardLayoutNative.GetForegroundWindow())); + + public void Start() + { + _lastForegroundWindow = KeyboardLayoutNative.GetForegroundWindow(); + _lastLocaleId = KeyboardLayoutNative.GetLocaleIdOf(_lastForegroundWindow); + _pollTimer.Start(); + } + + public void Stop() => _pollTimer.Stop(); + + public void Dispose() + { + _pollTimer.Stop(); + _pollTimer.Tick -= OnTick; + } + + private void OnTick(object? sender, EventArgs e) + { + IntPtr foreground = KeyboardLayoutNative.GetForegroundWindow(); + if (foreground == IntPtr.Zero) + { + return; + } + + bool appSwitched = foreground != _lastForegroundWindow; + _lastForegroundWindow = foreground; + + int localeId = KeyboardLayoutNative.GetLocaleIdOf(foreground); + if (localeId == _lastLocaleId) + { + return; + } + + _lastLocaleId = localeId; + + // Переход в другое приложение со своей раскладкой — не то же самое, + // что переключение раскладки пользователем, и подписчики вправе + // реагировать на эти случаи по-разному + LayoutChangeReason reason = appSwitched + ? LayoutChangeReason.ApplicationSwitched + : LayoutChangeReason.UserSwitched; + + LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(KeyboardLayout.FromLocaleId(localeId), reason)); + } +} diff --git a/CursorLang/Services/LayoutPopupService.cs b/CursorLang/Services/LayoutPopupService.cs new file mode 100644 index 0000000..144390e --- /dev/null +++ b/CursorLang/Services/LayoutPopupService.cs @@ -0,0 +1,61 @@ +using System.Windows.Threading; +using CursorLang.Models; +using CursorLang.ViewModels; +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; +} + +/// +/// Управляет временем жизни подсказки: окно отвечает только за показ, +/// а решение «когда показать и когда убрать» принимается здесь. +/// +public sealed class LayoutPopupService : ILayoutPopupService, IDisposable +{ + private readonly LayoutPopupWindow _window; + private readonly LayoutPopupViewModel _viewModel; + private readonly DispatcherTimer _hideTimer; + + public LayoutPopupService(LayoutPopupWindow window, LayoutPopupViewModel viewModel, PopupOptions options) + { + _window = window; + _viewModel = viewModel; + + _hideTimer = new DispatcherTimer { Interval = options.Duration }; + _hideTimer.Tick += OnHideTimerTick; + } + + public void Show(KeyboardLayout layout) + { + _viewModel.ShortName = layout.ShortName; + _window.ShowAtCursor(); + + // Перезапускаем таймер, чтобы быстрые переключения продлевали показ + _hideTimer.Stop(); + _hideTimer.Start(); + } + + public void Dispose() + { + _hideTimer.Stop(); + _hideTimer.Tick -= OnHideTimerTick; + _window.Close(); + } + + private void OnHideTimerTick(object? sender, EventArgs e) + { + _hideTimer.Stop(); + _window.Hide(); + } +} diff --git a/CursorLang/ViewModels/LayoutPopupViewModel.cs b/CursorLang/ViewModels/LayoutPopupViewModel.cs new file mode 100644 index 0000000..99da121 --- /dev/null +++ b/CursorLang/ViewModels/LayoutPopupViewModel.cs @@ -0,0 +1,12 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CursorLang.ViewModels; + +/// +/// Содержимое подсказки у курсора. +/// +public sealed partial class LayoutPopupViewModel : ObservableObject +{ + [ObservableProperty] + private string _shortName = "—"; +} diff --git a/CursorLang/ViewModels/MainViewModel.cs b/CursorLang/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..78c5cb5 --- /dev/null +++ b/CursorLang/ViewModels/MainViewModel.cs @@ -0,0 +1,41 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CursorLang.Models; +using CursorLang.Services; + +namespace CursorLang.ViewModels; + +/// +/// Показывает текущую раскладку в главном окне и просит показать подсказку +/// у курсора, когда пользователь переключил раскладку сам. +/// +public sealed partial class MainViewModel : ObservableObject, IDisposable +{ + private readonly IKeyboardLayoutService _layoutService; + private readonly ILayoutPopupService _popupService; + + [ObservableProperty] + private string _currentLayout; + + public MainViewModel(IKeyboardLayoutService layoutService, ILayoutPopupService popupService) + { + _layoutService = layoutService; + _popupService = popupService; + + _currentLayout = layoutService.Current.DisplayName; + _layoutService.LayoutChanged += OnLayoutChanged; + } + + public void Dispose() => _layoutService.LayoutChanged -= OnLayoutChanged; + + private void OnLayoutChanged(object? sender, LayoutChangedEventArgs e) + { + CurrentLayout = e.Layout.DisplayName; + + // При переходе в другое приложение раскладка меняется без участия + // пользователя, и всплывающая подсказка была бы навязчивой + if (e.Reason == LayoutChangeReason.UserSwitched) + { + _popupService.Show(e.Layout); + } + } +} diff --git a/CursorLang/LayoutPopupWindow.xaml b/CursorLang/Views/LayoutPopupWindow.xaml similarity index 57% rename from CursorLang/LayoutPopupWindow.xaml rename to CursorLang/Views/LayoutPopupWindow.xaml index da327e0..0ce3a2e 100644 --- a/CursorLang/LayoutPopupWindow.xaml +++ b/CursorLang/Views/LayoutPopupWindow.xaml @@ -1,6 +1,11 @@ - - + Text="{Binding ShortName}" /> diff --git a/CursorLang/Views/LayoutPopupWindow.xaml.cs b/CursorLang/Views/LayoutPopupWindow.xaml.cs new file mode 100644 index 0000000..b3e07cf --- /dev/null +++ b/CursorLang/Views/LayoutPopupWindow.xaml.cs @@ -0,0 +1,75 @@ +using System.Windows; +using System.Windows.Interop; +using CursorLang.Interop; +using CursorLang.Services; +using CursorLang.ViewModels; + +namespace CursorLang.Views; + +/// +/// Всплывающая подсказка у курсора с коротким именем раскладки. +/// Отвечает только за показ: когда её убрать, решает . +/// +public partial class LayoutPopupWindow : Window +{ + private readonly double _cursorOffset; + private PopupWindowNative.Point _cursor; + + public LayoutPopupWindow(LayoutPopupViewModel viewModel, PopupOptions options) + { + InitializeComponent(); + + DataContext = viewModel; + _cursorOffset = options.CursorOffset; + } + + /// + /// Показывает подсказку у текущего положения курсора. + /// + public void ShowAtCursor() + { + ResizeToContent(); + _cursor = PopupWindowNative.GetCursorPosition(); + + // Для уже созданного окна двигаем до показа; при самом первом вызове + // хэндла ещё нет, и позиционирование выполнит OnSourceInitialized + MoveToCursor(); + Show(); + } + + // Хэндл создан, но окно ещё не отрисовано: здесь и настраиваем стили, + // и ставим окно на место, чтобы первый показ прошёл уже на нужном мониторе + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + + PopupWindowNative.MakePassive(new WindowInteropHelper(this).Handle); + MoveToCursor(); + } + + // Размер считаем сами, а не через SizeToContent: тот вычисляет его при создании + // окна, когда содержимое ещё не измерено, и первый показ выходит шире текста. + // Рамок у окна нет, поэтому его размер равен размеру содержимого + private void ResizeToContent() + { + var content = (FrameworkElement)Content; + content.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + + Width = content.DesiredSize.Width; + Height = content.DesiredSize.Height; + } + + private void MoveToCursor() + { + IntPtr handle = new WindowInteropHelper(this).Handle; + if (handle == IntPtr.Zero) + { + return; + } + + // Отступ задан в единицах WPF, а двигаем окно в пикселях: пересчитываем + // его по масштабу монитора, на котором сейчас курсор + int offset = (int)Math.Round(_cursorOffset * PopupWindowNative.GetScaleAt(_cursor)); + PopupWindowNative.MoveTo(handle, _cursor.X + offset, _cursor.Y + offset); + } +} diff --git a/CursorLang/MainWindow.xaml b/CursorLang/Views/MainWindow.xaml similarity index 64% rename from CursorLang/MainWindow.xaml rename to CursorLang/Views/MainWindow.xaml index d1ffd46..9d2c61f 100644 --- a/CursorLang/MainWindow.xaml +++ b/CursorLang/Views/MainWindow.xaml @@ -1,15 +1,16 @@ - - diff --git a/CursorLang/Views/MainWindow.xaml.cs b/CursorLang/Views/MainWindow.xaml.cs new file mode 100644 index 0000000..e48bb74 --- /dev/null +++ b/CursorLang/Views/MainWindow.xaml.cs @@ -0,0 +1,16 @@ +using System.Windows; +using CursorLang.ViewModels; + +namespace CursorLang.Views; + +/// +/// Главное окно: показывает текущую раскладку. +/// +public partial class MainWindow : Window +{ + public MainWindow(MainViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + } +}