-
Notifications
You must be signed in to change notification settings - Fork 0
UI Input and Focus
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).
Every input event travels a route through the element tree. There are three strategies
(RoutingStrategy):
-
Tunnel— thePreview*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.
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).
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 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.
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.
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.
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.
UIApplication.FocusManager is the keyboard-focus singleton. Three properties on UIElement govern
participation:
-
Focusable— can receive keyboard focus. Defaultfalseat theUIElementtier; interactive controls opt in. -
IsTabStop— participates in Tab navigation (defaulttrue). A non-tab-stop element is still programmatically focusable. -
TabIndex— tab-order rank (defaultint.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); // TabSetFocus 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.EditCommitRequestedinstead, so a text field can commit its edit without losing its place.
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.
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 scopeCursorial 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.
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 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.
- 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-keyspseudo-classes - Windowing — surfaces, modal dialogs, popups, and how they own focus scopes
Cursorial.Core
Cursorial.Rendering
Drawing & Animation
Cursorial.UI
- Overview
- Layout & panels
- Controls
- Styling & themes
- Data binding
- Input & focus
- Windowing
- Animation & transitions
Declarative