diff --git a/CursorLang/App.xaml.cs b/CursorLang/App.xaml.cs index f87db0c..608f4d9 100644 --- a/CursorLang/App.xaml.cs +++ b/CursorLang/App.xaml.cs @@ -28,12 +28,11 @@ public partial class App : Application MainWindow.Show(); _services.GetRequiredService().Start(); + _services.GetRequiredService().Start(); } protected override void OnExit(ExitEventArgs e) { - // Контейнер сам остановит таймеры, сохранит настройки - // и закроет окно подсказки _services?.Dispose(); base.OnExit(e); } @@ -51,7 +50,9 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/CursorLang/Interop/KeyboardLayoutNative.cs b/CursorLang/Interop/KeyboardLayoutNative.cs index d706d2e..cd90b32 100644 --- a/CursorLang/Interop/KeyboardLayoutNative.cs +++ b/CursorLang/Interop/KeyboardLayoutNative.cs @@ -7,6 +7,14 @@ namespace CursorLang.Interop; /// internal static class KeyboardLayoutNative { + private const uint WmInputLangChangeRequest = 0x0050; + + /// Взять следующую раскладку из системного списка. + private static readonly IntPtr InputLangChangeForward = new(0x0002); + + /// HKL_NEXT — та же просьба на языке старых версий Windows. + private static readonly IntPtr HklNext = new(1); + [DllImport("user32.dll")] internal static extern IntPtr GetForegroundWindow(); @@ -16,24 +24,53 @@ internal static class KeyboardLayoutNative [DllImport("user32.dll")] private static extern IntPtr GetKeyboardLayout(uint idThread); + [DllImport("user32.dll")] + private static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + /// /// Раскладка, которой сейчас печатает пользователь. /// + internal static int GetActiveLocaleId() => GetLocaleIdOf(GetInputWindow()); + + /// + /// Просит активное приложение перейти на следующую раскладку из системного списка. + /// + /// + /// Синтезировать системное сочетание вроде Alt+Shift не годится: его назначение + /// пользователь меняет в настройках Windows и может отключить вовсе. Просьба + /// сообщением от таких настроек не зависит и работает в чужом процессе. + /// + internal static void RequestNextLayout() + { + IntPtr target = GetInputWindow(); + if (target == IntPtr.Zero) + { + return; + } + + // Оба параметра означают одно и то же: разные версии Windows и разные + // библиотеки интерфейса смотрят то на флаг, то на lParam + PostMessage(target, WmInputLangChangeRequest, InputLangChangeForward, HklNext); + } + + /// + /// Окно, которому принадлежит ввод с клавиатуры. + /// /// /// Спрашиваем окно с фокусом клавиатуры, а не окно верхнего плана: у Блокнота /// Windows 11, меню «Пуск» и прочих приложений на WinUI поле ввода живёт в /// отдельном потоке, и раскладка меняется только у него. У потока главного /// окна она остаётся прежней, и переключение проходит незамеченным. /// - internal static int GetActiveLocaleId() + private static IntPtr GetInputWindow() { if (ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info) && info.hwndFocus != IntPtr.Zero) { - return GetLocaleIdOf(info.hwndFocus); + return info.hwndFocus; } - return GetLocaleIdOf(GetForegroundWindow()); + return GetForegroundWindow(); } /// diff --git a/CursorLang/Interop/LowLevelKeyboardHook.cs b/CursorLang/Interop/LowLevelKeyboardHook.cs new file mode 100644 index 0000000..1fc3ae8 --- /dev/null +++ b/CursorLang/Interop/LowLevelKeyboardHook.cs @@ -0,0 +1,130 @@ +using System.Runtime.InteropServices; + +namespace CursorLang.Interop; + +/// +/// Системный перехватчик клавиатуры (WH_KEYBOARD_LL): видит нажатия во всех +/// приложениях и умеет не пропускать их дальше. +/// +/// +/// Ставится только низкоуровневый хук: обычный WH_KEYBOARD требует внедрения +/// DLL в чужие процессы, что для managed-кода невозможно. Обратный вызов +/// приходит на тот поток, который поставил хук, и поток обязан крутить цикл +/// сообщений — отсюда требование ставить хук из потока пользовательского +/// интерфейса. Возврат управления затягивать нельзя: по истечении системного +/// тайм-аута Windows молча снимает хук. +/// +internal sealed class LowLevelKeyboardHook : IDisposable +{ + /// + /// Обработчик события клавиши. Возвращает true, если событие нужно + /// проглотить — тогда приложение переднего плана его не увидит. + /// + internal delegate bool KeyFilter(int virtualKey, bool isKeyDown); + + private const int WhKeyboardLowLevel = 13; + private const int HcAction = 0; + + private const int WmKeyDown = 0x0100; + private const int WmKeyUp = 0x0101; + private const int WmSysKeyDown = 0x0104; + private const int WmSysKeyUp = 0x0105; + + /// Событие пришло не от живого нажатия, а из SendInput. + private const uint LowLevelKeyHookFlagInjected = 0x10; + + private readonly KeyFilter _filter; + + // Делегат живёт в поле не для удобства: ссылку на него держит только Win32, + // о которой сборщик мусора не знает, и без поля хук перестаёт работать + // через случайное время + private readonly HookProc _callback; + + private IntPtr _handle; + + internal LowLevelKeyboardHook(KeyFilter filter) + { + _filter = filter; + _callback = OnHookEvent; + } + + private delegate IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam); + + internal bool IsInstalled => _handle != IntPtr.Zero; + + /// + /// Ставит хук. Возвращает false, если система отказала. + /// + internal bool Install() + { + if (_handle != IntPtr.Zero) + { + return true; + } + + _handle = SetWindowsHookEx(WhKeyboardLowLevel, _callback, GetModuleHandle(null), 0); + return _handle != IntPtr.Zero; + } + + internal void Uninstall() + { + if (_handle == IntPtr.Zero) + { + return; + } + + UnhookWindowsHookEx(_handle); + _handle = IntPtr.Zero; + } + + public void Dispose() => Uninstall(); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, uint dwThreadId); + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool UnhookWindowsHookEx(IntPtr hhk); + + [DllImport("user32.dll")] + private static extern IntPtr CallNextHookEx(IntPtr hhk, int code, IntPtr wParam, IntPtr lParam); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr GetModuleHandle(string? lpModuleName); + + private IntPtr OnHookEvent(int code, IntPtr wParam, IntPtr lParam) + { + if (code != HcAction) + { + return CallNextHookEx(_handle, code, wParam, lParam); + } + + var message = (int)wParam; + bool isKeyDown = message is WmKeyDown or WmSysKeyDown; + bool isKeyUp = message is WmKeyUp or WmSysKeyUp; + + var data = Marshal.PtrToStructure(lParam); + + // Синтетический ввод присылают экранные клавиатуры, программы + // автозамены и средства автоматизации: подменять их работу не наше дело + bool injected = (data.flags & LowLevelKeyHookFlagInjected) != 0; + + if ((isKeyDown || isKeyUp) && !injected && _filter((int)data.vkCode, isKeyDown)) + { + // Ненулевой результат вместо CallNextHookEx обрывает цепочку: событие + // не дойдёт ни до приложения, ни до обработчика регистра в Windows + return 1; + } + + return CallNextHookEx(_handle, code, wParam, lParam); + } + + [StructLayout(LayoutKind.Sequential)] + private struct KeyboardHookData + { + public uint vkCode; + public uint scanCode; + public uint flags; + public uint time; + public IntPtr dwExtraInfo; + } +} diff --git a/CursorLang/Models/AppSettings.cs b/CursorLang/Models/AppSettings.cs index 3c1b328..94f8bb4 100644 --- a/CursorLang/Models/AppSettings.cs +++ b/CursorLang/Models/AppSettings.cs @@ -56,6 +56,20 @@ public sealed partial class AppSettings : ObservableObject [ObservableProperty] private double _durationMilliseconds = 500; + /// + /// Перехватывать Caps Lock и переключать по ней раскладку вместо смены регистра. + /// По умолчанию выключено: приложение не должно менять поведение системы, + /// пока его об этом не попросили. + /// + [ObservableProperty] + private bool _useCapsLockHotkey; + + /// + /// С какого времени удержания Caps Lock переключение отменяется, в миллисекундах. + /// + [ObservableProperty] + private double _capsLockHoldMilliseconds = 300; + [ObservableProperty] private Color _backgroundColor = Color.FromRgb(0x20, 0x20, 0x20); @@ -64,4 +78,7 @@ public sealed partial class AppSettings : ObservableObject [JsonIgnore] public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMilliseconds); + + [JsonIgnore] + public TimeSpan CapsLockHoldDelay => TimeSpan.FromMilliseconds(CapsLockHoldMilliseconds); } diff --git a/CursorLang/Resources/Strings.resx b/CursorLang/Resources/Strings.resx index fde6dd5..c052c17 100644 --- a/CursorLang/Resources/Strings.resx +++ b/CursorLang/Resources/Strings.resx @@ -172,4 +172,16 @@ ms + + Caps Lock + + + Switch the layout instead of changing case + + + Hold threshold + + + Holding the key longer shows the tooltip without switching the layout. + diff --git a/CursorLang/Resources/Strings.ru.resx b/CursorLang/Resources/Strings.ru.resx index 161acd8..a77b2b4 100644 --- a/CursorLang/Resources/Strings.ru.resx +++ b/CursorLang/Resources/Strings.ru.resx @@ -172,4 +172,16 @@ мс + + Caps Lock + + + Переключать раскладку вместо смены регистра + + + Порог удержания + + + Более долгое нажатие показывает подсказку и не переключает раскладку. + diff --git a/CursorLang/Services/CapsLockHotkeyService.cs b/CursorLang/Services/CapsLockHotkeyService.cs new file mode 100644 index 0000000..8a1a9ed --- /dev/null +++ b/CursorLang/Services/CapsLockHotkeyService.cs @@ -0,0 +1,135 @@ +using System.Windows.Threading; +using CursorLang.Interop; +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// Держит системный перехват Caps Lock и отличает короткое нажатие от удержания. +/// +/// +/// Различить их можно только по факту отпускания клавиши, поэтому перехватываются +/// оба события — и нажатие, и отпускание. Заодно это единственный способ отменить +/// смену регистра: Windows переключает Caps Lock по событию нажатия, и пропустить +/// его «на всякий случай» нельзя. +/// +public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable +{ + private const int VirtualKeyCapsLock = 0x14; + + private readonly AppSettings _settings; + private readonly LowLevelKeyboardHook _hook; + private readonly DispatcherTimer _holdTimer = new(); + private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher; + + private bool _isPressed; + private bool _isHolding; + + public CapsLockHotkeyService(AppSettings settings) + { + _settings = settings; + _hook = new LowLevelKeyboardHook(OnKeyEvent); + _holdTimer.Tick += OnHoldTimerTick; + } + + public event EventHandler? Tapped; + + public event EventHandler? HoldStarted; + + public event EventHandler? HoldEnded; + + public bool IsRunning => _hook.IsInstalled; + + public void Start() => _hook.Install(); + + public void Stop() + { + _hook.Uninstall(); + ResetPress(); + } + + // При закрытии приложения событий уже никто не ждёт, поэтому в отличие + // от Stop состояние сбрасывается молча: очередь диспетчера в этот момент + // может быть закрыта + public void Dispose() + { + _holdTimer.Tick -= OnHoldTimerTick; + _holdTimer.Stop(); + _isPressed = false; + _isHolding = false; + _hook.Dispose(); + } + + // Вызывается системным хуком, то есть внутри разбора очереди сообщений. + // Здесь только учёт состояния: показывать окна и рассылать события отсюда + // нельзя — обработчик обязан вернуть управление за считанные миллисекунды + private bool OnKeyEvent(int virtualKey, bool isKeyDown) + { + if (virtualKey != VirtualKeyCapsLock) + { + return false; + } + + if (isKeyDown) + { + // Пока клавишу держат, Windows повторяет нажатие: отсчёт удержания + // ведём от первого события и на повторы не реагируем + if (!_isPressed) + { + _isPressed = true; + + // Порог читаем при каждом нажатии: его меняют в настройках на лету + _holdTimer.Interval = _settings.CapsLockHoldDelay; + _holdTimer.Start(); + } + + return true; + } + + _isPressed = false; + _holdTimer.Stop(); + + if (_isHolding) + { + _isHolding = false; + Notify(HoldEnded); + } + else + { + Notify(Tapped); + } + + return true; + } + + private void OnHoldTimerTick(object? sender, EventArgs e) + { + _holdTimer.Stop(); + _isHolding = true; + HoldStarted?.Invoke(this, EventArgs.Empty); + } + + // Событие уходит подписчикам после возврата из хука: они вправе показывать + // окна и делать что угодно ещё, не задерживая обработку нажатия + private void Notify(EventHandler? handler) + { + if (handler is not null) + { + _dispatcher.BeginInvoke(() => handler(this, EventArgs.Empty)); + } + } + + // Перехват могли снять с зажатой клавишей — например, сняв галочку + // в настройках. Подсказку в этом случае нужно убрать + private void ResetPress() + { + _isPressed = false; + _holdTimer.Stop(); + + if (_isHolding) + { + _isHolding = false; + Notify(HoldEnded); + } + } +} diff --git a/CursorLang/Services/CapsLockSwitchCoordinator.cs b/CursorLang/Services/CapsLockSwitchCoordinator.cs new file mode 100644 index 0000000..a7b7a50 --- /dev/null +++ b/CursorLang/Services/CapsLockSwitchCoordinator.cs @@ -0,0 +1,77 @@ +using System.ComponentModel; +using CursorLang.Models; + +namespace CursorLang.Services; + +/// +/// Превращает нажатия Caps Lock в действия: короткое — переключает раскладку, +/// длинное — только показывает подсказку. Он же включает и выключает перехват +/// вслед за галочкой в настройках. +/// +public sealed class CapsLockSwitchCoordinator : IDisposable +{ + private readonly ICapsLockHotkeyService _hotkeyService; + private readonly IKeyboardLayoutService _layoutService; + private readonly ILayoutPopupService _popupService; + private readonly AppSettings _settings; + + public CapsLockSwitchCoordinator( + ICapsLockHotkeyService hotkeyService, + IKeyboardLayoutService layoutService, + ILayoutPopupService popupService, + AppSettings settings) + { + _hotkeyService = hotkeyService; + _layoutService = layoutService; + _popupService = popupService; + _settings = settings; + } + + public void Start() + { + _hotkeyService.Tapped += OnTapped; + _hotkeyService.HoldStarted += OnHoldStarted; + _hotkeyService.HoldEnded += OnHoldEnded; + _settings.PropertyChanged += OnSettingsChanged; + + ApplySetting(); + } + + public void Dispose() + { + _settings.PropertyChanged -= OnSettingsChanged; + _hotkeyService.Tapped -= OnTapped; + _hotkeyService.HoldStarted -= OnHoldStarted; + _hotkeyService.HoldEnded -= OnHoldEnded; + _hotkeyService.Stop(); + } + + private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(AppSettings.UseCapsLockHotkey)) + { + ApplySetting(); + } + } + + private void ApplySetting() + { + if (_settings.UseCapsLockHotkey) + { + _hotkeyService.Start(); + } + else + { + _hotkeyService.Stop(); + } + } + + private void OnTapped(object? sender, EventArgs e) => _layoutService.SwitchToNext(); + + // Раскладку не меняем, но и молчать нельзя: без подсказки долгое нажатие + // выглядит как то, что клавиша просто не сработала + private void OnHoldStarted(object? sender, EventArgs e) => + _popupService.ShowUntilHidden(_layoutService.Current); + + private void OnHoldEnded(object? sender, EventArgs e) => _popupService.Hide(); +} diff --git a/CursorLang/Services/ICapsLockHotkeyService.cs b/CursorLang/Services/ICapsLockHotkeyService.cs new file mode 100644 index 0000000..c0fd0e6 --- /dev/null +++ b/CursorLang/Services/ICapsLockHotkeyService.cs @@ -0,0 +1,29 @@ +namespace CursorLang.Services; + +/// +/// Перехватывает Caps Lock на уровне системы и делит нажатия на короткие +/// и длинные. Что делать с ними — решают подписчики. +/// +public interface ICapsLockHotkeyService +{ + /// Короткое нажатие: клавишу отпустили до порога удержания. + event EventHandler? Tapped; + + /// Порог удержания пройден, клавишу всё ещё держат. + event EventHandler? HoldStarted; + + /// Удержание закончилось: клавишу отпустили. + event EventHandler? HoldEnded; + + /// Стоит ли перехват прямо сейчас. + bool IsRunning { get; } + + /// + /// Начинает перехват. Вызывать нужно из потока пользовательского интерфейса: + /// системный хук клавиатуры работает только на потоке с циклом сообщений. + /// + void Start(); + + /// Снимает перехват, возвращая клавише её обычное поведение. + void Stop(); +} diff --git a/CursorLang/Services/IKeyboardLayoutService.cs b/CursorLang/Services/IKeyboardLayoutService.cs index 0a16570..3687168 100644 --- a/CursorLang/Services/IKeyboardLayoutService.cs +++ b/CursorLang/Services/IKeyboardLayoutService.cs @@ -3,7 +3,8 @@ using CursorLang.Models; namespace CursorLang.Services; /// -/// Следит за раскладкой активного окна — в том числе в чужих приложениях. +/// Следит за раскладкой активного окна — в том числе в чужих приложениях — +/// и умеет её переключать. /// public interface IKeyboardLayoutService { @@ -15,4 +16,6 @@ public interface IKeyboardLayoutService void Start(); void Stop(); + + void SwitchToNext(); } diff --git a/CursorLang/Services/ILayoutPopupService.cs b/CursorLang/Services/ILayoutPopupService.cs index 5ea7494..abea18f 100644 --- a/CursorLang/Services/ILayoutPopupService.cs +++ b/CursorLang/Services/ILayoutPopupService.cs @@ -7,5 +7,14 @@ namespace CursorLang.Services; /// public interface ILayoutPopupService { + /// Показывает подсказку и убирает её через заданное в настройках время. void Show(KeyboardLayout layout); + + /// + /// Показывает подсказку до явного вызова : нужна там, где + /// время показа задаёт не таймер, а действие пользователя. + /// + void ShowUntilHidden(KeyboardLayout layout); + + void Hide(); } diff --git a/CursorLang/Services/KeyboardLayoutService.cs b/CursorLang/Services/KeyboardLayoutService.cs index 193bbb6..4810e7e 100644 --- a/CursorLang/Services/KeyboardLayoutService.cs +++ b/CursorLang/Services/KeyboardLayoutService.cs @@ -48,6 +48,8 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable public void Stop() => _pollTimer.Stop(); + public void SwitchToNext() => KeyboardLayoutNative.RequestNextLayout(); + public void Dispose() { _pollTimer.Stop(); diff --git a/CursorLang/Services/LayoutPopupService.cs b/CursorLang/Services/LayoutPopupService.cs index 621b604..124d23b 100644 --- a/CursorLang/Services/LayoutPopupService.cs +++ b/CursorLang/Services/LayoutPopupService.cs @@ -27,16 +27,28 @@ public sealed class LayoutPopupService : ILayoutPopupService, IDisposable public void Show(KeyboardLayout layout) { - _viewModel.ShortName = layout.ShortName; - _window.ShowPopup(); + ShowUntilHidden(layout); // Длительность читаем при каждом показе: её меняют в настройках на лету. // Перезапуск таймера заодно продлевает показ при быстрых переключениях - _hideTimer.Stop(); _hideTimer.Interval = _settings.Duration; _hideTimer.Start(); } + public void ShowUntilHidden(KeyboardLayout layout) + { + _hideTimer.Stop(); + + _viewModel.ShortName = layout.ShortName; + _window.ShowPopup(); + } + + public void Hide() + { + _hideTimer.Stop(); + _window.Hide(); + } + public void Dispose() { _hideTimer.Stop(); diff --git a/CursorLang/Themes/Controls.xaml b/CursorLang/Themes/Controls.xaml index b457c99..8c76073 100644 --- a/CursorLang/Themes/Controls.xaml +++ b/CursorLang/Themes/Controls.xaml @@ -152,6 +152,56 @@ + + + +