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 @@
-
-
+ Content="{Binding CurrentLayout}" />
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;
+ }
+}