TextAreaNode
A multi-line text input with word wrap, vertical scrolling, and newline insertion.
TextAreaNode shares its editing core (cursor, selection, history, paste) with TextInputNode via the common base class TextInputBaseNode.
Basic Usage
var textArea = new TextAreaNode()
.WithPlaceholder("Enter your message...");
// Enter submits, Ctrl+Enter inserts newlines
textArea.Submitted.Subscribe(text => Console.WriteLine($"Submitted:\n{text}"));Features
- Multi-line text editing with Ctrl+Enter for newlines
- Word wrap with dynamic height
- Vertical scrolling when content exceeds viewport
- Visual line navigation (Up/Down moves between wrapped lines)
- All shared features from
TextInputBaseNode: cursor blink, selection, word navigation, history, paste
Keyboard Shortcuts
| Key | Action |
|---|---|
Enter | Submit |
Ctrl+Enter | Insert newline (configurable modifier) |
Alt+Enter | Insert newline (universal fallback) |
←/→ | Move cursor |
Ctrl+←/→ | Move by word |
Shift+←/→ | Select text |
↑/↓ | Move between visual lines (or history when text is empty) |
Home | Start of current visual line |
End | End of current visual line |
Ctrl+Home | Start of document |
Ctrl+End | End of document |
Backspace | Delete before cursor |
Ctrl+Backspace | Delete word before |
Delete | Delete after cursor |
Ctrl+A | Select all |
Escape | Clear text |
Multi-Line Input
Enter submits (same as TextInputNode). Use Ctrl+Enter (or Alt+Enter) to insert newlines:
var textArea = new TextAreaNode()
.WithPlaceholder("Write your story...");
// Ctrl+Enter inserts newlines while editing
// Enter submits the full multi-line content
textArea.Submitted.Subscribe(text =>
{
// text contains the full multi-line content
// e.g., "line 1\nline 2\nline 3"
ProcessMultiLineInput(text);
});Newline Modifier
By default, Ctrl+Enter inserts a newline and bare Enter submits. You can change which modifier inserts a newline:
// Shift+Enter inserts newline instead of Ctrl+Enter
new TextAreaNode()
.WithNewlineModifier(ConsoleModifiers.Shift);Terminal Compatibility
Standard Linux terminals send the same byte (0x0D) for both Enter and Ctrl+Enter, making them indistinguishable. Termina enables the kitty keyboard protocol at startup, which causes modern terminals to send distinct CSI u escape sequences (e.g. ESC[13;5u for Ctrl+Enter). This is supported by kitty, WezTerm, Ghostty, Alacritty, foot, and others.
Terminals that do not support the kitty protocol will silently ignore the enable sequence and Ctrl+Enter will behave the same as Enter (submit). Alt+Enter always works as a universal fallback because terminals encode the Alt modifier as an ESC prefix (ESC + CR), which is reliably detected even inside tmux.
Word Wrap
Word wrap is enabled by default. Text wraps at word boundaries when it exceeds the available width:
// Disable word wrap (long lines extend beyond viewport)
new TextAreaNode()
.WithWordWrap(false);Height Control
TextAreaNode uses Auto height by default (min: 1, max: 10 rows). The height grows with content up to the maximum:
// Limit to 5 visible rows (scrolls when content exceeds)
new TextAreaNode()
.WithMaxHeight(5);MaxLines
Limit the number of logical lines (newlines) the user can enter:
// Allow at most 10 lines
new TextAreaNode()
.WithMaxLines(10);When at the limit, Ctrl+Enter is consumed but no newline is inserted.
Input History
Like TextInputNode, history is opt-in. When the text area is empty, Up/Down navigate history. When there is content, Up/Down navigate between visual lines instead:
var textArea = new TextAreaNode()
.WithHistory(maxEntries: 20);Paste Handling
Paste behavior is identical to TextInputNode — both use the shared TextInputBaseNode logic:
- Single-line paste: inserted inline at the cursor position
- Multi-line paste: stored as a committed segment with a summary display (e.g.,
[Pasted 3 lines, 45 chars]), with the full content preserved for submission
textArea.HandlePaste(new PasteEvent("line 1\nline 2\nline 3"));
// textArea.Text shows "[Pasted 3 lines, 29 chars] "
// But Enter submits the full original content: "line 1\nline 2\nline 3"Styling
new TextAreaNode()
.WithForeground(Color.White)
.WithBackground(Color.DarkBlue)
.WithPlaceholder("Describe the issue...")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 |
Comparison: TextInputNode vs TextAreaNode
| Aspect | TextInputNode | TextAreaNode |
|---|---|---|
| Lines | Single-line | Multi-line |
| Enter | Submit | Submit |
| Newline | N/A | Ctrl+Enter or Alt+Enter (configurable) |
| Up/Down | History only | Visual lines (history when empty) |
| Home/End | Start/end of text | Start/end of visual line |
| Multi-line paste | Summary placeholder | Summary placeholder |
| Height | Fixed 1 row | Auto (1–10 rows, configurable) |
| Scroll | Horizontal | Vertical |
Observables
| Observable | Type | Description |
|---|---|---|
TextChanged | Observable<string> | Emits when text changes (including newlines) |
Submitted | Observable<string> | Emits on Enter |
Invalidated | Observable<Unit> | Emits when redraw is needed |
API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
Text | string | "" | Current text value (includes newlines) |
Placeholder | string? | null | Placeholder text |
MaxLength | int | 0 | Max total characters (0 = unlimited) |
HasSelection | bool | - | Has selected text |
SelectedText | string | - | Currently selected text |
Fluent Methods
| Method | Returns | Description |
|---|---|---|
WithPlaceholder(string) | TextAreaNode | Set placeholder text |
WithForeground(Color) | TextAreaNode | Set text color |
WithBackground(Color) | TextAreaNode | Set background color |
WithMaxLength(int) | TextAreaNode | Set max character count |
WithMaxLines(int) | TextAreaNode | Set max logical lines (0 = unlimited) |
WithMaxHeight(int) | TextAreaNode | Set max visible rows |
WithWordWrap(bool) | TextAreaNode | Enable/disable word wrap |
WithNewlineModifier(ConsoleModifiers) | TextAreaNode | Set newline key modifier |
WithHistory(int) | TextAreaNode | Enable input history |
Methods
| Method | Description |
|---|---|
HandleInput(ConsoleKeyInfo) | Process a key press |
HandlePaste(PasteEvent) | Handle pasted content (shared base class logic) |
Clear() | Clear text and reset cursor |
AddHistory(string) | Programmatically add a history entry |
Start() | Start cursor animation |
Stop() | Stop cursor animation |
Source Code
View TextAreaNode 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 multi-line text input node with word wrap and vertical scrolling.
/// Enter submits; Ctrl+Enter (configurable) or Alt+Enter inserts a newline.
/// Alt+Enter is a universal fallback that works on all terminals including tmux,
/// since terminals encode Alt as an ESC prefix.
/// Paste behavior matches <see cref="TextInputNode"/>: multi-line pastes show
/// a summary placeholder, and the full content is submitted on Enter.
/// </summary>
public sealed class TextAreaNode : TextInputBaseNode
{
private int _scrollTopLine;
private int _lastKnownWidth;
private List<WrappedLine>? _wrappedLines;
private ConsoleModifiers _newlineModifier = ConsoleModifiers.Control;
private int _maxLines;
private bool _wordWrap = true;
private record struct WrappedLine(int TextStartIndex, int Length);
public TextAreaNode(int cursorBlinkMs = 530, TimeProvider? timeProvider = null)
: base(cursorBlinkMs, timeProvider)
{
HeightConstraint = new SizeConstraint.Auto { Min = 1, Max = 10 };
WidthConstraint = new SizeConstraint.Fill();
}
// Text and HandlePaste use base class behavior (committed segments with summaries).
#region Fluent API
/// <summary>
/// Set placeholder text.
/// </summary>
public TextAreaNode WithPlaceholder(string placeholder)
{
Placeholder = placeholder;
return this;
}
/// <summary>
/// Set foreground color.
/// </summary>
public TextAreaNode WithForeground(Color color)
{
Foreground = color;
return this;
}
/// <summary>
/// Set background color.
/// </summary>
public TextAreaNode WithBackground(Color color)
{
Background = color;
return this;
}
/// <summary>
/// Set max length (total characters, 0 = unlimited).
/// </summary>
public TextAreaNode WithMaxLength(int length)
{
MaxLength = length;
return this;
}
/// <summary>
/// Set maximum number of logical lines (0 = unlimited).
/// When set, Enter at the limit is consumed but doesn't insert a newline.
/// </summary>
public TextAreaNode WithMaxLines(int lines)
{
_maxLines = lines;
return this;
}
/// <summary>
/// Enable or disable word wrapping (default: enabled).
/// </summary>
public TextAreaNode WithWordWrap(bool enabled)
{
_wordWrap = enabled;
InvalidateWrappedLines();
return this;
}
/// <summary>
/// Set the modifier key required for inserting a newline (default: Control).
/// Bare Enter submits; modifier+Enter inserts a newline.
/// Use <see cref="ConsoleModifiers.Control"/> for Ctrl+Enter (default),
/// <see cref="ConsoleModifiers.Alt"/> for Alt+Enter, etc.
/// </summary>
public TextAreaNode WithNewlineModifier(ConsoleModifiers mod)
{
_newlineModifier = mod;
return this;
}
/// <summary>
/// Enable built-in input history.
/// </summary>
/// <param name="maxEntries">Maximum history entries to keep (0 = unlimited).</param>
public TextAreaNode WithHistory(int maxEntries = 0)
{
EnableHistory(maxEntries);
return this;
}
/// <summary>
/// Set maximum visible height in rows. Shorthand for HeightAuto(1, rows).
/// </summary>
public TextAreaNode WithMaxHeight(int rows)
{
HeightConstraint = new SizeConstraint.Auto { Min = 1, Max = rows };
return this;
}
#endregion
#region Overrides
/// <inheritdoc />
protected override bool HandleEnter(ConsoleModifiers modifiers)
{
// Modifier+Enter inserts a newline; bare Enter submits.
// Primary: the configured _newlineModifier (default Ctrl+Enter) —
// requires kitty keyboard protocol support in the terminal.
// Fallback: Alt+Enter always works because terminals universally
// encode Alt as an ESC prefix, which EscapeSequenceParser detects.
if (modifiers.HasFlag(_newlineModifier) || modifiers.HasFlag(ConsoleModifiers.Alt))
{
return InsertNewline();
}
PerformSubmit();
return true;
}
private bool InsertNewline()
{
// Check max lines constraint (only counts newlines in _text, not committed segments)
if (_maxLines > 0)
{
var currentLineCount = 1;
foreach (var c in _text)
{
if (c == '\n') currentLineCount++;
}
if (currentLineCount >= _maxLines)
return true; // Consume the key but don't insert
}
// Check max length
if (MaxLength > 0 && _text.Length + 1 > MaxLength)
return true;
if (HasSelection)
DeleteSelection();
_text = _text.Insert(_cursorPosition, "\n");
_cursorPosition++;
OnTextBufferChanged();
_textChanged.OnNext(Text);
return true;
}
/// <inheritdoc />
protected override bool HandleUpArrow()
{
if (_text.Length == 0 && _committedSegments.Count == 0)
return base.HandleUpArrow();
var lines = GetWrappedLines(EffectiveWidth);
var displayPos = DisplayCursorPosition;
var (row, col) = GetVisualPosition(displayPos, lines);
if (row == 0)
return true; // At top — consume the key
var targetCol = Math.Min(col, lines[row - 1].Length);
var targetDisplayPos = lines[row - 1].TextStartIndex + targetCol;
_cursorPosition = DisplayToTextPosition(targetDisplayPos);
_selectionStart = -1;
return true;
}
/// <inheritdoc />
protected override bool HandleDownArrow()
{
if (_text.Length == 0 && _committedSegments.Count == 0)
return base.HandleDownArrow();
var lines = GetWrappedLines(EffectiveWidth);
var displayPos = DisplayCursorPosition;
var (row, col) = GetVisualPosition(displayPos, lines);
if (row >= lines.Count - 1)
return true; // At bottom — consume the key
var targetCol = Math.Min(col, lines[row + 1].Length);
var targetDisplayPos = lines[row + 1].TextStartIndex + targetCol;
_cursorPosition = DisplayToTextPosition(targetDisplayPos);
_selectionStart = -1;
return true;
}
/// <inheritdoc />
protected override bool HandleHome(ConsoleModifiers modifiers)
{
if (modifiers.HasFlag(ConsoleModifiers.Shift))
{
if (_selectionStart < 0)
_selectionStart = _cursorPosition;
}
else
{
_selectionStart = -1;
}
if (modifiers.HasFlag(ConsoleModifiers.Control))
{
_cursorPosition = 0;
}
else
{
var lines = GetWrappedLines(EffectiveWidth);
var displayPos = DisplayCursorPosition;
var (row, _) = GetVisualPosition(displayPos, lines);
_cursorPosition = DisplayToTextPosition(lines[row].TextStartIndex);
}
return true;
}
/// <inheritdoc />
protected override bool HandleEnd(ConsoleModifiers modifiers)
{
if (modifiers.HasFlag(ConsoleModifiers.Shift))
{
if (_selectionStart < 0)
_selectionStart = _cursorPosition;
}
else
{
_selectionStart = -1;
}
if (modifiers.HasFlag(ConsoleModifiers.Control))
{
_cursorPosition = _text.Length;
}
else
{
var lines = GetWrappedLines(EffectiveWidth);
var displayPos = DisplayCursorPosition;
var (row, _) = GetVisualPosition(displayPos, lines);
var line = lines[row];
_cursorPosition = DisplayToTextPosition(line.TextStartIndex + line.Length);
}
return true;
}
/// <inheritdoc />
public override void Clear()
{
_scrollTopLine = 0;
InvalidateWrappedLines();
base.Clear();
}
/// <inheritdoc />
protected override void OnTextBufferChanged()
{
InvalidateWrappedLines();
}
/// <inheritdoc />
public override Size Measure(Size available)
{
var prefixWidth = CommittedDisplayPrefix.Length;
var width = WidthConstraint.Compute(available.Width, prefixWidth + _text.Length + 1, available.Width);
_lastKnownWidth = width;
var lines = GetWrappedLines(width);
var lineCount = Math.Max(1, lines.Count);
var height = HeightConstraint.Compute(available.Height, lineCount, available.Height);
return new Size(width, height);
}
/// <inheritdoc />
public override void Render(IRenderContext context, Rect bounds)
{
if (!bounds.HasArea)
return;
var inputContext = context.CreateSubContext(bounds);
_lastKnownWidth = bounds.Width;
var prefix = CommittedDisplayPrefix;
var prefixLen = prefix.Length;
var activeText = _text;
if (IsPassword && activeText.Length > 0)
activeText = new string(PasswordChar, activeText.Length);
var fullDisplayText = prefix + activeText;
var lines = GetWrappedLines(bounds.Width);
// Show placeholder if completely empty
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;
}
// Cursor position in display coordinates
var displayCursor = prefixLen + _cursorPosition;
var (cursorRow, cursorCol) = GetVisualPosition(displayCursor, lines);
EnsureCursorVisible(cursorRow, bounds.Height);
// Selection range in display coordinates
var selDisplayStart = -1;
var selDisplayEnd = -1;
if (HasSelection)
{
selDisplayStart = prefixLen + Math.Min(_selectionStart, _cursorPosition);
selDisplayEnd = prefixLen + Math.Max(_selectionStart, _cursorPosition);
}
// Pre-compute committed segment boundaries for coloring
var segBoundaries = new List<(int Start, int End, SegmentKind Kind)>();
var segOff = 0;
foreach (var seg in _committedSegments)
{
segBoundaries.Add((segOff, segOff + seg.DisplayText.Length, seg.Kind));
segOff += seg.DisplayText.Length;
}
// Render visible lines
for (var row = 0; row < bounds.Height; row++)
{
var lineIndex = _scrollTopLine + row;
if (lineIndex >= lines.Count)
break;
var line = lines[lineIndex];
for (var col = 0; col < line.Length && col < bounds.Width; col++)
{
var displayIndex = line.TextStartIndex + col;
if (displayIndex >= fullDisplayText.Length)
break;
var ch = fullDisplayText[displayIndex];
inputContext.ResetColors();
// Selection highlighting
if (selDisplayStart >= 0 && displayIndex >= selDisplayStart && displayIndex < selDisplayEnd)
{
inputContext.SetBackground(SelectionColor);
}
else if (Background.HasValue)
{
inputContext.SetBackground(Background.Value);
}
// Foreground: committed segment coloring vs active text
if (displayIndex < prefixLen)
{
// Find which committed segment this character belongs to
var segColor = Foreground;
foreach (var (start, end, kind) in segBoundaries)
{
if (displayIndex >= start && displayIndex < end)
{
if (kind == SegmentKind.Pasted)
segColor = Color.BrightBlack;
break;
}
}
if (segColor.HasValue)
inputContext.SetForeground(segColor.Value);
}
else
{
if (Foreground.HasValue)
inputContext.SetForeground(Foreground.Value);
}
inputContext.WriteAt(col, row, ch);
}
}
inputContext.ResetColors();
// Draw cursor
if (_cursorVisible)
{
var visibleCursorRow = cursorRow - _scrollTopLine;
if (visibleCursorRow >= 0 && visibleCursorRow < bounds.Height
&& cursorCol >= 0 && cursorCol < bounds.Width)
{
inputContext.SetBackground(CursorColor);
inputContext.SetForeground(Background ?? Color.Black);
char cursorChar;
if (cursorRow < lines.Count)
{
var cursorDisplayIndex = lines[cursorRow].TextStartIndex + cursorCol;
cursorChar = cursorDisplayIndex < fullDisplayText.Length
? fullDisplayText[cursorDisplayIndex]
: ' ';
}
else
{
cursorChar = ' ';
}
inputContext.WriteAt(cursorCol, visibleCursorRow, cursorChar);
inputContext.ResetColors();
}
}
}
#endregion
#region Word wrap and position mapping
/// <summary>
/// The effective width used for wrapping calculations.
/// </summary>
private int EffectiveWidth => _lastKnownWidth > 0 ? _lastKnownWidth : 80;
/// <summary>
/// Cursor position in the full display text coordinate space.
/// </summary>
private int DisplayCursorPosition => CommittedDisplayPrefix.Length + _cursorPosition;
/// <summary>
/// Converts a position in the full display text back to an active-text position,
/// clamping so the cursor never lands inside the committed prefix.
/// </summary>
private int DisplayToTextPosition(int displayPos)
{
var prefixLen = CommittedDisplayPrefix.Length;
return Math.Clamp(displayPos - prefixLen, 0, _text.Length);
}
/// <summary>
/// Builds the full display text used for wrapping and rendering:
/// committed segment display prefix + active text (with password masking).
/// </summary>
private string GetFullDisplayText()
{
var activeText = IsPassword && _text.Length > 0
? new string(PasswordChar, _text.Length)
: _text;
return CommittedDisplayPrefix + activeText;
}
private void InvalidateWrappedLines()
{
_wrappedLines = null;
}
private List<WrappedLine> GetWrappedLines(int width)
{
if (width <= 0)
width = 1;
if (_wrappedLines is not null && _lastKnownWidth == width)
return _wrappedLines;
_lastKnownWidth = width;
_wrappedLines = ComputeWrappedLines(width);
return _wrappedLines;
}
private List<WrappedLine> ComputeWrappedLines(int width)
{
var result = new List<WrappedLine>();
var fullText = GetFullDisplayText();
if (fullText.Length == 0)
{
result.Add(new WrappedLine(0, 0));
return result;
}
// Split by logical lines (newlines), then word-wrap each
var lineStart = 0;
for (var i = 0; i <= fullText.Length; i++)
{
if (i == fullText.Length || fullText[i] == '\n')
{
var logicalLineLength = i - lineStart;
if (!_wordWrap || logicalLineLength <= width)
{
result.Add(new WrappedLine(lineStart, logicalLineLength));
}
else
{
WrapLogicalLine(result, fullText, lineStart, logicalLineLength, width);
}
lineStart = i + 1;
}
}
// NOTE: No separate trailing-newline check is needed here. When the text
// ends with '\n', the main loop's i == fullText.Length iteration already
// creates an empty WrappedLine for the content after the last newline.
// Adding another would create a duplicate, causing the cursor to appear
// one row too low and then "jump up" when the user starts typing.
return result;
}
private static void WrapLogicalLine(
List<WrappedLine> result, string text, int lineStart, int lineLength, int width)
{
var pos = lineStart;
var end = lineStart + lineLength;
while (pos < end)
{
var remaining = end - pos;
if (remaining <= width)
{
result.Add(new WrappedLine(pos, remaining));
break;
}
// Try to break at a word boundary
var breakPos = pos + width;
var wordBreak = breakPos;
while (wordBreak > pos && !char.IsWhiteSpace(text[wordBreak - 1]))
wordBreak--;
if (wordBreak == pos)
wordBreak = breakPos;
result.Add(new WrappedLine(pos, wordBreak - pos));
pos = wordBreak;
// Skip leading whitespace on the new line
while (pos < end && text[pos] == ' ')
pos++;
}
}
/// <summary>
/// Maps a display-text index to a visual (row, col) position within the wrapped lines.
/// </summary>
private (int Row, int Col) GetVisualPosition(int displayIndex, List<WrappedLine> lines)
{
if (lines.Count == 0)
return (0, 0);
for (var i = 0; i < lines.Count; i++)
{
var line = lines[i];
var lineEnd = line.TextStartIndex + line.Length;
if (displayIndex >= line.TextStartIndex && displayIndex <= lineEnd)
{
// If exactly at the start of the next line, prefer the next line
if (displayIndex == lineEnd && i + 1 < lines.Count
&& displayIndex == lines[i + 1].TextStartIndex)
continue;
return (i, displayIndex - line.TextStartIndex);
}
}
// Past the end — cursor at end of last line
var lastLine = lines[^1];
return (lines.Count - 1, lastLine.Length);
}
private void EnsureCursorVisible(int cursorRow, int viewportHeight)
{
if (viewportHeight <= 0)
return;
if (cursorRow < _scrollTopLine)
{
_scrollTopLine = cursorRow;
}
else if (cursorRow >= _scrollTopLine + viewportHeight)
{
_scrollTopLine = cursorRow - viewportHeight + 1;
}
}
#endregion
}