Skip to content

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

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

KeyAction
←/→Move cursor
Ctrl+←/→Move by word
Shift+←/→Select text
Home/EndJump to start/end
BackspaceDelete before cursor
Ctrl+BackspaceDelete word before
DeleteDelete after cursor
Ctrl+ASelect all
↑/↓Navigate history (when enabled via WithHistory())
EnterSubmit (auto-records to history when enabled)
EscapeClear 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.

csharp
var input = new TextInputNode()
    .WithPlaceholder("Enter command...")
    .WithHistory();          // Unlimited history

var input2 = new TextInputNode()
    .WithPlaceholder("Enter command...")
    .WithHistory(maxEntries: 50);  // Keep last 50 entries

History is off by default — existing behavior is unchanged unless you call WithHistory().

Programmatic History

Use AddHistory() for submissions that bypass the normal Enter flow:

csharp
// E.g., custom prompts from a SelectionListNode's "Other" option
input.AddHistory(customPrompt);

History Keyboard Shortcuts

KeyAction
Recall previous entry (saves current input on first press)
Recall next entry (restores saved input when past the end)

Password Mode

csharp
new TextInputNode()
    .AsPassword()        // Use default mask '•'

new TextInputNode()
    .AsPassword('*')     // Use custom mask character

Styling

csharp
new TextInputNode()
    .WithForeground(Color.White)
    .WithBackground(Color.Blue)
    .WithPlaceholder("Type here...")

Colors

PropertyDefaultDescription
Foregroundterminal defaultText color
Backgroundterminal defaultBackground color
PlaceholderColorBrightBlackPlaceholder text color
CursorColorWhiteCursor background color
SelectionColorBlueSelection background color

Handling Input

TextInputNode is a layout node that should be owned by the Page. There are two patterns for input handling:

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);
    }
}

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

  1. The user pastes text (Ctrl+V or right-click paste)
  2. The terminal wraps the content in ESC[200~...ESC[201~ markers
  3. Termina detects the markers and emits a PasteEvent
  4. TextInputNode shows a summary: [Pasted 500 lines, 12345 chars]
  5. On Enter, the full paste content (with newlines preserved) is submitted
  6. 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.

csharp
// 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):

csharp
Input.OfType<PasteEvent>()
    .Subscribe(paste =>
    {
        // paste.Content contains the full pasted text
        ProcessPastedContent(paste.Content);
    })
    .DisposeWith(Subscriptions);

Observables

ObservableTypeDescription
TextChangedIObservable<string>Emits when text changes
SubmittedIObservable<string>Emits when Enter is pressed
InvalidatedIObservable<Unit>Emits when redraw is needed

API Reference

Properties

PropertyTypeDefaultDescription
Textstring""Current text value
Placeholderstring?nullPlaceholder text
MaxLengthint0Max length (0 = unlimited)
IsPasswordboolfalsePassword mode
PasswordCharchar'•'Mask character
HasSelectionbool-Has selected text
SelectedTextstring-Currently selected text

Methods

MethodDescription
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
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 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
    }
}

Released under the Apache 2.0 License.