PanelNode
A bordered container with optional title and content.
Basic Usage
new PanelNode()
.WithTitle("My Panel")
.WithContent(new TextNode("Panel content"))Border Styles
Termina supports four border styles:
// Single line border (default)
new PanelNode().WithBorder(BorderStyle.Single)
// ┌─────────┐
// │ Content │
// └─────────┘
// Double line border
new PanelNode().WithBorder(BorderStyle.Double)
// ╔═════════╗
// ║ Content ║
// ╚═════════╝
// Rounded corners
new PanelNode().WithBorder(BorderStyle.Rounded)
// ╭─────────╮
// │ Content │
// ╰─────────╯
// ASCII fallback
new PanelNode().WithBorder(BorderStyle.Ascii)
// +---------+
// | Content |
// +---------+Styling
new PanelNode()
.WithTitle("Styled Panel")
.WithBorder(BorderStyle.Rounded)
.WithBorderColor(Color.Blue)
.WithTitleColor(Color.Cyan)
.WithPadding(1)
.WithContent(content)Background Surface
WithBackground gives the panel a background color. The panel fills its complete bounds with the color. This includes the border cells, the padding, and the content area.
var surface = Color.FromRgb(23, 27, 34);
new PanelNode()
.WithBorder(BorderStyle.None)
.WithBackground(surface)
.WithPadding(1)
.WithContent(content)The surface follows these rules:
- A child that calls
ResetColorsreturns to the surface, not to the terminal default. - A child that selects its own background keeps that background. The surface resumes after the child resets its colors.
- A nested panel with a background shows its own surface. The outer surface resumes after the nested panel.
- The surface does not reach the next sibling of the panel.
- A scrollable or streaming child in the panel still receives the mouse wheel when the pointer is over it.
- A panel without a background does not change the terminal background.
A border-less panel with a background marks a region without border glyphs. The glyphs of a border become part of a native terminal text selection. A region without them gives a clean copy.
WithBackground sets a property. It does not request a redraw. The render reads the value at render time. Change the value before the next frame, or build a new panel in a reactive layout.
TIP
Use an indexed color (Color.FromIndex) if the app must run on a terminal without 24-bit color. Termina does not reduce an RGB color for a terminal with less color. A wrong surface color covers the complete panel, so it is more visible than a wrong text color. See Colors.
Content Types
Panel content can be any layout node or plain text:
// Text string (auto-wrapped in TextNode)
new PanelNode().WithContent("Simple text")
// Layout node
new PanelNode().WithContent(new TextNode("Styled").Bold())
// Nested layouts
new PanelNode().WithContent(
Layouts.Vertical()
.WithChild(new TextNode("Line 1"))
.WithChild(new TextNode("Line 2")))Reactive Content
Bind panel content to observables:
new PanelNode()
.WithTitle("Counter")
.WithContent(
ViewModel.CountChanged
.Select(c => new TextNode($"Value: {c}"))
.AsLayout())Size Constraints
PanelNode defaults to HeightConstraint = Auto and WidthConstraint = Fill:
// Fixed height panel
new PanelNode()
.WithContent(content)
.Height(5)
// Fill available space
new PanelNode()
.WithContent(content)
.Fill()API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
Title | string? | null | Title in top border |
Border | BorderStyle | Single | Border style |
BorderColor | Color? | null | Border color |
Background | Color? | null | Background color for the complete panel |
TitleColor | Color? | null | Title color |
Padding | int | 0 | Inner padding |
Fluent Methods
| Method | Description |
|---|---|
.WithTitle(string) | Set the panel title |
.WithBorder(BorderStyle) | Set border style |
.WithBorderColor(Color) | Set border color |
.WithBackground(Color) | Set the background color for the complete panel |
.WithTitleColor(Color) | Set title color |
.WithContent(ILayoutNode) | Set content node |
.WithContent(string) | Set text content |
.WithPadding(int) | Set inner padding |
Source Code
View PanelNode 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 R3;
using Termina.Rendering;
using Termina.Terminal;
namespace Termina.Layout;
/// <summary>
/// A layout node that renders a bordered panel with optional title and content.
/// </summary>
public sealed class PanelNode : LayoutNode, IInvalidatingNode
{
private ILayoutNode _content = new EmptyNode();
private IDisposable? _contentInvalidationSubscription;
private readonly Subject<Unit> _invalidated = new();
/// <inheritdoc />
public Observable<Unit> Invalidated => _invalidated;
/// <summary>
/// Panel title (displayed in top border).
/// </summary>
public string? Title { get; private set; }
/// <summary>
/// Border style.
/// </summary>
public BorderStyle Border { get; private set; } = BorderStyle.Single;
/// <summary>
/// Border color.
/// </summary>
public Color? BorderColor { get; private set; }
/// <summary>
/// Background color for the complete panel surface.
/// </summary>
public Color? Background { get; private set; }
/// <summary>
/// Title color.
/// </summary>
public Color? TitleColor { get; private set; }
/// <summary>
/// Inner padding.
/// </summary>
public int Padding { get; private set; }
public PanelNode()
{
// Default to auto sizing
HeightConstraint = new SizeConstraint.Auto();
WidthConstraint = new SizeConstraint.Fill();
}
/// <summary>
/// Set the panel title.
/// </summary>
public PanelNode WithTitle(string title)
{
Title = title;
return this;
}
/// <summary>
/// Set the border style.
/// </summary>
public PanelNode WithBorder(BorderStyle style)
{
Border = style;
return this;
}
/// <summary>
/// Set the border color.
/// </summary>
public PanelNode WithBorderColor(Color color)
{
BorderColor = color;
return this;
}
/// <summary>
/// Set the background color for the complete panel surface.
/// </summary>
public PanelNode WithBackground(Color color)
{
Background = color;
return this;
}
/// <summary>
/// Set the title color.
/// </summary>
public PanelNode WithTitleColor(Color color)
{
TitleColor = color;
return this;
}
/// <summary>
/// Set the content of the panel.
/// </summary>
public PanelNode WithContent(ILayoutNode content)
{
// Dispose the old content's subscription, and deactivate the old content
// (active/inactive pattern) rather than dispose it — a swapped-out node may
// still be reused. Content is disposed only at teardown, in Dispose().
_contentInvalidationSubscription?.Dispose();
if (_content is IActivatableNode oldContent)
oldContent.OnDeactivate();
// Set new content
_content = content;
// Subscribe to new content's invalidation events
if (content is IInvalidatingNode invalidating)
{
_contentInvalidationSubscription = invalidating.Invalidated
.Subscribe(_ => _invalidated.OnNext(Unit.Default));
}
return this;
}
/// <summary>
/// Set the content to a text string.
/// </summary>
public PanelNode WithContent(string text)
{
return WithContent(new TextNode(text));
}
/// <summary>
/// Set inner padding.
/// </summary>
public PanelNode WithPadding(int padding)
{
Padding = padding;
return this;
}
/// <inheritdoc />
internal override IEnumerable<ILayoutNode> GetChildNodes() => [_content];
/// <inheritdoc />
internal override void DisconnectChildInvalidationSubscriptions()
{
_contentInvalidationSubscription?.Dispose();
_contentInvalidationSubscription = null;
}
/// <inheritdoc />
public override Size Measure(Size available)
{
// Border takes 2 chars horizontally (left + right) and 2 rows vertically (top + bottom)
var borderSize = Border == BorderStyle.None ? 0 : 2;
var totalPadding = borderSize + (Padding * 2);
var innerAvailable = available.Shrink(totalPadding, totalPadding);
var contentSize = _content.Measure(innerAvailable);
var totalWidth = contentSize.Width + totalPadding;
var totalHeight = contentSize.Height + totalPadding;
var width = WidthConstraint.Compute(available.Width, totalWidth, available.Width);
var height = HeightConstraint.Compute(available.Height, totalHeight, available.Height);
return new Size(width, height);
}
/// <inheritdoc />
public override void Render(IRenderContext context, Rect bounds)
{
if (!bounds.HasArea)
return;
// Create a sub-context for this panel's bounds so all coordinates are relative to the panel
var unwrappedContext = context.CreateSubContext(bounds);
var panelContext = unwrappedContext;
if (Background.HasValue)
{
// Select the surface once. A child that selects another background keeps it,
// because nothing below selects the surface again until a child resets its colors.
unwrappedContext.SetBackground(Background.Value);
// Wrap the panel context itself, so the border code and the content both
// return to the surface after a color reset.
panelContext = new SurfaceRenderContext(unwrappedContext, Background.Value);
panelContext.Clear();
}
var hasBorder = Border != BorderStyle.None;
var borderChars = GetBorderChars(Border);
// Set border color
if (BorderColor.HasValue)
panelContext.SetForeground(BorderColor.Value);
if (hasBorder && bounds.Height >= 2 && bounds.Width >= 2)
{
// Top border with title
panelContext.WriteAt(0, 0, borderChars.TopLeft.ToString());
var titleStart = 2;
var titleEnd = titleStart;
if (!string.IsNullOrEmpty(Title) && bounds.Width > 4)
{
var maxTitleLen = bounds.Width - 4;
var displayTitle = DisplayWidth.GetColumnCount(Title) > maxTitleLen ? DisplayWidth.TruncateToColumns(Title, maxTitleLen) : Title;
// Write border before title
panelContext.WriteAt(1, 0, new string(borderChars.Horizontal, 1));
// Write title
if (TitleColor.HasValue)
panelContext.SetForeground(TitleColor.Value);
panelContext.WriteAt(2, 0, displayTitle);
if (BorderColor.HasValue)
panelContext.SetForeground(BorderColor.Value);
else if (TitleColor.HasValue)
panelContext.ResetColors();
titleEnd = 2 + DisplayWidth.GetColumnCount(displayTitle);
}
// Rest of top border
var remainingTop = bounds.Width - titleEnd - 1;
if (remainingTop > 0)
panelContext.WriteAt(titleEnd, 0, new string(borderChars.Horizontal, remainingTop));
panelContext.WriteAt(bounds.Width - 1, 0, borderChars.TopRight.ToString());
// Side borders
for (var y = 1; y < bounds.Height - 1; y++)
{
panelContext.WriteAt(0, y, borderChars.Vertical.ToString());
panelContext.WriteAt(bounds.Width - 1, y, borderChars.Vertical.ToString());
}
// Bottom border
panelContext.WriteAt(0, bounds.Height - 1, borderChars.BottomLeft.ToString());
panelContext.WriteAt(1, bounds.Height - 1, new string(borderChars.Horizontal, bounds.Width - 2));
panelContext.WriteAt(bounds.Width - 1, bounds.Height - 1, borderChars.BottomRight.ToString());
}
// Reset colors before content
if (BorderColor.HasValue)
panelContext.ResetColors();
// Render content inside border
var borderOffset = hasBorder ? 1 : 0;
var contentBounds = new Rect(
borderOffset + Padding,
borderOffset + Padding,
bounds.Width - 2 * (borderOffset + Padding),
bounds.Height - 2 * (borderOffset + Padding));
if (contentBounds.HasArea)
{
// Create a sub-context for the content area
var contentContext = panelContext.CreateSubContext(contentBounds);
var innerBounds = new Rect(0, 0, contentBounds.Width, contentBounds.Height);
_content.Render(contentContext, innerBounds);
}
// Reset the unwrapped context, so the surface does not reach the next sibling.
if (Background.HasValue)
unwrappedContext.ResetColors();
}
/// <inheritdoc />
public override void OnActivate()
{
if (_content is IActivatableNode contentNode)
{
contentNode.OnActivate();
}
base.OnActivate();
}
/// <inheritdoc />
public override void OnDeactivate()
{
if (_content is IActivatableNode contentNode)
{
contentNode.OnDeactivate();
}
base.OnDeactivate();
}
/// <inheritdoc />
public override void Dispose()
{
_contentInvalidationSubscription?.Dispose();
_invalidated.OnCompleted();
_invalidated.Dispose();
_content.Dispose();
base.Dispose();
}
private static BorderChars GetBorderChars(BorderStyle style) => style switch
{
BorderStyle.Single => new BorderChars('┌', '┐', '└', '┘', '─', '│'),
BorderStyle.Double => new BorderChars('╔', '╗', '╚', '╝', '═', '║'),
BorderStyle.Rounded => new BorderChars('╭', '╮', '╰', '╯', '─', '│'),
BorderStyle.Ascii => new BorderChars('+', '+', '+', '+', '-', '|'),
_ => new BorderChars(' ', ' ', ' ', ' ', ' ', ' ')
};
private readonly record struct BorderChars(
char TopLeft, char TopRight,
char BottomLeft, char BottomRight,
char Horizontal, char Vertical);
/// <summary>
/// Restores the panel surface after a color reset.
/// </summary>
/// <remarks>
/// This wrapper must forward each internal capability interface that the inner context
/// implements. A node that tests its context for such an interface loses the capability
/// when the wrapper hides it. <see cref="IScreenPositionedRenderContext"/> is the one today.
/// The constructor does not select the surface. The panel selects it once, so a child
/// that selects its own background keeps it across <see cref="CreateSubContext"/>.
/// </remarks>
private sealed class SurfaceRenderContext : IRenderContext, IScreenPositionedRenderContext
{
private readonly IRenderContext _inner;
private readonly Color _background;
public SurfaceRenderContext(IRenderContext inner, Color background)
{
_inner = inner;
_background = background;
}
ScreenBounds IScreenPositionedRenderContext.GetScreenBounds(Rect bounds) =>
_inner is IScreenPositionedRenderContext positioned
? positioned.GetScreenBounds(bounds)
: ScreenBounds.Empty;
public int Width => _inner.Width;
public int Height => _inner.Height;
public void WriteAt(int x, int y, string text) => _inner.WriteAt(x, y, text);
public void WriteAt(int x, int y, char c) => _inner.WriteAt(x, y, c);
public void SetForeground(Color color) => _inner.SetForeground(color);
public void SetBackground(Color color) => _inner.SetBackground(color);
public void ResetColors()
{
_inner.ResetColors();
_inner.SetBackground(_background);
}
public void SetDecoration(TextDecoration decoration) => _inner.SetDecoration(decoration);
public void ApplyStyle(TextStyle style) => _inner.ApplyStyle(style);
public void Fill(int x, int y, int width, int height, char c = ' ') =>
_inner.Fill(x, y, width, height, c);
public void Clear() => _inner.Clear();
public IRenderContext CreateSubContext(Rect bounds) =>
new SurfaceRenderContext(_inner.CreateSubContext(bounds), _background);
}
}