ScrollableContainerNode
A container that provides vertical scrolling for content that exceeds the viewport.
Basic Usage
csharp
new ScrollableContainerNode()
.WithContent(
Layouts.Vertical()
.WithChild(...) // Many children
.WithChild(...))
.Fill()Auto-Scroll Policies
Control how the container scrolls when content changes:
csharp
// Scroll to bottom only if already at bottom (default)
.WithAutoScroll(AutoScrollPolicy.TailWhenAtBottom)
// Always scroll to bottom when content changes
.WithAutoScroll(AutoScrollPolicy.AlwaysTail)
// Never auto-scroll
.WithAutoScroll(AutoScrollPolicy.None)| Policy | Behavior |
|---|---|
TailWhenAtBottom | Auto-scroll if at bottom, stay in place otherwise |
AlwaysTail | Always scroll to show latest content |
None | Never auto-scroll |
Scrollbar
The scrollbar is shown automatically when content exceeds viewport:
csharp
// Show scrollbar (default)
.WithScrollbar(true)
// Hide scrollbar
.WithScrollbar(false)
// Customize scrollbar colors
.WithScrollbarColors(
track: Color.BrightBlack,
thumb: Color.White)Programmatic Scrolling
csharp
var scroller = new ScrollableContainerNode()
.WithContent(content);
// Scroll methods
scroller.ScrollUp();
scroller.ScrollDown();
scroller.PageUp();
scroller.PageDown();
scroller.ScrollToTop();
scroller.ScrollToBottom();
scroller.ScrollTo(offset: 10);Scroll State
Query the current scroll state:
csharp
var offset = scroller.ScrollOffset; // Current position
var height = scroller.ContentHeight; // Total content height
var canUp = scroller.CanScrollUp; // Can scroll up?
var canDown = scroller.CanScrollDown; // Can scroll down?
var atBottom = scroller.IsNearBottom; // At or near bottom?Example: Chat with Auto-Scroll
csharp
new ScrollableContainerNode()
.WithContent(
ViewModel.MessagesChanged
.Select(messages =>
Layouts.Vertical(
messages.Select(m =>
new TextNode(m.Text)
.WithForeground(m.IsUser ? Color.Cyan : Color.Green))
.ToArray()))
.AsLayout())
.WithAutoScroll(AutoScrollPolicy.TailWhenAtBottom)
.Fill()API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
AutoScroll | AutoScrollPolicy | TailWhenAtBottom | Auto-scroll behavior |
ShowScrollbar | bool | true | Show scrollbar |
ScrollbarTrackColor | Color | BrightBlack | Track color |
ScrollbarThumbColor | Color | White | Thumb color |
ScrollOffset | int | 0 | Current scroll position |
ContentHeight | int | - | Total content height |
CanScrollUp | bool | - | Can scroll up |
CanScrollDown | bool | - | Can scroll down |
MaxScroll | int | - | Maximum scroll offset |
IsNearBottom | bool | - | Within 2 lines of bottom |
Methods
| Method | Description |
|---|---|
.WithContent(ILayoutNode) | Set scrollable content |
.WithAutoScroll(AutoScrollPolicy) | Set auto-scroll policy |
.WithScrollbar(bool) | Show/hide scrollbar |
.WithScrollbarColors(track, thumb) | Set scrollbar colors |
.ScrollUp() | Scroll up one line |
.ScrollDown() | Scroll down one line |
.PageUp() | Scroll up one page |
.PageDown() | Scroll down one page |
.ScrollTo(int) | Scroll to offset |
.ScrollToTop() | Scroll to top |
.ScrollToBottom() | Scroll to bottom |
Source Code
View ScrollableContainerNode 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.Reactive;
using System.Reactive.Disposables;
using System.Reactive.Subjects;
using Termina.Rendering;
using Termina.Terminal;
namespace Termina.Layout;
/// <summary>
/// Defines automatic scrolling behavior for scrollable containers.
/// </summary>
public enum AutoScrollPolicy
{
/// <summary>
/// No automatic scrolling - scroll position is manually controlled.
/// </summary>
None,
/// <summary>
/// Automatically scroll to bottom when new content is added,
/// but only if already at or near the bottom.
/// </summary>
TailWhenAtBottom,
/// <summary>
/// Always scroll to bottom when content changes.
/// </summary>
AlwaysTail
}
/// <summary>
/// A layout node that provides vertical scrolling for content that exceeds the viewport.
/// Manages scroll position state internally.
/// </summary>
public sealed class ScrollableContainerNode : LayoutNode, IInvalidatingNode
{
private ILayoutNode _content = new EmptyNode();
private int _scrollOffset;
private int _contentHeight;
private int _viewportHeight;
private int _previousContentHeight;
private readonly Subject<Unit> _invalidated = new();
private IDisposable? _contentSubscription;
/// <inheritdoc />
public IObservable<Unit> Invalidated => _invalidated;
/// <summary>
/// Gets or sets the automatic scroll policy.
/// </summary>
public AutoScrollPolicy AutoScroll { get; set; } = AutoScrollPolicy.TailWhenAtBottom;
/// <summary>
/// Gets or sets whether to display a scrollbar.
/// </summary>
public bool ShowScrollbar { get; set; } = true;
/// <summary>
/// Gets or sets the scrollbar track color.
/// </summary>
public Color ScrollbarTrackColor { get; set; } = Color.BrightBlack;
/// <summary>
/// Gets or sets the scrollbar thumb color.
/// </summary>
public Color ScrollbarThumbColor { get; set; } = Color.White;
/// <summary>
/// Gets the current scroll offset.
/// </summary>
public int ScrollOffset => _scrollOffset;
/// <summary>
/// Gets the total content height.
/// </summary>
public int ContentHeight => _contentHeight;
/// <summary>
/// Gets whether scrolling down is possible.
/// </summary>
public bool CanScrollDown => _scrollOffset < MaxScroll;
/// <summary>
/// Gets whether scrolling up is possible.
/// </summary>
public bool CanScrollUp => _scrollOffset > 0;
/// <summary>
/// Gets the maximum scroll offset.
/// </summary>
public int MaxScroll => Math.Max(0, _contentHeight - _viewportHeight);
/// <summary>
/// Gets whether the view is at or near the bottom (within a few lines).
/// </summary>
public bool IsNearBottom => _scrollOffset >= MaxScroll - 2;
/// <summary>
/// Set the content to scroll.
/// </summary>
public ScrollableContainerNode WithContent(ILayoutNode content)
{
// Dispose previous subscription and content
_contentSubscription?.Dispose();
_content.Dispose();
_content = content;
// If content is invalidating, subscribe to the observable
if (_content is IInvalidatingNode invalidating)
{
_contentSubscription = invalidating.Invalidated.Subscribe(_ => OnContentInvalidated());
}
return this;
}
/// <summary>
/// Set the auto-scroll policy.
/// </summary>
public ScrollableContainerNode WithAutoScroll(AutoScrollPolicy policy)
{
AutoScroll = policy;
return this;
}
/// <summary>
/// Enable or disable the scrollbar.
/// </summary>
public ScrollableContainerNode WithScrollbar(bool show)
{
ShowScrollbar = show;
return this;
}
/// <summary>
/// Set scrollbar colors.
/// </summary>
public ScrollableContainerNode WithScrollbarColors(Color track, Color thumb)
{
ScrollbarTrackColor = track;
ScrollbarThumbColor = thumb;
return this;
}
/// <summary>
/// Scroll down by one line.
/// </summary>
public void ScrollDown()
{
if (CanScrollDown)
{
_scrollOffset++;
_invalidated.OnNext(Unit.Default);
}
}
/// <summary>
/// Scroll up by one line.
/// </summary>
public void ScrollUp()
{
if (CanScrollUp)
{
_scrollOffset--;
_invalidated.OnNext(Unit.Default);
}
}
/// <summary>
/// Scroll down by a page.
/// </summary>
public void PageDown()
{
var newOffset = Math.Min(_scrollOffset + _viewportHeight, MaxScroll);
if (newOffset != _scrollOffset)
{
_scrollOffset = newOffset;
_invalidated.OnNext(Unit.Default);
}
}
/// <summary>
/// Scroll up by a page.
/// </summary>
public void PageUp()
{
var newOffset = Math.Max(_scrollOffset - _viewportHeight, 0);
if (newOffset != _scrollOffset)
{
_scrollOffset = newOffset;
_invalidated.OnNext(Unit.Default);
}
}
/// <summary>
/// Scroll to a specific offset.
/// </summary>
public void ScrollTo(int offset)
{
var newOffset = Math.Clamp(offset, 0, MaxScroll);
if (newOffset != _scrollOffset)
{
_scrollOffset = newOffset;
_invalidated.OnNext(Unit.Default);
}
}
/// <summary>
/// Scroll to the top.
/// </summary>
public void ScrollToTop()
{
ScrollTo(0);
}
/// <summary>
/// Scroll to the bottom.
/// </summary>
public void ScrollToBottom()
{
ScrollTo(MaxScroll);
}
private void OnContentInvalidated()
{
// Content changed - check if we need to auto-scroll
ApplyAutoScrollPolicy();
_invalidated.OnNext(Unit.Default);
}
private void ApplyAutoScrollPolicy()
{
switch (AutoScroll)
{
case AutoScrollPolicy.AlwaysTail:
_scrollOffset = MaxScroll;
break;
case AutoScrollPolicy.TailWhenAtBottom:
// If we were at or near the bottom before, stay at the bottom
if (IsNearBottom || _previousContentHeight <= _viewportHeight)
{
_scrollOffset = MaxScroll;
}
break;
case AutoScrollPolicy.None:
default:
// Clamp to valid range
_scrollOffset = Math.Clamp(_scrollOffset, 0, MaxScroll);
break;
}
}
/// <inheritdoc />
public override Size Measure(Size available)
{
// Calculate available width accounting for scrollbar
var contentWidth = ShowScrollbar && available.Width > 1
? available.Width - 1
: available.Width;
// Measure content with unlimited height to get true content size
var contentAvailable = new Size(contentWidth, int.MaxValue / 2);
var contentSize = _content.Measure(contentAvailable);
_previousContentHeight = _contentHeight;
_contentHeight = contentSize.Height;
_viewportHeight = HeightConstraint.Compute(available.Height, contentSize.Height, available.Height);
// Apply auto-scroll if content height changed
if (_contentHeight != _previousContentHeight)
{
ApplyAutoScrollPolicy();
}
var width = WidthConstraint.Compute(available.Width, contentSize.Width + (ShowScrollbar ? 1 : 0), available.Width);
return new Size(width, _viewportHeight);
}
/// <inheritdoc />
public override void Render(IRenderContext context, Rect bounds)
{
if (!bounds.HasArea)
return;
_viewportHeight = bounds.Height;
var contentWidth = ShowScrollbar && bounds.Width > 1
? bounds.Width - 1
: bounds.Width;
// Create a clipped/scrolled render context for content
var contentBounds = new Rect(0, 0, contentWidth, bounds.Height);
var scrolledContext = new ScrolledRenderContext(context, 0, -_scrollOffset, contentWidth, _contentHeight);
// Render content
_content.Render(scrolledContext, new Rect(0, 0, contentWidth, _contentHeight));
// Draw scrollbar if needed
if (ShowScrollbar && _contentHeight > _viewportHeight)
{
DrawScrollbar(context, bounds);
}
}
private void DrawScrollbar(IRenderContext context, Rect bounds)
{
var x = bounds.Width - 1;
var trackHeight = bounds.Height;
if (trackHeight <= 0 || _contentHeight <= _viewportHeight)
return;
// Calculate thumb size and position
var thumbHeight = Math.Max(1, (int)((float)_viewportHeight / _contentHeight * trackHeight));
var maxThumbTop = trackHeight - thumbHeight;
var thumbTop = MaxScroll > 0
? (int)((float)_scrollOffset / MaxScroll * maxThumbTop)
: 0;
// Draw track and thumb
for (var y = 0; y < trackHeight; y++)
{
if (y >= thumbTop && y < thumbTop + thumbHeight)
{
// Thumb
context.SetForeground(ScrollbarThumbColor);
context.WriteAt(x, y, '█');
}
else
{
// Track
context.SetForeground(ScrollbarTrackColor);
context.WriteAt(x, y, '░');
}
}
context.ResetColors();
}
/// <inheritdoc />
public override void Dispose()
{
_contentSubscription?.Dispose();
_invalidated.OnCompleted();
_invalidated.Dispose();
_content.Dispose();
base.Dispose();
}
/// <summary>
/// A render context that applies vertical scroll offset.
/// </summary>
private sealed class ScrolledRenderContext : IRenderContext
{
private readonly IRenderContext _parent;
private readonly int _offsetX;
private readonly int _offsetY;
private readonly int _clipHeight;
public ScrolledRenderContext(IRenderContext parent, int offsetX, int offsetY, int width, int clipHeight)
{
_parent = parent;
_offsetX = offsetX;
_offsetY = offsetY;
Width = width;
_clipHeight = clipHeight;
}
public int Width { get; }
public int Height => _clipHeight;
public void WriteAt(int x, int y, string text)
{
var actualY = y + _offsetY;
if (actualY < 0 || actualY >= _parent.Height)
return;
if (x < 0)
{
text = text[Math.Min(-x, text.Length)..];
x = 0;
}
if (x >= Width)
return;
if (x + text.Length > Width)
text = text[..(Width - x)];
_parent.WriteAt(x + _offsetX, actualY, text);
}
public void WriteAt(int x, int y, char c)
{
var actualY = y + _offsetY;
if (x < 0 || x >= Width || actualY < 0 || actualY >= _parent.Height)
return;
_parent.WriteAt(x + _offsetX, actualY, c);
}
public void SetForeground(Color color) => _parent.SetForeground(color);
public void SetBackground(Color color) => _parent.SetBackground(color);
public void ResetColors() => _parent.ResetColors();
public void SetDecoration(Terminal.TextDecoration decoration) => _parent.SetDecoration(decoration);
public void ApplyStyle(Terminal.TextStyle style) => _parent.ApplyStyle(style);
public void Fill(int x, int y, int width, int height, char c = ' ')
{
var actualY = y + _offsetY;
if (actualY < 0)
{
height += actualY;
actualY = 0;
}
if (actualY + height > _parent.Height)
height = _parent.Height - actualY;
if (height <= 0)
return;
_parent.Fill(x + _offsetX, actualY, width, height, c);
}
public void Clear() => Fill(0, 0, Width, Height, ' ');
public IRenderContext CreateSubContext(Rect bounds)
{
var clippedX = Math.Max(0, bounds.X);
var clippedY = Math.Max(0, bounds.Y);
var clippedRight = Math.Min(Width, bounds.Right);
var clippedBottom = Math.Min(_clipHeight, bounds.Bottom);
var clippedWidth = Math.Max(0, clippedRight - clippedX);
var clippedHeight = Math.Max(0, clippedBottom - clippedY);
return new ScrolledRenderContext(
_parent,
_offsetX + clippedX,
_offsetY + clippedY,
clippedWidth,
clippedHeight);
}
}
}