diff --git a/CursorLang/App.xaml.cs b/CursorLang/App.xaml.cs
index 9f3548b..9582793 100644
--- a/CursorLang/App.xaml.cs
+++ b/CursorLang/App.xaml.cs
@@ -1,6 +1,4 @@
-using System.Configuration;
-using System.Data;
-using System.Windows;
+using System.Windows;
namespace CursorLang;
diff --git a/CursorLang/LayoutPopupWindow.xaml b/CursorLang/LayoutPopupWindow.xaml
new file mode 100644
index 0000000..da327e0
--- /dev/null
+++ b/CursorLang/LayoutPopupWindow.xaml
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/CursorLang/LayoutPopupWindow.xaml.cs b/CursorLang/LayoutPopupWindow.xaml.cs
new file mode 100644
index 0000000..3c4a187
--- /dev/null
+++ b/CursorLang/LayoutPopupWindow.xaml.cs
@@ -0,0 +1,149 @@
+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 b/CursorLang/MainWindow.xaml
index 7d85b43..d1ffd46 100644
--- a/CursorLang/MainWindow.xaml
+++ b/CursorLang/MainWindow.xaml
@@ -6,6 +6,10 @@
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
-
+
diff --git a/CursorLang/MainWindow.xaml.cs b/CursorLang/MainWindow.xaml.cs
index 2994a5f..3dac859 100644
--- a/CursorLang/MainWindow.xaml.cs
+++ b/CursorLang/MainWindow.xaml.cs
@@ -1,13 +1,8 @@
-using System.Text;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Data;
-using System.Windows.Documents;
-using System.Windows.Input;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Navigation;
-using System.Windows.Shapes;
+using System.Windows;
+// ====
+using System.Windows.Interop;
+using System.Windows.Threading;
+using System.Runtime.InteropServices;
namespace CursorLang;
@@ -16,8 +11,172 @@ namespace CursorLang;
///
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