TextInputNode
A single-line text input with cursor, selection, and keyboard handling.
For multi-line input, see TextAreaNode. Both share a common base class (TextInputBaseNode) for cursor management, selection, history, and paste handling.
Basic Usage
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 |
↑/↓ | Navigate history (when enabled via WithHistory()) |
Enter | Submit (auto-records to history when enabled) |
Escape | Clear text |
Input History
TextInputNode has built-in, opt-in input history. When enabled, Up/Down arrow keys navigate through previous submissions, and Enter auto-records non-empty text.
var input = new TextInputNode()
.WithPlaceholder("Enter command...")
.WithHistory(); // Unlimited history
var input2 = new TextInputNode()
.WithPlaceholder("Enter command...")
.WithHistory(maxEntries: 50); // Keep last 50 entriesHistory is off by default — existing behavior is unchanged unless you call WithHistory().
Programmatic History
Use AddHistory() for submissions that bypass the normal Enter flow:
// E.g., custom prompts from a SelectionListNode's "Other" option
input.AddHistory(customPrompt);History Keyboard Shortcuts
| Key | Action |
|---|---|
↑ | Recall previous entry (saves current input on first press) |
↓ | Recall next entry (restores saved input when past the end) |
Password Mode
new TextInputNode()
.AsPassword() // Use default mask '•'
new TextInputNode()
.AsPassword('*') // Use custom mask characterStyling
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:
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:
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);
}
}Paste Handling
TextInputNode implements IPasteReceiver and automatically handles bracketed paste mode. When the user pastes text from the clipboard, Termina detects the terminal's paste escape sequences and delivers the content as a single PasteEvent rather than individual key presses.
How It Works
- The user pastes text (Ctrl+V or right-click paste)
- The terminal wraps the content in
ESC[200~...ESC[201~markers - Termina detects the markers and emits a
PasteEvent TextInputNodeshows a summary:[Pasted 500 lines, 12345 chars]- On Enter, the full paste content (with newlines preserved) is submitted
- On any editing action (typing, backspace, delete, escape), the paste is cleared
This prevents multi-line pastes from triggering individual submissions for each line — a common issue in terminal applications.
// Paste handling is automatic — no additional setup needed
var input = new TextInputNode()
.WithPlaceholder("Paste or type here...");
// Submitted receives the full paste content when Enter is pressed
input.Submitted.Subscribe(text =>
{
// 'text' contains the full paste with newlines preserved
Console.WriteLine($"Received {text.Length} chars");
});Paste in ViewModels
If you need to handle paste events at the ViewModel level (e.g., when no TextInputNode has focus):
Input.OfType<PasteEvent>()
.Subscribe(paste =>
{
// paste.Content contains the full pasted text
ProcessPastedContent(paste.Content);
})
.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 |
|---|---|
WithHistory(int maxEntries = 0) | Enable built-in input history (0 = unlimited) |
AddHistory(string entry) | Programmatically add a history entry (no-op when disabled) |
HandleInput(ConsoleKeyInfo) | Process a key press |
HandlePaste(PasteEvent) | Handle bracketed paste (implements IPasteReceiver) |
Clear() | Clear text and reset cursor |
Start() | Start cursor animation |
Stop() | Stop cursor animation |
Source Code
View TextInputNode implementation
// 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 Termina.Rendering;
using Termina.Terminal;
namespace Termina.Layout;
/// <summary>
/// A single-line text input node with horizontal scrolling.
/// Supports optional built-in input history via <see cref="WithHistory"/>.
/// </summary>
public sealed class TextInputNode : TextInputBaseNode
{
private int _scrollOffset;
public TextInputNode(int cursorBlinkMs = 530, TimeProvider? timeProvider = null)
: base(cursorBlinkMs, timeProvider)
{
HeightConstraint = new SizeConstraint.Fixed(1);
WidthConstraint = new SizeConstraint.Fill();
}
/// <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 = '\u2022')
{
IsPassword = true;
PasswordChar = maskChar;
return this;
}
/// <summary>
/// Enable built-in input history. When enabled, Up/Down arrow keys navigate
/// through previous submissions, and Enter auto-records non-empty text.
/// </summary>
/// <param name="maxEntries">Maximum history entries to keep (0 = unlimited).</param>
public TextInputNode WithHistory(int maxEntries = 0)
{
EnableHistory(maxEntries);
return this;
}
/// <inheritdoc />
public override void Clear()
{
_scrollOffset = 0;
base.Clear();
}
/// <inheritdoc />
public override Size Measure(Size available)
{
var prefixWidth = CommittedDisplayPrefix.Length;
var width = WidthConstraint.Compute(available.Width, prefixWidth + _text.Length + 1, available.Width);
return new Size(width, 1);
}
/// <inheritdoc />
public override void Render(IRenderContext context, Rect bounds)
{
if (!bounds.HasArea)
return;
var inputContext = context.CreateSubContext(bounds);
var prefix = CommittedDisplayPrefix;
var prefixWidth = prefix.Length;
var activeText = _text;
if (IsPassword && activeText.Length > 0)
{
activeText = new string(PasswordChar, activeText.Length);
}
var fullDisplayText = prefix + activeText;
var displayCursor = prefixWidth + _cursorPosition;
if (fullDisplayText.Length == 0 && !string.IsNullOrEmpty(Placeholder))
{
inputContext.SetForeground(PlaceholderColor);
var placeholder = Placeholder.Length > bounds.Width
? Placeholder[..bounds.Width]
: Placeholder;
inputContext.WriteAt(0, 0, placeholder);
inputContext.ResetColors();
if (_cursorVisible)
{
inputContext.SetBackground(CursorColor);
inputContext.WriteAt(0, 0, ' ');
inputContext.ResetColors();
}
return;
}
if (displayCursor < _scrollOffset)
{
_scrollOffset = displayCursor;
}
else if (displayCursor >= _scrollOffset + bounds.Width)
{
_scrollOffset = displayCursor - bounds.Width + 1;
}
var visStart = _scrollOffset;
var visEnd = Math.Min(fullDisplayText.Length, _scrollOffset + bounds.Width);
var segOffset = 0;
var x = 0;
foreach (var seg in _committedSegments)
{
var segStart = segOffset;
var segEnd = segOffset + seg.DisplayText.Length;
var drawStart = Math.Max(segStart, visStart);
var drawEnd = Math.Min(segEnd, visEnd);
if (drawStart < drawEnd)
{
if (seg.Kind == SegmentKind.Pasted)
inputContext.SetForeground(Color.BrightBlack);
else if (Foreground.HasValue)
inputContext.SetForeground(Foreground.Value);
else
inputContext.ResetColors();
if (Background.HasValue)
inputContext.SetBackground(Background.Value);
var text = seg.DisplayText[(drawStart - segStart)..(drawEnd - segStart)];
inputContext.WriteAt(drawStart - _scrollOffset, 0, text);
x = drawEnd - _scrollOffset;
}
segOffset = segEnd;
}
var activeStart = prefixWidth;
var activeEnd = prefixWidth + activeText.Length;
var activeDrawStart = Math.Max(activeStart, visStart);
var activeDrawEnd = Math.Min(activeEnd, visEnd);
if (activeDrawStart < activeDrawEnd)
{
inputContext.ResetColors();
if (Foreground.HasValue)
inputContext.SetForeground(Foreground.Value);
if (Background.HasValue)
inputContext.SetBackground(Background.Value);
if (HasSelection)
{
var selStart = Math.Min(_selectionStart, _cursorPosition) + prefixWidth;
var selEnd = Math.Max(_selectionStart, _cursorPosition) + prefixWidth;
for (var i = activeDrawStart; i < activeDrawEnd; i++)
{
if (i >= selStart && i < selEnd)
{
inputContext.SetBackground(SelectionColor);
}
else
{
if (Background.HasValue)
inputContext.SetBackground(Background.Value);
else
inputContext.ResetColors();
if (Foreground.HasValue)
inputContext.SetForeground(Foreground.Value);
}
inputContext.WriteAt(i - _scrollOffset, 0, fullDisplayText[i]);
}
}
else
{
var text = activeText[(activeDrawStart - activeStart)..(activeDrawEnd - activeStart)];
inputContext.WriteAt(activeDrawStart - _scrollOffset, 0, text);
}
}
inputContext.ResetColors();
if (_cursorVisible)
{
var cursorX = displayCursor - _scrollOffset;
if (cursorX >= 0 && cursorX < bounds.Width)
{
inputContext.SetBackground(CursorColor);
inputContext.SetForeground(Background ?? Color.Black);
var cursorChar = cursorX < fullDisplayText.Length - _scrollOffset
? fullDisplayText[cursorX + _scrollOffset]
: ' ';
inputContext.WriteAt(cursorX, 0, cursorChar);
inputContext.ResetColors();
}
}
}
/// <summary>
/// Resets the horizontal scroll offset in addition to base escape behavior.
/// </summary>
protected override void OnTextBufferChanged()
{
// No-op for single-line — _scrollOffset is adjusted during Render
}
}