Skip to content

Core Input

Mike Strobel edited this page Jun 26, 2026 · 1 revision

Input

Cursorial's input layer turns the raw, protocol-soup byte stream coming off a terminal into a clean sequence of strongly-typed InputEvents — keys, mouse, focus, paste, resize, and device responses. Reach for it whenever you want correct, cross-platform terminal input without hand-rolling a VT/ANSI parser, and especially when you want one event model that's identical across legacy xterm, the Kitty keyboard protocol, and Windows console input.

You usually obtain a device from a TerminalSession (session.Input is an IAsyncInputDevice), but the input types stand alone — you can wrap any byte source, recorded trace, or console handle. Everything lives in the Cursorial.Input namespace (events under Cursorial.Input.Events).

The event model

InputEvent is a sealed record hierarchy. You pattern-match on the concrete type — the same shape Avalonia and WPF consumers will recognize, but delivered as a flat stream rather than routed up a tree (routing is the UI layer's job).

Every event carries:

  • Timestamp (DateTimeOffset) — when the device observed it (stamped from a TimeProvider, so it's deterministic under test).
  • Synthesized (bool) — true when a decorator fabricated the event (a timer-inferred key-up, a recognized click) rather than reading it off the wire. Lets you distinguish device-reported truth from best-effort heuristics.

The concrete types:

Type What it carries
KeyEvent Key, Modifiers, Kind (Down/Up), IsRepeat/RepeatCount, the composed Text, optional RawCode.
MouseEvent Kind (ButtonDown/ButtonUp/Move/Drag/Wheel/Click), Position, Button, ButtonsHeld, Modifiers, WheelDeltaX/Y, ClickCount.
FocusEvent HasFocus — the terminal window gained or lost focus.
PasteEvent Text — a whole bracketed paste delivered as one event.
ResizeEvent Columns/Rows, plus optional PixelWidth/PixelHeight and CellPixelWidth/CellPixelHeight.
DeviceResponseEvent Kind + raw Payload — a reply to a query you issued (DA1/DA2, cursor position, color, …).
UnknownEvent RawBytes — an input chunk the parser couldn't classify, surfaced rather than dropped.
await foreach (var evt in session.Input.ReadAllAsync(ct))
{
    switch (evt)
    {
        case KeyEvent { Kind: KeyEventKind.Down, Key: Key.Escape }:
            return;

        case KeyEvent { Kind: KeyEventKind.Down } k:
            // Match a modifier combination on the lock-state-free Modifiers...
            if (k is { Key: Key.Character, Modifiers: KeyModifiers.Control, Text.Span: "s" })
                Save();
            else if (!k.Text.IsEmpty)
                Type(k.Text.Span);
            break;

        case MouseEvent { Kind: MouseEventKind.ButtonDown } m:
            HandleClick(m.Position.Column, m.Position.Row, m.Button);
            break;

        case ResizeEvent r:
            buffer.Resize(r.Columns, r.Rows);
            break;
    }
}

Keys

KeyEvent.Key is the Key enum — Key.Character for printable keys (read the composed Text), named members otherwise (Enter, Escape, Tab, Backspace, Space, the arrows, Home/PageUp/…, F1F12 and beyond). A key-down is KeyEventKind.Down; auto-repeat arrives as a Down with IsRepeat set (RepeatCount is populated when the source coalesces repeats, as Win32 console does). KeyEventKind.Up is reported only by devices that distinguish release — Kitty-protocol terminals, Win32 input mode, or the key-release synthesizer below.

Two modifier views, deliberately distinct:

  • ModifiersShift/Control/Alt/Super/Hyper/Meta, without lock-key state. Match against this so a Ctrl+S pattern fires regardless of whether CapsLock is on.
  • ExtendedModifiers — the same OR'd with CapsLock/NumLock/ScrollLock toggle bits. Always a superset of Modifiers; read it only when you actually need lock state.

Text is a ReadOnlyMemory<char> (empty for named/control keys). Note the wire reality baked into the model: a real spacebar press is (Key.Character, Text: " ") on every protocol — Key.Space is only ever the NUL/Ctrl+Space wire.

Mouse

Position is a CellPosition (zero-based Column/Row, plus optional PixelX/PixelY when the terminal reports SGR-Pixels coordinates). Button is the button for this event (MouseButton.None for motion); ButtonsHeld is the MouseButtons bitmask of everything down at the moment, so a drag carries an accurate held-button set. Wheel deltas are in 1/120-notch units (WheelDeltaY positive = away from the user). ClickCount defaults to 1 — terminals don't report multi-click, so it stays 1 unless you add a click recognizer to the pipeline (see below).

Pull vs. push delivery

A device offers events through one of two surfaces. Pick exactly one per device instance — a single implementation may expose both, but driving both concurrently has undefined delivery.

PullIAsyncInputDevice.ReadAllAsync(ct) returns an IAsyncEnumerable<InputEvent> you drive with await foreach. This is the natural fit for an event loop you own. Iteration starts reading on the first MoveNextAsync; cancel the token or break to stop. Devices over stdin are single-shot — calling ReadAllAsync twice throws.

PushIEventInputDevice raises classic EventHandler<> events (Input, Error, Completed); call StartAsync to begin pumping and StopAsync to halt. EventInputDevice wraps any pull device as a push device:

var push = new EventInputDevice(session.Input);
push.Input += (_, evt) => Log(evt);
push.Completed += (_, _) => Console.WriteLine("input ended");
await push.StartAsync();

By default handlers fire on the background pump thread. If you're driving a UI, marshal raises onto your dispatcher by passing a SynchronizationContext — or, when constructing on the UI thread, use EventInputDevice.CapturingCurrentContext(inner), which captures SynchronizationContext.Current and Posts each raise onto it (order-preserving, never blocks the pump). EventInputDevice takes ownership of the inner device — disposing the wrapper disposes the inner. It's single-shot for the same reason its source is.

// On a UI thread — Input/Error/Completed handlers run back on this context.
var push = EventInputDevice.CapturingCurrentContext(session.Input);

The pull surface needs no such ceremony: await foreach resumes on the consumer's own captured context, while the input layer's internals stay off it.

Composing devices: decorators and transforms

Input devices form chains. The base interface is IInputDevice (carries InputCapabilities, is IAsyncDisposable); IAsyncInputDevice and IEventInputDevice extend it with the two surfaces.

There are two ways to add a pipeline stage:

  • Decorator — a device that wraps another device and owns its lifetime. Use this when the stage must fabricate events on its own schedule (timers), independent of input arrival. A decorator may implement IInputDeviceDecorator (exposing Inner) so consumers can walk the chain for diagnostics.
  • Transform — an IInputTransformer rewrites the event stream (filter, reorder, fabricate) without owning the source. Prefer this lighter shape for synchronous, source-agnostic stages. TransformingInputDevice is the one reusable adapter that applies any transformer over an inner IAsyncInputDevice; the device.Transform(...) extension is the sugar. Both report effective capabilities via TransformCapabilities, so a pipeline assembler can compute the composed device's capabilities without running it.

Click recognition

MouseClickSynthesizer is a transform that counts rapid presses on the same cell into single/double/triple clicks (WPF-style: the count is decided at the press and carried to the matching release). Add it with the WithClickSynthesis extension:

var device = session.Input.WithClickSynthesis(new MouseClickOptions
{
    MultiClickThreshold = TimeSpan.FromMilliseconds(400), // window for the next press (default 500 ms)
    SynthesizeClickEvents = true,                          // emit a Click event after a same-cell release
    ClickCount = ClickCountTarget.Click,                   // surface the count on the Click (default ButtonDown)
});

await foreach (var evt in device.ReadAllAsync(ct))
    if (evt is MouseEvent { Kind: MouseEventKind.Click, ClickCount: 2 } dbl)
        OpenItemAt(dbl.Position);

MouseClickOptions.ClickCount (a ClickCountTargetNone/ButtonDown/ButtonUp/Click) chooses which event of the gesture carries the count; the others stay at 1. Click requires SynthesizeClickEvents, or the synthesizer throws. It's purely synchronous — no timers — reading each event's Timestamp, so it's deterministic and reusable.

Synthetic key-up / repeat

Many terminals don't report key release at all. KeyReleaseSynthesizer is a device decorator (it owns a timer) that fabricates KeyEventKind.Up and inferred repeat events for those terminals:

var device = new KeyReleaseSynthesizer(session.Input); // default 50 ms up timeout, 150 ms repeat window

Constructor knobs (all optional): upTimeout (time after a Down before a release is fabricated), repeatTimeout (time before the key is forgotten — a new Down inside this window counts as a repeat), and a TimeProvider for deterministic testing. Every event it fabricates carries Synthesized = true, and its capability projection flips DistinguishesKeyUpDown on. Use this only when you genuinely need release events on a terminal that lacks them — if the negotiator already enabled the Kitty keyboard protocol, real up/repeat events arrive natively.

What gets decoded

The interpreter behind a VT input device decodes a broad slice of the terminal input surface into the events above:

  • Keyboard — printable UTF-8 runs (one per grapheme, with cross-read buffering), C0 controls (Tab, Enter, Backspace, NUL→Ctrl+Space, Ctrl+letter), bare ESC, cursor/Home/End/Insert/Delete/PageUp/PageDown, function keys F1–F20 (xterm + vt220 + SS3 forms), BackTab, the full xterm modifier-bit range (Shift/Alt/Ctrl/Super/Hyper/Meta + lock state), modifyOtherKeys level 2, the CSI-u shorthand, the Kitty keyboard protocol (up/down/repeat, text payload, functional keys), and Win32 Input Mode (console input records with VK→Key mapping).
  • Mouse — SGR (DECSET 1006) and SGR-Pixels (1016) press/release/drag/motion/wheel including buttons X1–X4, and X10 mouse. Held-button state accumulates across events.
  • Focus — focus-in/out (only when bracketed focus reporting was enabled).
  • Paste — bracketed paste accumulated into a single PasteEvent.
  • Device responses — DA1/DA2/DA3, cursor position (DSR-CPR), window/cell size in pixels, OSC color queries, XTVERSION, DECRQSS, XTGETTCAP — surfaced as DeviceResponseEvent with a recognized DeviceResponseKind.
  • Resize — pushed into the stream on SIGWINCH (POSIX), so a single loop sees resizes in order with other input.

Anything the parser can't classify becomes an UnknownEvent carrying the original wire bytes — nothing is silently swallowed, so you can log, forward, or parse it yourself.

Buffer lifetime. The ReadOnlyMemory<char>/ReadOnlyMemory<byte> fields on events (KeyEvent.Text, PasteEvent.Text, DeviceResponseEvent.Payload, UnknownEvent.RawBytes) are owned by the event and safe to retain for as long as you hold it — the parser copies out of the underlying pipe before constructing the event.

See also

  • Capability negotiation — how TerminalSession enables the input protocols decoded above.
  • Output — the byte-emitting writers for the reply side (cursor, SGR, screen).
  • Input & focus (UI layer) — routed events, focus, and gestures built on top of this stream.
  • Architecture — where the input layer sits in the overall stack.

Clone this wiki locally