Navigation
Termina provides navigation actions within ViewModels to move between pages.
Navigation Actions
ViewModels have access to protected navigation methods wired up by the framework:
Navigate
Navigate directly by path:
Navigate("/"); // Go to home
Navigate("/settings"); // Go to settings
Navigate("/items/42"); // Go to item 422
3
NavigateWithParams
Navigate using a template with parameters:
NavigateWithParams("/items/{id}", new { id = 42 });
NavigateWithParams("/users/{name}", new { name = "alice" });2
Shutdown
Request graceful application shutdown:
Shutdown(); // Exits the applicationNavigation Example
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);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Navigation Behavior
When registering routes, you can control how navigation behaves:
termina.RegisterRoute<DetailPage, DetailViewModel>(
"/items/{id}",
NavigationBehavior.PreserveState); // Keep ViewModel state2
3
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 subscriptionsOnActivated()recreates subscriptions
- Layout lifecycle:
OnDeactivate()pauses timers, stops animations, pauses observable subscriptionsOnActivate()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.
Navigation History
When a page navigates to a different path, the current page is pushed onto the history stack:
Navigate("/items/42"); // "/menu" is pushed onto history
Navigate("/settings"); // "/items/42" is pushed onto history2
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:
if (termina.CanGoBack)
{
termina.GoBack();
}2
3
4
CanGoBackistruewhen 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:
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;2
3
4
5
6
7
8
9
10
11
12
13
Two patterns apply:
- 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. - 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:
if (wizard.TryGoBack())
{
// Escape was consumed by the wizard's internal state
return;
}
// Wizard is at its first step, so page-level back navigation applies2
3
4
5
6
7
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.
NavigationBackRequested
Termina.Navigation.NavigationBackRequested is the framework event that requests a history-based back navigation. The application event loop handles it by calling GoBack():
case NavigationBackRequested:
GoBack();
return;2
3
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:
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);
}
}2
3
4
5
6
7
8
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:
public override void OnActivated()
{
// Setup subscriptions
Input.OfType<KeyPressed>()
.Subscribe(HandleKey)
.DisposeWith(Subscriptions);
// Load data
LoadItems();
}2
3
4
5
6
7
8
9
10
OnDeactivating
Called when navigating away:
public override void OnDeactivating()
{
// Subscriptions are auto-disposed
base.OnDeactivating(); // Important: call base
// Optional: save state, cancel operations
SaveDraft();
}2
3
4
5
6
7
8
RequestRedraw
For asynchronous content updates (like streaming), request a UI refresh:
private async Task StreamDataAsync()
{
await foreach (var chunk in dataStream)
{
Messages = Messages.Append(chunk).ToList();
RequestRedraw(); // Trigger UI update
}
}2
3
4
5
6
7
8
ViewModel Source Code
View ReactiveViewModel implementation
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<T></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<T></c> for observable state. Pages subscribe directly
/// to the property (which is an <c>Observable<T></c>) for automatic UI updates.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// public class CounterViewModel : ReactiveViewModel
/// {
/// public ReactiveProperty<int> Count { get; } = new(0);
///
/// public override void OnActivated()
/// {
/// Input.OfType<IInputEvent, KeyPressed>()
/// .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;
});
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219