Todo List Tutorial
Build a todo list manager with list selection, item management, and multi-page navigation.
What You'll Build
A todo list application with:
- Scrollable list with selection highlighting
- Add, toggle, and delete items
- Text input mode for new items
- Navigation to other pages
- Progress tracking
Project Setup
This tutorial extends from the counter app. If starting fresh:
dotnet new console -n TodoDemo
cd TodoDemo
dotnet add package Termina
dotnet add package Microsoft.Extensions.HostingStep 1: Create the Data Model
First, define a simple record for todo items:
public record TodoItem(string Description, bool IsCompleted);Step 2: Create the ViewModel
The ViewModel manages the list state and handles all input.
View complete TodoListViewModel.cs
using System.Reactive.Linq;
using Termina.Input;
using Termina.Reactive;
namespace Termina.Demo.Pages;
/// <summary>
/// ViewModel for a todo list demo.
/// Contains only state and business logic - no UI concerns.
/// </summary>
public partial class TodoListViewModel : ReactiveViewModel
{
private readonly TraceFileInfo _traceFileInfo;
public TodoListViewModel(TraceFileInfo traceFileInfo)
{
_traceFileInfo = traceFileInfo;
}
/// <summary>
/// Gets the path to the trace log file for display in the UI.
/// </summary>
public string TraceFilePath => _traceFileInfo.FilePath;
[Reactive] private IReadOnlyList<TodoItem> _items = new List<TodoItem>
{
new("Learn Termina reactive patterns", false),
new("Build a TUI app", false),
new("Deploy to production", false),
new("Celebrate success", false)
};
[Reactive] private int _selectedIndex;
[Reactive] private string _statusMessage = "Navigate with ↑/↓, Space to toggle, C for counter, Q to quit";
[Reactive] private bool _isAddingItem;
[Reactive] private bool _showPriorityModal;
[Reactive] private string _pendingTaskText = "";
public override void OnActivated()
{
// Subscribe to keyboard input
Input.OfType<KeyPressed>()
.Subscribe(HandleKeyPress)
.DisposeWith(Subscriptions);
}
/// <summary>
/// Called by Page when text input is submitted.
/// </summary>
public void OnTextInputSubmitted(string text)
{
if (!string.IsNullOrWhiteSpace(text))
{
PendingTaskText = text.Trim();
ShowPriorityModal = true;
StatusMessage = "Select priority with ↑/↓ or number keys, Enter to confirm";
}
else
{
CancelAddItem();
}
}
/// <summary>
/// Called by Page when priority is selected.
/// </summary>
public void OnPrioritySelected(string priority)
{
var newItems = Items.ToList();
var displayText = priority != "Normal" ? $"[{priority}] {PendingTaskText}" : PendingTaskText;
newItems.Add(new TodoItem(displayText, false));
Items = newItems;
SelectedIndex = Items.Count - 1;
StatusMessage = $"Added: {displayText}";
// Clean up state
ShowPriorityModal = false;
IsAddingItem = false;
PendingTaskText = "";
}
/// <summary>
/// Called by Page when priority selection is cancelled.
/// </summary>
public void OnPriorityCancelled()
{
ShowPriorityModal = false;
}
/// <summary>
/// Called by Page when add modal is dismissed.
/// </summary>
public void OnAddModalDismissed()
{
CancelAddItem();
}
/// <summary>
/// Called by Page when priority modal is dismissed.
/// </summary>
public void OnPriorityModalDismissed()
{
ShowPriorityModal = false;
IsAddingItem = false;
StatusMessage = "Cancelled adding item";
}
private void HandleKeyPress(KeyPressed key)
{
// Don't handle keys when in modal mode - let the modals handle input
if (IsAddingItem || ShowPriorityModal)
return;
switch (key.KeyInfo.Key)
{
case ConsoleKey.UpArrow:
if (SelectedIndex > 0)
{
SelectedIndex--;
StatusMessage = $"Selected: {Items[SelectedIndex].Description}";
}
break;
case ConsoleKey.DownArrow:
if (SelectedIndex < Items.Count - 1)
{
SelectedIndex++;
StatusMessage = $"Selected: {Items[SelectedIndex].Description}";
}
break;
case ConsoleKey.Spacebar:
ToggleSelected();
break;
case ConsoleKey.A:
StartAddingItem();
break;
case ConsoleKey.D:
DeleteSelected();
break;
case ConsoleKey.C:
Navigate("/counter");
break;
case ConsoleKey.Q:
Shutdown();
break;
}
}
private void StartAddingItem()
{
IsAddingItem = true;
ShowPriorityModal = false;
PendingTaskText = "";
StatusMessage = "Enter task name and press Enter, or Escape to cancel";
}
private void CancelAddItem()
{
IsAddingItem = false;
ShowPriorityModal = false;
PendingTaskText = "";
StatusMessage = "Cancelled adding item";
}
private void ToggleSelected()
{
if (Items.Count == 0) return;
var item = Items[SelectedIndex];
var newItem = item with { IsCompleted = !item.IsCompleted };
var newItems = Items.ToList();
newItems[SelectedIndex] = newItem;
Items = newItems;
StatusMessage = newItem.IsCompleted
? $"Completed: {newItem.Description}"
: $"Uncompleted: {newItem.Description}";
}
private void DeleteSelected()
{
if (Items.Count == 0) return;
var deleted = Items[SelectedIndex];
var newItems = Items.ToList();
newItems.RemoveAt(SelectedIndex);
Items = newItems;
if (SelectedIndex >= Items.Count && Items.Count > 0)
{
SelectedIndex = Items.Count - 1;
}
StatusMessage = $"Deleted: {deleted.Description}";
}
}
/// <summary>
/// A simple todo item.
/// </summary>
public record TodoItem(string Description, bool IsCompleted);Key Points
Collection State
[Reactive] private IReadOnlyList<TodoItem> _items = new List<TodoItem> { ... };
[Reactive] private int _selectedIndex;Use immutable collections with [Reactive]. To update, create a new list:
var newItems = Items.ToList();
newItems.Add(new TodoItem("New task", false));
Items = newItems; // Triggers UI updateInput Mode Handling
[Reactive] private bool _isAddingItem;
[Reactive] private string _newItemText = "";
private void HandleKeyPress(KeyPressed key)
{
if (IsAddingItem)
{
HandleTextEntryKeyPress(key);
return;
}
// Normal mode handling...
}Switch input handling based on current mode.
Navigation
case ConsoleKey.C:
Navigate("/counter");
break;Use Navigate() to move between pages.
Step 3: Create the Page
The Page renders the list and responds to state changes.
View complete TodoListPage.cs
// 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.Linq;
using Termina.Extensions;
using Termina.Input;
using Termina.Layout;
using Termina.Reactive;
using Termina.Rendering;
using Termina.Terminal;
namespace Termina.Demo.Pages;
/// <summary>
/// Page for the todo list demo.
/// Handles all UI concerns including layout nodes and focus management.
/// Reacts to ViewModel state changes.
/// </summary>
public class TodoListPage : ReactivePage<TodoListViewModel>
{
// Layout nodes owned by the Page
private ModalNode _addModal = null!;
private ModalNode _priorityModal = null!;
private SelectionListNode<string> _priorityList = null!;
private TextInputNode _textInput = null!;
protected override void OnBound()
{
// Layout nodes will be created in BuildLayout() where they're used
}
public override void OnNavigatedTo()
{
base.OnNavigatedTo();
// Subscribe to text input submission
_textInput.Submitted
.Subscribe(text => ViewModel.OnTextInputSubmitted(text))
.DisposeWith(Subscriptions);
// Subscribe to priority selection events
_priorityList.SelectionConfirmed
.Subscribe(selected =>
{
var priority = selected.FirstOrDefault() ?? "Normal";
ViewModel.OnPrioritySelected(priority);
})
.DisposeWith(Subscriptions);
_priorityList.OtherSelected
.Subscribe(customPriority => ViewModel.OnPrioritySelected(customPriority))
.DisposeWith(Subscriptions);
_priorityList.Cancelled
.Subscribe(_ => ViewModel.OnPriorityCancelled())
.DisposeWith(Subscriptions);
_addModal.Dismissed
.Subscribe(_ => ViewModel.OnAddModalDismissed())
.DisposeWith(Subscriptions);
_priorityModal.Dismissed
.Subscribe(_ => ViewModel.OnPriorityModalDismissed())
.DisposeWith(Subscriptions);
// React to state changes for focus management
// When IsAddingItem becomes true (and not showing priority), focus add modal
ViewModel.IsAddingItemChanged
.CombineLatest(
ViewModel.ShowPriorityModalChanged,
(adding, showPriority) => (adding, showPriority))
.DistinctUntilChanged()
.Subscribe(state =>
{
if (state.adding && !state.showPriority)
{
_textInput.Clear();
Focus.PushFocus(_addModal);
Focus.PushFocus(_textInput);
}
else if (!state.adding && !state.showPriority)
{
Focus.ClearFocus();
}
})
.DisposeWith(Subscriptions);
// When ShowPriorityModal becomes true, switch focus to priority modal
ViewModel.ShowPriorityModalChanged
.Where(show => show)
.Subscribe(_ =>
{
Focus.PopFocus(); // Remove add modal focus
Focus.PushFocus(_priorityModal);
Focus.PushFocus(_priorityList);
})
.DisposeWith(Subscriptions);
}
public override ILayoutNode BuildLayout()
{
// Create layout nodes as part of the layout tree lifecycle
_textInput = new TextInputNode()
.WithPlaceholder("Enter task description...");
_priorityList = Layouts.SelectionList("High", "Medium", "Low")
.WithMode(SelectionMode.Single)
.WithShowNumbers(true)
.WithHighlightColors(Color.Black, Color.Cyan)
.WithOtherOption("Custom priority...");
_addModal = Layouts.Modal()
.WithTitle("Add New Task")
.WithBorder(BorderStyle.Rounded)
.WithBorderColor(Color.Cyan)
.WithBackdrop(BackdropStyle.Dim)
.WithPosition(ModalPosition.Center)
.WithPadding(1)
.WithContent(_textInput)
.WithDismissOnEscape(true);
_priorityModal = Layouts.Modal()
.WithTitle("Select Priority")
.WithBorder(BorderStyle.Rounded)
.WithBorderColor(Color.Yellow)
.WithBackdrop(BackdropStyle.Dim)
.WithPosition(ModalPosition.Center)
.WithPadding(1)
.WithContent(_priorityList)
.WithDismissOnEscape(true);
// Build the main content
var mainContent = Layouts.Vertical()
.WithChild(
new PanelNode()
.WithTitle("Todo List Demo")
.WithBorder(BorderStyle.Double)
.WithBorderColor(Color.Cyan)
.WithContent(
ViewModel.ItemsChanged
.CombineLatest(ViewModel.SelectedIndexChanged, (items, selectedIdx) => (items, selectedIdx))
.Select(tuple => BuildTodoList(tuple.items, tuple.selectedIdx))
.AsLayout())
.Fill())
.WithChild(
new TextNode("[↑/↓] Navigate [Space] Toggle [A] Add [D] Delete [C] Counter [Q] Quit")
.WithForeground(Color.BrightBlack)
.Height(1))
.WithChild(
ViewModel.StatusMessageChanged
.Select(msg => new TextNode(msg).WithForeground(Color.White))
.AsLayout()
.Height(1))
.WithChild(
new TextNode($"Trace log: {ViewModel.TraceFilePath}")
.WithForeground(Color.DarkGray)
.Height(1));
// Build the layer with modal overlays
return Layouts.Stack()
.WithChild(mainContent)
.WithChild(
ViewModel.IsAddingItemChanged
.CombineLatest(ViewModel.ShowPriorityModalChanged, (adding, showPriority) => adding && !showPriority)
.Select(showModal => showModal
? (ILayoutNode)_addModal
: Layouts.Empty())
.AsLayout())
.WithChild(
ViewModel.ShowPriorityModalChanged
.Select(showPriority => showPriority
? (ILayoutNode)_priorityModal
: Layouts.Empty())
.AsLayout());
}
private static ILayoutNode BuildTodoList(IReadOnlyList<TodoItem> items, int selectedIndex)
{
if (items.Count == 0)
{
return new TextNode("\n No items - press [A] to add\n")
.WithForeground(Color.DarkGray)
.Italic();
}
var completedCount = items.Count(i => i.IsCompleted);
var statsText = $"{completedCount}/{items.Count} completed";
var container = Layouts.Vertical()
.WithChild(
new TextNode($"\n {statsText}\n")
.WithForeground(Color.Gray));
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
var isSelected = i == selectedIndex;
var checkbox = item.IsCompleted ? "[✓]" : "[ ]";
var text = $" {checkbox} {item.Description}";
var textNode = new TextNode(text);
if (isSelected)
{
textNode = textNode.WithForeground(Color.Black).WithBackground(Color.Green);
}
else if (item.IsCompleted)
{
textNode = textNode.WithForeground(Color.DarkGray);
}
else
{
textNode = textNode.WithForeground(Color.White);
}
container = container.WithChild(textNode.Height(1));
}
return container;
}
}Key Points
Combining Observables
ViewModel.ItemsChanged
.CombineLatest(ViewModel.SelectedIndexChanged, (items, idx) => (items, idx))
.Select(tuple => BuildTodoList(tuple.items, tuple.idx))
.AsLayout()Use CombineLatest when the UI depends on multiple reactive properties.
Conditional Rendering
ViewModel.IsAddingItemChanged
.Select(isAdding => isAdding
? BuildTextInputRow()
: BuildHelpText())
.AsLayout()Switch layouts based on state.
Selection Highlighting
if (isSelected)
{
textNode = textNode
.WithForeground(Color.Black)
.WithBackground(Color.Green);
}Style items based on selection state.
Dynamic List Building
private static ILayoutNode BuildTodoList(IReadOnlyList<TodoItem> items, int selectedIndex)
{
var container = Layouts.Vertical();
for (var i = 0; i < items.Count; i++)
{
container = container.WithChild(BuildItem(items[i], i == selectedIndex));
}
return container;
}Build layout nodes dynamically from collections.
Step 4: Register Routes
View complete Program.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Termina.Demo;
using Termina.Demo.Pages;
using Termina.Diagnostics;
using Termina.Hosting;
using Termina.Input;
using Termina.Pages;
// Check for --test flag (used in CI/CD to run scripted test and exit)
var testMode = args.Contains("--test");
// Set up diagnostic tracing with timestamped log file in temp directory
var traceDir = Path.Combine(Path.GetTempPath(), "termina-logs");
Directory.CreateDirectory(traceDir);
var traceFile = Path.Combine(traceDir, $"trace-{DateTime.Now:yyyyMMdd-HHmmss}.log");
var builder = Host.CreateApplicationBuilder(args);
// Enable file tracing and register the path for UI display
builder.Services.AddTerminaFileTracing(traceFile, TerminaTraceCategory.All, TerminaTraceLevel.Debug);
builder.Services.AddSingleton(new TraceFileInfo(traceFile));
// Set up input source based on mode
VirtualInputSource? scriptedInput = null;
if (testMode)
{
scriptedInput = new VirtualInputSource();
builder.Services.AddTerminaVirtualInput(scriptedInput);
}
// Register Termina with reactive pages using route-based navigation
// Use PreserveState so state persists when navigating between pages
builder.Services.AddTermina("/counter", termina =>
{
termina.RegisterRoute<CounterPage, CounterViewModel>("/counter", NavigationBehavior.PreserveState);
termina.RegisterRoute<TodoListPage, TodoListViewModel>("/todos", NavigationBehavior.PreserveState);
});
var host = builder.Build();
if (testMode && scriptedInput != null)
{
// Queue up scripted input to test basic functionality then quit
_ = Task.Run(async () =>
{
await Task.Delay(500); // Wait for initial render
// Test counter: increment twice
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
// Navigate to todo list
scriptedInput.EnqueueKey(ConsoleKey.T);
await Task.Delay(200);
// Navigate in todo list
scriptedInput.EnqueueKey(ConsoleKey.DownArrow);
scriptedInput.EnqueueKey(ConsoleKey.Spacebar); // Toggle item
// Quit
scriptedInput.EnqueueKey(ConsoleKey.Q);
scriptedInput.Complete();
});
}
await host.RunAsync();Key Points
Multiple Routes
builder.Services.AddTermina("/counter", termina =>
{
termina.RegisterRoute<CounterPage, CounterViewModel>("/counter");
termina.RegisterRoute<TodoListPage, TodoListViewModel>("/todos");
});Register multiple pages with their routes.
PreserveState
termina.RegisterRoute<TodoListPage, TodoListViewModel>(
"/todos",
NavigationBehavior.PreserveState);Use PreserveState to keep state when navigating away and back.
Run the App
dotnet runControls:
↑/↓- Navigate listSpace- Toggle completionA- Add new itemD- Delete selectedC- Go to counter pageQ- Quit
Patterns Demonstrated
Immutable Updates
Always create new collections to trigger updates:
// Bad - mutation doesn't trigger update
Items.Add(newItem); // Won't update UI!
// Good - replacement triggers update
Items = Items.Append(newItem).ToList();Mode-Based Input
Use state to control input behavior:
private void HandleKeyPress(KeyPressed key)
{
if (IsAddingItem)
{
HandleTextEntryMode(key);
return;
}
HandleNormalMode(key);
}Progress Tracking
Calculate derived values from state:
var completedCount = items.Count(i => i.IsCompleted);
var statsText = $"{completedCount}/{items.Count} completed";Complete Code
using System.Reactive.Linq;
using Termina.Input;
using Termina.Reactive;
namespace Termina.Demo.Pages;
/// <summary>
/// ViewModel for a todo list demo.
/// Contains only state and business logic - no UI concerns.
/// </summary>
public partial class TodoListViewModel : ReactiveViewModel
{
private readonly TraceFileInfo _traceFileInfo;
public TodoListViewModel(TraceFileInfo traceFileInfo)
{
_traceFileInfo = traceFileInfo;
}
/// <summary>
/// Gets the path to the trace log file for display in the UI.
/// </summary>
public string TraceFilePath => _traceFileInfo.FilePath;
[Reactive] private IReadOnlyList<TodoItem> _items = new List<TodoItem>
{
new("Learn Termina reactive patterns", false),
new("Build a TUI app", false),
new("Deploy to production", false),
new("Celebrate success", false)
};
[Reactive] private int _selectedIndex;
[Reactive] private string _statusMessage = "Navigate with ↑/↓, Space to toggle, C for counter, Q to quit";
[Reactive] private bool _isAddingItem;
[Reactive] private bool _showPriorityModal;
[Reactive] private string _pendingTaskText = "";
public override void OnActivated()
{
// Subscribe to keyboard input
Input.OfType<KeyPressed>()
.Subscribe(HandleKeyPress)
.DisposeWith(Subscriptions);
}
/// <summary>
/// Called by Page when text input is submitted.
/// </summary>
public void OnTextInputSubmitted(string text)
{
if (!string.IsNullOrWhiteSpace(text))
{
PendingTaskText = text.Trim();
ShowPriorityModal = true;
StatusMessage = "Select priority with ↑/↓ or number keys, Enter to confirm";
}
else
{
CancelAddItem();
}
}
/// <summary>
/// Called by Page when priority is selected.
/// </summary>
public void OnPrioritySelected(string priority)
{
var newItems = Items.ToList();
var displayText = priority != "Normal" ? $"[{priority}] {PendingTaskText}" : PendingTaskText;
newItems.Add(new TodoItem(displayText, false));
Items = newItems;
SelectedIndex = Items.Count - 1;
StatusMessage = $"Added: {displayText}";
// Clean up state
ShowPriorityModal = false;
IsAddingItem = false;
PendingTaskText = "";
}
/// <summary>
/// Called by Page when priority selection is cancelled.
/// </summary>
public void OnPriorityCancelled()
{
ShowPriorityModal = false;
}
/// <summary>
/// Called by Page when add modal is dismissed.
/// </summary>
public void OnAddModalDismissed()
{
CancelAddItem();
}
/// <summary>
/// Called by Page when priority modal is dismissed.
/// </summary>
public void OnPriorityModalDismissed()
{
ShowPriorityModal = false;
IsAddingItem = false;
StatusMessage = "Cancelled adding item";
}
private void HandleKeyPress(KeyPressed key)
{
// Don't handle keys when in modal mode - let the modals handle input
if (IsAddingItem || ShowPriorityModal)
return;
switch (key.KeyInfo.Key)
{
case ConsoleKey.UpArrow:
if (SelectedIndex > 0)
{
SelectedIndex--;
StatusMessage = $"Selected: {Items[SelectedIndex].Description}";
}
break;
case ConsoleKey.DownArrow:
if (SelectedIndex < Items.Count - 1)
{
SelectedIndex++;
StatusMessage = $"Selected: {Items[SelectedIndex].Description}";
}
break;
case ConsoleKey.Spacebar:
ToggleSelected();
break;
case ConsoleKey.A:
StartAddingItem();
break;
case ConsoleKey.D:
DeleteSelected();
break;
case ConsoleKey.C:
Navigate("/counter");
break;
case ConsoleKey.Q:
Shutdown();
break;
}
}
private void StartAddingItem()
{
IsAddingItem = true;
ShowPriorityModal = false;
PendingTaskText = "";
StatusMessage = "Enter task name and press Enter, or Escape to cancel";
}
private void CancelAddItem()
{
IsAddingItem = false;
ShowPriorityModal = false;
PendingTaskText = "";
StatusMessage = "Cancelled adding item";
}
private void ToggleSelected()
{
if (Items.Count == 0) return;
var item = Items[SelectedIndex];
var newItem = item with { IsCompleted = !item.IsCompleted };
var newItems = Items.ToList();
newItems[SelectedIndex] = newItem;
Items = newItems;
StatusMessage = newItem.IsCompleted
? $"Completed: {newItem.Description}"
: $"Uncompleted: {newItem.Description}";
}
private void DeleteSelected()
{
if (Items.Count == 0) return;
var deleted = Items[SelectedIndex];
var newItems = Items.ToList();
newItems.RemoveAt(SelectedIndex);
Items = newItems;
if (SelectedIndex >= Items.Count && Items.Count > 0)
{
SelectedIndex = Items.Count - 1;
}
StatusMessage = $"Deleted: {deleted.Description}";
}
}
/// <summary>
/// A simple todo item.
/// </summary>
public record TodoItem(string Description, bool IsCompleted);// 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.Linq;
using Termina.Extensions;
using Termina.Input;
using Termina.Layout;
using Termina.Reactive;
using Termina.Rendering;
using Termina.Terminal;
namespace Termina.Demo.Pages;
/// <summary>
/// Page for the todo list demo.
/// Handles all UI concerns including layout nodes and focus management.
/// Reacts to ViewModel state changes.
/// </summary>
public class TodoListPage : ReactivePage<TodoListViewModel>
{
// Layout nodes owned by the Page
private ModalNode _addModal = null!;
private ModalNode _priorityModal = null!;
private SelectionListNode<string> _priorityList = null!;
private TextInputNode _textInput = null!;
protected override void OnBound()
{
// Layout nodes will be created in BuildLayout() where they're used
}
public override void OnNavigatedTo()
{
base.OnNavigatedTo();
// Subscribe to text input submission
_textInput.Submitted
.Subscribe(text => ViewModel.OnTextInputSubmitted(text))
.DisposeWith(Subscriptions);
// Subscribe to priority selection events
_priorityList.SelectionConfirmed
.Subscribe(selected =>
{
var priority = selected.FirstOrDefault() ?? "Normal";
ViewModel.OnPrioritySelected(priority);
})
.DisposeWith(Subscriptions);
_priorityList.OtherSelected
.Subscribe(customPriority => ViewModel.OnPrioritySelected(customPriority))
.DisposeWith(Subscriptions);
_priorityList.Cancelled
.Subscribe(_ => ViewModel.OnPriorityCancelled())
.DisposeWith(Subscriptions);
_addModal.Dismissed
.Subscribe(_ => ViewModel.OnAddModalDismissed())
.DisposeWith(Subscriptions);
_priorityModal.Dismissed
.Subscribe(_ => ViewModel.OnPriorityModalDismissed())
.DisposeWith(Subscriptions);
// React to state changes for focus management
// When IsAddingItem becomes true (and not showing priority), focus add modal
ViewModel.IsAddingItemChanged
.CombineLatest(
ViewModel.ShowPriorityModalChanged,
(adding, showPriority) => (adding, showPriority))
.DistinctUntilChanged()
.Subscribe(state =>
{
if (state.adding && !state.showPriority)
{
_textInput.Clear();
Focus.PushFocus(_addModal);
Focus.PushFocus(_textInput);
}
else if (!state.adding && !state.showPriority)
{
Focus.ClearFocus();
}
})
.DisposeWith(Subscriptions);
// When ShowPriorityModal becomes true, switch focus to priority modal
ViewModel.ShowPriorityModalChanged
.Where(show => show)
.Subscribe(_ =>
{
Focus.PopFocus(); // Remove add modal focus
Focus.PushFocus(_priorityModal);
Focus.PushFocus(_priorityList);
})
.DisposeWith(Subscriptions);
}
public override ILayoutNode BuildLayout()
{
// Create layout nodes as part of the layout tree lifecycle
_textInput = new TextInputNode()
.WithPlaceholder("Enter task description...");
_priorityList = Layouts.SelectionList("High", "Medium", "Low")
.WithMode(SelectionMode.Single)
.WithShowNumbers(true)
.WithHighlightColors(Color.Black, Color.Cyan)
.WithOtherOption("Custom priority...");
_addModal = Layouts.Modal()
.WithTitle("Add New Task")
.WithBorder(BorderStyle.Rounded)
.WithBorderColor(Color.Cyan)
.WithBackdrop(BackdropStyle.Dim)
.WithPosition(ModalPosition.Center)
.WithPadding(1)
.WithContent(_textInput)
.WithDismissOnEscape(true);
_priorityModal = Layouts.Modal()
.WithTitle("Select Priority")
.WithBorder(BorderStyle.Rounded)
.WithBorderColor(Color.Yellow)
.WithBackdrop(BackdropStyle.Dim)
.WithPosition(ModalPosition.Center)
.WithPadding(1)
.WithContent(_priorityList)
.WithDismissOnEscape(true);
// Build the main content
var mainContent = Layouts.Vertical()
.WithChild(
new PanelNode()
.WithTitle("Todo List Demo")
.WithBorder(BorderStyle.Double)
.WithBorderColor(Color.Cyan)
.WithContent(
ViewModel.ItemsChanged
.CombineLatest(ViewModel.SelectedIndexChanged, (items, selectedIdx) => (items, selectedIdx))
.Select(tuple => BuildTodoList(tuple.items, tuple.selectedIdx))
.AsLayout())
.Fill())
.WithChild(
new TextNode("[↑/↓] Navigate [Space] Toggle [A] Add [D] Delete [C] Counter [Q] Quit")
.WithForeground(Color.BrightBlack)
.Height(1))
.WithChild(
ViewModel.StatusMessageChanged
.Select(msg => new TextNode(msg).WithForeground(Color.White))
.AsLayout()
.Height(1))
.WithChild(
new TextNode($"Trace log: {ViewModel.TraceFilePath}")
.WithForeground(Color.DarkGray)
.Height(1));
// Build the layer with modal overlays
return Layouts.Stack()
.WithChild(mainContent)
.WithChild(
ViewModel.IsAddingItemChanged
.CombineLatest(ViewModel.ShowPriorityModalChanged, (adding, showPriority) => adding && !showPriority)
.Select(showModal => showModal
? (ILayoutNode)_addModal
: Layouts.Empty())
.AsLayout())
.WithChild(
ViewModel.ShowPriorityModalChanged
.Select(showPriority => showPriority
? (ILayoutNode)_priorityModal
: Layouts.Empty())
.AsLayout());
}
private static ILayoutNode BuildTodoList(IReadOnlyList<TodoItem> items, int selectedIndex)
{
if (items.Count == 0)
{
return new TextNode("\n No items - press [A] to add\n")
.WithForeground(Color.DarkGray)
.Italic();
}
var completedCount = items.Count(i => i.IsCompleted);
var statsText = $"{completedCount}/{items.Count} completed";
var container = Layouts.Vertical()
.WithChild(
new TextNode($"\n {statsText}\n")
.WithForeground(Color.Gray));
for (var i = 0; i < items.Count; i++)
{
var item = items[i];
var isSelected = i == selectedIndex;
var checkbox = item.IsCompleted ? "[✓]" : "[ ]";
var text = $" {checkbox} {item.Description}";
var textNode = new TextNode(text);
if (isSelected)
{
textNode = textNode.WithForeground(Color.Black).WithBackground(Color.Green);
}
else if (item.IsCompleted)
{
textNode = textNode.WithForeground(Color.DarkGray);
}
else
{
textNode = textNode.WithForeground(Color.White);
}
container = container.WithChild(textNode.Height(1));
}
return container;
}
}using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Termina.Demo;
using Termina.Demo.Pages;
using Termina.Diagnostics;
using Termina.Hosting;
using Termina.Input;
using Termina.Pages;
// Check for --test flag (used in CI/CD to run scripted test and exit)
var testMode = args.Contains("--test");
// Set up diagnostic tracing with timestamped log file in temp directory
var traceDir = Path.Combine(Path.GetTempPath(), "termina-logs");
Directory.CreateDirectory(traceDir);
var traceFile = Path.Combine(traceDir, $"trace-{DateTime.Now:yyyyMMdd-HHmmss}.log");
var builder = Host.CreateApplicationBuilder(args);
// Enable file tracing and register the path for UI display
builder.Services.AddTerminaFileTracing(traceFile, TerminaTraceCategory.All, TerminaTraceLevel.Debug);
builder.Services.AddSingleton(new TraceFileInfo(traceFile));
// Set up input source based on mode
VirtualInputSource? scriptedInput = null;
if (testMode)
{
scriptedInput = new VirtualInputSource();
builder.Services.AddTerminaVirtualInput(scriptedInput);
}
// Register Termina with reactive pages using route-based navigation
// Use PreserveState so state persists when navigating between pages
builder.Services.AddTermina("/counter", termina =>
{
termina.RegisterRoute<CounterPage, CounterViewModel>("/counter", NavigationBehavior.PreserveState);
termina.RegisterRoute<TodoListPage, TodoListViewModel>("/todos", NavigationBehavior.PreserveState);
});
var host = builder.Build();
if (testMode && scriptedInput != null)
{
// Queue up scripted input to test basic functionality then quit
_ = Task.Run(async () =>
{
await Task.Delay(500); // Wait for initial render
// Test counter: increment twice
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
// Navigate to todo list
scriptedInput.EnqueueKey(ConsoleKey.T);
await Task.Delay(200);
// Navigate in todo list
scriptedInput.EnqueueKey(ConsoleKey.DownArrow);
scriptedInput.EnqueueKey(ConsoleKey.Spacebar); // Toggle item
// Quit
scriptedInput.EnqueueKey(ConsoleKey.Q);
scriptedInput.Complete();
});
}
await host.RunAsync();Next Steps
- Learn about StreamingTextNode for real-time content
- Build a streaming chat with async data
- Explore TextInputNode for proper text input handling