Input Handling
Termina provides a structured input routing system with two phases: capture (page-level) and bubble (component-level), followed by the ViewModel's input observable for any remaining events.
Input Flow Overview
Keyboard Input
│
▼
┌─────────────────────────────────────────┐
│ CAPTURE PHASE (Page) │
│ Page.KeyBindings intercepts first │
│ Good for: Escape, Tab, global hotkeys │
└─────────────────────────────────────────┘
│ (if not consumed)
▼
┌─────────────────────────────────────────┐
│ BUBBLE PHASE (Components) │
│ FocusManager routes to focused node │
│ Good for: Text input, list navigation │
└─────────────────────────────────────────┘
│ (if not consumed)
▼
┌─────────────────────────────────────────┐
│ VIEWMODEL (ViewModel.Input) │
│ Observable receives unconsumed events │
│ Good for: State changes, fallback │
└─────────────────────────────────────────┘This model is similar to DOM event handling where events are captured from the top down, then bubble up from the target.
Page-Level Key Bindings (Capture Phase)
Use KeyBindings in your Page to intercept keys before focused components receive them. This is essential for navigation keys like Escape that should always work, regardless of what component has focus.
Basic Usage
public class MyPage : ReactivePage<MyViewModel>
{
public override void OnNavigatedTo()
{
base.OnNavigatedTo();
// Register page-level key bindings (capture phase)
// Pages have direct access to Navigate() and Shutdown()
KeyBindings.Register(ConsoleKey.Escape, () => Navigate("/menu"));
KeyBindings.Register(ConsoleKey.Q, () => Shutdown());
}
}With Modifiers
public override void OnNavigatedTo()
{
base.OnNavigatedTo();
// Ctrl+S to save
KeyBindings.Register(ConsoleKey.S, ConsoleModifiers.Control, () => ViewModel.Save());
// Shift+Tab for reverse navigation
KeyBindings.Register(ConsoleKey.Tab, ConsoleModifiers.Shift, () => FocusPrevious());
// Plain Tab for forward navigation
KeyBindings.Register(ConsoleKey.Tab, () => FocusNext());
}Focus Cycling Example
public class FormPage : ReactivePage<FormViewModel>
{
private TextInputNode _nameInput = null!;
private TextInputNode _emailInput = null!;
private int _focusIndex;
public override void OnNavigatedTo()
{
base.OnNavigatedTo();
KeyBindings.Register(ConsoleKey.Escape, () => Navigate("/"));
KeyBindings.Register(ConsoleKey.Tab, CycleFocus);
// Start with first input focused
_focusIndex = 0;
Focus.PushFocus(_nameInput);
}
private void CycleFocus()
{
_focusIndex = (_focusIndex + 1) % 2;
var target = _focusIndex == 0 ? _nameInput : _emailInput;
Focus.PushFocus(target);
}
}When to Use Page-Level Bindings
| Use Case | Example |
|---|---|
| Navigation keys | Escape to go back, Q to quit |
| Global shortcuts | Ctrl+S save, Ctrl+Z undo |
| Focus management | Tab/Shift+Tab between inputs |
| Modal dismissal | Escape to close dialog |
| Menu shortcuts | F1 for help, F5 for refresh |
Component-Level Input (Bubble Phase)
After the capture phase, input routes to the currently focused component via FocusManager. Components implement IFocusable.HandleInput() to process keys.
Built-in Focusable Components
| Component | Handles |
|---|---|
TextInputNode | Character input, backspace, cursor movement |
SelectionListNode | Arrow keys, Enter, number keys 1-9 |
ScrollableContainerNode | Arrow keys for scrolling |
Focus Management
public class MyPage : ReactivePage<MyViewModel>
{
private SelectionListNode<string> _list = null!;
public override void OnNavigatedTo()
{
base.OnNavigatedTo();
// Set initial focus
Focus.PushFocus(_list);
}
private void ShowTextInput()
{
// Push new focus (previous is saved)
Focus.PushFocus(_textInput);
}
private void HideTextInput()
{
// Pop focus (returns to previous)
Focus.PopFocus();
}
}Custom Focusable Component
public class CustomInput : LayoutNode, IFocusable
{
public bool IsFocused { get; set; }
public bool HandleInput(ConsoleKeyInfo keyInfo)
{
// Return true if you handled (consumed) the key
// Return false to let it bubble to ViewModel
if (keyInfo.Key == ConsoleKey.Enter)
{
OnSubmit();
return true; // Consumed
}
return false; // Not consumed, continue to ViewModel
}
}ViewModel Input Observable
Keys not consumed by the capture or bubble phases arrive at the ViewModel's Input observable:
public partial class MyViewModel : ReactiveViewModel
{
public override void OnActivated()
{
// Handle keys that weren't consumed by Page or focused component
Input.OfType<KeyPressed>()
.Subscribe(HandleKey)
.DisposeWith(Subscriptions);
}
private void HandleKey(KeyPressed key)
{
// This only receives keys not handled elsewhere
switch (key.KeyInfo.Key)
{
case ConsoleKey.F5:
Refresh();
break;
}
}
}Migration Note
Prior to v0.5, the ViewModel's Input observable received all keys. Now, with page-level key bindings, navigation keys like Escape should be handled in the Page using KeyBindings.Register() rather than in the ViewModel.
Input Events
All input events implement IInputEvent. The framework provides:
KeyPressed- Keyboard inputMouseEvent- Mouse clicks, movement, scrollingResizeEvent- Terminal window resize
Keyboard Input Details
Checking Modifiers
private void HandleKey(KeyPressed key)
{
var info = key.KeyInfo;
// Check for Ctrl+C
if (info.Key == ConsoleKey.C &&
(info.Modifiers & ConsoleModifiers.Control) != 0)
{
Shutdown();
}
}Character Input
private void HandleKey(KeyPressed key)
{
var ch = key.KeyInfo.KeyChar;
if (!char.IsControl(ch))
{
// Regular character typed
Text += ch;
}
}Mouse Input
Mouse Event Properties
Input.OfType<MouseEvent>()
.Subscribe(mouse =>
{
var x = mouse.X; // Column position
var y = mouse.Y; // Row position
var button = mouse.Button; // Which button
var type = mouse.EventType;// Press, Release, etc.
var mods = mouse.Modifiers;// Shift, Ctrl, Alt
})
.DisposeWith(Subscriptions);Mouse Buttons
| Button | Description |
|---|---|
MouseButton.None | No button |
MouseButton.Left | Left click |
MouseButton.Right | Right click |
MouseButton.Middle | Middle button |
MouseButton.WheelUp | Scroll up |
MouseButton.WheelDown | Scroll down |
Mouse Event Types
| Type | Description |
|---|---|
MouseEventType.Press | Button pressed down |
MouseEventType.Release | Button released |
MouseEventType.Drag | Move while button held |
MouseEventType.Move | Move without button |
MouseEventType.Scroll | Wheel scrolled |
Click Handling Example
Input.OfType<MouseEvent>()
.Where(m => m.EventType == MouseEventType.Press &&
m.Button == MouseButton.Left)
.Subscribe(HandleClick)
.DisposeWith(Subscriptions);
private void HandleClick(MouseEvent mouse)
{
if (IsInButtonBounds(mouse.X, mouse.Y))
{
OnButtonClicked();
}
}Terminal Resize
Input.OfType<ResizeEvent>()
.Subscribe(resize =>
{
var width = resize.Width; // New width in columns
var height = resize.Height; // New height in rows
// Layout is automatically recalculated
// Use this for custom resize logic
})
.DisposeWith(Subscriptions);Input Filtering with Rx
Filter by Type
Input.OfType<KeyPressed>() // Only keyboard events
Input.OfType<MouseEvent>() // Only mouse eventsFilter by Condition
Input.OfType<KeyPressed>()
.Where(k => k.KeyInfo.Key == ConsoleKey.Enter)
.Subscribe(HandleEnter);Throttle Input
Input.OfType<KeyPressed>()
.Throttle(TimeSpan.FromMilliseconds(100))
.Subscribe(HandleKey);Combine Streams
var escPressed = Input.OfType<KeyPressed>()
.Where(k => k.KeyInfo.Key == ConsoleKey.Escape);
var rightClick = Input.OfType<MouseEvent>()
.Where(m => m.Button == MouseButton.Right);
Observable.Merge(escPressed.Select(_ => Unit.Default),
rightClick.Select(_ => Unit.Default))
.Subscribe(_ => CloseMenu());Best Practices
1. Use Page-Level Bindings for Navigation
// ✅ Good - Page intercepts Escape before any component
public override void OnNavigatedTo()
{
KeyBindings.Register(ConsoleKey.Escape, () => Navigate("/"));
}
// ❌ Avoid - Component might consume Escape first
public override void OnActivated()
{
Input.OfType<KeyPressed>()
.Where(k => k.KeyInfo.Key == ConsoleKey.Escape)
.Subscribe(_ => Navigate("/"));
}2. Let Components Handle Their Own Input
// ✅ Good - SelectionListNode handles its own navigation
Focus.PushFocus(_selectionList); // Arrow keys, Enter work automatically
// ❌ Avoid - Manually routing input to components
Input.OfType<KeyPressed>()
.Subscribe(k => _selectionList.HandleInput(k.KeyInfo));3. Use ViewModel.Input for State Changes
// ✅ Good - ViewModel handles business logic keys
Input.OfType<KeyPressed>()
.Where(k => k.KeyInfo.Key == ConsoleKey.F5)
.Subscribe(_ => RefreshData());Source Code
View PageKeyBindings
// Copyright (c) Petabridge, LLC. All rights reserved.
// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information.
namespace Termina.Input;
/// <summary>
/// Manages page-level key bindings that intercept input before focused components.
/// This implements a "capture phase" for keyboard input, allowing pages to handle
/// keys like Escape or Tab before they reach child components.
/// </summary>
/// <remarks>
/// <para>
/// Key bindings registered here take precedence over focused component handlers.
/// This solves the common problem where components consume keys (like Escape) that
/// pages need for navigation.
/// </para>
/// <para>
/// Bindings support modifier keys (Ctrl, Alt, Shift) for complex shortcuts.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // In a ReactivePage, register bindings in OnNavigatedTo:
/// KeyBindings.Register(ConsoleKey.Escape, () => ViewModel.Navigate("/menu"));
/// KeyBindings.Register(ConsoleKey.Tab, () => CycleFocus());
/// KeyBindings.Register(ConsoleKey.S, ConsoleModifiers.Control, () => Save());
/// </code>
/// </example>
public sealed class PageKeyBindings
{
private readonly Dictionary<KeyBinding, Action> _bindings = new();
/// <summary>
/// Registers a key binding with optional modifiers.
/// </summary>
/// <param name="key">The key to bind.</param>
/// <param name="handler">Action to execute when key is pressed.</param>
public void Register(ConsoleKey key, Action handler)
{
Register(key, 0, handler);
}
/// <summary>
/// Registers a key binding with specific modifiers.
/// </summary>
/// <param name="key">The key to bind.</param>
/// <param name="modifiers">Required modifier keys (Ctrl, Alt, Shift).</param>
/// <param name="handler">Action to execute when key combination is pressed.</param>
public void Register(ConsoleKey key, ConsoleModifiers modifiers, Action handler)
{
var binding = new KeyBinding(key, modifiers);
_bindings[binding] = handler;
}
/// <summary>
/// Unregisters a key binding.
/// </summary>
/// <param name="key">The key to unbind.</param>
/// <param name="modifiers">The modifier combination to unbind.</param>
/// <returns>True if a binding was removed, false if none existed.</returns>
public bool Unregister(ConsoleKey key, ConsoleModifiers modifiers = 0)
{
return _bindings.Remove(new KeyBinding(key, modifiers));
}
/// <summary>
/// Attempts to handle a key press using registered bindings.
/// </summary>
/// <param name="keyInfo">The key press information.</param>
/// <returns>True if a binding handled the key, false otherwise.</returns>
public bool TryHandle(ConsoleKeyInfo keyInfo)
{
var binding = new KeyBinding(keyInfo.Key, keyInfo.Modifiers);
if (_bindings.TryGetValue(binding, out var handler))
{
handler();
return true;
}
return false;
}
/// <summary>
/// Clears all registered key bindings.
/// </summary>
public void Clear()
{
_bindings.Clear();
}
/// <summary>
/// Gets the number of registered bindings.
/// </summary>
public int Count => _bindings.Count;
/// <summary>
/// Represents a key + modifier combination for binding lookup.
/// </summary>
private readonly record struct KeyBinding(ConsoleKey Key, ConsoleModifiers Modifiers);
}View KeyPressed
namespace Termina.Input;
/// <summary>
/// Low-level keyboard input event.
/// Contains the raw ConsoleKeyInfo from the keyboard.
/// </summary>
public sealed record KeyPressed(ConsoleKeyInfo KeyInfo) : IInputEvent;View MouseEvent
// Copyright (c) Petabridge, LLC. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
namespace Termina.Input;
/// <summary>
/// Type of mouse button.
/// </summary>
public enum MouseButton
{
/// <summary>
/// No button.
/// </summary>
None,
/// <summary>
/// Left mouse button.
/// </summary>
Left,
/// <summary>
/// Right mouse button.
/// </summary>
Right,
/// <summary>
/// Middle mouse button (scroll wheel click).
/// </summary>
Middle,
/// <summary>
/// Scroll wheel up.
/// </summary>
WheelUp,
/// <summary>
/// Scroll wheel down.
/// </summary>
WheelDown
}
/// <summary>
/// Type of mouse event.
/// </summary>
public enum MouseEventType
{
/// <summary>
/// Mouse button pressed down.
/// </summary>
Press,
/// <summary>
/// Mouse button released.
/// </summary>
Release,
/// <summary>
/// Mouse moved while button held.
/// </summary>
Drag,
/// <summary>
/// Mouse moved without button held.
/// </summary>
Move,
/// <summary>
/// Mouse wheel scrolled.
/// </summary>
Scroll
}
/// <summary>
/// Low-level mouse input event.
/// </summary>
/// <param name="X">X position (column) of the mouse cursor.</param>
/// <param name="Y">Y position (row) of the mouse cursor.</param>
/// <param name="Button">The mouse button involved.</param>
/// <param name="EventType">The type of mouse event.</param>
/// <param name="Modifiers">Any keyboard modifiers held during the event.</param>
public sealed record MouseEvent(
int X,
int Y,
MouseButton Button,
MouseEventType EventType,
ConsoleModifiers Modifiers = 0) : IInputEvent;View ResizeEvent
// Copyright (c) Petabridge, LLC. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
namespace Termina.Input;
/// <summary>
/// Terminal resize event.
/// Fired when the terminal window dimensions change.
/// </summary>
/// <param name="Width">New terminal width in columns.</param>
/// <param name="Height">New terminal height in rows.</param>
public sealed record ResizeEvent(int Width, int Height) : IInputEvent;