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, scrollingMouseScrollEvent- Mouse wheel scroll (routed toIScrollablecomponents)PasteEvent- Bracketed paste (routed toIPasteReceivercomponents)ResizeEvent- Terminal window resize
Automatic Routing
Some events are automatically routed to the focused component before reaching the ViewModel's Input observable:
| Event | Routed To | Fallback |
|---|---|---|
MouseScrollEvent | Focused IScrollable component | ViewModel.Input |
PasteEvent | Focused IPasteReceiver component | ViewModel.Input |
This means StreamingTextNode (which implements IScrollable) automatically handles mouse wheel scrolling, and TextInputNode (which implements IPasteReceiver) automatically handles paste — no manual wiring needed.
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);Mouse Wheel Scrolling
Mouse wheel events are detected via SGR mouse escape sequences and emitted as MouseScrollEvent:
Input.OfType<MouseScrollEvent>()
.Subscribe(scroll =>
{
// scroll.Delta: +1 = scroll up, -1 = scroll down
if (scroll.Delta > 0)
ScrollContentUp();
else
ScrollContentDown();
})
.DisposeWith(Subscriptions);Automatic Routing
If the focused component implements IScrollable (like StreamingTextNode), mouse scroll events are routed directly to it — each tick scrolls 3 lines. The event only reaches ViewModel.Input if no IScrollable component has focus.
Paste Events
Termina automatically enables bracketed paste mode so multi-line pastes are received as a single PasteEvent instead of individual key presses:
Input.OfType<PasteEvent>()
.Subscribe(paste =>
{
// paste.Content contains the full text including newlines
var lines = paste.Content.Split('\n');
})
.DisposeWith(Subscriptions);Automatic Routing
If the focused component implements IPasteReceiver (like TextInputNode), paste events are routed directly to it. TextInputNode shows a summary placeholder and submits the full content on Enter. The event only reaches ViewModel.Input if no IPasteReceiver component has focus.
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;View MouseScrollEvent
// 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>
/// Input event fired when the user scrolls the mouse wheel.
/// </summary>
/// <remarks>
/// This event is emitted when mouse reporting is active
/// (<c>ESC[?1000h</c> combined with SGR mode <c>ESC[?1006h</c>).
/// The terminal reports scroll wheel events using SGR button codes 64 (up) and 65 (down).
/// </remarks>
/// <param name="Delta">
/// The scroll direction and magnitude.
/// Positive values indicate scrolling up (toward older/earlier content).
/// Negative values indicate scrolling down (toward newer/later content).
/// Each wheel tick typically has a magnitude of 1.
/// </param>
public sealed record MouseScrollEvent(int Delta) : IInputEvent;View PasteEvent
// 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>
/// Input event fired when the user pastes text via the terminal's bracketed paste mode.
/// </summary>
/// <remarks>
/// This event is only emitted when bracketed paste mode is active
/// (<c>ESC[?2004h</c> has been sent to the terminal). In that mode the terminal
/// wraps pasted content between <c>ESC[200~</c> and <c>ESC[201~</c>, allowing the
/// application to receive the entire paste as a single event rather than individual
/// key presses.
/// </remarks>
/// <param name="Content">The full pasted text, including any newlines present in the paste.</param>
public sealed record PasteEvent(string Content) : IInputEvent;View IPasteReceiver
// 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>
/// Interface for components that can accept pasted content from the terminal.
/// </summary>
/// <remarks>
/// Implement this interface on focusable layout nodes (such as <c>TextInputNode</c>)
/// to receive bracketed paste events. When a focused component implements this interface,
/// <c>TerminaApplication</c> routes <see cref="PasteEvent"/>s directly to it rather
/// than broadcasting them through the input observable.
/// </remarks>
public interface IPasteReceiver
{
/// <summary>
/// Handle a paste event from the terminal.
/// </summary>
/// <param name="paste">The paste event containing the pasted text.</param>
/// <returns>
/// <see langword="true"/> if the paste was handled; <see langword="false"/> otherwise.
/// </returns>
bool HandlePaste(PasteEvent paste);
}View IScrollable
// 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.Layout;
/// <summary>
/// Interface for layout nodes that support programmatic scrolling.
/// </summary>
/// <remarks>
/// The scroll direction convention used here matches the reading direction:
/// <list type="bullet">
/// <item><see cref="ScrollUp"/> moves toward older/earlier content (upward in the buffer).</item>
/// <item><see cref="ScrollDown"/> moves toward newer/later content (downward toward the bottom).</item>
/// </list>
/// Implementations should use cached viewport dimensions when the viewport size is not
/// otherwise available (e.g., outside of a <c>Render</c> call).
/// </remarks>
public interface IScrollable
{
/// <summary>
/// Gets whether scrolling upward (toward older content) is possible.
/// </summary>
bool CanScrollUp { get; }
/// <summary>
/// Gets whether scrolling downward (toward newer content) is possible.
/// </summary>
bool CanScrollDown { get; }
/// <summary>
/// Scrolls upward toward older content by the specified number of lines.
/// </summary>
/// <param name="lines">Number of lines to scroll. Defaults to 1.</param>
void ScrollUp(int lines = 1);
/// <summary>
/// Scrolls downward toward newer content by the specified number of lines.
/// </summary>
/// <param name="lines">Number of lines to scroll. Defaults to 1.</param>
void ScrollDown(int lines = 1);
}