Skip to content

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)

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.

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

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 ​

PropertyTypeDefaultDescription
Titlestring?nullTitle in top border
BorderBorderStyleSingleBorder style
BorderColorColor?nullBorder color
BackgroundColor?nullBackground color for the complete panel
TitleColorColor?nullTitle color
Paddingint0Inner padding

Fluent Methods ​

MethodDescription
.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
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 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);
    }
}

Released under the Apache 2.0 License.