Skip to content

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 ​

csharp
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 ​

KeyAction
EnterSubmit
Ctrl+EnterInsert newline (configurable modifier)
Alt+EnterInsert newline (universal fallback)
←/→Move cursor
Ctrl+←/→Move by word
Shift+←/→Select text
↑/↓Move between visual lines, then traverse history at the first or last boundary
HomeStart of current visual line
EndEnd of current visual line
Ctrl+HomeStart of document
Ctrl+EndEnd of document
BackspaceDelete before cursor
Ctrl+BackspaceDelete word before
DeleteDelete after cursor
Ctrl+ASelect all
EscapeClear text

Multi-Line Input ​

Enter submits (same as TextInputNode). Use Ctrl+Enter (or Alt+Enter) to insert newlines:

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

csharp
// 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.

Termina probes the active Kitty flags at startup. It publishes a TerminalInputCapabilitiesChanged event after the probe. Read TerminalInputCapabilities.ModifiedEnterKeySupport before you claim that a modified Enter shortcut works. The result is also available from TerminaApplication.InputCapabilities.

Word Wrap ​

Word wrap is enabled by default. Text wraps at word boundaries when it exceeds the available width:

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

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

csharp
// 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. Up and Down move between visual lines first. Up recalls history from the first visual line. Down restores the saved draft from the last visual line.

csharp
var textArea = new TextAreaNode()
    .WithHistory(maxEntries: 20);

Cancel active history navigation to restore the saved draft:

csharp
if (textArea.CancelHistoryNavigation())
{
    // The draft now contains the text from before the first Up key.
}

The method returns false when no history navigation is active.

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
csharp
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 ​

csharp
new TextAreaNode()
    .WithForeground(Color.White)
    .WithBackground(Color.DarkBlue)
    .WithPlaceholder("Describe the issue...")

Colors ​

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

Comparison: TextInputNode vs TextAreaNode ​

AspectTextInputNodeTextAreaNode
LinesSingle-lineMulti-line
EnterSubmitSubmit
NewlineN/ACtrl+Enter or Alt+Enter (configurable)
Up/DownHistory onlyVisual lines, then history at the first or last boundary
Home/EndStart/end of textStart/end of visual line
Multi-line pasteSummary placeholderSummary placeholder
HeightFixed 1 rowAuto (1–10 rows, configurable)
ScrollHorizontalVertical

Observables ​

ObservableTypeDescription
TextChangedObservable<string>Emits when text changes (including newlines)
SubmittedObservable<string>Emits on Enter
InvalidatedObservable<Unit>Emits when redraw is needed

API Reference ​

Constructor ​

csharp
public TextAreaNode(int cursorBlinkMs = 530)

Text areas attached to a page layout tree receive runtime context automatically, so cursor-blink invalidation is delivered on the Termina render loop. For deterministic component tests, set a LayoutRuntimeContext before focusing the node.

Properties ​

PropertyTypeDefaultDescription
Textstring""Current text value (includes newlines)
Placeholderstring?nullPlaceholder text
MaxLengthint0Max total characters (0 = unlimited)
HasSelectionbool-Has selected text
SelectedTextstring-Currently selected text
CursorPositionint0Cursor as a UTF-16 index into the editable text (clamped, clears selection). See Pre-filled Values

Fluent Methods ​

MethodReturnsDescription
WithPlaceholder(string)TextAreaNodeSet placeholder text
WithForeground(Color)TextAreaNodeSet text color
WithBackground(Color)TextAreaNodeSet background color
WithMaxLength(int)TextAreaNodeSet max character count
WithMaxLines(int)TextAreaNodeSet max logical lines (0 = unlimited)
WithMaxHeight(int)TextAreaNodeSet max visible rows
WithWordWrap(bool)TextAreaNodeEnable/disable word wrap
WithNewlineModifier(ConsoleModifiers)TextAreaNodeSet newline key modifier
WithHistory(int)TextAreaNodeEnable input history

Methods ​

MethodDescription
HandleInput(ConsoleKeyInfo)Process a key press
HandlePaste(PasteEvent)Handle pasted content (shared base class logic)
MoveCursorToEnd()Move the cursor after the last editable character
Clear()Clear text and reset cursor
AddHistory(string)Programmatically add a history entry
CancelHistoryNavigation()Cancel history navigation and restore the saved draft
Start()Start cursor animation
Stop()Stop cursor animation

Source Code ​

View TextAreaNode 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 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, int ColumnCount);

    public TextAreaNode(int cursorBlinkMs = 530)
        : base(cursorBlinkMs)
    {
        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)
        {
            base.HandleUpArrow();
            return true;
        }

        var previousLine = lines[row - 1];
        var targetCol = Math.Min(col, previousLine.ColumnCount);
        var targetDisplayPos = previousLine.TextStartIndex + GetCharIndexForColumn(previousLine, 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)
        {
            base.HandleDownArrow();
            return true;
        }

        var nextLine = lines[row + 1];
        var targetCol = Math.Min(col, nextLine.ColumnCount);
        var targetDisplayPos = nextLine.TextStartIndex + GetCharIndexForColumn(nextLine, 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 = DisplayWidth.GetColumnCount(CommittedDisplayPrefix);
        var width = WidthConstraint.Compute(available.Width, prefixWidth + DisplayWidth.GetColumnCount(_text) + 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 = DisplayWidth.GetColumnCount(Placeholder) > bounds.Width
                ? DisplayWidth.TruncateToColumns(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-text character 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];
            var lineText = fullDisplayText.Substring(line.TextStartIndex, line.Length);
            var column = 0;

            foreach (var cell in DisplayWidth.EnumerateCells(lineText))
            {
                if (column + cell.ColumnWidth > bounds.Width)
                    break;

                var displayIndex = line.TextStartIndex + cell.StartIndex;
                if (displayIndex >= fullDisplayText.Length)
                    break;

                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(column, row, cell.Text);
                column += cell.ColumnWidth;
            }
        }

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

                string cursorText;
                if (cursorRow < lines.Count)
                {
                    var cursorDisplayIndex = lines[cursorRow].TextStartIndex + GetCharIndexForColumn(lines[cursorRow], cursorCol);
                    cursorText = cursorDisplayIndex < fullDisplayText.Length
                        ? DisplayWidth.GetTextElementAt(fullDisplayText, cursorDisplayIndex)
                        : " ";
                }
                else
                {
                    cursorText = " ";
                }

                inputContext.WriteAt(cursorCol, visibleCursorRow, cursorText);
                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, 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;
                var logicalLine = fullText.Substring(lineStart, logicalLineLength);
                var logicalLineWidth = DisplayWidth.GetColumnCount(logicalLine);

                if (!_wordWrap || logicalLineWidth <= width)
                {
                    result.Add(new WrappedLine(lineStart, logicalLineLength, logicalLineWidth));
                }
                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 remainingText = text[pos..end];
            var remainingWidth = DisplayWidth.GetColumnCount(remainingText);
            if (remainingWidth <= width)
            {
                result.Add(new WrappedLine(pos, end - pos, remainingWidth));
                break;
            }

            // Try to break at a word boundary
            var breakOffset = DisplayWidth.GetStringIndexForColumnCount(remainingText, width);
            if (breakOffset == 0)
                breakOffset = DisplayWidth.EnumerateCells(remainingText).First().Length;

            var breakPos = pos + breakOffset;
            var wordBreak = breakPos;

            while (wordBreak > pos && !char.IsWhiteSpace(text[wordBreak - 1]))
                wordBreak--;

            if (wordBreak == pos)
                wordBreak = breakPos;

            var wrappedLength = wordBreak - pos;
            var wrappedText = text.Substring(pos, wrappedLength);
            result.Add(new WrappedLine(pos, wrappedLength, DisplayWidth.GetColumnCount(wrappedText)));
            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;

                var lineText = GetFullDisplayText().Substring(line.TextStartIndex, line.Length);
                var charOffset = Math.Clamp(displayIndex - line.TextStartIndex, 0, line.Length);
                return (i, DisplayWidth.CursorPositionToColumn(lineText, charOffset));
            }
        }

        // Past the end — cursor at end of last line
        var lastLine = lines[^1];
        return (lines.Count - 1, lastLine.ColumnCount);
    }

    private int GetCharIndexForColumn(WrappedLine line, int column)
    {
        if (line.Length == 0 || column <= 0)
            return 0;

        var lineText = GetFullDisplayText().Substring(line.TextStartIndex, line.Length);
        return DisplayWidth.GetStringIndexForColumnCount(lineText, column);
    }

    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
}

Released under the Apache 2.0 License.