Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions demos/Termina.Demo.Gallery/Pages/FilePickerGalleryPage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// 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 R3;
using Termina.Extensions;
using Termina.Layout;
using Termina.Reactive;
using Termina.Rendering;
using Termina.Terminal;

namespace Termina.Demo.Gallery.Pages;

/// <summary>
/// Gallery page showcasing FilePickerNode capabilities.
/// Demonstrates two pickers side by side: single file selection and folder-only selection.
/// </summary>
public class FilePickerGalleryPage : ReactivePage<FilePickerGalleryViewModel>
{
private FilePickerNode _filePicker = null!;
private FilePickerNode _folderPicker = null!;
private int _focusedPickerIndex;

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

// Don't register Escape at the page level — FilePickerNode needs Escape
// for clearing filters. Navigate back via the picker's Cancelled observable.
KeyBindings.Register(ConsoleKey.Tab, CycleFocus);

_filePicker.SelectionConfirmed
.Subscribe(paths => ViewModel.OnFileSelected(paths))
.DisposeWith(Subscriptions);

_filePicker.Cancelled
.Subscribe(_ => Navigate("/menu"))
.DisposeWith(Subscriptions);

_folderPicker.SelectionConfirmed
.Subscribe(paths => ViewModel.OnFolderSelected(paths))
.DisposeWith(Subscriptions);

_folderPicker.Cancelled
.Subscribe(_ => Navigate("/menu"))
.DisposeWith(Subscriptions);

_focusedPickerIndex = 0;
Focus.SetFocus(_filePicker);
}

private void CycleFocus()
{
_focusedPickerIndex = (_focusedPickerIndex + 1) % 2;
IFocusable target = _focusedPickerIndex == 0 ? _filePicker : _folderPicker;
Focus.SetFocus(target);
ViewModel.OnFocusChanged(_focusedPickerIndex);
}

public override ILayoutNode BuildLayout()
{
var startPath = Environment.CurrentDirectory;

_filePicker = Layouts.FilePicker(startPath)
.WithMode(FilePickerMode.Files)
.WithSelectionMode(FilePickerSelectionMode.Single)
.WithHighlightColors(Color.Black, Color.Green)
.WithFillHeight();

_folderPicker = Layouts.FilePicker(startPath)
.WithMode(FilePickerMode.Directories)
.WithSelectionMode(FilePickerSelectionMode.Single)
.WithHighlightColors(Color.Black, Color.Yellow)
.WithFillHeight();

return Layouts.Vertical()
.WithChild(BuildHeader())
.WithChild(BuildPickerGrid().Fill())
.WithChild(BuildStatusBar());
}

private static ILayoutNode BuildHeader()
{
return new PanelNode()
.WithTitle("FilePickerNode Gallery")
.WithBorder(BorderStyle.Rounded)
.WithBorderColor(Color.Magenta)
.WithContent(
new TextNode("Browse the filesystem with keyboard navigation, fuzzy filtering (type to search), and directory traversal")
.WithForeground(Color.Gray))
.Height(4);
}

private GridNode BuildPickerGrid()
{
var grid = new GridNode()
.WithColumns(
SizeConstraint.Percentage(50),
SizeConstraint.Percentage(50))
.WithRows(SizeConstraint.FillRemaining())
.WithGridLines(BorderStyle.Single)
.WithGridLineColor(Color.BrightBlack);

grid.SetCell(0, 0,
Layouts.Vertical()
.WithChild(
new TextNode("Single File Select")
.WithForeground(Color.BrightGreen)
.Bold()
.Height(1))
.WithChild(
new TextNode("Enter on file to select, Enter on folder to open")
.WithForeground(Color.DarkGray)
.Height(2))
.WithChild(_filePicker));

grid.SetCell(0, 1,
Layouts.Vertical()
.WithChild(
new TextNode("Folder Select")
.WithForeground(Color.BrightYellow)
.Bold()
.Height(1))
.WithChild(
new TextNode("Space on folder to select, Enter to browse into")
.WithForeground(Color.DarkGray)
.Height(2))
.WithChild(_folderPicker));

return grid;
}

private ILayoutNode BuildStatusBar()
{
return Layouts.Horizontal()
.WithChild(
ViewModel.StatusMessage
.Select<string, ILayoutNode>(msg => new TextNode(msg).WithForeground(Color.White))
.AsLayout()
.Fill())
.WithChild(
new TextNode("[Tab] Switch Picker [Esc] Menu")
.WithForeground(Color.BrightBlack)
.NoWrap()
.WidthAuto())
.Height(1);
}
}
41 changes: 41 additions & 0 deletions demos/Termina.Demo.Gallery/Pages/FilePickerGalleryViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 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 R3;
using Termina.Reactive;

namespace Termina.Demo.Gallery.Pages;

/// <summary>
/// ViewModel for the File Picker gallery page.
/// </summary>
public class FilePickerGalleryViewModel : ReactiveViewModel
{
public ReactiveProperty<string> StatusMessage { get; } = new("Navigate directories, select files or folders");

public void OnFileSelected(IReadOnlyList<string> paths)
{
StatusMessage.Value = paths.Count == 1
? $"File selected: {paths[0]}"
: $"Selected {paths.Count} files: {string.Join(", ", paths.Select(Path.GetFileName))}";
}

public void OnFolderSelected(IReadOnlyList<string> paths)
{
StatusMessage.Value = paths.Count == 1
? $"Folder selected: {paths[0]}"
: $"Selected {paths.Count} folders: {string.Join(", ", paths.Select(Path.GetFileName))}";
}

public void OnFocusChanged(int pickerIndex)
{
var name = pickerIndex == 0 ? "File Picker" : "Folder Picker";
StatusMessage.Value = $"Focus: {name} — Use Tab to switch";
}

public override void Dispose()
{
StatusMessage.Dispose();
base.Dispose();
}
}
2 changes: 1 addition & 1 deletion demos/Termina.Demo.Gallery/Pages/GalleryMenuPage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public override ILayoutNode BuildLayout()
.WithChild(_menuList))
.Fill())
.WithChild(
new TextNode("[↑/↓] Navigate [Enter] Select [1-5] Quick Select [Q] Quit")
new TextNode("[↑/↓] Navigate [Enter] Select [1-6] Quick Select [Q] Quit")
.WithForeground(Color.BrightBlack)
.Height(1));
}
Expand Down
3 changes: 2 additions & 1 deletion demos/Termina.Demo.Gallery/Pages/GalleryMenuViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public partial class GalleryMenuViewModel : ReactiveViewModel
new("Text Input", "Text fields, placeholder text, submission handling", "/textinput"),
new("Clipboard", "OSC 52 copy, toasts, and paste validation", "/clipboard"),
new("Layouts", "Vertical, horizontal, grid, panels, borders", "/layouts"),
new("Animations", "Spinners, streaming text, progress indicators", "/animations")
new("Animations", "Spinners, streaming text, progress indicators", "/animations"),
new("File Picker", "File/folder selection, directory navigation, fuzzy filtering", "/filepicker")
};

public void NavigateToGallery(string route)
Expand Down
1 change: 1 addition & 0 deletions demos/Termina.Demo.Gallery/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
termina.RegisterRoute<ClipboardGalleryPage, ClipboardGalleryViewModel>("/clipboard", NavigationBehavior.PreserveState);
termina.RegisterRoute<LayoutGalleryPage, LayoutGalleryViewModel>("/layouts", NavigationBehavior.PreserveState);
termina.RegisterRoute<AnimationsGalleryPage, AnimationsGalleryViewModel>("/animations", NavigationBehavior.PreserveState);
termina.RegisterRoute<FilePickerGalleryPage, FilePickerGalleryViewModel>("/filepicker", NavigationBehavior.PreserveState);
});

var host = builder.Build();
Expand Down
5 changes: 4 additions & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ export default defineConfig({
collapsed: false,
items: [
{ text: 'TextInputNode', link: '/components/text-input-node' },
{ text: 'SelectionListNode', link: '/components/selection-list-node' }
{ text: 'TextAreaNode', link: '/components/text-area-node' },
{ text: 'CopyableTextNode', link: '/components/copyable-text-node' },
{ text: 'SelectionListNode', link: '/components/selection-list-node' },
{ text: 'FilePickerNode', link: '/components/file-picker-node' }
]
},
{
Expand Down
56 changes: 26 additions & 30 deletions docs/advanced/custom-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,42 +283,44 @@ When using `NavigationBehavior.PreserveState`, layout nodes are preserved across

### Implementing Lifecycle Methods

Extend `LayoutNode` and override `OnActivate()` and `OnDeactivate()`:
Lifecycle dispatch goes through the `IActivatableNode` interface. `LayoutNode` implements it, so subclasses simply override `OnActivate()` and `OnDeactivate()`. Components that implement the layout interfaces directly — without extending `LayoutNode` — should implement `IActivatableNode` themselves to participate (this is what `FilePickerNode` and `SelectionListNode` do).

```csharp
public class AnimatedNode : LayoutNode
{
private readonly Timer _timer;
private readonly TimeProvider _timeProvider;
private IDisposable? _timer;
private int _frame;

public AnimatedNode()
public AnimatedNode(TimeProvider? timeProvider = null)
{
_timer = new Timer(100);
_timer.Elapsed += (_, _) =>
{
_frame++;
// Trigger UI update
};
_timeProvider = timeProvider ?? TimeProvider.System;
}

public override void OnActivate()
{
// Resume animation when page becomes active
_timer.Start();
_timer ??= Observable.Interval(TimeSpan.FromMilliseconds(100), _timeProvider)
.Subscribe(_ =>
{
_frame++;
// Trigger UI update
});
base.OnActivate();
}

public override void OnDeactivate()
{
// Pause animation when navigating away
_timer.Stop();
_timer?.Dispose();
_timer = null;
base.OnDeactivate();
}

public override void Dispose()
{
// Final cleanup when component is destroyed
_timer.Dispose();
_timer?.Dispose();
base.Dispose();
}
}
Expand Down Expand Up @@ -349,36 +351,30 @@ Key differences:
```csharp
public class LiveDataNode : LayoutNode, IInvalidatingNode
{
private readonly IObservable<string> _source;
private readonly Observable<string> _source;
private readonly Subject<Unit> _invalidated = new();
private IDisposable? _subscription;
private string _currentValue = "";

public IObservable<Unit> Invalidated => _invalidated;
public Observable<Unit> Invalidated => _invalidated.AsObservable();

public LiveDataNode(IObservable<string> source)
public LiveDataNode(Observable<string> source)
{
_source = source;
_subscription = SubscribeToSource();
}

// Create initial subscription
_subscription = source.Subscribe(value =>
private IDisposable SubscribeToSource() =>
_source.Subscribe(value =>
{
_currentValue = value;
_invalidated.OnNext(Unit.Default);
});
}

public override void OnActivate()
{
// Recreate subscription if it was disposed during deactivation
if (_subscription == null || _subscription is BooleanDisposable { IsDisposed: true })
{
_subscription = _source.Subscribe(value =>
{
_currentValue = value;
_invalidated.OnNext(Unit.Default);
});
}
// Recreate the subscription if it was paused during deactivation
_subscription ??= SubscribeToSource();
base.OnActivate();
}

Expand All @@ -403,7 +399,7 @@ public class LiveDataNode : LayoutNode, IInvalidatingNode

### Container Lifecycle Propagation

If your custom container holds child nodes, propagate lifecycle calls:
If your custom container holds child nodes, propagate lifecycle calls. Dispatch on `IActivatableNode` — not the concrete `LayoutNode` class — so interface-based children (like `FilePickerNode`) receive lifecycle calls too:

```csharp
public class CustomContainer : LayoutNode
Expand All @@ -415,7 +411,7 @@ public class CustomContainer : LayoutNode
// Activate all children
foreach (var child in _children)
{
if (child is LayoutNode node)
if (child is IActivatableNode node)
node.OnActivate();
}
base.OnActivate();
Expand All @@ -426,7 +422,7 @@ public class CustomContainer : LayoutNode
// Deactivate all children
foreach (var child in _children)
{
if (child is LayoutNode node)
if (child is IActivatableNode node)
node.OnDeactivate();
}
base.OnDeactivate();
Expand Down
Loading
Loading