Skip to content

SelectionListNode

An interactive list selection component with keyboard navigation, supporting single and multi-select modes. Supports both simple text items and rich content with multiple lines and styled/animated segments.

Basic Usage

csharp
// String list with factory method
var list = Layouts.SelectionList("Option 1", "Option 2", "Option 3");

// Or from an enumerable
var list = Layouts.SelectionList(options);

// Typed list with custom display
var list = Layouts.SelectionList(items, item => item.Name);

Rich Content

For items that need multiple lines, styled text, or animated elements (like spinners), use the constructor that accepts a Func<T, SelectionItemContent>:

csharp
var list = new SelectionListNode<ServerInfo>(servers, server =>
    new SelectionItemContent()
        .AddLine(server.Name, Color.White, decoration: TextDecoration.Bold)
        .AddLine($"   {server.Address}:{server.Port}", Color.BrightBlack)
);

Multi-line Items with Animations

Rich content items can include animated segments like spinners:

csharp
var list = new SelectionListNode<ConnectionState>(connections, conn =>
{
    var content = new SelectionItemContent()
        .AddLine(conn.ServerName, Color.Cyan, decoration: TextDecoration.Bold);

    if (conn.IsConnecting)
    {
        content.AddLine(
            new StaticTextSegment("   "),
            new SpinnerSegment(SpinnerStyle.Dots, Color.Yellow),
            new StaticTextSegment(" Connecting...", Color.Yellow)
        );
    }
    else
    {
        content.AddLine($"   Status: {conn.Status}", Color.Green);
    }

    return content;
});

SelectionItemContent API

SelectionItemContent provides a fluent API for building multi-line, styled content:

csharp
var content = new SelectionItemContent()
    // Add a simple text line
    .AddLine("First line")

    // Add a styled text line
    .AddLine("Bold and Blue", Color.Blue, decoration: TextDecoration.Bold)

    // Add a line with multiple segments
    .AddLine(
        new StaticTextSegment("Status: ", Color.White),
        new StaticTextSegment("Active", Color.Green, decoration: TextDecoration.Bold)
    )

    // Add a line with an animated spinner
    .AddLine(
        new SpinnerSegment(SpinnerStyle.Line, Color.Cyan),
        new StaticTextSegment(" Loading...")
    );

CompositeTextSegment

Use CompositeTextSegment to combine multiple segments into a single unit:

csharp
var composite = new CompositeTextSegment(
    new StaticTextSegment("["),
    new SpinnerSegment(SpinnerStyle.Dots, Color.Blue),
    new StaticTextSegment("] Processing...")
);

content.AddLine(composite);

Features

  • Arrow key navigation (Up/Down)
  • Home/End to jump to first/last item
  • Space to toggle selection (multi-select mode)
  • Enter to confirm selection
  • Number keys (1-9) for quick selection
  • Escape to cancel
  • Scrolling for long lists
  • Optional "Other" option for custom text input

Selection Modes

Single Select (Default)

Only one item can be selected at a time:

csharp
var list = Layouts.SelectionList("Low", "Medium", "High")
    .WithMode(SelectionMode.Single);

list.SelectionConfirmed.Subscribe(selected => {
    var choice = selected.FirstOrDefault();
    Console.WriteLine($"Selected: {choice}");
});

Multi-Select

Multiple items can be selected with Space, confirmed with Enter:

csharp
var list = Layouts.SelectionList("Feature A", "Feature B", "Feature C")
    .WithMode(SelectionMode.Multi);

list.SelectionConfirmed.Subscribe(selected => {
    foreach (var item in selected)
    {
        Console.WriteLine($"Enabled: {item}");
    }
});

Keyboard Shortcuts

KeyAction
↑/↓Move highlight
HomeJump to first item
EndJump to last item
SpaceToggle selection (multi-select)
EnterConfirm selection
1-9Quick select by number
EscapeCancel

"Other" Option for Custom Input

Add a custom text input option that appears at the end of the list:

csharp
var list = Layouts.SelectionList("High", "Medium", "Low")
    .WithOtherOption("Custom priority...");

// Handle standard selections
list.SelectionConfirmed.Subscribe(selected => {
    ProcessPriority(selected.First());
});

// Handle custom "Other" input
list.OtherSelected.Subscribe(customText => {
    ProcessPriority(customText);
});

When the user navigates to or selects the "Other" option, a text input appears immediately inline, allowing them to type without pressing Enter first.

Styling

csharp
Layouts.SelectionList("A", "B", "C")
    .WithHighlightColors(Color.Black, Color.Cyan)  // Highlighted row colors
    .WithForeground(Color.White)                    // Default text color
    .WithSelectedForeground(Color.Green)            // Checked items color
    .WithShowNumbers(true)                          // Show 1. 2. 3. prefixes
    .WithVisibleRows(5)                             // Max visible before scrolling

With Typed Items

Use custom types with a display selector:

csharp
public record Priority(int Level, string Name);

var priorities = new[]
{
    new Priority(1, "Critical"),
    new Priority(2, "High"),
    new Priority(3, "Medium"),
    new Priority(4, "Low")
};

var list = Layouts.SelectionList(priorities, p => p.Name)
    .WithMode(SelectionMode.Single);

list.SelectionConfirmed.Subscribe(selected => {
    Priority priority = selected.First();
    Console.WriteLine($"Selected level {priority.Level}");
});

Inside a Modal

SelectionListNode works seamlessly with ModalNode. The Page owns both nodes and manages Focus:

csharp
public class MyPage : ReactivePage<MyViewModel>
{
    private SelectionListNode<string> _priorityList = null!;
    private ModalNode _priorityModal = null!;

    protected override void OnBound()
    {
        _priorityList = Layouts.SelectionList("High", "Medium", "Low")
            .WithMode(SelectionMode.Single)
            .WithShowNumbers(true)
            .WithHighlightColors(Color.Black, Color.Cyan)
            .WithOtherOption("Custom...");

        _priorityModal = Layouts.Modal()
            .WithTitle("Select Priority")
            .WithBorder(BorderStyle.Rounded)
            .WithBorderColor(Color.Yellow)
            .WithBackdrop(BackdropStyle.Dim)
            .WithContent(_priorityList);

        // Handle selections - call ViewModel methods
        _priorityList.SelectionConfirmed.Subscribe(selected =>
            ViewModel.OnPrioritySelected(selected.First()))
            .DisposeWith(Subscriptions);

        _priorityList.OtherSelected.Subscribe(custom =>
            ViewModel.OnPrioritySelected(custom))
            .DisposeWith(Subscriptions);

        _priorityList.Cancelled.Subscribe(_ =>
            ViewModel.OnPriorityCancelled())
            .DisposeWith(Subscriptions);

        // React to ViewModel state to manage Focus
        ViewModel.ShowPriorityModalChanged
            .Subscribe(show => {
                if (show)
                {
                    Focus.PushFocus(_priorityModal);
                    Focus.PushFocus(_priorityList);
                }
                else
                {
                    Focus.ClearFocus();
                }
            })
            .DisposeWith(Subscriptions);
    }
}

Complete Example

Here's the recommended pattern where ViewModel handles state and Page owns layout nodes and Focus:

ViewModel - State and business logic:

csharp
public partial class SettingsViewModel : ReactiveViewModel
{
    [Reactive] private bool _showThemeSelector;
    [Reactive] private string _currentTheme = "System Default";

    public void OnThemeSelected(string theme)
    {
        CurrentTheme = theme;
        ApplyTheme(theme);
        ShowThemeSelector = false;
    }

    public void OnThemeSelectorCancelled()
    {
        ShowThemeSelector = false;
    }

    public void OpenThemeSelector()
    {
        ShowThemeSelector = true;
    }
}

Page - Owns nodes and manages Focus:

csharp
public class SettingsPage : ReactivePage<SettingsViewModel>
{
    private SelectionListNode<string> _themeList = null!;
    private ModalNode _themeModal = null!;

    protected override void OnBound()
    {
        _themeList = Layouts.SelectionList("Light", "Dark", "System Default")
            .WithMode(SelectionMode.Single)
            .WithShowNumbers(true)
            .WithHighlightColors(Color.Black, Color.White);

        _themeList.SelectionConfirmed
            .Subscribe(selected => ViewModel.OnThemeSelected(selected.First()))
            .DisposeWith(Subscriptions);

        _themeList.Cancelled
            .Subscribe(_ => ViewModel.OnThemeSelectorCancelled())
            .DisposeWith(Subscriptions);

        _themeModal = Layouts.Modal()
            .WithTitle("Choose Theme")
            .WithBorder(BorderStyle.Rounded)
            .WithContent(_themeList);

        _themeModal.Dismissed
            .Subscribe(_ => ViewModel.OnThemeSelectorCancelled())
            .DisposeWith(Subscriptions);

        // React to ViewModel state to manage Focus
        ViewModel.ShowThemeSelectorChanged
            .Subscribe(show => {
                if (show)
                {
                    Focus.PushFocus(_themeModal);
                    Focus.PushFocus(_themeList);
                }
                else
                {
                    Focus.ClearFocus();
                }
            })
            .DisposeWith(Subscriptions);
    }

    public override ILayoutNode BuildLayout()
    {
        return Layouts.Stack()
            .WithChild(mainContent)
            .WithChild(
                ViewModel.ShowThemeSelectorChanged
                    .Select(show => show ? (ILayoutNode)_themeModal : Layouts.Empty())
                    .AsLayout());
    }
}

Observables

ObservableTypeDescription
SelectionConfirmedIObservable<IReadOnlyList<T>>Emits selected items on Enter
OtherSelectedIObservable<string>Emits custom text from "Other" option
CancelledIObservable<Unit>Emits when Escape is pressed
InvalidatedIObservable<Unit>Emits when redraw is needed

API Reference

Constructors

ConstructorDescription
SelectionListNode(IEnumerable<T>, Func<T, string>)Create with plain text display
SelectionListNode(IEnumerable<T>, Func<T, SelectionItemContent>)Create with rich content display

Properties

PropertyTypeDescription
ItemsIReadOnlyList<SelectionItem<T>>All items in the list
SelectedItemsIReadOnlyList<T>Currently selected items
HighlightedItemSelectionItem<T>?Currently highlighted item
CanFocusboolAlways true
HasFocusboolWhether list has focus
FocusPriorityint10 (lower than modal)

Fluent Methods

MethodDescription
.WithMode(SelectionMode)Set Single or Multi select mode
.WithHighlightColors(fg, bg)Set highlight row colors
.WithForeground(Color)Set default text color
.WithSelectedForeground(Color)Set checked item color
.WithShowNumbers(bool)Show/hide number prefixes
.WithVisibleRows(int)Set max visible rows before scroll
.WithOtherOption(string, Action?)Add custom input option

SelectionItem Properties

PropertyTypeDescription
ValueTThe item value
DisplayTextstringFirst line text (plain text)
ContentSelectionItemContentFull rich content with all lines and styling
LineCountintNumber of lines this item occupies
IsSelectedboolWhether item is selected
IsOtherboolWhether this is the "Other" option

SelectionItemContent Properties

PropertyTypeDescription
LinesIReadOnlyList<IReadOnlyList<ITextSegment>>All lines of content
LineCountintNumber of lines
HasAnimationsboolWhether content has animated segments
InvalidatedIObservable<Unit>Fires when animated content changes

SelectionItemContent Methods

MethodDescription
.AddLine(params ITextSegment[])Add a line with multiple segments
.AddLine(string, Color?, Color?, TextDecoration)Add a simple styled text line
.ToPlainText()Get all lines as plain text
.GetFirstLineText()Get first line as plain text
FromString(string)Static factory for single-line content

Enums

SelectionMode

ValueDescription
SingleOnly one item can be selected
MultiMultiple items can be selected

Source Code

View SelectionListNode 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.Linq;
using System.Reactive.Subjects;
using Termina.Components.Streaming;
using Termina.Rendering;
using Termina.Terminal;

namespace Termina.Layout;

/// <summary>
/// An interactive list selection component with keyboard navigation.
/// </summary>
/// <typeparam name="T">The type of items in the list.</typeparam>
/// <remarks>
/// <para>
/// SelectionListNode provides keyboard-driven item selection with:
/// - Arrow key navigation (Up/Down)
/// - Home/End to jump to first/last item
/// - Space to toggle selection (in Multi mode)
/// - Enter to confirm selection
/// - Number keys (1-9) for quick selection
/// - Escape to cancel
/// </para>
/// <para>
/// Supports both simple string display and rich content with multiple lines
/// and styled/animated segments.
/// </para>
/// <para>
/// Optionally supports an "Other" option for custom text input.
/// </para>
/// </remarks>
public sealed class SelectionListNode<T> : IFocusable, IInvalidatingNode
{
    private readonly Subject<Unit> _invalidated = new();
    private readonly Subject<IReadOnlyList<T>> _selectionConfirmed = new();
    private readonly Subject<string> _otherSelected = new();
    private readonly Subject<Unit> _cancelled = new();
    private readonly List<SelectionItem<T>> _items = new();

    private int _highlightedIndex;
    private int _scrollOffset; // Now in lines, not items
    private int _visibleRows = 10;
    private bool _isEditingOther;
    private TextInputNode? _otherInput;
    private string? _otherLabel;
    private Action<string>? _otherCallback;
    private bool _hasFocus;
    private bool _disposed;

    private SelectionMode _mode = SelectionMode.Single;
    private Color _highlightForeground = Color.Black;
    private Color _highlightBackground = Color.White;
    private Color? _foreground;
    private Color? _selectedForeground;
    private bool _showNumbers = true;

    /// <summary>
    /// Creates a new SelectionListNode with the specified items and plain text display.
    /// </summary>
    /// <param name="items">The items to display.</param>
    /// <param name="displaySelector">A function to convert items to display text.</param>
    public SelectionListNode(IEnumerable<T> items, Func<T, string> displaySelector)
    {
        ArgumentNullException.ThrowIfNull(displaySelector);

        foreach (var item in items)
        {
            var selectionItem = new SelectionItem<T>(item, displaySelector(item));
            _items.Add(selectionItem);
            SubscribeToItemAnimations(selectionItem);
        }

        if (_items.Count > 0)
            _highlightedIndex = 0;
    }

    /// <summary>
    /// Creates a new SelectionListNode with rich content display supporting multiple lines
    /// and styled/animated segments.
    /// </summary>
    /// <param name="items">The items to display.</param>
    /// <param name="contentSelector">A function to convert items to rich content.</param>
    public SelectionListNode(IEnumerable<T> items, Func<T, SelectionItemContent> contentSelector)
    {
        ArgumentNullException.ThrowIfNull(contentSelector);

        foreach (var item in items)
        {
            var selectionItem = new SelectionItem<T>(item, contentSelector(item));
            _items.Add(selectionItem);
            SubscribeToItemAnimations(selectionItem);
        }

        if (_items.Count > 0)
            _highlightedIndex = 0;
    }

    private void SubscribeToItemAnimations(SelectionItem<T> item)
    {
        item.SubscribeToAnimations(_ => Invalidate());
    }

    /// <inheritdoc />
    public IObservable<Unit> Invalidated => _invalidated.AsObservable();

    /// <summary>
    /// Observable that emits the selected items when Enter is pressed.
    /// </summary>
    public IObservable<IReadOnlyList<T>> SelectionConfirmed => _selectionConfirmed.AsObservable();

    /// <summary>
    /// Observable that emits the custom text when "Other" is selected and confirmed.
    /// </summary>
    public IObservable<string> OtherSelected => _otherSelected.AsObservable();

    /// <summary>
    /// Observable that emits when Escape is pressed.
    /// </summary>
    public IObservable<Unit> Cancelled => _cancelled.AsObservable();

    /// <inheritdoc />
    public SizeConstraint WidthConstraint => SizeConstraint.FillRemaining();

    /// <inheritdoc />
    public SizeConstraint HeightConstraint => SizeConstraint.AutoSize();

    /// <inheritdoc />
    public bool CanFocus => true;

    /// <inheritdoc />
    public bool HasFocus => _hasFocus;

    /// <summary>
    /// Selection list has medium priority (lower than modal).
    /// </summary>
    public int FocusPriority => 10;

    /// <summary>
    /// Gets the currently highlighted item.
    /// </summary>
    public SelectionItem<T>? HighlightedItem =>
        _highlightedIndex >= 0 && _highlightedIndex < _items.Count ? _items[_highlightedIndex] : null;

    /// <summary>
    /// Gets all selected items.
    /// </summary>
    public IReadOnlyList<T> SelectedItems =>
        _items.Where(i => i.IsSelected && !i.IsOther).Select(i => i.Value).ToList();

    /// <summary>
    /// Gets the items in the list.
    /// </summary>
    public IReadOnlyList<SelectionItem<T>> Items => _items;

    /// <summary>
    /// Gets the total number of lines across all items.
    /// </summary>
    private int TotalLineCount => _items.Sum(i => i.LineCount);

    /// <summary>
    /// Sets the selection mode (Single or Multi).
    /// </summary>
    public SelectionListNode<T> WithMode(SelectionMode mode)
    {
        _mode = mode;
        return this;
    }

    /// <summary>
    /// Sets the highlight colors for the selected row.
    /// </summary>
    public SelectionListNode<T> WithHighlightColors(Color foreground, Color background)
    {
        _highlightForeground = foreground;
        _highlightBackground = background;
        return this;
    }

    /// <summary>
    /// Sets the default foreground color.
    /// </summary>
    public SelectionListNode<T> WithForeground(Color color)
    {
        _foreground = color;
        return this;
    }

    /// <summary>
    /// Sets the color for selected (checked) items.
    /// </summary>
    public SelectionListNode<T> WithSelectedForeground(Color color)
    {
        _selectedForeground = color;
        return this;
    }

    /// <summary>
    /// Sets whether to show number prefixes (1-9) for quick selection.
    /// </summary>
    public SelectionListNode<T> WithShowNumbers(bool show = true)
    {
        _showNumbers = show;
        return this;
    }

    /// <summary>
    /// Sets the maximum number of visible rows before scrolling.
    /// </summary>
    public SelectionListNode<T> WithVisibleRows(int rows)
    {
        _visibleRows = Math.Max(1, rows);
        return this;
    }

    /// <summary>
    /// Adds an "Other..." option that allows custom text input.
    /// </summary>
    /// <param name="label">The label for the "Other" option (default: "Other...").</param>
    /// <param name="onOther">Callback when custom text is submitted.</param>
    public SelectionListNode<T> WithOtherOption(string label = "Other...", Action<string>? onOther = null)
    {
        _otherLabel = label;
        _otherCallback = onOther;

        // Add Other as a special item at the end
        // We use default(T)! as the value since it won't be used
        var otherItem = new SelectionItem<T>(default!, label, isOther: true);
        _items.Add(otherItem);

        return this;
    }

    /// <inheritdoc />
    public void OnFocused()
    {
        _hasFocus = true;
        Invalidate();
    }

    /// <inheritdoc />
    public void OnBlurred()
    {
        _hasFocus = false;
        _isEditingOther = false;
        Invalidate();
    }

    /// <inheritdoc />
    public bool HandleInput(ConsoleKeyInfo key)
    {
        // If editing "Other" text, forward to text input
        if (_isEditingOther && _otherInput != null)
        {
            if (key.Key == ConsoleKey.Enter)
            {
                var text = _otherInput.Text;
                _isEditingOther = false;
                _otherCallback?.Invoke(text);
                _otherSelected.OnNext(text);
                Invalidate();
                return true;
            }

            if (key.Key == ConsoleKey.Escape)
            {
                _isEditingOther = false;
                Invalidate();
                return true;
            }

            return _otherInput.HandleInput(key);
        }

        // Handle navigation and selection
        switch (key.Key)
        {
            case ConsoleKey.UpArrow:
                MoveHighlight(-1);
                return true;

            case ConsoleKey.DownArrow:
                MoveHighlight(1);
                return true;

            case ConsoleKey.Home:
                _highlightedIndex = 0;
                EnsureVisible();
                Invalidate();
                return true;

            case ConsoleKey.End:
                _highlightedIndex = _items.Count - 1;
                EnsureVisible();
                Invalidate();
                return true;

            case ConsoleKey.Spacebar:
                ToggleSelection();
                return true;

            case ConsoleKey.Enter:
                ConfirmSelection();
                return true;

            case ConsoleKey.Escape:
                _cancelled.OnNext(Unit.Default);
                return true;

            // Number keys for quick selection (1-9)
            case >= ConsoleKey.D1 and <= ConsoleKey.D9 when _showNumbers:
                var index = key.Key - ConsoleKey.D1;
                if (index < _items.Count)
                {
                    _highlightedIndex = index;
                    var selectedItem = _items[index];

                    // If selecting "Other", start text input immediately
                    if (selectedItem.IsOther)
                    {
                        StartOtherInput();
                    }
                    else if (_mode == SelectionMode.Single)
                    {
                        // In single mode, number key confirms immediately
                        SelectItem(index);
                        ConfirmSelection();
                    }
                    else
                    {
                        // In multi mode, number key toggles selection
                        ToggleSelection();
                    }
                }
                return true;

            default:
                return false;
        }
    }

    private void MoveHighlight(int delta)
    {
        if (_items.Count == 0)
            return;

        _highlightedIndex = Math.Clamp(_highlightedIndex + delta, 0, _items.Count - 1);
        EnsureVisible();
        Invalidate();
    }

    /// <summary>
    /// Gets the starting line offset for a given item index.
    /// </summary>
    private int GetItemLineOffset(int itemIndex)
    {
        var offset = 0;
        for (var i = 0; i < itemIndex && i < _items.Count; i++)
        {
            offset += _items[i].LineCount;
        }
        return offset;
    }

    private void EnsureVisible()
    {
        var itemLineStart = GetItemLineOffset(_highlightedIndex);
        var itemLineEnd = itemLineStart + (_highlightedIndex < _items.Count ? _items[_highlightedIndex].LineCount : 1);

        if (itemLineStart < _scrollOffset)
        {
            _scrollOffset = itemLineStart;
        }
        else if (itemLineEnd > _scrollOffset + _visibleRows)
        {
            _scrollOffset = itemLineEnd - _visibleRows;
        }
    }

    private void ToggleSelection()
    {
        if (_highlightedIndex < 0 || _highlightedIndex >= _items.Count)
            return;

        var item = _items[_highlightedIndex];

        if (item.IsOther)
        {
            // Start editing "Other" text
            StartOtherInput();
            return;
        }

        if (_mode == SelectionMode.Single)
        {
            // Clear other selections
            foreach (var i in _items)
                i.IsSelected = false;
        }

        item.IsSelected = !item.IsSelected;
        Invalidate();
    }

    private void SelectItem(int index)
    {
        if (index < 0 || index >= _items.Count)
            return;

        if (_mode == SelectionMode.Single)
        {
            foreach (var i in _items)
                i.IsSelected = false;
        }

        _items[index].IsSelected = true;
        Invalidate();
    }

    private void StartOtherInput()
    {
        _otherInput ??= new TextInputNode()
            .WithPlaceholder("Enter custom value...");

        _otherInput.Clear();
        _isEditingOther = true;
        Invalidate();
    }

    private void ConfirmSelection()
    {
        if (_highlightedIndex >= 0 && _highlightedIndex < _items.Count)
        {
            var item = _items[_highlightedIndex];

            if (item.IsOther)
            {
                StartOtherInput();
                return;
            }

            // In single mode, always select the highlighted item on Enter
            if (_mode == SelectionMode.Single)
            {
                // Clear other selections first
                foreach (var i in _items)
                    i.IsSelected = false;
                item.IsSelected = true;
            }
        }

        var selected = _items
            .Where(i => i.IsSelected && !i.IsOther)
            .Select(i => i.Value)
            .ToList();

        _selectionConfirmed.OnNext(selected);
    }

    /// <inheritdoc />
    public Size Measure(Size available)
    {
        var totalLines = TotalLineCount;
        var height = Math.Min(totalLines, _visibleRows);

        // Calculate width based on content
        var maxItemWidth = _items.Count > 0
            ? _items.Max(i => GetItemDisplayWidth(i, _items.IndexOf(i)))
            : 10;

        var width = Math.Min(maxItemWidth + 2, available.Width);

        return new Size(width, height);
    }

    private int GetItemDisplayWidth(SelectionItem<T> item, int index)
    {
        var prefix = GetItemPrefix(item, index);

        // Find the maximum width across all lines
        var maxLineWidth = 0;
        foreach (var line in item.Content.Lines)
        {
            var lineWidth = line.Sum(s => s.GetCurrentSegment().Text.Length);
            maxLineWidth = Math.Max(maxLineWidth, lineWidth);
        }

        return prefix.Length + maxLineWidth;
    }

    private string GetItemPrefix(SelectionItem<T> item, int index)
    {
        var parts = new List<string>();

        // Number prefix (display numbers for all items; keyboard shortcuts still limited to 1-9)
        if (_showNumbers)
        {
            parts.Add($"{index + 1}.");
        }

        // Checkbox for multi-select
        if (_mode == SelectionMode.Multi && !item.IsOther)
        {
            parts.Add(item.IsSelected ? "[x]" : "[ ]");
        }
        else if (_mode == SelectionMode.Single && item.IsSelected && !item.IsOther)
        {
            parts.Add("●");
        }

        return parts.Count > 0 ? string.Join(" ", parts) + " " : "";
    }

    /// <inheritdoc />
    public void Render(IRenderContext context, Rect bounds)
    {
        if (!bounds.HasArea || _items.Count == 0)
            return;

        // Create a sub-context for this node's bounds so all coordinates are relative
        var subContext = context.CreateSubContext(bounds);

        var visibleLines = Math.Min(_visibleRows, bounds.Height);
        var needsScrollbar = TotalLineCount > visibleLines;
        var contentWidth = needsScrollbar ? bounds.Width - 1 : bounds.Width;

        var currentLine = 0;
        var currentItemLineOffset = 0;

        for (var itemIndex = 0; itemIndex < _items.Count && currentLine < visibleLines; itemIndex++)
        {
            var item = _items[itemIndex];
            var itemLineCount = item.LineCount;
            var itemEndOffset = currentItemLineOffset + itemLineCount;

            // Skip items entirely before the scroll offset
            if (itemEndOffset <= _scrollOffset)
            {
                currentItemLineOffset = itemEndOffset;
                continue;
            }

            // Calculate which lines of this item to render
            var itemLineStart = Math.Max(0, _scrollOffset - currentItemLineOffset);
            var renderRow = currentLine;

            // If this is the "Other" item and we're editing, render the text input instead
            if (item.IsOther && _isEditingOther && _otherInput != null)
            {
                RenderOtherInput(subContext, renderRow, contentWidth, itemIndex);
                currentLine++;
            }
            else
            {
                var isHighlighted = itemIndex == _highlightedIndex && _hasFocus && !_isEditingOther;

                // Render each visible line of this item
                for (var lineIndex = itemLineStart; lineIndex < itemLineCount && currentLine < visibleLines; lineIndex++)
                {
                    RenderItemLine(subContext, item, itemIndex, lineIndex, currentLine, contentWidth, isHighlighted);
                    currentLine++;
                }
            }

            currentItemLineOffset = itemEndOffset;
        }

        // Render scrollbar if needed
        if (needsScrollbar)
        {
            RenderScrollbar(subContext, bounds.Width - 1, visibleLines);
        }
    }

    private void RenderOtherInput(IRenderContext context, int row, int width, int itemIndex)
    {
        if (_otherInput == null)
            return;

        // Render the number prefix (e.g., "4. " or "10. ") in dimmed color
        var prefix = _showNumbers ? $"{itemIndex + 1}. " : "";
        if (prefix.Length > 0)
        {
            context.SetForeground(Color.BrightBlack);
            context.WriteAt(0, row, prefix);
            context.ResetColors();
        }

        // Render the text input after the prefix
        var inputX = prefix.Length;
        var inputWidth = Math.Max(1, width - inputX);
        var inputBounds = new Rect(inputX, row, inputWidth, 1);
        var inputContext = context.CreateSubContext(inputBounds);
        var innerBounds = new Rect(0, 0, inputWidth, 1);
        _otherInput.Render(inputContext, innerBounds);
    }

    private void RenderItemLine(IRenderContext context, SelectionItem<T> item, int itemIndex,
        int lineIndex, int row, int width, bool isHighlighted)
    {
        // Get the line content
        var lines = item.Content.Lines;
        if (lineIndex >= lines.Count)
            return;

        var lineSegments = lines[lineIndex];

        // Calculate prefix - only show on first line
        var prefix = lineIndex == 0 ? GetItemPrefix(item, itemIndex) : new string(' ', GetItemPrefix(item, itemIndex).Length);

        // Set background for highlighted items
        if (isHighlighted)
        {
            context.SetBackground(_highlightBackground);
            // Fill the entire row with background color
            context.Fill(0, row, width, 1, ' ');
        }

        // Render prefix
        if (prefix.Length > 0)
        {
            if (isHighlighted)
            {
                context.SetForeground(_highlightForeground);
            }
            else if (lineIndex > 0)
            {
                // Indent continuation lines with dimmed color
                context.SetForeground(Color.BrightBlack);
            }
            else if (item.IsSelected && _selectedForeground.HasValue)
            {
                context.SetForeground(_selectedForeground.Value);
            }
            else if (_foreground.HasValue)
            {
                context.SetForeground(_foreground.Value);
            }

            context.WriteAt(0, row, prefix);
        }

        // Render segments
        var xPos = prefix.Length;
        foreach (var segment in lineSegments)
        {
            if (xPos >= width)
                break;

            var styledSegment = segment.GetCurrentSegment();
            var text = styledSegment.Text;
            var style = styledSegment.Style;

            // Truncate if needed
            var remainingWidth = width - xPos;
            if (text.Length > remainingWidth)
            {
                text = text[..(remainingWidth - 1)] + "…";
            }

            // Apply colors - highlight overrides segment colors for foreground
            if (isHighlighted)
            {
                context.SetForeground(_highlightForeground);
                context.SetBackground(_highlightBackground);
            }
            else
            {
                // Apply segment style, falling back to item/list defaults
                var fg = style.HasForeground ? style.Foreground :
                    (item.IsSelected && _selectedForeground.HasValue ? _selectedForeground.Value :
                    (_foreground ?? Color.Default));
                var bg = style.HasBackground ? style.Background : Color.Default;

                context.SetForeground(fg);
                if (style.HasBackground)
                {
                    context.SetBackground(bg);
                }
            }

            // Apply decoration if present
            if (style.HasDecoration)
            {
                context.SetDecoration(style.Decoration);
            }

            context.WriteAt(xPos, row, text);
            xPos += text.Length;

            // Reset decoration after each segment
            if (style.HasDecoration)
            {
                context.SetDecoration(TextDecoration.None);
            }
        }

        context.ResetColors();
    }

    private void RenderScrollbar(IRenderContext context, int x, int height)
    {
        var totalLines = TotalLineCount;
        if (totalLines <= height)
            return;

        var thumbSize = Math.Max(1, height * height / totalLines);
        var thumbPos = _scrollOffset * (height - thumbSize) / (totalLines - height);

        context.SetForeground(Color.BrightBlack);

        for (var row = 0; row < height; row++)
        {
            var isThumb = row >= thumbPos && row < thumbPos + thumbSize;
            context.WriteAt(x, row, isThumb ? '█' : '░');
        }

        context.ResetColors();
    }

    private void Invalidate()
    {
        if (!_disposed)
            _invalidated.OnNext(Unit.Default);
    }

    /// <summary>
    /// Called when the node becomes active (page navigated to).
    /// Activates the embedded TextInputNode if it exists.
    /// </summary>
    public void OnActivate()
    {
        // Activate the embedded "Other" input if it exists
        _otherInput?.OnActivate();
    }

    /// <summary>
    /// Called when the node becomes inactive (navigating away from page).
    /// Deactivates the embedded TextInputNode to conserve resources.
    /// </summary>
    public void OnDeactivate()
    {
        // Deactivate the embedded "Other" input if it exists
        _otherInput?.OnDeactivate();
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (_disposed)
            return;

        _disposed = true;

        _invalidated.OnCompleted();
        _invalidated.Dispose();
        _selectionConfirmed.OnCompleted();
        _selectionConfirmed.Dispose();
        _otherSelected.OnCompleted();
        _otherSelected.Dispose();
        _cancelled.OnCompleted();
        _cancelled.Dispose();

        _otherInput?.Dispose();

        // Dispose all items (which will dispose their content and animation subscriptions)
        foreach (var item in _items)
        {
            item.Dispose();
        }
    }
}
View SelectionItemContent 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.Disposables;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Termina.Components.Streaming;
using Termina.Terminal;

namespace Termina.Layout;

/// <summary>
/// Represents the display content for a selection list item, supporting multiple lines
/// and multiple styled/animated segments per line.
/// </summary>
/// <remarks>
/// Example usage:
/// <code>
/// var content = new SelectionItemContent()
///     .AddLine(new StaticTextSegment("Server Name", Color.White, decoration: TextDecoration.Bold))
///     .AddLine(
///         new StaticTextSegment("   "),
///         new SpinnerSegment(SpinnerStyle.Dots, Color.Blue),
///         new StaticTextSegment(" Connecting...")
///     );
/// </code>
/// </remarks>
public sealed class SelectionItemContent : IDisposable
{
    private readonly List<IReadOnlyList<ITextSegment>> _lines = new();
    private readonly Subject<Unit> _invalidated = new();
    private readonly CompositeDisposable _subscriptions = new();
    private bool _disposed;

    /// <summary>
    /// Gets all lines of content. Each line is a list of segments.
    /// </summary>
    public IReadOnlyList<IReadOnlyList<ITextSegment>> Lines => _lines.AsReadOnly();

    /// <summary>
    /// Gets the number of lines in this content.
    /// </summary>
    public int LineCount => _lines.Count;

    /// <summary>
    /// Observable that fires when any animated segment in this content changes.
    /// </summary>
    public IObservable<Unit> Invalidated => _invalidated.AsObservable();

    /// <summary>
    /// Gets whether this content contains any animated segments.
    /// </summary>
    public bool HasAnimations => _subscriptions.Count > 0;

    /// <summary>
    /// Adds a line with the specified segments.
    /// </summary>
    /// <param name="segments">The segments that make up this line.</param>
    /// <returns>This instance for fluent chaining.</returns>
    public SelectionItemContent AddLine(params ITextSegment[] segments)
    {
        return AddLine((IEnumerable<ITextSegment>)segments);
    }

    /// <summary>
    /// Adds a line with the specified segments.
    /// </summary>
    /// <param name="segments">The segments that make up this line.</param>
    /// <returns>This instance for fluent chaining.</returns>
    public SelectionItemContent AddLine(IEnumerable<ITextSegment> segments)
    {
        var lineSegments = segments.ToList();
        _lines.Add(lineSegments.AsReadOnly());

        // Subscribe to any animated segments
        foreach (var segment in lineSegments)
        {
            SubscribeToAnimations(segment);
        }

        return this;
    }

    /// <summary>
    /// Adds a simple text line with optional styling.
    /// </summary>
    /// <param name="text">The text for this line.</param>
    /// <param name="foreground">Optional foreground color.</param>
    /// <param name="background">Optional background color.</param>
    /// <param name="decoration">Optional text decoration.</param>
    /// <returns>This instance for fluent chaining.</returns>
    public SelectionItemContent AddLine(string text, Color? foreground = null, Color? background = null,
        TextDecoration decoration = TextDecoration.None)
    {
        return AddLine(new StaticTextSegment(text, foreground, background, decoration));
    }

    /// <summary>
    /// Gets the plain text representation of this content (all lines joined with newlines).
    /// </summary>
    public string ToPlainText()
    {
        return string.Join("\n", _lines.Select(line =>
            string.Concat(line.Select(s => s.GetCurrentSegment().Text))));
    }

    /// <summary>
    /// Gets the plain text of the first line (for display in single-line contexts).
    /// </summary>
    public string GetFirstLineText()
    {
        if (_lines.Count == 0) return string.Empty;
        return string.Concat(_lines[0].Select(s => s.GetCurrentSegment().Text));
    }

    private void SubscribeToAnimations(ITextSegment segment)
    {
        if (segment is IAnimatedTextSegment animated)
        {
            var subscription = animated.Invalidated.Subscribe(_ =>
            {
                if (!_disposed)
                {
                    _invalidated.OnNext(Unit.Default);
                }
            });
            _subscriptions.Add(subscription);
        }

        // Also check composite segments for nested animations
        if (segment is ICompositeTextSegment composite)
        {
            foreach (var child in composite.Children)
            {
                SubscribeToAnimations(child);
            }
        }
    }

    /// <summary>
    /// Creates a simple single-line content from a plain string.
    /// </summary>
    /// <param name="text">The text to display.</param>
    /// <returns>A new SelectionItemContent with one line.</returns>
    public static SelectionItemContent FromString(string text)
    {
        return new SelectionItemContent().AddLine(text);
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        _subscriptions.Dispose();
        _invalidated.OnCompleted();
        _invalidated.Dispose();

        // Dispose all segments
        foreach (var line in _lines)
        {
            foreach (var segment in line)
            {
                segment.Dispose();
            }
        }
    }
}
View SelectionItem 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 Termina.Components.Streaming;

namespace Termina.Layout;

/// <summary>
/// Represents an item in a SelectionListNode.
/// </summary>
/// <typeparam name="T">The type of the underlying value.</typeparam>
public sealed class SelectionItem<T> : IDisposable
{
    private readonly SelectionItemContent _content;
    private IDisposable? _animationSubscription;
    private bool _disposed;

    /// <summary>
    /// Creates a new selection item with plain text display.
    /// </summary>
    /// <param name="value">The underlying value.</param>
    /// <param name="displayText">The text to display for this item.</param>
    /// <param name="isOther">Whether this is the "Other..." option.</param>
    public SelectionItem(T value, string displayText, bool isOther = false)
    {
        Value = value;
        IsOther = isOther;
        _content = SelectionItemContent.FromString(displayText);
    }

    /// <summary>
    /// Creates a new selection item with rich content display.
    /// </summary>
    /// <param name="value">The underlying value.</param>
    /// <param name="content">The rich content to display for this item.</param>
    /// <param name="isOther">Whether this is the "Other..." option.</param>
    public SelectionItem(T value, SelectionItemContent content, bool isOther = false)
    {
        Value = value;
        IsOther = isOther;
        _content = content ?? throw new ArgumentNullException(nameof(content));
    }

    /// <summary>
    /// The underlying value for this item.
    /// </summary>
    public T Value { get; }

    /// <summary>
    /// The text displayed for this item (first line, plain text).
    /// For full content with multiple lines and styling, use <see cref="Content"/>.
    /// </summary>
    public string DisplayText => _content.GetFirstLineText();

    /// <summary>
    /// The rich content for this item, supporting multiple lines and styled segments.
    /// </summary>
    public SelectionItemContent Content => _content;

    /// <summary>
    /// Gets the number of lines this item occupies.
    /// </summary>
    public int LineCount => _content.LineCount;

    /// <summary>
    /// Whether this item is selected.
    /// </summary>
    public bool IsSelected { get; internal set; }

    /// <summary>
    /// Whether this item represents the "Other..." option for custom input.
    /// </summary>
    public bool IsOther { get; }

    /// <summary>
    /// Subscribes to animation invalidation events from this item's content.
    /// </summary>
    /// <param name="onInvalidated">The action to invoke when content changes.</param>
    internal void SubscribeToAnimations(Action<Unit> onInvalidated)
    {
        if (_content.HasAnimations && _animationSubscription == null)
        {
            _animationSubscription = _content.Invalidated.Subscribe(onInvalidated);
        }
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        _animationSubscription?.Dispose();
        _content.Dispose();
    }
}
View CompositeTextSegment 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.Disposables;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Text;
using Termina.Terminal;

namespace Termina.Components.Streaming;

/// <summary>
/// A composite text segment that groups multiple child segments together.
/// Useful for combining static and animated segments into a single trackable unit.
/// </summary>
/// <remarks>
/// Example usage:
/// <code>
/// var composite = new CompositeTextSegment(
///     new StaticTextSegment("["),
///     new SpinnerSegment(SpinnerStyle.Dots, Color.Blue),
///     new StaticTextSegment(" Processing...]")
/// );
/// </code>
/// </remarks>
public sealed class CompositeTextSegment : ICompositeTextSegment, IAnimatedTextSegment
{
    private readonly List<ITextSegment> _children;
    private readonly Subject<Unit> _invalidated = new();
    private readonly CompositeDisposable _subscriptions = new();
    private bool _disposed;
    private bool _isAnimating;

    /// <summary>
    /// Creates a composite segment from the provided children.
    /// </summary>
    /// <param name="children">The child segments to compose together.</param>
    public CompositeTextSegment(params ITextSegment[] children) : this((IEnumerable<ITextSegment>)children)
    {
    }

    /// <summary>
    /// Creates a composite segment from the provided children.
    /// </summary>
    /// <param name="children">The child segments to compose together.</param>
    public CompositeTextSegment(IEnumerable<ITextSegment> children)
    {
        _children = children.ToList();

        // Subscribe to any animated children's invalidation events
        foreach (var child in _children)
        {
            if (child is IAnimatedTextSegment animated)
            {
                var subscription = animated.Invalidated.Subscribe(_ =>
                {
                    if (!_disposed)
                    {
                        _invalidated.OnNext(Unit.Default);
                    }
                });
                _subscriptions.Add(subscription);
                _isAnimating = _isAnimating || animated.IsAnimating;
            }
        }
    }

    /// <inheritdoc />
    public IReadOnlyList<ITextSegment> Children => _children.AsReadOnly();

    /// <inheritdoc />
    public IObservable<Unit> Invalidated => _invalidated.AsObservable();

    /// <inheritdoc />
    public bool IsAnimating => _isAnimating && _children.OfType<IAnimatedTextSegment>().Any(a => a.IsAnimating);

    /// <summary>
    /// Gets the combined text of all children as a single segment.
    /// Note: Individual child styles are not preserved in this representation.
    /// To render with proper styles, iterate <see cref="Children"/> and call GetCurrentSegment on each.
    /// </summary>
    public StyledSegment GetCurrentSegment()
    {
        var sb = new StringBuilder();
        foreach (var child in _children)
        {
            sb.Append(child.GetCurrentSegment().Text);
        }
        return new StyledSegment(sb.ToString());
    }

    /// <inheritdoc />
    public void Start()
    {
        foreach (var child in _children.OfType<IAnimatedTextSegment>())
        {
            child.Start();
        }
        _isAnimating = true;
    }

    /// <inheritdoc />
    public void Stop()
    {
        foreach (var child in _children.OfType<IAnimatedTextSegment>())
        {
            child.Stop();
        }
        _isAnimating = false;
    }

    /// <inheritdoc />
    public void Dispose()
    {
        if (_disposed) return;
        _disposed = true;

        Stop();
        _subscriptions.Dispose();
        _invalidated.OnCompleted();
        _invalidated.Dispose();

        foreach (var child in _children)
        {
            child.Dispose();
        }
    }
}

Released under the Apache 2.0 License.