Skip to content

SelectionListNode<T>: add a fill-height mode (long lists capped at the 10-row default) #207

@Aaronontheweb

Description

@Aaronontheweb

Summary

SelectionListNode<T> has no way to fill the height its parent allocates. A
long list is capped at the _visibleRows default of 10 regardless of
terminal size, and the only control — WithVisibleRows(int) — takes a static
count rather than a "fill available space" mode.

Observed in Termina 0.8.0.

Current behavior

In SelectionListNode<T>:

  • private int _visibleRows = 10;
  • public SizeConstraint HeightConstraint => SizeConstraint.AutoSize();
  • Measure(Size available) returns Math.Min(TotalLineCount, _visibleRows) for
    height — note it does not consult available.Height.
  • WithVisibleRows(int rows) sets _visibleRows = Math.Max(1, rows) — the only
    knob, and it's a fixed number.

The scrolling internals are otherwise fine: Render clamps
num = Math.Min(_visibleRows, bounds.Height) and draws a scrollbar whenever
TotalLineCount > num. The problem is purely that the node's measured height
is stuck at _visibleRows, with no fill option.

Net effect: drop 100+ items into a SelectionListNode on a 50-row terminal and
only 10 render, with a 10-cell scrollbar floating in an otherwise empty panel.

Why a static WithVisibleRows isn't a fix

EnsureVisible() uses _visibleRows for its scroll-offset math, so
_visibleRows must equal the actual number of rows on screen. Passing a large
constant (WithVisibleRows(1000)) breaks scrolling: the node believes 1000 rows
are visible and never advances _scrollOffset, so the highlight walks off the
bottom of the viewport.

The correct visible-row count is only known at measure/render time (from
available.Height / bounds.Height), which a caller can't supply up front.

Workaround

Consumers can wrap the node in a Fill-constrained LayoutNode whose
Measure/Render push the allocated height into WithVisibleRows(...) before
delegating:

internal sealed class FillSelectionListNode<T> : LayoutNode, IInvalidatingNode
{
    private readonly SelectionListNode<T> _inner;

    public FillSelectionListNode(SelectionListNode<T> inner)
    {
        _inner = inner;
        Fill();
        WidthFill();
    }

    public Observable<Unit> Invalidated => _inner.Invalidated;

    public override Size Measure(Size available)
    {
        _inner.WithVisibleRows(available.Height);
        return available;
    }

    public override void Render(IRenderContext context, Rect bounds)
    {
        _inner.WithVisibleRows(bounds.Height);
        _inner.Render(context, bounds);
    }

    public override void Dispose()
    {
        _inner.Dispose();
        base.Dispose();
    }
}

This works, but every consumer that wants a full-height list has to reinvent it,
and GetChildNodes() is internal so the wrapper can't participate fully in
tree traversal.

Proposed enhancement

Give SelectionListNode<T> a first-class fill mode. Either:

Option A — explicit builder. WithFillHeight() flips HeightConstraint to
SizeConstraint.Fill, and Measure/Render use available.Height /
bounds.Height as the visible-row count (keeping _visibleRows — or whatever
backs EnsureVisible() — in sync so scroll math and the scrollbar stay
correct).

Option B — honor a caller-set constraint. If the caller sets a Fill
HeightConstraint, size to the allocated height instead of _visibleRows.

Either way the 10-row AutoSize default stays as-is for the short menus that
are the common case — this only adds an opt-in path for long, scrollable lists.

Context

Surfaced while fixing a downstream TUI (netclaw approvals) where a
100+ entry approvals list rendered only 10 rows. The fix there is currently the
FillSelectionListNode<T> wrapper above; it would be deleted in favour of a
built-in fill mode.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions