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)

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
TitleColorColor?nullTitle color
Paddingint0Inner padding

Fluent Methods

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

Released under the Apache 2.0 License.