Skip to content

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:

bash
dotnet new console -n CounterDemo
cd CounterDemo
dotnet add package Termina
dotnet add package Microsoft.Extensions.Hosting

Step 1: Create the ViewModel

The ViewModel holds your application state and handles input.

View complete CounterViewModel.cs
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.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

csharp
[Reactive] private int _count;
[Reactive] private string _statusMessage = "Initial status";

The [Reactive] attribute generates:

  • A Count property with getter/setter
  • A CountChanged observable for UI binding
  • A BehaviorSubject<int> backing field

Input Handling

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

csharp
public partial class CounterViewModel : ReactiveViewModel

Step 2: Create the Page

The Page builds the layout from ViewModel state.

View complete CounterPage.cs
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.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

csharp
ViewModel.CountChanged
    .Select(count => new TextNode($"Count: {count}"))
    .AsLayout()

The AsLayout() extension creates a ReactiveLayoutNode that automatically updates when the observable emits.

Layout Composition

csharp
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

csharp
new TextNode("Title")
    .WithForeground(Color.Cyan)
    .Bold()

Chain style methods for formatted text.

Step 3: Wire Up the Host

View complete Program.cs
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 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

csharp
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

bash
dotnet run

Use:

  • / to change the counter
  • Type text and press Enter to add messages
  • Escape to quit

Complete Code

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

Next Steps

Released under the Apache 2.0 License.