PanelNode
A bordered container with optional title and content.
Basic Usage
csharp
new PanelNode()
.WithTitle("My Panel")
.WithContent(new TextNode("Panel content"))Border Styles
Termina supports four border styles:
csharp
// 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
csharp
new PanelNode()
.WithTitle("Styled Panel")
.WithBorder(BorderStyle.Rounded)
.WithBorderColor(Color.Blue)
.WithTitleColor(Color.Cyan)
.WithPadding(1)
.WithContent(content)Content Types
Panel content can be any layout node or plain text:
csharp
// 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:
csharp
new PanelNode()
.WithTitle("Counter")
.WithContent(
ViewModel.CountChanged
.Select(c => new TextNode($"Value: {c}"))
.AsLayout())Size Constraints
PanelNode defaults to HeightConstraint = Auto and WidthConstraint = Fill:
csharp
// 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 |
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 |
.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
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.Subjects;
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 IObservable<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>
/// 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 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 old content and subscription
_contentInvalidationSubscription?.Dispose();
_content.Dispose();
// 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 />
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 panelContext = context.CreateSubContext(bounds);
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 = Title.Length > maxTitleLen ? 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 + displayTitle.Length;
}
// 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);
}
}
/// <inheritdoc />
public override void OnActivate()
{
if (_content is LayoutNode contentNode)
{
contentNode.OnActivate();
}
base.OnActivate();
}
/// <inheritdoc />
public override void OnDeactivate()
{
if (_content is LayoutNode 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);
}