added layour swithing by caps key

This commit is contained in:
2026-08-09 01:02:52 +05:00
parent 7c5675f69f
commit e99f30fcbc
15 changed files with 578 additions and 9 deletions
@@ -0,0 +1,135 @@
using System.Windows.Threading;
using CursorLang.Interop;
using CursorLang.Models;
namespace CursorLang.Services;
/// <summary>
/// Держит системный перехват Caps Lock и отличает короткое нажатие от удержания.
/// </summary>
/// <remarks>
/// Различить их можно только по факту отпускания клавиши, поэтому перехватываются
/// оба события — и нажатие, и отпускание. Заодно это единственный способ отменить
/// смену регистра: Windows переключает Caps Lock по событию нажатия, и пропустить
/// его «на всякий случай» нельзя.
/// </remarks>
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);
}
}
}
@@ -0,0 +1,77 @@
using System.ComponentModel;
using CursorLang.Models;
namespace CursorLang.Services;
/// <summary>
/// Превращает нажатия Caps Lock в действия: короткое — переключает раскладку,
/// длинное — только показывает подсказку. Он же включает и выключает перехват
/// вслед за галочкой в настройках.
/// </summary>
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();
}
@@ -0,0 +1,29 @@
namespace CursorLang.Services;
/// <summary>
/// Перехватывает Caps Lock на уровне системы и делит нажатия на короткие
/// и длинные. Что делать с ними — решают подписчики.
/// </summary>
public interface ICapsLockHotkeyService
{
/// <summary>Короткое нажатие: клавишу отпустили до порога удержания.</summary>
event EventHandler? Tapped;
/// <summary>Порог удержания пройден, клавишу всё ещё держат.</summary>
event EventHandler? HoldStarted;
/// <summary>Удержание закончилось: клавишу отпустили.</summary>
event EventHandler? HoldEnded;
/// <summary>Стоит ли перехват прямо сейчас.</summary>
bool IsRunning { get; }
/// <summary>
/// Начинает перехват. Вызывать нужно из потока пользовательского интерфейса:
/// системный хук клавиатуры работает только на потоке с циклом сообщений.
/// </summary>
void Start();
/// <summary>Снимает перехват, возвращая клавише её обычное поведение.</summary>
void Stop();
}
@@ -3,7 +3,8 @@ using CursorLang.Models;
namespace CursorLang.Services;
/// <summary>
/// Следит за раскладкой активного окна — в том числе в чужих приложениях.
/// Следит за раскладкой активного окна — в том числе в чужих приложениях
/// и умеет её переключать.
/// </summary>
public interface IKeyboardLayoutService
{
@@ -15,4 +16,6 @@ public interface IKeyboardLayoutService
void Start();
void Stop();
void SwitchToNext();
}
@@ -7,5 +7,14 @@ namespace CursorLang.Services;
/// </summary>
public interface ILayoutPopupService
{
/// <summary>Показывает подсказку и убирает её через заданное в настройках время.</summary>
void Show(KeyboardLayout layout);
/// <summary>
/// Показывает подсказку до явного вызова <see cref="Hide"/>: нужна там, где
/// время показа задаёт не таймер, а действие пользователя.
/// </summary>
void ShowUntilHidden(KeyboardLayout layout);
void Hide();
}
@@ -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();
+15 -3
View File
@@ -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();