Skip to content

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.

FilePickerNode demo: browsing directories, filtering, and selecting files and folders

Side-by-side file and folder pickers in the gallery — navigation, filtering, and selection.

Basic Usage

csharp
// 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 IFileSystemProvider for deterministic testing

Picker Modes

FilePickerMode controls which entries are selectable. Directories are always shown for navigation (except files are hidden entirely in Directories mode):

csharp
// 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)

csharp
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

csharp
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

KeyAction
↑/↓Move highlight
Home / EndJump to first/last entry
EnterOpen directory / confirm selection (a toggled directory confirms in multi-select)
BackspaceGo up one directory
SpaceSelect (single) or toggle (multi)
/ or any letterOpen the filter bar
EscapeCancel the picker (emits Cancelled)

Filtering

KeyAction
Printable keysType into the filter (spaces allowed)
↑/↓ / Home / EndNavigate the filtered list
EnterOpen / confirm the highlighted match
BackspaceDelete a character (exits filter when empty)
EscapeClear 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:

csharp
var picker = Layouts.FilePicker()
    .WithFileFilter("*.cs");   // only .cs files are listed

Hidden Files

Dotfiles (names starting with .) are hidden by default:

csharp
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:

csharp
// Fill a panel
var picker = Layouts.FilePicker()
    .WithFillHeight();

// Or a fixed window of 15 rows
var picker = Layouts.FilePicker()
    .WithVisibleRows(15);

Styling

csharp
Layouts.FilePicker()
    .WithHighlightColors(Color.Black, Color.Cyan)  // Highlighted row colors
    .WithDirectoryColor(Color.BrightCyan)          // Directory entry color
    .WithFileColor(Color.Default);                 // File entry color

Testing with IFileSystemProvider

The picker reads the filesystem through IFileSystemProvider, so tests can supply a fake provider and never touch the disk:

csharp
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:

csharp
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:

csharp
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

ObservableTypeDescription
SelectionConfirmedObservable<IReadOnlyList<string>>Emits selected full paths on confirmation
CancelledObservable<Unit>Emits when Escape is pressed while browsing
DirectoryChangedObservable<string>Emits the new path when the user navigates (the initial lazy load does not emit)
InvalidatedObservable<Unit>Emits when a redraw is needed

API Reference

Constructor

ConstructorDescription
FilePickerNode(string? startPath = null)Create a picker; defaults to Environment.CurrentDirectory

Properties

PropertyTypeDescription
CurrentPathstringThe directory currently displayed
CanFocusboolAlways true
HasFocusboolWhether the picker has focus
FocusPriorityint10 (lower than modal)

Fluent Methods

MethodDescription
.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

ValueDescription
FilesOnly files are selectable; directories are navigation-only
DirectoriesOnly directories are shown and selectable
AllBoth files and directories are selectable

FilePickerSelectionMode

ValueDescription
SingleConfirm exactly one entry
MultiToggle entries with Space, confirm with Enter

FileSystemEntry

PropertyTypeDescription
NamestringEntry name (no path)
FullPathstringAbsolute path
IsDirectoryboolWhether the entry is a directory
Sizelong?File size in bytes (null for directories)
LastModifiedDateTimeOffset?Last write time

Source Code

View FilePickerNode implementation
csharp
// 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
csharp
// 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
csharp
// 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);

Released under the Apache 2.0 License.