Skip to content

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

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
EnterSubmit
EscapeClear text

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

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

Released under the Apache 2.0 License.