Skip to content

Navigation

Termina provides navigation actions within ViewModels to move between pages.

ViewModels have access to protected navigation methods wired up by the framework:

Navigate directly by path:

csharp
Navigate("/");           // Go to home
Navigate("/settings");   // Go to settings
Navigate("/items/42");   // Go to item 42

Navigate using a template with parameters:

csharp
NavigateWithParams("/items/{id}", new { id = 42 });
NavigateWithParams("/users/{name}", new { name = "alice" });

Shutdown

Request graceful application shutdown:

csharp
Shutdown();  // Exits the application
csharp
public partial class MenuViewModel : ReactiveViewModel
{
    [Reactive] private int _selectedIndex;

    public override void OnActivated()
    {
        Input.OfType<KeyPressed>()
            .Subscribe(HandleKey)
            .DisposeWith(Subscriptions);
    }

    private void HandleKey(KeyPressed key)
    {
        switch (key.KeyInfo.Key)
        {
            case ConsoleKey.Enter:
                NavigateToSelected();
                break;
            case ConsoleKey.Escape:
                Shutdown();
                break;
        }
    }

    private void NavigateToSelected()
    {
        var route = SelectedIndex switch
        {
            0 => "/counter",
            1 => "/todo",
            2 => "/settings",
            _ => "/"
        };
        Navigate(route);
    }
}

When registering routes, you can control how navigation behaves:

csharp
termina.RegisterRoute<DetailPage, DetailViewModel>(
    "/items/{id}",
    NavigationBehavior.PreserveState);  // Keep ViewModel state

ResetOnNavigation (Default)

  • Creates new Page and ViewModel on each navigation
  • State is reset each time
  • Use for pages that should start fresh

PreserveState

  • Reuses existing Page and ViewModel instances
  • State persists across navigations
  • Layout tree is preserved and reactivated, not disposed
  • ViewModel lifecycle:
    • OnDeactivating() disposes subscriptions
    • OnActivated() recreates subscriptions
  • Layout lifecycle:
    • OnDeactivate() pauses timers, stops animations, pauses observable subscriptions
    • OnActivate() resumes timers, animations, and subscriptions
  • Use for pages with expensive state, data caching, or complex UI state (e.g., form inputs, scroll positions)

Going Back

Termina tracks navigation history automatically. A forward navigation pushes the current page onto the stack, and the back APIs pop it.

When a page navigates to a different path, the current page is pushed onto the history stack:

csharp
Navigate("/items/42");   // "/menu" is pushed onto history
Navigate("/settings");   // "/items/42" is pushed onto history

History rules:

  • The concrete path is stored, so route parameters survive a back navigation.
  • Navigating to the same path does not push a new entry.
  • Going back restores the previous route without re-pushing the current page, so you never get a ping-pong loop between two pages.

Host-Driven Back Navigation

The host owns the history. Call TerminaApplication.GoBack() to return to the previous route:

csharp
if (termina.CanGoBack)
{
    termina.GoBack();
}
  • CanGoBack is true when the history stack has at least one entry.
  • GoBack() is a no-op when the history is empty. It does not throw and it does not shut the application down.
  • Call GoBack() from host code when a destination page can be reached from more than one caller. The history stack returns the user to the route they actually came from, so nothing gets hard-coded.

Page and ViewModel Patterns

Pages handle back navigation through the key-binding capture phase. Key bindings registered in OnNavigatedTo are checked before focused components receive input, so Escape can drive navigation even when a text input has focus:

csharp
public override void OnNavigatedTo()
{
    base.OnNavigatedTo();

    // Fixed caller route: navigate back explicitly
    KeyBindings.Register(ConsoleKey.Escape, () => Navigate("/menu"));

    // Multi-caller route: raise an event the host subscribes to
    KeyBindings.Register(ConsoleKey.Escape, () => BackRequested?.Invoke());
}

// User-defined event, wired by host code to TerminaApplication.GoBack()
public event Action? BackRequested;

Two patterns apply:

  1. Fixed caller route. When a page can only be reached from one place, register a key binding that calls Navigate() back to that route. This is the simplest pattern and needs no host wiring.
  2. Multi-caller route. When a page can be reached from several routes, do not hard-code a caller route. Raise an event or callback that host code handles by calling TerminaApplication.GoBack(), which returns the user to wherever they actually came from.

ViewModels can do the same. Subscribe to Input for KeyPressed events, and expose a callback or event that host code wires to GoBack().

Nested State Consumes Escape First

Components with their own internal state consume Escape before page-level navigation runs. WizardNode is the reference example: Escape walks back through sub-steps and steps first, and TryGoBack() returns false when the wizard is already at its first step:

csharp
if (wizard.TryGoBack())
{
    // Escape was consumed by the wizard's internal state
    return;
}

// Wizard is at its first step, so page-level back navigation applies

Follow this pattern in your own components: consume the key locally until the nested state is exhausted, then let the page (or host) handle application-level back navigation.

Termina.Navigation.NavigationBackRequested is the framework event that requests a history-based back navigation. The application event loop handles it by calling GoBack():

csharp
case NavigationBackRequested:
    GoBack();
    return;

The event is mainly useful for custom input sources that map a global key (for example Ctrl+B) to back navigation. An input source pushes events into the application channel, so it can emit NavigationBackRequested just like it emits KeyPressed:

csharp
public sealed class BackKeyInputSource : IInputSource
{
    public async Task RunAsync(ChannelWriter<object> writer, CancellationToken cancellationToken)
    {
        // Read keys from the terminal; when Ctrl+B is pressed:
        await writer.WriteAsync(new NavigationBackRequested(), cancellationToken);
    }
}

When no history exists, a NavigationBackRequested is ignored. The application does not shut down and nothing throws. Prefer calling GoBack() directly from host code. Reserve the event for input sources and integrations that push events into the application channel.

Back Navigation and PreserveState

Going back to a page registered with NavigationBehavior.PreserveState reuses the cached page and ViewModel instances. The lifecycle methods still run:

  • OnDeactivating() runs on the page being left.
  • OnActivated() runs on the restored page when it becomes active again.

State kept in ReactiveProperty fields survives the round trip, so a form partially filled in before navigating away is intact when the user comes back.

Lifecycle Methods

OnActivated

Called when navigating to the page:

csharp
public override void OnActivated()
{
    // Setup subscriptions
    Input.OfType<KeyPressed>()
        .Subscribe(HandleKey)
        .DisposeWith(Subscriptions);

    // Load data
    LoadItems();
}

OnDeactivating

Called when navigating away:

csharp
public override void OnDeactivating()
{
    // Subscriptions are auto-disposed
    base.OnDeactivating();  // Important: call base

    // Optional: save state, cancel operations
    SaveDraft();
}

RequestRedraw

For asynchronous content updates (like streaming), request a UI refresh:

csharp
private async Task StreamDataAsync()
{
    await foreach (var chunk in dataStream)
    {
        Messages = Messages.Append(chunk).ToList();
        RequestRedraw();  // Trigger UI update
    }
}

ViewModel Source Code

View ReactiveViewModel implementation
csharp
using R3;
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 <c>ReactiveProperty&lt;T&gt;</c> properties</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>
/// Use <c>ReactiveProperty&lt;T&gt;</c> for observable state. Pages subscribe directly
/// to the property (which is an <c>Observable&lt;T&gt;</c>) for automatic UI updates.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// public class CounterViewModel : ReactiveViewModel
/// {
///     public ReactiveProperty&lt;int&gt; Count { get; } = new(0);
///
///     public override void OnActivated()
///     {
///         Input.OfType&lt;IInputEvent, KeyPressed&gt;()
///             .Subscribe(HandleKey)
///             .DisposeWith(Subscriptions);
///     }
///
///     private void HandleKey(KeyPressed key)
///     {
///         if (key.KeyInfo.Key == ConsoleKey.UpArrow)
///             Count.Value++;
///     }
///
///     public override void Dispose()
///     {
///         Count.Dispose();
///         base.Dispose();
///     }
/// }
/// </code>
/// </example>
public abstract class ReactiveViewModel : IDisposable
{
    private CompositeDisposable _subscriptions = new();
    private Action<Action> _post = _ => { };
    private Func<Action, CancellationToken, Task> _invokeAsync = (action, _) =>
    {
        action();
        return Task.CompletedTask;
    };

    /// <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 Observable<IInputEvent> Input { get; private set; } = null!;

    /// <summary>
    /// R3 frame provider bound to the owning Termina application render loop.
    /// </summary>
    public FrameProvider RenderFrameProvider { get; private set; } = ObservableSystem.DefaultFrameProvider;

    /// <summary>
    /// Time provider configured for the owning Termina application.
    /// </summary>
    public TimeProvider TimeProvider { get; private set; } = TimeProvider.System;

    /// <summary>
    /// Enqueue work to run on the owning Termina application's render loop.
    /// </summary>
    public void Post(Action action) => _post(action);

    /// <summary>
    /// Enqueue work to run on the owning Termina application's render loop and await completion.
    /// </summary>
    public Task InvokeAsync(Action action, CancellationToken cancellationToken = default) =>
        _invokeAsync(action, cancellationToken);

    /// <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,
        Observable<IInputEvent> input,
        FrameProvider? renderFrameProvider = null,
        TimeProvider? timeProvider = null,
        Action<Action>? post = null,
        Func<Action, CancellationToken, Task>? invokeAsync = null)
    {
        Navigate = navigate;
        NavigateWithParams = navigateWithParams;
        Shutdown = shutdown;
        RequestRedraw = requestRedraw;
        Input = input;
        RenderFrameProvider = renderFrameProvider ?? ObservableSystem.DefaultFrameProvider;
        TimeProvider = timeProvider ?? TimeProvider.System;
        _post = post ?? (_ => { });
        _invokeAsync = invokeAsync ?? ((action, _) =>
        {
            action();
            return Task.CompletedTask;
        });
    }
}

Released under the Apache 2.0 License.