From a1ff8cc9234dc0a16f489379c3d39090a8a9e27c Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Sat, 8 Aug 2026 21:03:13 +0500 Subject: [PATCH] implemeted main feature - first try --- CursorLang/App.xaml.cs | 4 +- CursorLang/LayoutPopupWindow.xaml | 22 ++++ CursorLang/LayoutPopupWindow.xaml.cs | 149 ++++++++++++++++++++++ CursorLang/MainWindow.xaml | 6 +- CursorLang/MainWindow.xaml.cs | 179 +++++++++++++++++++++++++-- 5 files changed, 346 insertions(+), 14 deletions(-) create mode 100644 CursorLang/LayoutPopupWindow.xaml create mode 100644 CursorLang/LayoutPopupWindow.xaml.cs 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