Skip to content
github-actions[bot] edited this page Sep 30, 2026 · 2 revisions

Input handling

Package Dapplo.Windows.Input. Full version: Keyboard and mouse.

Keyboard hook

KeyboardHook and MouseHook are static and run on their own thread. Your OnNext runs on that thread while the keyboard of the whole system waits, and Windows removes a hook that is too slow. So only decide Handled there, and ObserveOn for the real work. Listeners that never set Handled should use KeyboardEventsNonBlocking.

// The hook is installed with the first subscription and removed with the last
IDisposable subscription = KeyboardHook.KeyboardEvents
    .Where(args => args.IsKeyDown)
    .Subscribe(args => Console.WriteLine($"{args.Key} down, Ctrl: {args.IsControl}, Shift: {args.IsShift}"));

// ...
subscription.Dispose();
// Decide Handled on the hook thread (quick!), then do the real work on the UI thread.
// Call this on the UI thread, so SynchronizationContext.Current is the one of the UI.
var subscription = KeyboardHook.KeyboardEvents
    .Where(args => args.Key == VirtualKeyCode.PrintScreen)
    // Swallow the key-down and the key-up, so Windows doesn't take its own screenshot
    .Do(args => args.Handled = true)
    .Where(args => args.IsKeyDown)
    .ObserveOn(SynchronizationContext.Current)
    .Subscribe(args => TakeScreenshot());

Key combinations

KeyCombinationHandler fires when exactly the keys of the combination are down, and swallows them (set IsPassThrough = true to let them through). Control, Shift, Menu (Alt) and Win match the left and right key.

// Ctrl+Shift+S, Control and Shift match both the left and the right key.
// The handler marks the key events as handled, so other applications don't see the combination.
var subscription = KeyboardHook.KeyboardEvents
    .Where(new KeyCombinationHandler(VirtualKeyCode.Control, VirtualKeyCode.Shift, VirtualKeyCode.KeyS))
    .ObserveOn(SynchronizationContext.Current)
    .Subscribe(args => SaveAll());

A handler has state: use one instance per subscription, or the factory overload of Where:

// A handler keeps track of the pressed keys, so it may be used by one subscription only.
// When an observable is subscribed more than once, give Where a factory: every subscription gets its own handler.
IObservable<KeyboardHookEventArgs> saveHotkey = KeyboardHook.KeyboardEvents
    .Where(() => new KeyCombinationHandler(VirtualKeyCode.Control, VirtualKeyCode.KeyS));

var first = saveHotkey.Subscribe(_ => Console.WriteLine("Save (1)"));
var second = saveHotkey.Subscribe(_ => Console.WriteLine("Save (2)"));
// Ctrl+K followed by Ctrl+C (like Visual Studio), at most 2 seconds apart
var handler = new KeySequenceHandler(
    new KeyCombinationHandler(VirtualKeyCode.Control, VirtualKeyCode.KeyK),
    new KeyCombinationHandler(VirtualKeyCode.Control, VirtualKeyCode.KeyC))
{
    Timeout = TimeSpan.FromSeconds(2)
};

var subscription = KeyboardHook.KeyboardEvents.Where(handler).Subscribe(_ => Console.WriteLine("Comment selection"));

TriggerMode sets when a KeyCombinationHandler fires: KeyDown (default), FirstKeyUp (the first key of the combination is released, the others can still be down) or AllKeysUp (the last key is released; another key pressed in between cancels it). In the key-up modes the keys are passed on to the active application. AllKeysUp is the mode for hotkeys which send input, see the documentation.

// Ctrl+Alt+D types the date into the active application, when the user released all keys of the combination
var handler = new KeyCombinationHandler(VirtualKeyCode.Control, VirtualKeyCode.Menu, VirtualKeyCode.KeyD)
{
    TriggerMode = TriggerMode.AllKeysUp
};

var subscription = KeyboardHook.KeyboardEvents
    .Where(handler)
    // Leave the hook thread, sending input from there would block the keyboard
    .ObserveOn(TaskPoolScheduler.Default)
    // No key of the combination is down anymore, so the text isn't combined with Ctrl or Alt
    .Subscribe(_ => KeyboardInputGenerator.TypeText(DateTime.Now.ToString("yyyy-MM-dd")));

KeyboardState.IsDown / IsAnyDown read the current (asynchronous) key state, IsDownForCurrentThread and IsToggled the state of the calling thread's message queue:

// Right now, system wide (GetAsyncKeyState): use this in background threads and hooks
bool shiftDown = KeyboardState.IsDown(VirtualKeyCode.Shift);
bool anyModifier = KeyboardState.IsAnyDown(VirtualKeyCode.Shift, VirtualKeyCode.Control, VirtualKeyCode.Menu, VirtualKeyCode.Win);

// At the time of the keyboard message the UI thread is processing (GetKeyState): use this in a key event handler
bool ctrlWithThisKey = KeyboardState.IsDownForCurrentThread(VirtualKeyCode.Control);

// Toggle keys
bool capsLock = KeyboardState.IsToggled(VirtualKeyCode.Capital);

Text sent as Unicode characters (TypeText, an IME, remote desktop) arrives in the hook as VirtualKeyCode.Packet events (IsPacket, PacketCharacter); the handlers ignore them.

Generating input

// Press and release keys one after the other: types "hi" (with the current keyboard layout)
KeyboardInputGenerator.KeyPresses(VirtualKeyCode.KeyH, VirtualKeyCode.KeyI);

// Ctrl+C: all keys down, then all keys up in reverse order
KeyboardInputGenerator.KeyCombinationPress(VirtualKeyCode.Control, VirtualKeyCode.KeyC);

// Hold Shift while pressing some keys
KeyboardInputGenerator.KeyDown(VirtualKeyCode.Shift);
KeyboardInputGenerator.KeyPresses(VirtualKeyCode.Right, VirtualKeyCode.Right);
KeyboardInputGenerator.KeyUp(VirtualKeyCode.Shift);

// Win+D shows the desktop, Win is sent as the left Windows key
KeyboardInputGenerator.KeyCombinationPress(VirtualKeyCode.Win, VirtualKeyCode.KeyD);

TypeText types text as Unicode characters, independent of the keyboard layout. Line breaks become Enter, \t becomes Tab, other control characters are skipped. Keys the user holds are combined with the input.

// Types the text as Unicode characters, independent of the keyboard layout.
// Line breaks become Enter, \t becomes Tab, other control characters are skipped.
var text = "Grüße aus Köln 👋\r\nPrice:\t42 €";
uint inserted = KeyboardInputGenerator.TypeText(text);

// Two events (down and up) per UTF-16 code unit, Enter and Tab, fewer means the input was blocked
if (inserted < KeyboardInput.ForText(text).Length)
{
    Console.Error.WriteLine("The input was blocked, e.g. by an elevated application");
}
// Coordinates are screen pixels, also on monitors left of or above the primary one
MouseInputGenerator.MoveMouse(new NativePoint(100, 200));

// Click at the current position, or at a location
MouseInputGenerator.MouseClick(MouseButtons.Left);
MouseInputGenerator.MouseClick(MouseButtons.Right, new NativePoint(300, 400));

// Drag: press, move, release
MouseInputGenerator.MouseDown(MouseButtons.Left, new NativePoint(100, 100));
MouseInputGenerator.MoveMouse(new NativePoint(400, 100));
MouseInputGenerator.MouseUp(MouseButtons.Left, new NativePoint(400, 100));

// Scroll one notch down
MouseInputGenerator.MoveMouseWheel(-120);

Mouse hook

// WindowsMessage tells what happened: WM_MOUSEMOVE, WM_LBUTTONDOWN, WM_MOUSEWHEEL, ...
var clicks = MouseHook.MouseEvents
    .Where(args => args.WindowsMessage == WindowsMessages.WM_LBUTTONDOWN)
    .Subscribe(args => Console.WriteLine($"Left click at {args.Point}"));

// Wheel events have a WheelDelta, 120 is one notch (positive is away from the user)
var wheel = MouseHook.MouseEventsNonBlocking
    .Where(args => args.WindowsMessage == WindowsMessages.WM_MOUSEWHEEL)
    .Subscribe(args => Console.WriteLine($"Wheel {args.WheelDelta / 120} notches"));

// Mouse moves are frequent: sample them, and never do slow work in the hook
var moves = MouseHook.MouseEventsNonBlocking
    .Where(args => args.WindowsMessage == WindowsMessages.WM_MOUSEMOVE)
    .Sample(TimeSpan.FromMilliseconds(100))
    .Subscribe(args => Console.WriteLine($"Mouse at {args.Point}"));

Raw input, idle time and suppressing mouse events: see the documentation.

Clone this wiki locally