-
Notifications
You must be signed in to change notification settings - Fork 0
Core 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).
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 aTimeProvider, so it's deterministic under test). -
Synthesized(bool) —truewhen 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;
}
}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/…, F1–F12 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:
-
Modifiers—Shift/Control/Alt/Super/Hyper/Meta, without lock-key state. Match against this so aCtrl+Spattern fires regardless of whether CapsLock is on. -
ExtendedModifiers— the same OR'd withCapsLock/NumLock/ScrollLocktoggle bits. Always a superset ofModifiers; 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.
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).
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.
Pull — IAsyncInputDevice.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.
Push — IEventInputDevice 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.
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(exposingInner) so consumers can walk the chain for diagnostics. -
Transform — an
IInputTransformerrewrites the event stream (filter, reorder, fabricate) without owning the source. Prefer this lighter shape for synchronous, source-agnostic stages.TransformingInputDeviceis the one reusable adapter that applies any transformer over an innerIAsyncInputDevice; thedevice.Transform(...)extension is the sugar. Both report effective capabilities viaTransformCapabilities, so a pipeline assembler can compute the composed device's capabilities without running it.
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 ClickCountTarget — None/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.
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 windowConstructor 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.
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),
modifyOtherKeyslevel 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→Keymapping). - 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
DeviceResponseEventwith a recognizedDeviceResponseKind. - 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.
-
Capability negotiation — how
TerminalSessionenables 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.
Cursorial.Core
Cursorial.Rendering
Drawing & Animation
Cursorial.UI
- Overview
- Layout & panels
- Controls
- Styling & themes
- Data binding
- Input & focus
- Windowing
- Animation & transitions
Declarative