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
// 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>:
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:
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:
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:
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:
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:
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
| Key | Action |
|---|---|
↑/↓ | Move highlight |
Home | Jump to first item |
End | Jump to last item |
Space | Toggle selection (multi-select) |
Enter | Confirm selection |
1-9 | Quick select by number |
Escape | Cancel |
"Other" Option for Custom Input
Add a custom text input option that appears at the end of the list:
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
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 scrollingWith Typed Items
Use custom types with a display selector:
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:
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:
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:
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
| Observable | Type | Description |
|---|---|---|
SelectionConfirmed | IObservable<IReadOnlyList<T>> | Emits selected items on Enter |
OtherSelected | IObservable<string> | Emits custom text from "Other" option |
Cancelled | IObservable<Unit> | Emits when Escape is pressed |
Invalidated | IObservable<Unit> | Emits when redraw is needed |
API Reference
Constructors
| Constructor | Description |
|---|---|
SelectionListNode(IEnumerable<T>, Func<T, string>) | Create with plain text display |
SelectionListNode(IEnumerable<T>, Func<T, SelectionItemContent>) | Create with rich content display |
Properties
| Property | Type | Description |
|---|---|---|
Items | IReadOnlyList<SelectionItem<T>> | All items in the list |
SelectedItems | IReadOnlyList<T> | Currently selected items |
HighlightedItem | SelectionItem<T>? | Currently highlighted item |
CanFocus | bool | Always true |
HasFocus | bool | Whether list has focus |
FocusPriority | int | 10 (lower than modal) |
Fluent Methods
| Method | Description |
|---|---|
.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
| Property | Type | Description |
|---|---|---|
Value | T | The item value |
DisplayText | string | First line text (plain text) |
Content | SelectionItemContent | Full rich content with all lines and styling |
LineCount | int | Number of lines this item occupies |
IsSelected | bool | Whether item is selected |
IsOther | bool | Whether this is the "Other" option |
SelectionItemContent Properties
| Property | Type | Description |
|---|---|---|
Lines | IReadOnlyList<IReadOnlyList<ITextSegment>> | All lines of content |
LineCount | int | Number of lines |
HasAnimations | bool | Whether content has animated segments |
Invalidated | IObservable<Unit> | Fires when animated content changes |
SelectionItemContent Methods
| Method | Description |
|---|---|
.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
| Value | Description |
|---|---|
Single | Only one item can be selected |
Multi | Multiple items can be selected |
Source Code
View SelectionListNode 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 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
// 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
// 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
// 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();
}
}
}