Counter App Tutorial
Build a reactive counter application that demonstrates Termina's core concepts.
What You'll Build
A counter application with:
- Increment/decrement with arrow keys
- Text input for messages
- Real-time status updates
- Escape to quit
Project Setup
Create a new console application:
dotnet new console -n CounterDemo
cd CounterDemo
dotnet add package Termina
dotnet add package Microsoft.Extensions.HostingStep 1: Create the ViewModel
The ViewModel holds your application state and handles input.
View complete CounterViewModel.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.Input;
using Termina.Reactive;
namespace Termina.Demo.RegionBased;
/// <summary>
/// ViewModel for the counter demo.
/// Demonstrates reactive properties with the v2 rendering infrastructure.
/// </summary>
public partial class CounterViewModel : ReactiveViewModel
{
[Reactive] private int _count;
[Reactive] private string _statusMessage = "Press Up/Down to change count, Enter to submit message, Escape to quit";
[Reactive] private string _inputText = "";
[Reactive] private List<string> _messages = new();
public override void OnActivated()
{
// Subscribe to keyboard input
Input.OfType<KeyPressed>()
.Subscribe(HandleKeyPress)
.DisposeWith(Subscriptions);
}
private void HandleKeyPress(KeyPressed key)
{
switch (key.KeyInfo.Key)
{
case ConsoleKey.UpArrow:
Count++;
StatusMessage = $"Incremented to {Count}";
break;
case ConsoleKey.DownArrow:
Count--;
StatusMessage = $"Decremented to {Count}";
break;
case ConsoleKey.Enter:
if (!string.IsNullOrWhiteSpace(InputText))
{
var newMessages = new List<string>(Messages)
{
$"[{DateTime.Now:HH:mm:ss}] {InputText}"
};
// Keep last 8 messages
if (newMessages.Count > 8)
newMessages.RemoveAt(0);
Messages = newMessages;
InputText = "";
StatusMessage = "Message sent!";
}
break;
case ConsoleKey.Backspace:
if (InputText.Length > 0)
InputText = InputText[..^1];
break;
case ConsoleKey.Escape:
Shutdown();
break;
default:
// Handle printable characters for input
if (key.KeyInfo.KeyChar >= 32 && key.KeyInfo.KeyChar < 127)
{
InputText += key.KeyInfo.KeyChar;
}
break;
}
}
}Key Points
Reactive Properties
[Reactive] private int _count;
[Reactive] private string _statusMessage = "Initial status";The [Reactive] attribute generates:
- A
Countproperty with getter/setter - A
CountChangedobservable for UI binding - A
BehaviorSubject<int>backing field
Input Handling
public override void OnActivated()
{
Input.OfType<KeyPressed>()
.Subscribe(HandleKeyPress)
.DisposeWith(Subscriptions);
}Subscribe to the Input observable in OnActivated(). Use DisposeWith(Subscriptions) for automatic cleanup.
Partial Class
The class must be partial for the source generator to work:
public partial class CounterViewModel : ReactiveViewModelStep 2: Create the Page
The Page builds the layout from ViewModel state.
View complete CounterPage.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.Layout;
using Termina.Reactive;
using Termina.Rendering;
using Termina.Terminal;
namespace Termina.Demo.RegionBased;
/// <summary>
/// Page for the counter demo.
/// Demonstrates the declarative layout API with reactive bindings.
/// </summary>
public class CounterPage : ReactivePage<CounterViewModel>
{
public override ILayoutNode BuildLayout()
{
return Layouts.Vertical()
// Header panel
.WithChild(
new PanelNode()
.WithTitle("Termina v2 Demo")
.WithBorder(BorderStyle.Double)
.WithBorderColor(Color.Blue)
.WithTitleColor(Color.BrightCyan)
.WithContent(
new TextNode("Reactive Region-Based Rendering")
.WithForeground(Color.Cyan))
.Height(3))
// Counter display - reactive binding
.WithChild(
new PanelNode()
.WithTitle("Counter")
.WithBorder(BorderStyle.Single)
.WithBorderColor(Color.Green)
.WithContent(
ViewModel.CountChanged
.Select(count => new TextNode($"Count: {count}")
.WithForeground(Color.BrightCyan))
.AsLayout())
.Height(3))
// Input panel - reactive binding (NoWrap for single-line input)
.WithChild(
new PanelNode()
.WithTitle("Input")
.WithBorder(BorderStyle.Single)
.WithBorderColor(Color.Yellow)
.WithContent(
ViewModel.InputTextChanged
.Select(text => new TextNode($"> {text}_")
.WithForeground(Color.White)
.NoWrap())
.AsLayout())
.Height(3))
// Messages panel - reactive binding
.WithChild(
new PanelNode()
.WithTitle("Messages")
.WithBorder(BorderStyle.Single)
.WithBorderColor(Color.Magenta)
.WithContent(
ViewModel.MessagesChanged
.Select(messages => new TextNode(messages.Count > 0
? string.Join("\n", messages)
: "(no messages yet)")
.WithForeground(Color.Gray))
.AsLayout())
.Fill())
// Status bar at the bottom - reactive binding with instructions
// Note: Height(1) means only 1 row is available - any wrapped text will be clipped
.WithChild(
Layouts.Horizontal()
.WithChild(
ViewModel.StatusMessageChanged
.Select(status => new TextNode(status)
.WithForeground(Color.BrightYellow)
.NoWrap()) // Status should truncate, not wrap
.AsLayout()
.Fill())
.WithChild(
new TextNode("[↑/↓] Count [Enter] Send [Esc] Quit")
.WithForeground(Color.BrightBlack)
.NoWrap() // Instructions should truncate at narrow widths
.WidthAuto()) // Take only the width needed
.Height(1));
}
}Key Points
Reactive Bindings
ViewModel.CountChanged
.Select(count => new TextNode($"Count: {count}"))
.AsLayout()The AsLayout() extension creates a ReactiveLayoutNode that automatically updates when the observable emits.
Layout Composition
return Layouts.Vertical()
.WithChild(header.Height(3))
.WithChild(counter.Height(3))
.WithChild(messages.Fill())
.WithChild(status.Height(1));Build complex UIs by nesting layouts with size constraints.
Styling
new TextNode("Title")
.WithForeground(Color.Cyan)
.Bold()Chain style methods for formatted text.
Step 3: Wire Up the Host
View complete Program.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 Microsoft.Extensions.Hosting;
using Termina.Demo.RegionBased;
using Termina.Hosting;
using Termina.Input;
// Check for --test flag (used in CI/CD to run scripted test and exit)
var testMode = args.Contains("--test");
var builder = Host.CreateApplicationBuilder(args);
// Set up input source based on mode
VirtualInputSource? scriptedInput = null;
if (testMode)
{
scriptedInput = new VirtualInputSource();
builder.Services.AddTerminaVirtualInput(scriptedInput);
}
// Register Termina with the tree-based reactive page
builder.Services.AddTermina("/counter", termina =>
{
termina.RegisterRoute<CounterPage, CounterViewModel>("/counter");
});
var host = builder.Build();
// Test mode - queue up scripted input then quit
if (testMode && scriptedInput != null)
{
_ = Task.Run(async () =>
{
await Task.Delay(100); // Wait for initial render
// Increment counter
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
await Task.Delay(50);
// Decrement counter
scriptedInput.EnqueueKey(ConsoleKey.DownArrow);
await Task.Delay(50);
// Type a message
scriptedInput.EnqueueString("Hello from test!");
await Task.Delay(50);
scriptedInput.EnqueueKey(ConsoleKey.Enter);
await Task.Delay(50);
// Type another message
scriptedInput.EnqueueString("Testing reactive v2");
scriptedInput.EnqueueKey(ConsoleKey.Enter);
await Task.Delay(50);
// Quit
scriptedInput.EnqueueKey(ConsoleKey.Escape);
scriptedInput.Complete();
});
}
await host.RunAsync();Key Points
Service Registration
builder.Services.AddTermina("/counter", termina =>
{
termina.RegisterRoute<CounterPage, CounterViewModel>("/counter");
});Register your page and ViewModel with a route template.
Initial Route
The first argument to AddTermina specifies the starting route.
Run the App
dotnet runUse:
↑/↓to change the counter- Type text and press
Enterto add messages Escapeto quit
Complete Code
// 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.Input;
using Termina.Reactive;
namespace Termina.Demo.RegionBased;
/// <summary>
/// ViewModel for the counter demo.
/// Demonstrates reactive properties with the v2 rendering infrastructure.
/// </summary>
public partial class CounterViewModel : ReactiveViewModel
{
[Reactive] private int _count;
[Reactive] private string _statusMessage = "Press Up/Down to change count, Enter to submit message, Escape to quit";
[Reactive] private string _inputText = "";
[Reactive] private List<string> _messages = new();
public override void OnActivated()
{
// Subscribe to keyboard input
Input.OfType<KeyPressed>()
.Subscribe(HandleKeyPress)
.DisposeWith(Subscriptions);
}
private void HandleKeyPress(KeyPressed key)
{
switch (key.KeyInfo.Key)
{
case ConsoleKey.UpArrow:
Count++;
StatusMessage = $"Incremented to {Count}";
break;
case ConsoleKey.DownArrow:
Count--;
StatusMessage = $"Decremented to {Count}";
break;
case ConsoleKey.Enter:
if (!string.IsNullOrWhiteSpace(InputText))
{
var newMessages = new List<string>(Messages)
{
$"[{DateTime.Now:HH:mm:ss}] {InputText}"
};
// Keep last 8 messages
if (newMessages.Count > 8)
newMessages.RemoveAt(0);
Messages = newMessages;
InputText = "";
StatusMessage = "Message sent!";
}
break;
case ConsoleKey.Backspace:
if (InputText.Length > 0)
InputText = InputText[..^1];
break;
case ConsoleKey.Escape:
Shutdown();
break;
default:
// Handle printable characters for input
if (key.KeyInfo.KeyChar >= 32 && key.KeyInfo.KeyChar < 127)
{
InputText += key.KeyInfo.KeyChar;
}
break;
}
}
}// 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.Layout;
using Termina.Reactive;
using Termina.Rendering;
using Termina.Terminal;
namespace Termina.Demo.RegionBased;
/// <summary>
/// Page for the counter demo.
/// Demonstrates the declarative layout API with reactive bindings.
/// </summary>
public class CounterPage : ReactivePage<CounterViewModel>
{
public override ILayoutNode BuildLayout()
{
return Layouts.Vertical()
// Header panel
.WithChild(
new PanelNode()
.WithTitle("Termina v2 Demo")
.WithBorder(BorderStyle.Double)
.WithBorderColor(Color.Blue)
.WithTitleColor(Color.BrightCyan)
.WithContent(
new TextNode("Reactive Region-Based Rendering")
.WithForeground(Color.Cyan))
.Height(3))
// Counter display - reactive binding
.WithChild(
new PanelNode()
.WithTitle("Counter")
.WithBorder(BorderStyle.Single)
.WithBorderColor(Color.Green)
.WithContent(
ViewModel.CountChanged
.Select(count => new TextNode($"Count: {count}")
.WithForeground(Color.BrightCyan))
.AsLayout())
.Height(3))
// Input panel - reactive binding (NoWrap for single-line input)
.WithChild(
new PanelNode()
.WithTitle("Input")
.WithBorder(BorderStyle.Single)
.WithBorderColor(Color.Yellow)
.WithContent(
ViewModel.InputTextChanged
.Select(text => new TextNode($"> {text}_")
.WithForeground(Color.White)
.NoWrap())
.AsLayout())
.Height(3))
// Messages panel - reactive binding
.WithChild(
new PanelNode()
.WithTitle("Messages")
.WithBorder(BorderStyle.Single)
.WithBorderColor(Color.Magenta)
.WithContent(
ViewModel.MessagesChanged
.Select(messages => new TextNode(messages.Count > 0
? string.Join("\n", messages)
: "(no messages yet)")
.WithForeground(Color.Gray))
.AsLayout())
.Fill())
// Status bar at the bottom - reactive binding with instructions
// Note: Height(1) means only 1 row is available - any wrapped text will be clipped
.WithChild(
Layouts.Horizontal()
.WithChild(
ViewModel.StatusMessageChanged
.Select(status => new TextNode(status)
.WithForeground(Color.BrightYellow)
.NoWrap()) // Status should truncate, not wrap
.AsLayout()
.Fill())
.WithChild(
new TextNode("[↑/↓] Count [Enter] Send [Esc] Quit")
.WithForeground(Color.BrightBlack)
.NoWrap() // Instructions should truncate at narrow widths
.WidthAuto()) // Take only the width needed
.Height(1));
}
}// 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 Microsoft.Extensions.Hosting;
using Termina.Demo.RegionBased;
using Termina.Hosting;
using Termina.Input;
// Check for --test flag (used in CI/CD to run scripted test and exit)
var testMode = args.Contains("--test");
var builder = Host.CreateApplicationBuilder(args);
// Set up input source based on mode
VirtualInputSource? scriptedInput = null;
if (testMode)
{
scriptedInput = new VirtualInputSource();
builder.Services.AddTerminaVirtualInput(scriptedInput);
}
// Register Termina with the tree-based reactive page
builder.Services.AddTermina("/counter", termina =>
{
termina.RegisterRoute<CounterPage, CounterViewModel>("/counter");
});
var host = builder.Build();
// Test mode - queue up scripted input then quit
if (testMode && scriptedInput != null)
{
_ = Task.Run(async () =>
{
await Task.Delay(100); // Wait for initial render
// Increment counter
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
scriptedInput.EnqueueKey(ConsoleKey.UpArrow);
await Task.Delay(50);
// Decrement counter
scriptedInput.EnqueueKey(ConsoleKey.DownArrow);
await Task.Delay(50);
// Type a message
scriptedInput.EnqueueString("Hello from test!");
await Task.Delay(50);
scriptedInput.EnqueueKey(ConsoleKey.Enter);
await Task.Delay(50);
// Type another message
scriptedInput.EnqueueString("Testing reactive v2");
scriptedInput.EnqueueKey(ConsoleKey.Enter);
await Task.Delay(50);
// Quit
scriptedInput.EnqueueKey(ConsoleKey.Escape);
scriptedInput.Complete();
});
}
await host.RunAsync();Next Steps
- Add more pages and navigation
- Learn about size constraints for responsive layouts
- Build a todo list with list management