Architecture
Termina uses a reactive MVVM architecture with declarative layouts and surgical region-based rendering.
Overview
┌─────────────────────────────────────────────────────────────┐
│ Application │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ ViewModel │───▶│ Page │───▶│ Layout Tree │ │
│ │ Input,State │ │ Focus,Layout │ │ (ILayoutNode)│ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
│ ▲ │ │ │
│ │ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Keyboard │ │ Focus │ │ Renderer │ │
│ │ Input │ │ Manager │ │ (ANSI out) │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────┘Design Principles
1. Separation of Concerns
- ViewModels own state, handle business logic, and expose observable properties
- Pages own focus management, key bindings, navigation, build layouts from ViewModel state, and manage interactive layout nodes
- Layout Nodes render to terminal and may handle routed input when focused
2. Reactive by Default
State changes automatically propagate to the UI:
// ViewModel changes state
Count++; // Sets Count, emits to CountChanged
// Page automatically updates
ViewModel.CountChanged
.Select(c => new TextNode($"Count: {c}"))
.AsLayout() // Subscribes and updates on change3. Declarative Layouts
UI is described as a tree, not imperatively drawn:
return Layouts.Vertical()
.WithChild(header.Height(1))
.WithChild(content.Fill())
.WithChild(footer.Height(1));4. Region-Based Rendering
Only changed regions are re-rendered, not the entire screen. This provides:
- Smooth updates without flicker
- Efficient use of terminal bandwidth
- Better performance for streaming content
Component Lifecycle
Application Start
- Host builds and starts
- Router matches initial route
- Page/ViewModel pair created via DI
- ViewModel's
OnActivated()called - Page's
BuildLayout()called - Initial render to terminal
On Input
Termina uses a two-phase input routing model (similar to DOM event handling):
- Key press captured by application
- Capture Phase: Page's
KeyBindingschecked first- If matched, handler executes (e.g.,
Navigate("/menu")) - Key is consumed, stops here
- If matched, handler executes (e.g.,
- Bubble Phase: If not consumed,
FocusManagerroutes to focused component- Focused
IFocusablenodes (TextInput, SelectionList) handle input - If component returns
true, key is consumed
- Focused
- ViewModel Phase: Unconsumed input sent to ViewModel's
Inputobservable- ViewModel can subscribe for fallback handling
- Reactive bindings emit new values from state changes
- Affected layout nodes invalidate
- Changed regions re-render
This model ensures navigation keys (Escape, Tab) work reliably while allowing focused components to handle their own input.
On Navigation
ResetOnNavigation (default):
Navigate("/path")called- Current ViewModel's
OnDeactivating()called - Current Page's
OnNavigatingFrom()called (deactivates layout) - New Page/ViewModel created
- New ViewModel's
OnActivated()called - New Page's
OnNavigatedTo()called (builds and activates layout) - Full render
PreserveState:
Navigate("/path")called- Current ViewModel's
OnDeactivating()called (disposes subscriptions) - Current Page's
OnNavigatingFrom()called (callsOnDeactivate()on layout tree) - Cached Page/ViewModel retrieved (or created on first visit)
- ViewModel's
OnActivated()called (recreates subscriptions) - Page's
OnNavigatedTo()called (callsOnActivate()on layout tree) - Render (layout preserved, just reactivated)
The Rendering Pipeline
BuildLayout() → Measure() → Render()
│ │ │
▼ ▼ ▼
Tree of Calculate Write ANSI
ILayoutNode sizes to stdout1. BuildLayout
Creates the layout tree describing the UI structure.
2. Measure
Starting from root, each node calculates its desired size given available space. Two-pass algorithm for container nodes:
- Measure fixed/auto children
- Distribute remaining space to fill children
3. Render
Each node renders to its allocated bounds using ANSI escape sequences.
Why No Spectre.Console?
Termina V3 renders directly via ANSI escape sequences instead of using Spectre.Console. Benefits:
- Surgical Updates - Re-render only changed regions
- True Streaming - Character-by-character updates
- Full Control - Custom rendering optimization
- AOT Compatible - No reflection-based layout
Source Code
View ReactiveViewModel implementation
using System.Reactive.Disposables;
using Termina.Input;
namespace Termina.Reactive;
/// <summary>
/// Base class for reactive view models in Termina.
/// ViewModels contain application state and logic, exposing observable properties
/// that pages subscribe to for automatic UI updates.
/// </summary>
/// <remarks>
/// <para>
/// ReactiveViewModel is the "ViewModel" in MVVM pattern. It:
/// </para>
/// <list type="bullet">
/// <item>Owns application state as observable properties (use [Reactive] attribute)</item>
/// <item>Subscribes to input events and backend services</item>
/// <item>Provides navigation and shutdown actions</item>
/// <item>Manages subscription lifecycle via CompositeDisposable</item>
/// </list>
/// <para>
/// Properties marked with [Reactive] get source-generated BehaviorSubject backing.
/// Pages subscribe to the generated *Changed observables to update UI automatically.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// public partial class CounterViewModel : ReactiveViewModel
/// {
/// [Reactive] private int _count;
///
/// public CounterViewModel(IObservable<IInputEvent> input)
/// {
/// input.OfType<KeyPressed>()
/// .Subscribe(HandleKey)
/// .DisposeWith(Subscriptions);
/// }
///
/// private void HandleKey(KeyPressed key)
/// {
/// if (key.KeyInfo.Key == ConsoleKey.UpArrow)
/// Count++;
/// }
/// }
/// </code>
/// </example>
public abstract class ReactiveViewModel : IDisposable
{
private CompositeDisposable _subscriptions = new();
/// <summary>
/// Composite disposable for managing subscriptions.
/// Use <see cref="RxExtensions.DisposeWith{T}"/> to add subscriptions.
/// </summary>
/// <remarks>
/// <para>
/// Subscriptions added in <see cref="OnActivated"/> are automatically disposed when
/// <see cref="OnDeactivating"/> is called. This prevents duplicate subscriptions when using
/// <see cref="Pages.NavigationBehavior.PreserveState"/>.
/// </para>
/// <para>
/// For subscriptions that should persist across activations (e.g., subscriptions created in
/// the constructor), store the disposable manually and dispose it in <see cref="Dispose"/>.
/// </para>
/// </remarks>
protected CompositeDisposable Subscriptions => _subscriptions;
/// <summary>
/// Navigate to another page by path.
/// Set by the framework when the ViewModel is bound to a page.
/// </summary>
/// <example>
/// <code>
/// Navigate("/todos/42");
/// Navigate("/");
/// </code>
/// </example>
protected Action<string> Navigate { get; private set; } = _ => { };
/// <summary>
/// Navigate to another page using a route template and values.
/// Set by the framework when the ViewModel is bound to a page.
/// </summary>
/// <example>
/// <code>
/// NavigateWithParams("/todos/{id}", new { id = 42 });
/// </code>
/// </example>
protected Action<string, object?> NavigateWithParams { get; private set; } = (_, _) => { };
/// <summary>
/// Request graceful application shutdown.
/// Set by the framework when the ViewModel is bound to a page.
/// </summary>
protected Action Shutdown { get; private set; } = () => { };
/// <summary>
/// Request a UI redraw. Use this when content changes asynchronously
/// (e.g., from streaming data) and the display needs to be refreshed.
/// Public to allow Pages to trigger redraws on layout invalidation events.
/// </summary>
public Action RequestRedraw { get; private set; } = () => { };
/// <summary>
/// Observable stream of input events from the application.
/// Subscribe to this in the ViewModel to handle keyboard input,
/// or access from the Page to route input to interactive layout nodes.
/// </summary>
public IObservable<IInputEvent> Input { get; private set; } = null!;
/// <summary>
/// Request graceful application shutdown.
/// Called by Pages in response to user input (e.g., Ctrl+Q, Escape).
/// </summary>
/// <remarks>
/// Override this method to add custom shutdown behavior such as
/// confirmation dialogs, saving state, or cleanup operations.
/// The default implementation calls <see cref="Shutdown"/> directly.
/// </remarks>
public virtual void RequestShutdown() => Shutdown();
/// <summary>
/// Called when the page becomes active (navigated to).
/// Override to perform initialization that should happen each time the page is shown.
/// </summary>
public virtual void OnActivated()
{
}
/// <summary>
/// Called when the page is being deactivated (navigating away).
/// Override to perform cleanup or state saving.
/// </summary>
/// <remarks>
/// The base implementation disposes all <see cref="Subscriptions"/> to prevent
/// duplicate subscriptions when using <see cref="Pages.NavigationBehavior.PreserveState"/>.
/// If you override this method, always call the base implementation.
/// </remarks>
public virtual void OnDeactivating()
{
// Dispose subscriptions and create a new container for next activation
// This prevents duplicate subscriptions with PreserveState navigation
_subscriptions.Dispose();
_subscriptions = new CompositeDisposable();
}
/// <summary>
/// Disposes all subscriptions.
/// Called by the framework when the ViewModel is no longer needed.
/// </summary>
public virtual void Dispose()
{
_subscriptions.Dispose();
GC.SuppressFinalize(this);
}
/// <summary>
/// Wires up the navigation, shutdown actions, and input observable.
/// Called by the framework when binding to a page.
/// </summary>
internal void WireUp(
Action<string> navigate,
Action<string, object?> navigateWithParams,
Action shutdown,
Action requestRedraw,
IObservable<IInputEvent> input)
{
Navigate = navigate;
NavigateWithParams = navigateWithParams;
Shutdown = shutdown;
RequestRedraw = requestRedraw;
Input = input;
}
}