Skip to content

UI Input and Focus

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

Input, focus & commands

Cursorial's UI layer routes keyboard, mouse, and paste through a WPF/Avalonia-style routed-event system, with a focus manager for Tab and arrow-key navigation, an InputBinding/KeyGesture command surface, and an access-key (Alt-mnemonic) engine. Reach for this page when you're writing a control, wiring keyboard shortcuts, or customizing how focus moves through your UI.

If you've used WPF, almost everything here will feel familiar: tunneling Preview* events, bubbling main events, On* class handlers, AddHandler(..., handledEventsToo), Focusable/IsTabStop/ TabIndex, KeyboardNavigation attached properties, ICommand. The differences are noted inline — mostly terminal realities (no OS focus to Tab out to; capability-gated access-key cues).

Routed events

Every input event travels a route through the element tree. There are three strategies (RoutingStrategy):

  • Tunnel — the Preview* events, raised root → target (down the tree). Use these to intercept before children see the event.
  • Bubble — the main events, raised target → surface root (up the tree). The normal place to handle input.
  • Direct — raised on the target only, no walk (MouseEnter, MouseLeave, LostMouseCapture).

The vocabulary on UIElement comes in Preview* (tunnel) / main (bubble) pairs:

Tunnel (Preview) Bubble (main) Args
PreviewKeyDown / PreviewKeyUp KeyDown / KeyUp KeyEventArgs
PreviewTextInput TextInput TextInputEventArgs
PreviewMouseDown / PreviewMouseUp MouseDown / MouseUp MouseButtonEventArgs
PreviewMouseMove MouseMove MouseEventArgs
PreviewMouseWheel MouseWheel MouseWheelEventArgs

Plus the Direct events MouseEnter / MouseLeave (MouseEventArgs), the Bubble focus events GotFocus / LostFocus (FocusChangedEventArgs), and LostMouseCapture.

Each event exposes a CLR event for instance handlers and an On* virtual for the class-handler stage. Set e.Handled = true to stop normal propagation:

element.KeyDown += (sender, e) =>
{
    if (e.Key == Key.Enter)
    {
        Submit();
        e.Handled = true;       // later route nodes are skipped
    }
};

RoutedEventArgs carries Source / OriginalSource (the dispatch target — always equal in v1) and the mutable Handled flag.

Class handlers (On*)

When you subclass UIElement or a control, override the On* virtual rather than subscribing to your own event. The virtual is the class-handler stage: it runs at every route node before that node's instance handlers, and is skipped once Handled is set.

protected override void OnMouseDown(MouseButtonEventArgs e)
{
    if (e.ClickCount == 2)          // double-click logic lives on MouseDown
        OpenItem();
    base.OnMouseDown(e);
}

Controls activate on key down, not key up — OnKeyDown is where a button reacts to Space/Enter. The framework never activates anything on key-up (key-up exists only on Kitty/Win32 terminals).

AddHandler and handledEventsToo

Use AddHandler directly when you need to observe events that have already been marked handled — e.g. an outer panel that wants to track every key regardless of whether a child consumed it:

panel.AddHandler(UIElement.KeyDownEvent, OnAnyKeyDown, handledEventsToo: true);

With handledEventsToo: true the handler runs even when Handled is set, and may flip it back to false. RemoveHandler undoes one registration. Registration is a list, not a set — adding the same delegate twice invokes it twice.

To raise a routed event yourself, construct the args with the (routedEvent, source, ...) constructor and call RaiseEvent(args). Control authors building events on a hot path can rent pooled args via the protected RentEvent<TArgs>(routedEvent) and pass the rental to a single RaiseEvent.

Mouse

Mouse args expose the position in two frames: ScreenPosition (terminal cells) and GetPosition(relativeTo), which translates into any attached element's local cell frame (signed — may go negative under capture or a composite slide; never throws):

protected override void OnMouseMove(MouseEventArgs e)
{
    var local = e.GetPosition(this);   // CellPosition in this element's frame
    // local.Column / local.Row ...
}

MouseButtonEventArgs adds Button and ClickCount (multi-click counts ride MouseDown only). MouseWheelEventArgs carries WheelDeltaX / WheelDeltaY (1/120-notch units) and a LinesPerNotch hint. The wheel always targets the element under the pointer, never the focused element.

Hover and IsPointerOver

When the terminal reports motion, the framework maintains a hover chain and flips the IsPointerOver property on the element under the pointer and every ancestor. It's capability-honest: on a terminal that doesn't report motion, IsPointerOver is never set. The styling path for hover is the :pointerover pseudo-class (see Styling & themes) — read IsPointerOver directly only in code.

Capture

CaptureMouse(mode) requests mouse capture — a routing policy, not an OS capture. Two modes (CaptureMode):

  • Element (default) — every mouse event (except wheel) routes to the holder regardless of what the pointer is over. The slider/scrollbar-drag pattern.
  • SubTree — events route normally while the pointer is over the holder or a descendant, and redirect to the holder only when the pointer is outside. The menu / combo-box pattern, where items inside an open popup stay interactive but an outside press reaches the owner.
protected override void OnMouseDown(MouseButtonEventArgs e)
{
    CaptureMouse();              // own the drag until release
    e.Handled = true;
}

protected override void OnMouseUp(MouseButtonEventArgs e) => ReleaseMouseCapture();

ReleaseMouseCapture() releases (only the holder can). Losing capture — by release, transfer, detach, or terminal focus-out — raises the Direct LostMouseCapture event; release any pressed visuals in OnLostMouseCapture.

Cursor shape

UIElement.Cursor is a MouseCursorShape? (Core's type directly) requesting a pointer shape while the pointer is over the element. Resolution walks the hover chain leaf → root to the first non-null Cursor. Set ForceCursor = true to let an ancestor override a nearer descendant's cursor. Honored only on terminals that report OSC 22 (Kitty, Ghostty, Foot); silently inert elsewhere, with no polyfill. Changing it needs no explicit invalidation — it takes effect at the next hover re-resolution.

Focus

UIApplication.FocusManager is the keyboard-focus singleton. Three properties on UIElement govern participation:

  • Focusable — can receive keyboard focus. Default false at the UIElement tier; interactive controls opt in.
  • IsTabStop — participates in Tab navigation (default true). A non-tab-stop element is still programmatically focusable.
  • TabIndex — tab-order rank (default int.MaxValue; ties resolve to document order via a stable sort, so untouched trees tab in document order).

Move focus programmatically:

myTextBox.Focus();                                   // returns bool — success
app.FocusManager.SetFocus(myButton, FocusNavigationMethod.Programmatic);
app.FocusManager.MoveFocus(FocusNavigationDirection.Next);   // Tab

SetFocus validates the target (attached, focusable, effectively enabled and visible — no ancestor fallback) and commits focus state before raising LostFocus then GotFocus. Read-only mirrors IsFocused and IsKeyboardFocusWithin track the focused element and its ancestor chain.

Terminal-level focus (the terminal window losing OS focus) never moves keyboard focus — Cursorial retains the focused element and pulses InputDispatcher.EditCommitRequested instead, so a text field can commit its edit without losing its place.

Tab and directional navigation

MoveFocus(FocusNavigationDirection) drives navigation: Next / Previous walk the tab order; Up / Down / Left / Right are arrow-key directional moves. The dispatcher already wires Tab / Shift+Tab and (inside an opted-in container) the arrow keys to these — you rarely call it directly.

Per-container policy is set with the KeyboardNavigation attached properties:

KeyboardNavigation.SetTabNavigation(listBox, KeyboardNavigationMode.Once);
KeyboardNavigation.SetDirectionalNavigation(listBox, DirectionalNavigationMode.Cycle);
  • TabNavigation (KeyboardNavigationMode): Continue (default), Cycle (Tab wraps inside the container), None (excluded from tab order), Once (the whole container is a single tab stop — the ListBox shape; entry focuses the remembered or first item, the next Tab exits past it).
  • DirectionalNavigation (DirectionalNavigationMode): None (default — arrows do nothing, free for controls), Contained (arrows move among focusables, stop at edges), Cycle (wrap at edges).

Every surface root (window, popup) behaves as Cycle regardless — on a terminal there's no next application control to Tab out to, so roots trap by default and modal trapping is free.

Focus scopes

A logical focus scope remembers which element was focused inside it, so re-activating the scope restores that element. Window and popup roots are scopes automatically. Mark your own and prime its memory through FocusManager's static helpers:

FocusManager.SetIsFocusScope(panel, true);
FocusManager.SetFocusedElement(panel, defaultChild);   // primes the remembered element
var scope = FocusManager.GetFocusScope(someElement);   // nearest self-or-ancestor scope

:focus-visible

Cursorial follows the web :focus-visible policy: the focus ring shows for keyboard-driven focus (Tab, arrows, access keys, window-restore) but not for a pointer press. The FocusNavigationMethod passed to Focus/SetFocus drives this — Restore always shows the ring, Pointer never does, Programmatic shows it only when the last input was keyboard. The styling hook is the :focus-visible pseudo-class.

Commands

Bind a keyboard shortcut to an ICommand (the BCL System.Windows.Input.ICommand) with an InputBinding. A KeyGesture is a key plus an exact modifier set; KeyGesture.Parse reads a '+'-separated, case-insensitive string:

var save = new KeyGesture(Key.Character, KeyModifiers.Control, "S");  // Ctrl+S
var also = KeyGesture.Parse("Ctrl+S");                               // equivalent

element.InputBindings.Add(new KeyBinding(save, saveCommand));

The Parse grammar: modifier tokens are Ctrl/Control, Shift, Alt, Super/Win/Cmd, Meta, Hyper. The final token is either a single character (a character gesture — the identity of a printable key is (Key, Text), never the Key enum alone) or a named key, including aliases Esc, Return, Del, Ins, PgUp/PgDn, Up/Down/Left/Right, and Space. A single gesture matches the chord across every wire encoding (legacy control codes, Kitty CSI-u, ESC-prefix Alt) because the input layer normalizes them all.

InputBindings is swept per node during the KeyDown bubble, after that node's class and instance handlers, while the event is still unhandled. The collection is ordered — order is the priority: the first matching gesture whose command CanExecute(parameter) is true executes and marks the event handled. A false CanExecute is skipped without consuming, so later bindings and route nodes still get a chance. Effectively-disabled elements never execute bindings.

Place a binding on the window root for an app-wide shortcut, or on a pane for one that only fires while focus is inside it. There's no RoutedCommand/CommandManager in v1 — MVVM commands raise their own CanExecuteChanged (marshal cross-thread raises through the dispatcher; see Data binding).

Access keys (Alt mnemonics)

Access keys are the underlined-letter mnemonics on buttons and menu items. Declare one by prefixing the access character with an underscore in content — Content="_Save" makes S the access key. Built-in controls (Button, MenuItem, TabItem, Label) implement IAccessKeyTarget and register themselves with UIApplication.AccessKeys automatically; you only write the underscore.

<Button Content="_Save" Command="{Binding SaveCommand}" />

Pressing the access key (Alt-chord, or in menu mode the bare letter) invokes the unique matching target; when several targets share a key it cycles focus among them instead of invoking (the manager moves focus, the target does not act).

The capability gate. Whether a cue is shown on demand or always visible depends on the terminal, exposed as AccessKeyManager.Mode (AccessKeyMode):

  • AltHeld — the cue toggles with the physical Alt key. Requires a terminal that distinguishes key up/down and reports repeats (Kitty keyboard protocol), or Win32 input mode.
  • AlwaysVisible — the fallback on terminals that can't report Alt held/released: mnemonics are underlined permanently.

The cue itself is rendered through pure styling (the :access-keys pseudo-class drives an underline on the mnemonic), so you generally never touch the AccessKeyManager directly. For a custom activatable element, implement IAccessKeyTarget (IsAccessKeyEligible + OnAccessKey) and register with AccessKeys.Register(key, element) / Unregister.

See also

  • UI overview — the property system, element tree, and application spine
  • Controls — the controls that consume this input surface
  • Styling & themes — the :pointerover, :focus, :focus-visible, and :access-keys pseudo-classes
  • Windowing — surfaces, modal dialogs, popups, and how they own focus scopes

Clone this wiki locally