Skip to content

Commit d50182b

Browse files
Add page-level key binding system (capture phase) (#105)
* Add page-level key binding system (capture phase) (#104) Implement capture-phase input handling where pages intercept keys before focused components, enabling reliable navigation key handling (Escape, Tab). - Add PageKeyBindings class for registering page-level key handlers - Add HandlePageInput method to IBindablePage interface - Modify TerminaApplication.ProcessEvent() to call page handler first - Update ReactivePage to expose KeyBindings property - Update all gallery demo pages to use new KeyBindings pattern - Add comprehensive tests for page-level input handling Closes #104 * Add navigation capabilities to ReactivePage Give Pages direct access to Navigate(), NavigateWithParams(), and Shutdown() methods instead of requiring wrapper methods on ViewModels. Framework changes: - Add WireUpNavigation() to IBindablePage interface - Add protected Navigate/NavigateWithParams/Shutdown methods to ReactivePage - Update TerminaApplication to wire up navigation to pages This is a cleaner framework design where: - Pages use Navigate() for input-driven navigation (Escape key, etc.) - ViewModels use Navigate() for business-logic-driven navigation Documentation updates: - Update input-handling.md with correct Page.Navigate() usage - Update architecture.md with two-phase input routing model - Update concepts index with new data flow diagram * Fix ReactiveLayoutNode child invalidation propagation ReactiveLayoutNode now properly subscribes to its child's Invalidated observable and propagates those events upward. This fixes the issue where animated nodes (like SpinnerNode) nested inside reactive layouts would not trigger redraws because their invalidation events were lost at the ReactiveLayoutNode boundary. Changes: - Added _childInvalidationSubscription field to track child subscriptions - Added SubscribeToChildInvalidation helper method - Subscribe to child invalidation when new child is set from observable - Re-subscribe on OnActivate to handle reactivation after navigation - Properly dispose child invalidation subscription in Dispose - Applied same fix to both ReactiveLayoutNode and ReactiveLayoutNode<T> * Add Spacebar handling to AnimationsGalleryPage Register a page-level key binding for Spacebar that updates the spinner preview based on the currently highlighted item. This provides the same behavior as Enter for this single-select use case, making the UX more intuitive. * Rename third column to 'Scrolling List' in SelectionListGalleryPage Highlight the scrollbar feature by renaming the column header and updating the description to show visible rows configuration.
1 parent 81b988b commit d50182b

20 files changed

+944
-150
lines changed

demos/Termina.Demo.Gallery/Pages/AnimationsGalleryPage.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@ public override void OnNavigatedTo()
2424
{
2525
base.OnNavigatedTo();
2626

27+
// Page-level key binding for navigation (capture phase)
28+
KeyBindings.Register(ConsoleKey.Escape, () => Navigate("/menu"));
29+
30+
// Spacebar also updates the preview (same as Enter for this single-select use case)
31+
KeyBindings.Register(ConsoleKey.Spacebar, () =>
32+
{
33+
var highlighted = _styleList.HighlightedItem;
34+
if (highlighted != null)
35+
{
36+
ViewModel.SelectedStyle = highlighted.Value.Style;
37+
}
38+
});
39+
2740
// When user selects a style with Enter, update the ViewModel
2841
_styleList.SelectionConfirmed
2942
.Subscribe(items =>

demos/Termina.Demo.Gallery/Pages/AnimationsGalleryViewModel.cs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// Copyright (c) Petabridge, LLC. All rights reserved.
22
// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information.
33

4-
using System.Reactive.Linq;
5-
using Termina.Input;
64
using Termina.Reactive;
75
using Termina.Terminal;
86
using LayoutSpinnerStyle = Termina.Layout.SpinnerStyle;
@@ -25,12 +23,4 @@ public partial class AnimationsGalleryViewModel : ReactiveViewModel
2523
new("Box", LayoutSpinnerStyle.Box, Color.Cyan, "Rotating box corners"),
2624
new("Circle", LayoutSpinnerStyle.Circle, Color.Red, "Rotating circle")
2725
};
28-
29-
public override void OnActivated()
30-
{
31-
Input.OfType<KeyPressed>()
32-
.Where(k => k.KeyInfo.Key == ConsoleKey.Escape)
33-
.Subscribe(_ => Navigate("/menu"))
34-
.DisposeWith(Subscriptions);
35-
}
3626
}

demos/Termina.Demo.Gallery/Pages/GalleryMenuPage.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public override void OnNavigatedTo()
2222
{
2323
base.OnNavigatedTo();
2424

25+
// Page-level key binding for quit (capture phase)
26+
KeyBindings.Register(ConsoleKey.Q, () => Shutdown());
27+
2528
_menuList.SelectionConfirmed
2629
.Subscribe(items =>
2730
{

demos/Termina.Demo.Gallery/Pages/GalleryMenuViewModel.cs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// Copyright (c) Petabridge, LLC. All rights reserved.
22
// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information.
33

4-
using System.Reactive.Linq;
5-
using Termina.Input;
64
using Termina.Reactive;
75

86
namespace Termina.Demo.Gallery.Pages;
@@ -20,14 +18,6 @@ public partial class GalleryMenuViewModel : ReactiveViewModel
2018
new("Animations", "Spinners, streaming text, progress indicators", "/animations")
2119
};
2220

23-
public override void OnActivated()
24-
{
25-
Input.OfType<KeyPressed>()
26-
.Where(k => k.KeyInfo.Key == ConsoleKey.Q)
27-
.Subscribe(_ => Shutdown())
28-
.DisposeWith(Subscriptions);
29-
}
30-
3121
public void NavigateToGallery(string route)
3222
{
3323
Navigate(route);

demos/Termina.Demo.Gallery/Pages/LayoutGalleryPage.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ namespace Termina.Demo.Gallery.Pages;
1616
/// </summary>
1717
public class LayoutGalleryPage : ReactivePage<LayoutGalleryViewModel>
1818
{
19+
public override void OnNavigatedTo()
20+
{
21+
base.OnNavigatedTo();
22+
23+
// Page-level key binding for navigation (capture phase)
24+
KeyBindings.Register(ConsoleKey.Escape, () => Navigate("/menu"));
25+
}
26+
1927
public override ILayoutNode BuildLayout()
2028
{
2129
return Layouts.Vertical()
Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// Copyright (c) Petabridge, LLC. All rights reserved.
22
// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information.
33

4-
using System.Reactive.Linq;
5-
using Termina.Input;
64
using Termina.Reactive;
75

86
namespace Termina.Demo.Gallery.Pages;
@@ -12,11 +10,4 @@ namespace Termina.Demo.Gallery.Pages;
1210
/// </summary>
1311
public partial class LayoutGalleryViewModel : ReactiveViewModel
1412
{
15-
public override void OnActivated()
16-
{
17-
Input.OfType<KeyPressed>()
18-
.Where(k => k.KeyInfo.Key == ConsoleKey.Escape)
19-
.Subscribe(_ => Navigate("/menu"))
20-
.DisposeWith(Subscriptions);
21-
}
2213
}

demos/Termina.Demo.Gallery/Pages/SelectionListGalleryPage.cs

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ public override void OnNavigatedTo()
2929
{
3030
base.OnNavigatedTo();
3131

32+
// Page-level key bindings (capture phase - intercepts before focused components)
33+
// This is the new recommended pattern for page navigation keys
34+
KeyBindings.Register(ConsoleKey.Escape, () => Navigate("/menu"));
35+
KeyBindings.Register(ConsoleKey.Tab, CycleFocus);
36+
3237
// Subscribe to selection events
3338
_singleSelectList.SelectionConfirmed
3439
.Subscribe(items => ViewModel.OnSingleSelection(items.FirstOrDefault() ?? ""))
@@ -46,24 +51,6 @@ public override void OnNavigatedTo()
4651
.Subscribe(items => ViewModel.OnNumberedSelection(items.FirstOrDefault() ?? ""))
4752
.DisposeWith(Subscriptions);
4853

49-
// Subscribe to Cancelled (Escape key) on all lists - navigate back to menu
50-
_singleSelectList.Cancelled
51-
.Subscribe(_ => ViewModel.NavigateToMenu())
52-
.DisposeWith(Subscriptions);
53-
54-
_multiSelectRichList.Cancelled
55-
.Subscribe(_ => ViewModel.NavigateToMenu())
56-
.DisposeWith(Subscriptions);
57-
58-
_numberedList.Cancelled
59-
.Subscribe(_ => ViewModel.NavigateToMenu())
60-
.DisposeWith(Subscriptions);
61-
62-
// Subscribe to Tab key to cycle focus between lists
63-
ViewModel.TabPressed
64-
.Subscribe(_ => CycleFocus())
65-
.DisposeWith(Subscriptions);
66-
6754
// Default focus to first list
6855
_focusedListIndex = 0;
6956
Focus.PushFocus(_singleSelectList);
@@ -111,7 +98,7 @@ public override ILayoutNode BuildLayout()
11198
.WithHighlightColors(Color.Black, Color.Yellow)
11299
.WithVisibleRows(8);
113100

114-
// Numbered list with 12 items - demonstrates Issue #101 fix
101+
// Scrolling list with 12 items in 6 visible rows
115102
_numberedList = Layouts.SelectionList(
116103
Enumerable.Range(1, 12).Select(i => $"Item number {i}"))
117104
.WithMode(SelectionMode.Single)
@@ -176,16 +163,16 @@ private GridNode BuildDemoGrid()
176163
.Height(2))
177164
.WithChild(_multiSelectRichList));
178165

179-
// Column 3: Numbered list (12 items)
166+
// Column 3: Scrolling numbered list (12 items)
180167
grid.SetCell(0, 2,
181168
Layouts.Vertical()
182169
.WithChild(
183-
new TextNode("12+ Items (Issue #101)")
170+
new TextNode("Scrolling List")
184171
.WithForeground(Color.BrightCyan)
185172
.Bold()
186173
.Height(1))
187174
.WithChild(
188-
new TextNode("All items show numbers!")
175+
new TextNode("12 items in 6 visible rows")
189176
.WithForeground(Color.DarkGray)
190177
.Height(2))
191178
.WithChild(_numberedList));

demos/Termina.Demo.Gallery/Pages/SelectionListGalleryViewModel.cs

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
// Copyright (c) Petabridge, LLC. All rights reserved.
22
// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information.
33

4-
using System.Reactive;
5-
using System.Reactive.Linq;
6-
using System.Reactive.Subjects;
7-
using Termina.Extensions;
8-
using Termina.Input;
94
using Termina.Reactive;
105

116
namespace Termina.Demo.Gallery.Pages;
@@ -15,12 +10,8 @@ namespace Termina.Demo.Gallery.Pages;
1510
/// </summary>
1611
public partial class SelectionListGalleryViewModel : ReactiveViewModel
1712
{
18-
private readonly Subject<Unit> _tabPressed = new();
19-
2013
[Reactive] private string _statusMessage = "Select items and explore different SelectionList features";
2114

22-
public IObservable<Unit> TabPressed => _tabPressed.AsObservable();
23-
2415
public IReadOnlyList<ServerInfo> Servers { get; } = new List<ServerInfo>
2516
{
2617
new("prod-us-east-1", "US East", "Online", 45),
@@ -33,20 +24,6 @@ public partial class SelectionListGalleryViewModel : ReactiveViewModel
3324

3425
private static readonly string[] ListNames = { "Single Select", "Multi-Select", "Numbered List" };
3526

36-
public override void OnActivated()
37-
{
38-
// Handle Tab key to cycle focus between lists
39-
Input.OfType<KeyPressed>()
40-
.Where(k => k.KeyInfo.Key == ConsoleKey.Tab)
41-
.Subscribe(_ => _tabPressed.OnNext(Unit.Default))
42-
.DisposeWith(Subscriptions);
43-
}
44-
45-
public void NavigateToMenu()
46-
{
47-
Navigate("/menu");
48-
}
49-
5027
public void OnFocusChanged(int listIndex)
5128
{
5229
StatusMessage = $"Focus: {ListNames[listIndex]} - Use Tab to switch lists";

demos/Termina.Demo.Gallery/Pages/TextInputGalleryPage.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@ public class TextInputGalleryPage : ReactivePage<TextInputGalleryViewModel>
1818
private TextInputNode _basicInput = null!;
1919
private TextInputNode _placeholderInput = null!;
2020

21+
private int _focusedInputIndex;
22+
2123
public override void OnNavigatedTo()
2224
{
2325
base.OnNavigatedTo();
2426

27+
// Page-level key bindings (capture phase)
28+
KeyBindings.Register(ConsoleKey.Escape, () => Navigate("/menu"));
29+
KeyBindings.Register(ConsoleKey.Tab, CycleFocus);
30+
2531
_basicInput.Submitted
2632
.Subscribe(text => ViewModel.OnBasicInputSubmitted(text))
2733
.DisposeWith(Subscriptions);
@@ -30,9 +36,17 @@ public override void OnNavigatedTo()
3036
.Subscribe(text => ViewModel.OnPlaceholderInputSubmitted(text))
3137
.DisposeWith(Subscriptions);
3238

39+
_focusedInputIndex = 0;
3340
Focus.PushFocus(_basicInput);
3441
}
3542

43+
private void CycleFocus()
44+
{
45+
_focusedInputIndex = (_focusedInputIndex + 1) % 2;
46+
var targetInput = _focusedInputIndex == 0 ? _basicInput : _placeholderInput;
47+
Focus.PushFocus(targetInput);
48+
}
49+
3650
public override ILayoutNode BuildLayout()
3751
{
3852
_basicInput = new TextInputNode();

demos/Termina.Demo.Gallery/Pages/TextInputGalleryViewModel.cs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// Copyright (c) Petabridge, LLC. All rights reserved.
22
// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information.
33

4-
using System.Reactive.Linq;
5-
using Termina.Input;
64
using Termina.Reactive;
75

86
namespace Termina.Demo.Gallery.Pages;
@@ -14,14 +12,6 @@ public partial class TextInputGalleryViewModel : ReactiveViewModel
1412
{
1513
[Reactive] private string _statusMessage = "Type in the input fields and press Enter to submit";
1614

17-
public override void OnActivated()
18-
{
19-
Input.OfType<KeyPressed>()
20-
.Where(k => k.KeyInfo.Key == ConsoleKey.Escape)
21-
.Subscribe(_ => Navigate("/menu"))
22-
.DisposeWith(Subscriptions);
23-
}
24-
2515
public void OnBasicInputSubmitted(string text)
2616
{
2717
StatusMessage = $"Basic input submitted: \"{text}\"";

0 commit comments

Comments
 (0)