FilePickerNode
An interactive file and folder picker with breadcrumb navigation, keyboard-driven directory traversal, scrolling, and type-to-filter search. Supports file-only, directory-only, or mixed selection in single or multi-select modes.

Side-by-side file and folder pickers in the gallery — navigation, filtering, and selection.
Basic Usage
// Pick a single file, starting in the current directory
// (or pass a path: Layouts.FilePicker("/home/user/projects"))
var picker = Layouts.FilePicker()
.WithMode(FilePickerMode.Files)
.WithSelectionMode(FilePickerSelectionMode.Single);
picker.SelectionConfirmed.Subscribe(paths => {
var file = paths[0];
Console.WriteLine($"Selected: {file}");
});
picker.Cancelled.Subscribe(_ => {
// User pressed Escape — close the picker
});The picker loads its directory lazily on first render or focus, so constructing one is cheap.
Features
- Breadcrumb header showing the current directory
- Enter to descend into folders, Backspace to go up
- Type-to-filter: any printable character (or
/) opens an inline filter bar - File-only, directory-only, or mixed selection modes
- Single and multi-select (Space toggles, Enter confirms)
- Optional glob filter (e.g.
*.cs) applied to files - Hidden-file filtering (dotfiles excluded by default)
- Scrolling with scrollbar for large directories
- Pluggable
IFileSystemProviderfor deterministic testing
Picker Modes
FilePickerMode controls which entries are selectable. Directories are always shown for navigation (except files are hidden entirely in Directories mode):
// Only files can be selected; folders are navigation-only
Layouts.FilePicker().WithMode(FilePickerMode.Files);
// Only folders are shown and selectable (Space selects, Enter opens)
Layouts.FilePicker().WithMode(FilePickerMode.Directories);
// Both files and folders are selectable
Layouts.FilePicker().WithMode(FilePickerMode.All);Because Enter opens an untoggled directory, Space is the selection gesture for folders. This keeps deep navigation possible in directory-only mode. In multi-select, Enter on a directory you have toggled (its [x] is visible on the highlighted row) confirms the selection instead.
Selection Modes
Single Select (Default)
var picker = Layouts.FilePicker("/home/user/projects")
.WithSelectionMode(FilePickerSelectionMode.Single);
picker.SelectionConfirmed.Subscribe(paths => {
OpenFile(paths[0]);
});- Enter on a file confirms it immediately.
- Space on a selectable entry (per the picker mode) also confirms it.
Multi-Select
var picker = Layouts.FilePicker()
.WithSelectionMode(FilePickerSelectionMode.Multi);
picker.SelectionConfirmed.Subscribe(paths => {
foreach (var path in paths)
Stage(path);
});- Space toggles the highlighted entry and moves the cursor down.
- Enter on a file confirms all toggled entries plus the highlighted file.
- Enter on a toggled directory confirms the toggled set; an untoggled directory opens, so you can keep browsing.
- Selections persist across directory navigation — the footer shows a running count (e.g.
3 selected), and confirmation emits the full cross-directory set.
Keyboard Shortcuts
Browsing
| Key | Action |
|---|---|
↑/↓ | Move highlight |
Home / End | Jump to first/last entry |
Enter | Open directory / confirm selection (a toggled directory confirms in multi-select) |
Backspace | Go up one directory |
Space | Select (single) or toggle (multi) |
/ or any letter | Open the filter bar |
Escape | Cancel the picker (emits Cancelled) |
Filtering
| Key | Action |
|---|---|
| Printable keys | Type into the filter (spaces allowed) |
↑/↓ / Home / End | Navigate the filtered list |
Enter | Open / confirm the highlighted match |
Backspace | Delete a character (exits filter when empty) |
Escape | Clear the filter and return to browsing |
The filter is a case-insensitive substring match against entry names. Filtering resets when the picker loses focus or the user navigates to another directory.
Glob File Filter
Restrict the files shown with a glob pattern. Directories always pass through so navigation still works:
var picker = Layouts.FilePicker()
.WithFileFilter("*.cs"); // only .cs files are listedHidden Files
Dotfiles (names starting with .) are hidden by default:
var picker = Layouts.FilePicker()
.WithShowHidden(); // include .gitignore, .config, etc.Sizing
By default the picker auto-sizes with a maximum of 10 visible rows. Use WithFillHeight() to expand into the parent's available space, or WithVisibleRows() for a fixed row count:
// Fill a panel
var picker = Layouts.FilePicker()
.WithFillHeight();
// Or a fixed window of 15 rows
var picker = Layouts.FilePicker()
.WithVisibleRows(15);Styling
Layouts.FilePicker()
.WithHighlightColors(Color.Black, Color.Cyan) // Highlighted row colors
.WithDirectoryColor(Color.BrightCyan) // Directory entry color
.WithFileColor(Color.Default); // File entry colorTesting with IFileSystemProvider
The picker reads the filesystem through IFileSystemProvider, so tests can supply a fake provider and never touch the disk:
public class FakeFileSystemProvider : IFileSystemProvider
{
private readonly Dictionary<string, List<FileSystemEntry>> _dirs = new();
public FakeFileSystemProvider AddDirectory(string path, params FileSystemEntry[] entries)
{
// The GetEntries contract requires directories first, then files, alphabetical
_dirs[path] = entries
.OrderBy(e => !e.IsDirectory)
.ThenBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
return this;
}
public IReadOnlyList<FileSystemEntry> GetEntries(string path) =>
_dirs.TryGetValue(path, out var e) ? e : [];
public bool DirectoryExists(string path) => _dirs.ContainsKey(path);
public string? GetParentDirectory(string path)
{
var i = path.LastIndexOf('/');
return i <= 0 ? (i == 0 ? "/" : null) : path[..i];
}
}
[Fact]
public void Enter_On_Directory_NavigatesInto()
{
var fs = new FakeFileSystemProvider()
.AddDirectory("/root", new FileSystemEntry("src", "/root/src", true))
.AddDirectory("/root/src");
using var picker = new FilePickerNode("/root").WithFileSystemProvider(fs);
picker.OnFocused();
picker.HandleInput(new ConsoleKeyInfo('\0', ConsoleKey.Enter, false, false, false));
Assert.Equal("/root/src", picker.CurrentPath);
}The default provider (DefaultFileSystemProvider) wraps System.IO, skips entries it cannot access (UnauthorizedAccessException, IOException), and sorts directories first, then files, both alphabetically.
Complete Example
The recommended pattern — ViewModel handles state, Page owns nodes and Focus:
ViewModel:
public class OpenFileViewModel : ReactiveViewModel
{
public ReactiveProperty<string> StatusMessage { get; } = new("Pick a file");
public void OnFileSelected(IReadOnlyList<string> paths)
{
StatusMessage.Value = $"Opened {paths[0]}";
// load the file...
}
public override void Dispose()
{
StatusMessage.Dispose();
base.Dispose();
}
}Page:
public class OpenFilePage : ReactivePage<OpenFileViewModel>
{
private FilePickerNode _picker = null!;
public override void OnNavigatedTo()
{
base.OnNavigatedTo();
// Don't register Escape at the page level — the picker needs it
// for clearing filters. Use the Cancelled observable instead.
_picker.SelectionConfirmed
.Subscribe(paths => ViewModel.OnFileSelected(paths))
.DisposeWith(Subscriptions);
_picker.Cancelled
.Subscribe(_ => Navigate("/menu"))
.DisposeWith(Subscriptions);
Focus.SetFocus(_picker);
}
public override ILayoutNode BuildLayout()
{
_picker = Layouts.FilePicker(Environment.CurrentDirectory)
.WithMode(FilePickerMode.Files)
.WithFileFilter("*.json")
.WithFillHeight();
return Layouts.Vertical()
.WithChild(_picker)
.WithChild(
ViewModel.StatusMessage
.Select<string, ILayoutNode>(msg => new TextNode(msg))
.AsLayout()
.Height(1));
}
}Escape and page key bindings
Page-level KeyBindings run in the capture phase, before the focused component sees the key. Registering Escape at the page level will prevent the picker from clearing its filter. Subscribe to Cancelled instead.
Observables
| Observable | Type | Description |
|---|---|---|
SelectionConfirmed | Observable<IReadOnlyList<string>> | Emits selected full paths on confirmation |
Cancelled | Observable<Unit> | Emits when Escape is pressed while browsing |
DirectoryChanged | Observable<string> | Emits the new path when the user navigates (the initial lazy load does not emit) |
Invalidated | Observable<Unit> | Emits when a redraw is needed |
API Reference
Constructor
| Constructor | Description |
|---|---|
FilePickerNode(string? startPath = null) | Create a picker; defaults to Environment.CurrentDirectory |
Properties
| Property | Type | Description |
|---|---|---|
CurrentPath | string | The directory currently displayed |
CanFocus | bool | Always true |
HasFocus | bool | Whether the picker has focus |
FocusPriority | int | 10 (lower than modal) |
Fluent Methods
| Method | Description |
|---|---|
.WithStartPath(string) | Set the initial directory |
.WithMode(FilePickerMode) | What is selectable: Files, Directories, or All |
.WithSelectionMode(FilePickerSelectionMode) | Single or Multi |
.WithShowHidden(bool) | Include dotfiles (default false) |
.WithFileFilter(string?) | Glob pattern applied to files (e.g. *.cs) |
.WithHighlightColors(fg, bg) | Highlighted row colors |
.WithDirectoryColor(Color) | Directory entry color |
.WithFileColor(Color) | File entry color |
.WithVisibleRows(int) | Fixed visible row count (disables fill) |
.WithFillHeight(bool) | Fill available vertical space |
.WithFileSystemProvider(IFileSystemProvider) | Override filesystem access (testing) |
Enums
FilePickerMode
| Value | Description |
|---|---|
Files | Only files are selectable; directories are navigation-only |
Directories | Only directories are shown and selectable |
All | Both files and directories are selectable |
FilePickerSelectionMode
| Value | Description |
|---|---|
Single | Confirm exactly one entry |
Multi | Toggle entries with Space, confirm with Enter |
FileSystemEntry
| Property | Type | Description |
|---|---|---|
Name | string | Entry name (no path) |
FullPath | string | Absolute path |
IsDirectory | bool | Whether the entry is a directory |
Size | long? | File size in bytes (null for directories) |
LastModified | DateTimeOffset? | Last write time |
Source Code
View FilePickerNode implementation
// 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 System.IO.Enumeration;
using R3;
using Termina.Rendering;
using Termina.Terminal;
namespace Termina.Layout;
/// <summary>
/// An interactive file/folder picker with breadcrumb navigation, scrolling, and fuzzy filtering.
/// </summary>
public sealed class FilePickerNode : IFocusable, IInvalidatingNode, IActivatableNode, ILayoutRuntimeContextAware
{
private readonly Subject<Unit> _invalidated = new();
private readonly Subject<IReadOnlyList<string>> _selectionConfirmed = new();
private readonly Subject<Unit> _cancelled = new();
private readonly Subject<string> _directoryChanged = new();
private IFileSystemProvider _fileSystem = DefaultFileSystemProvider.Instance;
private string _currentPath;
private List<FileSystemEntry> _entries = new();
private List<FileSystemEntry> _filteredEntries = new();
private readonly HashSet<string> _selectedPaths = new();
private int _highlightedIndex;
private int _scrollOffset;
private int _visibleRows = 10;
private int _effectiveRows = 10;
private bool _hasFocus;
private bool _disposed;
private bool _isLoaded;
// Filter state
private bool _isFiltering;
private TextInputNode? _filterInput;
private LayoutRuntimeContext? _runtimeContext;
private IDisposable? _filterInvalidationSub;
// Configuration
private FilePickerMode _mode = FilePickerMode.Files;
private FilePickerSelectionMode _selectionMode = FilePickerSelectionMode.Single;
private bool _showHidden;
private string? _fileFilter;
private bool _fillHeight;
private Color _highlightForeground = Color.Black;
private Color _highlightBackground = Color.White;
private Color _directoryColor = Color.BrightCyan;
private Color _fileColor = Color.Default;
// ASCII icons (avoids surrogate-pair/column-width issues with emoji)
private const string DirIcon = "[D] ";
private const string FileIcon = " ";
private const string FilterPrefix = "/ ";
private const string BreadcrumbPrefix = ">> ";
public FilePickerNode(string? startPath = null)
{
_currentPath = startPath ?? Environment.CurrentDirectory;
}
public Observable<Unit> Invalidated => _invalidated.AsObservable();
/// <summary>
/// Emits selected full paths when the user confirms a selection.
/// </summary>
public Observable<IReadOnlyList<string>> SelectionConfirmed => _selectionConfirmed.AsObservable();
/// <summary>
/// Emits when the user presses Escape while browsing (an active filter is cleared instead).
/// </summary>
public Observable<Unit> Cancelled => _cancelled.AsObservable();
/// <summary>
/// Emits the new directory path when the user navigates to a different directory.
/// The initial lazy load does not emit.
/// </summary>
public Observable<string> DirectoryChanged => _directoryChanged.AsObservable();
/// <inheritdoc />
public void SetRuntimeContext(LayoutRuntimeContext context)
{
ArgumentNullException.ThrowIfNull(context);
_runtimeContext = context;
if (_filterInput is not null)
LayoutRuntimeContextInjector.Apply(_filterInput, context);
}
public SizeConstraint WidthConstraint => SizeConstraint.FillRemaining();
public SizeConstraint HeightConstraint => _fillHeight ? SizeConstraint.FillRemaining() : SizeConstraint.AutoSize();
public bool CanFocus => true;
public bool HasFocus => _hasFocus;
public int FocusPriority => 10;
/// <summary>
/// Gets the current directory path being displayed.
/// </summary>
public string CurrentPath => _currentPath;
#region Fluent Configuration
public FilePickerNode WithStartPath(string path)
{
_currentPath = path;
_isLoaded = false;
return this;
}
public FilePickerNode WithMode(FilePickerMode mode)
{
_mode = mode;
return this;
}
public FilePickerNode WithSelectionMode(FilePickerSelectionMode mode)
{
_selectionMode = mode;
return this;
}
public FilePickerNode WithShowHidden(bool show = true)
{
_showHidden = show;
return this;
}
public FilePickerNode WithFileFilter(string? globPattern)
{
_fileFilter = globPattern;
return this;
}
public FilePickerNode WithHighlightColors(Color foreground, Color background)
{
_highlightForeground = foreground;
_highlightBackground = background;
return this;
}
public FilePickerNode WithDirectoryColor(Color color)
{
_directoryColor = color;
return this;
}
public FilePickerNode WithFileColor(Color color)
{
_fileColor = color;
return this;
}
public FilePickerNode WithVisibleRows(int rows)
{
_visibleRows = Math.Max(1, rows);
_effectiveRows = _visibleRows;
_fillHeight = false;
return this;
}
public FilePickerNode WithFillHeight(bool fill = true)
{
_fillHeight = fill;
return this;
}
public FilePickerNode WithFileSystemProvider(IFileSystemProvider provider)
{
_fileSystem = provider ?? throw new ArgumentNullException(nameof(provider));
return this;
}
#endregion
#region Focus Lifecycle
public void OnFocused()
{
_hasFocus = true;
EnsureLoaded();
Invalidate();
}
public void OnBlurred()
{
_hasFocus = false;
if (_isFiltering)
StopFiltering();
Invalidate();
}
public void OnActivate()
{
EnsureLoaded();
_filterInput?.OnActivate();
}
public void OnDeactivate()
{
_filterInput?.OnDeactivate();
}
#endregion
#region Input Handling
public bool HandleInput(ConsoleKeyInfo key)
{
if (_isFiltering)
return HandleFilterInput(key);
return HandleBrowsingInput(key);
}
private bool HandleBrowsingInput(ConsoleKeyInfo key)
{
switch (key.Key)
{
case ConsoleKey.UpArrow:
case ConsoleKey.DownArrow:
case ConsoleKey.Home:
case ConsoleKey.End:
return HandleNavigationKey(key);
case ConsoleKey.Enter:
ActivateHighlighted();
return true;
case ConsoleKey.Backspace:
NavigateUp();
return true;
case ConsoleKey.Escape:
OnEscapePressed();
return true;
case ConsoleKey.Spacebar:
HandleSpacebar();
return true;
default:
// '/' activates filter; any printable char also activates filter + types it.
// Alt/Ctrl chords are left for page-level bindings.
if ((key.Modifiers & (ConsoleModifiers.Alt | ConsoleModifiers.Control)) != 0)
return false;
if (key.KeyChar == '/')
{
StartFiltering("");
return true;
}
if (!char.IsControl(key.KeyChar) && key.KeyChar != '\0')
{
StartFiltering(key.KeyChar.ToString());
return true;
}
return false;
}
}
private bool HandleFilterInput(ConsoleKeyInfo key)
{
switch (key.Key)
{
case ConsoleKey.Escape:
StopFiltering();
return true;
case ConsoleKey.UpArrow:
case ConsoleKey.DownArrow:
case ConsoleKey.Home:
case ConsoleKey.End:
return HandleNavigationKey(key);
case ConsoleKey.Enter:
ActivateHighlighted();
return true;
default:
if (_filterInput == null)
return false;
// Backspace on an already-empty filter exits filter mode
if (key.Key == ConsoleKey.Backspace && string.IsNullOrEmpty(_filterInput.Text))
{
StopFiltering();
return true;
}
var textBefore = _filterInput.Text;
var handled = _filterInput.HandleInput(key);
if (handled)
{
if (string.IsNullOrEmpty(_filterInput.Text))
StopFiltering();
else if (_filterInput.Text != textBefore)
ApplyFilter();
}
return handled;
}
}
private bool HandleNavigationKey(ConsoleKeyInfo key)
{
switch (key.Key)
{
case ConsoleKey.UpArrow:
MoveHighlight(-1);
return true;
case ConsoleKey.DownArrow:
MoveHighlight(1);
return true;
case ConsoleKey.Home:
_highlightedIndex = 0;
EnsureVisible();
Invalidate();
return true;
case ConsoleKey.End:
_highlightedIndex = Math.Max(0, _filteredEntries.Count - 1);
EnsureVisible();
Invalidate();
return true;
default:
return false;
}
}
private void HandleSpacebar()
{
if (_highlightedIndex < 0 || _highlightedIndex >= _filteredEntries.Count)
return;
var entry = _filteredEntries[_highlightedIndex];
if (_selectionMode == FilePickerSelectionMode.Multi)
{
ToggleSelection();
}
else
{
// Single mode: Space selects the item if it's selectable per mode
if (IsSelectable(entry))
EmitSelectionConfirmed(new[] { entry.FullPath });
}
}
private void OnEscapePressed()
{
if (!_disposed)
_cancelled.OnNext(Unit.Default);
}
#endregion
#region Navigation
private void EnsureLoaded()
{
if (!_isLoaded)
LoadDirectory(_currentPath, emitChanged: false);
}
private void LoadDirectory(string path, bool emitChanged = true)
{
if (!_fileSystem.DirectoryExists(path))
{
// Latch so we don't retry filesystem I/O on every render/focus.
// WithStartPath resets the latch to allow a reload.
_isLoaded = true;
return;
}
_currentPath = path;
var allEntries = _fileSystem.GetEntries(path);
_entries = new List<FileSystemEntry>(allEntries.Count);
foreach (var e in allEntries)
{
if (!_showHidden && e.Name.StartsWith('.'))
continue;
if (!e.IsDirectory && _fileFilter != null && !MatchesGlob(e.Name, _fileFilter))
continue;
if (_mode == FilePickerMode.Directories && !e.IsDirectory)
continue;
_entries.Add(e);
}
_filteredEntries = new List<FileSystemEntry>(_entries);
_highlightedIndex = 0;
_scrollOffset = 0;
_isLoaded = true;
if (_isFiltering)
{
_isFiltering = false;
_filterInput?.OnBlurred();
_filterInput?.OnDeactivate();
}
if (emitChanged)
EmitDirectoryChanged(_currentPath);
Invalidate();
}
private void NavigateUp()
{
var parent = _fileSystem.GetParentDirectory(_currentPath);
if (parent != null)
LoadDirectory(parent);
}
private void ActivateHighlighted()
{
if (_highlightedIndex < 0 || _highlightedIndex >= _filteredEntries.Count)
return;
var entry = _filteredEntries[_highlightedIndex];
if (entry.IsDirectory)
{
// Enter on a toggled directory confirms the selection; an untoggled
// directory always opens, so deep navigation stays possible.
if (_selectionMode == FilePickerSelectionMode.Multi &&
_selectedPaths.Contains(entry.FullPath))
{
EmitSelectionConfirmed(_selectedPaths.ToList());
}
else
{
LoadDirectory(entry.FullPath);
}
return;
}
// File selected
if (_selectionMode == FilePickerSelectionMode.Single)
{
EmitSelectionConfirmed(new[] { entry.FullPath });
}
else
{
// Confirm the toggled set plus the highlighted file, without
// mutating the toggle state.
var selected = _selectedPaths.ToList();
if (!_selectedPaths.Contains(entry.FullPath))
selected.Add(entry.FullPath);
EmitSelectionConfirmed(selected);
}
}
private void ToggleSelection()
{
if (_highlightedIndex < 0 || _highlightedIndex >= _filteredEntries.Count)
return;
var entry = _filteredEntries[_highlightedIndex];
if (!IsSelectable(entry))
return;
if (!_selectedPaths.Remove(entry.FullPath))
_selectedPaths.Add(entry.FullPath);
MoveHighlight(1);
}
private bool IsSelectable(FileSystemEntry entry)
{
return _mode switch
{
FilePickerMode.Files => !entry.IsDirectory,
FilePickerMode.Directories => entry.IsDirectory,
FilePickerMode.All => true,
_ => false
};
}
#endregion
#region Filtering
private void StartFiltering(string initialText)
{
_isFiltering = true;
if (_filterInput == null)
{
_filterInput = new TextInputNode()
.WithPlaceholder("Type to filter...");
if (_runtimeContext is not null)
LayoutRuntimeContextInjector.Apply(_filterInput, _runtimeContext);
// Bridge the embedded input's invalidations (cursor blink) to ours
_filterInvalidationSub = _filterInput.Invalidated.Subscribe(_ => Invalidate());
}
_filterInput.OnActivate();
_filterInput.OnFocused();
_filterInput.Clear();
// Seed with initial text if provided
if (!string.IsNullOrEmpty(initialText))
{
foreach (var c in initialText)
{
_filterInput.HandleInput(new ConsoleKeyInfo(c, ConsoleKey.None, false, false, false));
}
}
ApplyFilter();
Invalidate();
}
private void StopFiltering()
{
_isFiltering = false;
_filterInput?.OnBlurred();
_filterInput?.OnDeactivate();
_filteredEntries = new List<FileSystemEntry>(_entries);
_highlightedIndex = Math.Min(_highlightedIndex, Math.Max(0, _filteredEntries.Count - 1));
_scrollOffset = 0;
EnsureVisible();
Invalidate();
}
private void ApplyFilter()
{
var filterText = _filterInput?.Text ?? "";
if (string.IsNullOrEmpty(filterText))
{
_filteredEntries = new List<FileSystemEntry>(_entries);
}
else
{
_filteredEntries = _entries
.Where(e => e.Name.Contains(filterText, StringComparison.OrdinalIgnoreCase))
.ToList();
}
_highlightedIndex = 0;
_scrollOffset = 0;
Invalidate();
}
#endregion
#region Scrolling
private void MoveHighlight(int delta)
{
if (_filteredEntries.Count == 0)
return;
_highlightedIndex = Math.Clamp(_highlightedIndex + delta, 0, _filteredEntries.Count - 1);
EnsureVisible();
Invalidate();
}
private void EnsureVisible()
{
// _effectiveRows tracks the rows actually rendered, which may be fewer
// than the configured _visibleRows when the parent allocates less space.
if (_highlightedIndex < _scrollOffset)
{
_scrollOffset = _highlightedIndex;
}
else if (_highlightedIndex >= _scrollOffset + _effectiveRows)
{
_scrollOffset = _highlightedIndex - _effectiveRows + 1;
}
}
#endregion
#region Measure & Render
public Size Measure(Size available)
{
// Layout: breadcrumb (1) + optional filter (1) + file list + hints (1)
var headerLines = 1 + (_isFiltering ? 1 : 0);
var footerLines = 1;
if (_fillHeight)
{
_visibleRows = Math.Max(1, available.Height - headerLines - footerLines);
_effectiveRows = _visibleRows;
return new Size(available.Width, available.Height);
}
// Reserve at least one row: empty lists render an "Empty directory"/"No matches" message
var listHeight = Math.Max(1, Math.Min(_filteredEntries.Count, _visibleRows));
var totalHeight = headerLines + listHeight + footerLines;
return new Size(available.Width, totalHeight);
}
public void Render(IRenderContext context, Rect bounds)
{
if (!bounds.HasArea)
return;
// Lazy-load on first render so unfocused pickers show content
EnsureLoaded();
var sub = context.CreateSubContext(bounds);
var currentRow = 0;
var width = bounds.Width;
// Breadcrumb
RenderBreadcrumb(sub, currentRow, width);
currentRow++;
// Filter bar (if active)
if (_isFiltering)
{
RenderFilterBar(sub, currentRow, width);
currentRow++;
}
// Recalculate visible rows in fill mode
var footerLines = 1;
var availableForList = Math.Max(0, bounds.Height - currentRow - footerLines);
if (_fillHeight)
_visibleRows = Math.Max(1, availableForList);
var listHeight = Math.Min(_filteredEntries.Count, Math.Min(_visibleRows, availableForList));
// Keep scroll math in sync with what is actually rendered
_effectiveRows = Math.Max(1, Math.Min(_visibleRows, availableForList));
if (listHeight > 0)
_scrollOffset = Math.Clamp(_scrollOffset, 0, Math.Max(0, _filteredEntries.Count - listHeight));
// File list
RenderFileList(sub, currentRow, width, listHeight);
currentRow += Math.Max(listHeight, 1);
// Key hints footer
if (currentRow < bounds.Height)
RenderFooter(sub, currentRow, width);
}
private void RenderBreadcrumb(IRenderContext context, int row, int width)
{
if (width <= 0) return;
context.SetForeground(Color.BrightYellow);
context.SetDecoration(TextDecoration.Bold);
var pathDisplay = _currentPath;
var maxPathWidth = width - BreadcrumbPrefix.Length;
if (maxPathWidth > 3 && pathDisplay.Length > maxPathWidth)
pathDisplay = "..." + pathDisplay[(pathDisplay.Length - maxPathWidth + 3)..];
else if (maxPathWidth >= 0 && pathDisplay.Length > maxPathWidth)
pathDisplay = maxPathWidth > 0 ? pathDisplay[..maxPathWidth] : "";
context.WriteAt(0, row, BreadcrumbPrefix + pathDisplay);
context.SetDecoration(TextDecoration.None);
context.ResetColors();
}
private void RenderFilterBar(IRenderContext context, int row, int width)
{
context.SetForeground(Color.BrightMagenta);
context.WriteAt(0, row, FilterPrefix);
if (_filterInput != null)
{
var inputX = FilterPrefix.Length;
var inputBounds = new Rect(inputX, row, Math.Max(1, width - inputX), 1);
var inputContext = context.CreateSubContext(inputBounds);
_filterInput.Render(inputContext, new Rect(0, 0, inputBounds.Width, 1));
}
context.ResetColors();
}
private void RenderFileList(IRenderContext context, int startRow, int width, int listHeight)
{
if (_filteredEntries.Count == 0)
{
context.SetForeground(Color.BrightBlack);
context.WriteAt(0, startRow, _isFiltering ? "No matches" : "Empty directory");
context.ResetColors();
return;
}
var needsScrollbar = _filteredEntries.Count > listHeight;
var contentWidth = needsScrollbar ? width - 1 : width;
for (var i = 0; i < listHeight && (_scrollOffset + i) < _filteredEntries.Count; i++)
{
var entryIndex = _scrollOffset + i;
var entry = _filteredEntries[entryIndex];
var isHighlighted = entryIndex == _highlightedIndex && _hasFocus;
var isSelected = _selectedPaths.Contains(entry.FullPath);
var row = startRow + i;
RenderEntry(context, entry, row, contentWidth, isHighlighted, isSelected);
}
if (needsScrollbar)
RenderScrollbar(context, width - 1, startRow, listHeight);
}
private void RenderEntry(IRenderContext context, FileSystemEntry entry, int row, int width,
bool isHighlighted, bool isSelected)
{
// Build prefix: cursor + checkbox (multi) + icon
var cursor = isHighlighted ? "> " : " ";
var checkbox = "";
if (_selectionMode == FilePickerSelectionMode.Multi)
{
checkbox = IsSelectable(entry) ? (isSelected ? "[x] " : "[ ] ") : " ";
}
var icon = entry.IsDirectory ? DirIcon : FileIcon;
var name = entry.IsDirectory ? entry.Name + "/" : entry.Name;
var prefix = cursor + checkbox + icon;
var displayText = prefix + name;
if (width <= 0) return;
if (displayText.Length > width && width > 1)
displayText = displayText[..(width - 1)] + "~";
else if (displayText.Length > width)
displayText = displayText[..width];
// Apply highlight or normal colors
if (isHighlighted)
{
context.SetBackground(_highlightBackground);
context.Fill(0, row, width, 1, ' ');
context.SetForeground(_highlightForeground);
}
else if (isSelected)
{
context.SetForeground(Color.BrightGreen);
}
else
{
context.SetForeground(entry.IsDirectory ? _directoryColor : _fileColor);
}
context.WriteAt(0, row, displayText);
context.ResetColors();
}
private void RenderScrollbar(IRenderContext context, int x, int startRow, int height)
{
var totalItems = _filteredEntries.Count;
if (totalItems <= height)
return;
var thumbSize = Math.Max(1, height * height / totalItems);
var thumbPos = _scrollOffset * (height - thumbSize) / Math.Max(1, totalItems - height);
context.SetForeground(Color.BrightBlack);
for (var row = 0; row < height; row++)
{
var isThumb = row >= thumbPos && row < thumbPos + thumbSize;
context.WriteAt(x, startRow + row, isThumb ? '#' : '|');
}
context.ResetColors();
}
private void RenderFooter(IRenderContext context, int row, int width)
{
context.SetForeground(Color.BrightBlack);
string hints;
if (_isFiltering)
{
hints = "[Enter] Open [Esc] Clear filter";
}
else if (_selectionMode == FilePickerSelectionMode.Multi)
{
hints = _selectedPaths.Count > 0
? $"{_selectedPaths.Count} selected [Space] Toggle [Enter] Open/Confirm [Backspace] Up [Esc] Cancel"
: "[Space] Toggle [Enter] Open/Select [Backspace] Up [/] Filter [Esc] Cancel";
}
else if (_mode is FilePickerMode.Directories or FilePickerMode.All)
{
hints = "[Enter] Open [Space] Select [Backspace] Up [/] Filter [Esc] Cancel";
}
else
{
hints = "[Enter] Open/Select [Backspace] Up [/] Filter [Esc] Cancel";
}
if (hints.Length > width)
hints = hints[..width];
context.WriteAt(0, row, hints);
context.ResetColors();
}
#endregion
#region Utilities
private static bool MatchesGlob(string fileName, string pattern)
{
return FileSystemName.MatchesSimpleExpression(pattern, fileName, ignoreCase: true);
}
private void EmitDirectoryChanged(string path)
{
if (!_disposed)
_directoryChanged.OnNext(path);
}
private void EmitSelectionConfirmed(IReadOnlyList<string> paths)
{
if (!_disposed)
_selectionConfirmed.OnNext(paths);
}
private void Invalidate()
{
if (!_disposed)
_invalidated.OnNext(Unit.Default);
}
#endregion
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (_isFiltering)
{
_isFiltering = false;
_filterInput?.OnBlurred();
_filterInput?.OnDeactivate();
}
_filterInvalidationSub?.Dispose();
_invalidated.OnCompleted();
_invalidated.Dispose();
_selectionConfirmed.OnCompleted();
_selectionConfirmed.Dispose();
_cancelled.OnCompleted();
_cancelled.Dispose();
_directoryChanged.OnCompleted();
_directoryChanged.Dispose();
_filterInput?.Dispose();
}
}View IFileSystemProvider implementation
// 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.
namespace Termina.Layout;
/// <summary>
/// Abstracts filesystem access for <see cref="FilePickerNode"/> to enable testability.
/// </summary>
public interface IFileSystemProvider
{
/// <summary>
/// Returns all file and directory entries in the given directory, sorted with
/// directories first (alphabetical) then files (alphabetical).
/// </summary>
IReadOnlyList<FileSystemEntry> GetEntries(string directoryPath);
/// <summary>
/// Returns true if the given directory exists and is accessible.
/// </summary>
bool DirectoryExists(string path);
/// <summary>
/// Returns the parent directory path, or null if already at a root.
/// </summary>
string? GetParentDirectory(string path);
}
/// <summary>
/// Default <see cref="IFileSystemProvider"/> backed by <see cref="System.IO"/>.
/// </summary>
public sealed class DefaultFileSystemProvider : IFileSystemProvider
{
public static readonly DefaultFileSystemProvider Instance = new();
public IReadOnlyList<FileSystemEntry> GetEntries(string directoryPath)
{
var dir = new DirectoryInfo(directoryPath);
if (!dir.Exists)
return [];
var entries = new List<FileSystemEntry>();
try
{
foreach (var d in dir.EnumerateDirectories())
{
try
{
entries.Add(new FileSystemEntry(d.Name, d.FullName, IsDirectory: true,
LastModified: d.LastWriteTimeUtc));
}
catch (UnauthorizedAccessException) { }
catch (IOException) { }
}
}
catch (UnauthorizedAccessException) { }
catch (IOException) { }
try
{
foreach (var f in dir.EnumerateFiles())
{
try
{
entries.Add(new FileSystemEntry(f.Name, f.FullName, IsDirectory: false,
Size: f.Length, LastModified: f.LastWriteTimeUtc));
}
catch (UnauthorizedAccessException) { }
catch (IOException) { }
}
}
catch (UnauthorizedAccessException) { }
catch (IOException) { }
entries.Sort((a, b) =>
{
if (a.IsDirectory != b.IsDirectory)
return a.IsDirectory ? -1 : 1;
return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase);
});
return entries;
}
public bool DirectoryExists(string path)
{
return Directory.Exists(path);
}
public string? GetParentDirectory(string path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
try
{
// Trim trailing separators: Directory.GetParent("/home/user/")
// would otherwise return "/home/user" — the same directory.
var trimmed = Path.TrimEndingDirectorySeparator(path);
return Directory.GetParent(trimmed)?.FullName;
}
catch (ArgumentException)
{
return null;
}
catch (IOException)
{
return null;
}
}
}View FileSystemEntry implementation
// 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.
namespace Termina.Layout;
/// <summary>
/// Represents a file or directory entry in a <see cref="FilePickerNode"/>.
/// </summary>
public sealed record FileSystemEntry(
string Name,
string FullPath,
bool IsDirectory,
long? Size = null,
DateTimeOffset? LastModified = null);