TextInputNode
A single-line text input with cursor, selection, and keyboard handling.
Basic Usage
csharp
var input = new TextInputNode()
.WithPlaceholder("Enter text...");
// Handle submission
input.Submitted.Subscribe(text => Console.WriteLine($"Submitted: {text}"));Features
- Blinking cursor
- Text selection (Shift+Arrow keys)
- Word-by-word navigation (Ctrl+Arrow)
- Password masking
- Max length validation
- Placeholder text
Keyboard Shortcuts
| Key | Action |
|---|---|
←/→ | Move cursor |
Ctrl+←/→ | Move by word |
Shift+←/→ | Select text |
Home/End | Jump to start/end |
Backspace | Delete before cursor |
Ctrl+Backspace | Delete word before |
Delete | Delete after cursor |
Ctrl+A | Select all |
Enter | Submit |
Escape | Clear text |
Password Mode
csharp
new TextInputNode()
.AsPassword() // Use default mask '•'
new TextInputNode()
.AsPassword('*') // Use custom mask characterStyling
csharp
new TextInputNode()
.WithForeground(Color.White)
.WithBackground(Color.Blue)
.WithPlaceholder("Type here...")Colors
| Property | Default | Description |
|---|---|---|
Foreground | terminal default | Text color |
Background | terminal default | Background color |
PlaceholderColor | BrightBlack | Placeholder text color |
CursorColor | White | Cursor background color |
SelectionColor | Blue | Selection background color |
Handling Input
TextInputNode is a layout node that should be owned by the Page. There are two patterns for input handling:
Pattern 1: Inside a Modal (Recommended)
When TextInputNode is inside a Modal, Focus automatically routes input:
csharp
public class MyPage : ReactivePage<MyViewModel>
{
private TextInputNode _textInput = null!;
private ModalNode _modal = null!;
protected override void OnBound()
{
_textInput = new TextInputNode().WithPlaceholder("Enter command...");
_modal = Layouts.Modal().WithContent(_textInput);
_textInput.Submitted
.Subscribe(text => ViewModel.OnTextSubmitted(text))
.DisposeWith(Subscriptions);
// When showing modal, Focus handles input routing automatically
ViewModel.IsShowingModalChanged
.Where(show => show)
.Subscribe(_ => Focus.PushFocus(_modal))
.DisposeWith(Subscriptions);
}
}Pattern 2: Always-Visible Input
For always-visible text inputs, route input via ViewModel.Input:
csharp
public class MyPage : ReactivePage<MyViewModel>
{
private TextInputNode _promptInput = null!;
protected override void OnBound()
{
_promptInput = new TextInputNode().WithPlaceholder("Enter command...");
// Route input from ViewModel to the text input
ViewModel.Input.OfType<KeyPressed>()
.Subscribe(key => _promptInput.HandleInput(key.KeyInfo))
.DisposeWith(Subscriptions);
// Handle submission
_promptInput.Submitted
.Subscribe(text => {
ViewModel.OnTextSubmitted(text);
_promptInput.Clear();
})
.DisposeWith(Subscriptions);
}
}Observables
| Observable | Type | Description |
|---|---|---|
TextChanged | IObservable<string> | Emits when text changes |
Submitted | IObservable<string> | Emits when Enter is pressed |
Invalidated | IObservable<Unit> | Emits when redraw is needed |
API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
Text | string | "" | Current text value |
Placeholder | string? | null | Placeholder text |
MaxLength | int | 0 | Max length (0 = unlimited) |
IsPassword | bool | false | Password mode |
PasswordChar | char | '•' | Mask character |
HasSelection | bool | - | Has selected text |
SelectedText | string | - | Currently selected text |
Methods
| Method | Description |
|---|---|
HandleInput(ConsoleKeyInfo) | Process a key press |
Clear() | Clear text and reset cursor |
Start() | Start cursor animation |
Stop() | Stop cursor animation |
Source Code
View TextInputNode implementation
csharp
// 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.
using System.Reactive;
using System.Reactive.Subjects;
using System.Timers;
using Termina.Diagnostics;
using Termina.Rendering;
using Termina.Terminal;
using Timer = System.Timers.Timer;
namespace Termina.Layout;
/// <summary>
/// A stateful layout node that handles text input with cursor and selection.
/// </summary>
/// <remarks>
/// TextInputNode is a pure UI component that handles text editing (cursor movement, selection,
/// typing). It does NOT handle input history - that is the responsibility of the ViewModel
/// which can decide whether to enable history, how many entries to keep, and whether to
/// persist it across sessions.
/// </remarks>
public sealed class TextInputNode : LayoutNode, IAnimatedNode, IInvalidatingNode, IFocusable
{
private readonly Timer _cursorTimer;
private readonly Subject<Unit> _invalidated = new();
private readonly Subject<string> _textChanged = new();
private readonly Subject<string> _submitted = new();
private string _text = "";
private int _cursorPosition;
private int _selectionStart = -1;
private int _scrollOffset;
private bool _cursorVisible = true;
private bool _hasFocus;
private bool _disposed;
/// <inheritdoc />
public IObservable<Unit> Invalidated => _invalidated;
/// <summary>
/// Observable that emits when the text value changes.
/// </summary>
public IObservable<string> TextChanged => _textChanged;
/// <summary>
/// Observable that emits when Enter is pressed.
/// </summary>
public IObservable<string> Submitted => _submitted;
/// <inheritdoc />
public bool IsAnimating { get; private set; }
/// <inheritdoc />
public bool CanFocus => true;
/// <inheritdoc />
public bool HasFocus => _hasFocus;
/// <summary>
/// Text input has low-medium priority (lower than modal and selection list).
/// </summary>
public int FocusPriority => 5;
/// <inheritdoc />
public void OnFocused()
{
TerminaTrace.Focus.Debug(this, "OnFocused");
_hasFocus = true;
_cursorVisible = true;
Start(); // Ensure cursor is blinking
_invalidated.OnNext(Unit.Default);
}
/// <inheritdoc />
public void OnBlurred()
{
TerminaTrace.Focus.Debug(this, "OnBlurred");
_hasFocus = false;
Stop(); // Stop cursor blinking when not focused
_invalidated.OnNext(Unit.Default);
}
/// <summary>
/// Gets or sets the current text value.
/// </summary>
public string Text
{
get => _text;
set
{
if (_text != value)
{
_text = value ?? "";
_cursorPosition = Math.Min(_cursorPosition, _text.Length);
_selectionStart = -1;
_textChanged.OnNext(_text);
_invalidated.OnNext(Unit.Default);
}
}
}
/// <summary>
/// Gets or sets the placeholder text shown when empty.
/// </summary>
public string? Placeholder { get; set; }
/// <summary>
/// Gets or sets the foreground color.
/// </summary>
public Color? Foreground { get; private set; }
/// <summary>
/// Gets or sets the background color.
/// </summary>
public Color? Background { get; private set; }
/// <summary>
/// Gets or sets the placeholder color.
/// </summary>
public Color PlaceholderColor { get; set; } = Color.BrightBlack;
/// <summary>
/// Gets or sets the cursor color.
/// </summary>
public Color CursorColor { get; set; } = Color.White;
/// <summary>
/// Gets or sets the selection background color.
/// </summary>
public Color SelectionColor { get; set; } = Color.Blue;
/// <summary>
/// Gets or sets the maximum length (0 = unlimited).
/// </summary>
public int MaxLength { get; set; }
/// <summary>
/// Gets or sets whether to mask input (password mode).
/// </summary>
public bool IsPassword { get; set; }
/// <summary>
/// Gets or sets the mask character for password mode.
/// </summary>
public char PasswordChar { get; set; } = '•';
/// <summary>
/// Gets whether there is selected text.
/// </summary>
public bool HasSelection => _selectionStart >= 0 && _selectionStart != _cursorPosition;
/// <summary>
/// Gets the selected text.
/// </summary>
public string SelectedText
{
get
{
if (!HasSelection)
return "";
var start = Math.Min(_selectionStart, _cursorPosition);
var end = Math.Max(_selectionStart, _cursorPosition);
return _text[start..end];
}
}
public TextInputNode(int cursorBlinkMs = 530)
{
_cursorTimer = new Timer(cursorBlinkMs);
_cursorTimer.Elapsed += OnCursorBlink;
_cursorTimer.AutoReset = true;
HeightConstraint = new SizeConstraint.Fixed(1);
WidthConstraint = new SizeConstraint.Fill();
// Don't auto-start - animation should only start when focused
// Start() will be called by OnFocused() when the node gains focus
}
/// <summary>
/// Set placeholder text.
/// </summary>
public TextInputNode WithPlaceholder(string placeholder)
{
Placeholder = placeholder;
return this;
}
/// <summary>
/// Set foreground color.
/// </summary>
public TextInputNode WithForeground(Color color)
{
Foreground = color;
return this;
}
/// <summary>
/// Set background color.
/// </summary>
public TextInputNode WithBackground(Color color)
{
Background = color;
return this;
}
/// <summary>
/// Set max length.
/// </summary>
public TextInputNode WithMaxLength(int length)
{
MaxLength = length;
return this;
}
/// <summary>
/// Enable password mode.
/// </summary>
public TextInputNode AsPassword(char maskChar = '•')
{
IsPassword = true;
PasswordChar = maskChar;
return this;
}
/// <inheritdoc />
public void Start()
{
if (!IsAnimating)
{
IsAnimating = true;
_cursorVisible = true;
_cursorTimer.Start();
}
}
/// <inheritdoc />
public void Stop()
{
if (IsAnimating)
{
_cursorTimer.Stop();
IsAnimating = false;
_cursorVisible = false;
_invalidated.OnNext(Unit.Default);
}
}
private void OnCursorBlink(object? sender, ElapsedEventArgs e)
{
_cursorVisible = !_cursorVisible;
_invalidated.OnNext(Unit.Default);
}
/// <summary>
/// Handle a key input event. Returns true if the event was handled.
/// </summary>
public bool HandleInput(ConsoleKeyInfo key)
{
// Don't process input if disposed
if (_disposed)
{
TerminaTrace.Input.Debug(this, "HandleInput rejected: disposed");
return false;
}
TerminaTrace.Input.Trace(this, "HandleInput: key={0}, char='{1}'", key.Key, key.KeyChar);
// Reset cursor to visible on any input
_cursorVisible = true;
_cursorTimer.Stop();
_cursorTimer.Start();
var handled = key.Key switch
{
ConsoleKey.Backspace => HandleBackspace(key.Modifiers),
ConsoleKey.Delete => HandleDelete(key.Modifiers),
ConsoleKey.LeftArrow => HandleLeftArrow(key.Modifiers),
ConsoleKey.RightArrow => HandleRightArrow(key.Modifiers),
ConsoleKey.Home => HandleHome(key.Modifiers),
ConsoleKey.End => HandleEnd(key.Modifiers),
ConsoleKey.UpArrow => false, // Let ViewModel handle history
ConsoleKey.DownArrow => false, // Let ViewModel handle history
ConsoleKey.Enter => HandleEnter(),
ConsoleKey.Escape => HandleEscape(),
_ when key.KeyChar != '\0' && !char.IsControl(key.KeyChar) => HandleCharacter(key.KeyChar, key.Modifiers),
_ => false
};
TerminaTrace.Input.Trace(this, "HandleInput result: handled={0}", handled);
if (handled)
{
_invalidated.OnNext(Unit.Default);
}
return handled;
}
private bool HandleCharacter(char c, ConsoleModifiers modifiers)
{
// Handle Ctrl+A (select all)
if (modifiers.HasFlag(ConsoleModifiers.Control) && (c == 'a' || c == 'A'))
{
SelectAll();
return true;
}
// Handle Ctrl+C (copy - just clear selection for now)
if (modifiers.HasFlag(ConsoleModifiers.Control) && (c == 'c' || c == 'C'))
{
// Would need clipboard integration
return true;
}
// Handle Ctrl+V (paste - would need clipboard integration)
if (modifiers.HasFlag(ConsoleModifiers.Control) && (c == 'v' || c == 'V'))
{
// Would need clipboard integration
return true;
}
// Check max length
var addLength = HasSelection ? 1 - SelectedText.Length : 1;
if (MaxLength > 0 && _text.Length + addLength > MaxLength)
return false;
// Delete selection if any
if (HasSelection)
{
DeleteSelection();
}
// Insert character
_text = _text.Insert(_cursorPosition, c.ToString());
_cursorPosition++;
_textChanged.OnNext(_text);
return true;
}
private bool HandleBackspace(ConsoleModifiers modifiers)
{
if (HasSelection)
{
DeleteSelection();
_textChanged.OnNext(_text);
return true;
}
if (_cursorPosition == 0)
return false;
if (modifiers.HasFlag(ConsoleModifiers.Control))
{
// Delete word
var wordStart = FindWordBoundary(_cursorPosition, -1);
_text = _text.Remove(wordStart, _cursorPosition - wordStart);
_cursorPosition = wordStart;
}
else
{
// Delete single character
_text = _text.Remove(_cursorPosition - 1, 1);
_cursorPosition--;
}
_textChanged.OnNext(_text);
return true;
}
private bool HandleDelete(ConsoleModifiers modifiers)
{
if (HasSelection)
{
DeleteSelection();
_textChanged.OnNext(_text);
return true;
}
if (_cursorPosition >= _text.Length)
return false;
if (modifiers.HasFlag(ConsoleModifiers.Control))
{
// Delete word
var wordEnd = FindWordBoundary(_cursorPosition, 1);
_text = _text.Remove(_cursorPosition, wordEnd - _cursorPosition);
}
else
{
// Delete single character
_text = _text.Remove(_cursorPosition, 1);
}
_textChanged.OnNext(_text);
return true;
}
private bool HandleLeftArrow(ConsoleModifiers modifiers)
{
var newPos = modifiers.HasFlag(ConsoleModifiers.Control)
? FindWordBoundary(_cursorPosition, -1)
: Math.Max(0, _cursorPosition - 1);
if (modifiers.HasFlag(ConsoleModifiers.Shift))
{
if (_selectionStart < 0)
_selectionStart = _cursorPosition;
}
else
{
_selectionStart = -1;
}
_cursorPosition = newPos;
return true;
}
private bool HandleRightArrow(ConsoleModifiers modifiers)
{
var newPos = modifiers.HasFlag(ConsoleModifiers.Control)
? FindWordBoundary(_cursorPosition, 1)
: Math.Min(_text.Length, _cursorPosition + 1);
if (modifiers.HasFlag(ConsoleModifiers.Shift))
{
if (_selectionStart < 0)
_selectionStart = _cursorPosition;
}
else
{
_selectionStart = -1;
}
_cursorPosition = newPos;
return true;
}
private bool HandleHome(ConsoleModifiers modifiers)
{
if (modifiers.HasFlag(ConsoleModifiers.Shift))
{
if (_selectionStart < 0)
_selectionStart = _cursorPosition;
}
else
{
_selectionStart = -1;
}
_cursorPosition = 0;
return true;
}
private bool HandleEnd(ConsoleModifiers modifiers)
{
if (modifiers.HasFlag(ConsoleModifiers.Shift))
{
if (_selectionStart < 0)
_selectionStart = _cursorPosition;
}
else
{
_selectionStart = -1;
}
_cursorPosition = _text.Length;
return true;
}
private bool HandleEnter()
{
TerminaTrace.Input.Debug(this, "HandleEnter: submitting text length={0}", _text.Length);
// Emit to observable - ViewModel decides what to do (add to history, clear text, etc.)
_submitted.OnNext(_text);
return true;
}
/// <summary>
/// Clears the text and resets cursor position.
/// Call this from ViewModel after handling submission.
/// </summary>
public void Clear()
{
_text = "";
_cursorPosition = 0;
_selectionStart = -1;
_scrollOffset = 0;
_textChanged.OnNext(_text);
_invalidated.OnNext(Unit.Default);
}
private bool HandleEscape()
{
if (HasSelection)
{
_selectionStart = -1;
return true;
}
if (!string.IsNullOrEmpty(_text))
{
_text = "";
_cursorPosition = 0;
_textChanged.OnNext(_text);
return true;
}
return false;
}
private void SelectAll()
{
_selectionStart = 0;
_cursorPosition = _text.Length;
}
private void DeleteSelection()
{
if (!HasSelection)
return;
var start = Math.Min(_selectionStart, _cursorPosition);
var end = Math.Max(_selectionStart, _cursorPosition);
_text = _text.Remove(start, end - start);
_cursorPosition = start;
_selectionStart = -1;
}
private int FindWordBoundary(int from, int direction)
{
if (direction < 0)
{
if (from <= 0)
return 0;
var pos = from - 1;
// Skip whitespace
while (pos > 0 && char.IsWhiteSpace(_text[pos]))
pos--;
// Skip word characters
while (pos > 0 && !char.IsWhiteSpace(_text[pos - 1]))
pos--;
return pos;
}
else
{
if (from >= _text.Length)
return _text.Length;
var pos = from;
// Skip word characters
while (pos < _text.Length && !char.IsWhiteSpace(_text[pos]))
pos++;
// Skip whitespace
while (pos < _text.Length && char.IsWhiteSpace(_text[pos]))
pos++;
return pos;
}
}
/// <inheritdoc />
public override Size Measure(Size available)
{
var width = WidthConstraint.Compute(available.Width, _text.Length + 1, available.Width);
return new Size(width, 1);
}
/// <inheritdoc />
public override void Render(IRenderContext context, Rect bounds)
{
if (!bounds.HasArea)
return;
var displayText = _text;
var displayCursor = _cursorPosition;
// Apply password masking
if (IsPassword && displayText.Length > 0)
{
displayText = new string(PasswordChar, displayText.Length);
}
// Show placeholder if empty
if (string.IsNullOrEmpty(displayText) && !string.IsNullOrEmpty(Placeholder))
{
context.SetForeground(PlaceholderColor);
var placeholder = Placeholder.Length > bounds.Width
? Placeholder[..bounds.Width]
: Placeholder;
context.WriteAt(0, 0, placeholder);
context.ResetColors();
// Still show cursor at position 0
if (_cursorVisible)
{
context.SetBackground(CursorColor);
context.WriteAt(0, 0, ' ');
context.ResetColors();
}
return;
}
// Calculate scroll offset to keep cursor visible
if (displayCursor < _scrollOffset)
{
_scrollOffset = displayCursor;
}
else if (displayCursor >= _scrollOffset + bounds.Width)
{
_scrollOffset = displayCursor - bounds.Width + 1;
}
// Set colors
if (Foreground.HasValue)
context.SetForeground(Foreground.Value);
if (Background.HasValue)
context.SetBackground(Background.Value);
// Get visible portion
var visibleText = displayText.Length > _scrollOffset
? displayText[_scrollOffset..]
: "";
if (visibleText.Length > bounds.Width)
visibleText = visibleText[..bounds.Width];
// Draw selection background
if (HasSelection)
{
var selStart = Math.Min(_selectionStart, _cursorPosition);
var selEnd = Math.Max(_selectionStart, _cursorPosition);
var visSelStart = Math.Max(0, selStart - _scrollOffset);
var visSelEnd = Math.Min(bounds.Width, selEnd - _scrollOffset);
if (visSelEnd > visSelStart)
{
context.SetBackground(SelectionColor);
for (var x = visSelStart; x < visSelEnd && x < visibleText.Length; x++)
{
context.WriteAt(x, 0, visibleText[x]);
}
// Draw non-selected portions
context.ResetColors();
if (Foreground.HasValue)
context.SetForeground(Foreground.Value);
if (Background.HasValue)
context.SetBackground(Background.Value);
if (visSelStart > 0)
{
context.WriteAt(0, 0, visibleText[..visSelStart]);
}
if (visSelEnd < visibleText.Length)
{
context.WriteAt(visSelEnd, 0, visibleText[visSelEnd..]);
}
}
else
{
context.WriteAt(0, 0, visibleText);
}
}
else
{
context.WriteAt(0, 0, visibleText);
}
context.ResetColors();
// Draw cursor
if (_cursorVisible)
{
var cursorX = displayCursor - _scrollOffset;
if (cursorX >= 0 && cursorX < bounds.Width)
{
context.SetBackground(CursorColor);
context.SetForeground(Background ?? Color.Black);
var cursorChar = cursorX < visibleText.Length ? visibleText[cursorX] : ' ';
context.WriteAt(cursorX, 0, cursorChar);
context.ResetColors();
}
}
}
/// <inheritdoc />
public override void OnActivate()
{
// Resume cursor blinking if this node has focus
if (_hasFocus)
{
Start();
}
base.OnActivate();
}
/// <inheritdoc />
public override void OnDeactivate()
{
// Stop cursor blinking to conserve resources
Stop();
base.OnDeactivate();
}
/// <inheritdoc />
public override void Dispose()
{
if (_disposed)
return;
_disposed = true;
_cursorTimer.Stop();
_cursorTimer.Dispose();
_invalidated.OnCompleted();
_invalidated.Dispose();
_textChanged.OnCompleted();
_textChanged.Dispose();
_submitted.OnCompleted();
_submitted.Dispose();
base.Dispose();
}
}