Skip to content

ModalNode

A modal overlay component that displays content over the rest of the UI with configurable backdrop, borders, and positioning.

Basic Usage

csharp
var modal = Layouts.Modal()
    .WithTitle("Confirm Action")
    .WithContent(new TextNode("Are you sure?"))
    .WithBorder(BorderStyle.Rounded)
    .WithBackdrop(BackdropStyle.Dim);

// Show modal by pushing focus
Focus.PushFocus(modal);

// Handle dismissal
modal.Dismissed.Subscribe(_ => {
    Focus.PopFocus();
    // Handle cancel...
});

Features

  • Captures all keyboard input when focused (high priority = 100)
  • Configurable backdrop (transparent, dim, solid)
  • Multiple positioning options (center, top, bottom)
  • Border styles matching PanelNode
  • Forwards input to focusable content
  • Escape key dismissal (configurable)

Backdrop Styles

csharp
// Semi-transparent dim effect (default)
Layouts.Modal().WithBackdrop(BackdropStyle.Dim)
// ░░░░░░░░░░░░░░░░░
// ░░╭─ Modal ─╮░░░░
// ░░│ Content │░░░░
// ░░╰─────────╯░░░░
// ░░░░░░░░░░░░░░░░░

// Solid background (fully obscures content)
Layouts.Modal()
    .WithBackdrop(BackdropStyle.Solid)
    .WithBackdropColor(Color.DarkGray)

// Transparent (no backdrop)
Layouts.Modal().WithBackdrop(BackdropStyle.Transparent)

Positioning

csharp
// Centered (default)
Layouts.Modal().WithPosition(ModalPosition.Center)

// Near top of screen
Layouts.Modal().WithPosition(ModalPosition.Top)

// Near bottom of screen
Layouts.Modal().WithPosition(ModalPosition.Bottom)

Styling

csharp
Layouts.Modal()
    .WithTitle("Settings")
    .WithTitleColor(Color.Cyan)
    .WithBorder(BorderStyle.Double)
    .WithBorderColor(Color.Blue)
    .WithBackdrop(BackdropStyle.Dim)
    .WithBackdropColor(Color.BrightBlack)
    .WithBackdropChar('░')
    .WithPadding(2)
    .WithContent(content)

With Interactive Content

Modals forward keyboard input to focusable content like TextInputNode or SelectionListNode:

csharp
var textInput = new TextInputNode()
    .WithPlaceholder("Enter name...");

var modal = Layouts.Modal()
    .WithTitle("Enter Your Name")
    .WithContent(textInput);

// Handle text submission
textInput.Submitted.Subscribe(name => {
    Focus.PopFocus();
    ProcessName(name);
});

// Show modal
Focus.PushFocus(modal);

Complete Example

Here's the recommended pattern where the Page owns layout nodes (including modals) and manages Focus, while the ViewModel handles state:

ViewModel - State and business logic only:

csharp
public partial class MyViewModel : ReactiveViewModel
{
    [Reactive] private bool _isShowingModal;

    // Called by Page when text is submitted
    public void OnTextSubmitted(string text)
    {
        if (!string.IsNullOrWhiteSpace(text))
        {
            AddTask(text);
        }
        IsShowingModal = false;
    }

    // Called by Page when modal is dismissed
    public void OnModalDismissed()
    {
        IsShowingModal = false;
    }

    private void ShowAddModal()
    {
        IsShowingModal = true;
    }
}

Page - Owns modals and manages Focus:

csharp
public class MyPage : ReactivePage<MyViewModel>
{
    private ModalNode _modal = null!;
    private TextInputNode _textInput = null!;

    protected override void OnBound()
    {
        // Create text input
        _textInput = new TextInputNode()
            .WithPlaceholder("Enter task description...");

        _textInput.Submitted
            .Subscribe(text => ViewModel.OnTextSubmitted(text))
            .DisposeWith(Subscriptions);

        // Create modal
        _modal = Layouts.Modal()
            .WithTitle("Add New Task")
            .WithBorder(BorderStyle.Rounded)
            .WithBorderColor(Color.Cyan)
            .WithBackdrop(BackdropStyle.Dim)
            .WithPadding(1)
            .WithContent(_textInput)
            .WithDismissOnEscape(true);

        _modal.Dismissed
            .Subscribe(_ => ViewModel.OnModalDismissed())
            .DisposeWith(Subscriptions);

        // React to ViewModel state to manage Focus
        ViewModel.IsShowingModalChanged
            .Subscribe(show => {
                if (show)
                {
                    _textInput.Clear();
                    Focus.PushFocus(_modal);
                }
                else
                {
                    Focus.PopFocus();
                }
            })
            .DisposeWith(Subscriptions);
    }

    public override ILayoutNode BuildLayout()
    {
        return Layouts.Stack()
            .WithChild(mainContent)
            .WithChild(
                ViewModel.IsShowingModalChanged
                    .Select(show => show
                        ? (ILayoutNode)_modal
                        : Layouts.Empty())
                    .AsLayout());
    }
}

Observables

ObservableTypeDescription
DismissedIObservable<Unit>Emits when modal is dismissed (Escape)
InvalidatedIObservable<Unit>Emits when redraw is needed

API Reference

Properties

PropertyTypeDefaultDescription
ContentILayoutNode?nullModal content
CanFocusbooltrueAlways focusable
HasFocusbool-Whether modal has focus
FocusPriorityint100High priority to capture input

Fluent Methods

MethodDescription
.WithContent(ILayoutNode)Set modal content
.WithTitle(string)Set title in border
.WithTitleColor(Color)Set title color
.WithBorder(BorderStyle)Set border style
.WithBorderColor(Color)Set border color
.WithBackdrop(BackdropStyle)Set backdrop style
.WithBackdropColor(Color)Set backdrop color
.WithBackdropChar(char)Set backdrop character (Dim style)
.WithPosition(ModalPosition)Set vertical position
.WithPadding(int)Set content padding
.WithDismissOnEscape(bool)Enable/disable Escape dismissal

Enums

BackdropStyle

ValueDescription
TransparentNo backdrop - content behind visible
DimSemi-transparent pattern (default)
SolidSolid color background

ModalPosition

ValueDescription
CenterCentered vertically (default)
TopNear top of screen
BottomNear bottom of screen

Source Code

View ModalNode 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.Linq;
using System.Reactive.Subjects;
using Termina.Rendering;
using Termina.Terminal;

namespace Termina.Layout;

/// <summary>
/// A modal overlay component that displays content over the rest of the UI.
/// </summary>
/// <remarks>
/// <para>
/// ModalNode captures all keyboard input when focused (FocusPriority = 100).
/// It supports configurable backdrops, borders, and positioning.
/// </para>
/// <para>
/// Use the focus manager to show/hide modals:
/// <code>
/// Focus.PushFocus(modal);  // Show modal
/// Focus.PopFocus();        // Hide modal
/// </code>
/// </para>
/// </remarks>
public sealed class ModalNode : LayoutNode, IFocusable, IInvalidatingNode
{
    private readonly Subject<Unit> _invalidated = new();
    private readonly Subject<Unit> _dismissed = new();
    private ILayoutNode? _content;
    private string? _title;
    private Color? _titleColor;
    private BorderStyle _borderStyle = BorderStyle.Rounded;
    private Color? _borderColor;
    private BackdropStyle _backdrop = BackdropStyle.Dim;
    private Color _backdropColor = Color.BrightBlack;
    private char _backdropChar = '░';
    private ModalPosition _position = ModalPosition.Center;
    private int _padding = 1;
    private bool _dismissOnEscape = true;
    private bool _hasFocus;
    private bool _disposed;

    private readonly record struct BorderCharsData(
        char TopLeft, char TopRight,
        char BottomLeft, char BottomRight,
        char Horizontal, char Vertical);

    private static BorderCharsData GetBorderChars(BorderStyle style) => style switch
    {
        BorderStyle.Single => new BorderCharsData('┌', '┐', '└', '┘', '─', '│'),
        BorderStyle.Double => new BorderCharsData('╔', '╗', '╚', '╝', '═', '║'),
        BorderStyle.Rounded => new BorderCharsData('╭', '╮', '╰', '╯', '─', '│'),
        BorderStyle.Ascii => new BorderCharsData('+', '+', '+', '+', '-', '|'),
        _ => new BorderCharsData(' ', ' ', ' ', ' ', ' ', ' ')
    };

    /// <inheritdoc />
    public IObservable<Unit> Invalidated => _invalidated.AsObservable();

    /// <summary>
    /// Observable that emits when the modal is dismissed (e.g., Escape pressed).
    /// </summary>
    public IObservable<Unit> Dismissed => _dismissed.AsObservable();

    /// <inheritdoc />
    public new SizeConstraint WidthConstraint => SizeConstraint.FillRemaining();

    /// <inheritdoc />
    public new SizeConstraint HeightConstraint => SizeConstraint.FillRemaining();

    /// <inheritdoc />
    public bool CanFocus => true;

    /// <inheritdoc />
    public bool HasFocus => _hasFocus;

    /// <summary>
    /// Modal has high priority to capture all input.
    /// </summary>
    public int FocusPriority => 100;

    /// <summary>
    /// Gets or sets the modal content.
    /// </summary>
    public ILayoutNode? Content
    {
        get => _content;
        set
        {
            _content = value;
            Invalidate();
        }
    }

    /// <summary>
    /// Sets the modal content.
    /// </summary>
    public ModalNode WithContent(ILayoutNode content)
    {
        _content = content;
        return this;
    }

    /// <summary>
    /// Sets the modal title.
    /// </summary>
    public ModalNode WithTitle(string title)
    {
        _title = title;
        return this;
    }

    /// <summary>
    /// Sets the title color.
    /// </summary>
    public ModalNode WithTitleColor(Color color)
    {
        _titleColor = color;
        return this;
    }

    /// <summary>
    /// Sets the border style.
    /// </summary>
    public ModalNode WithBorder(BorderStyle style)
    {
        _borderStyle = style;
        return this;
    }

    /// <summary>
    /// Sets the border color.
    /// </summary>
    public ModalNode WithBorderColor(Color color)
    {
        _borderColor = color;
        return this;
    }

    /// <summary>
    /// Sets the backdrop style.
    /// </summary>
    public ModalNode WithBackdrop(BackdropStyle style)
    {
        _backdrop = style;
        return this;
    }

    /// <summary>
    /// Sets the backdrop color.
    /// </summary>
    public ModalNode WithBackdropColor(Color color)
    {
        _backdropColor = color;
        return this;
    }

    /// <summary>
    /// Sets the backdrop character (for Dim style).
    /// </summary>
    public ModalNode WithBackdropChar(char c)
    {
        _backdropChar = c;
        return this;
    }

    /// <summary>
    /// Sets the modal position.
    /// </summary>
    public ModalNode WithPosition(ModalPosition position)
    {
        _position = position;
        return this;
    }

    /// <summary>
    /// Sets the padding between border and content.
    /// </summary>
    public ModalNode WithPadding(int padding)
    {
        _padding = Math.Max(0, padding);
        return this;
    }

    /// <summary>
    /// Sets whether Escape key dismisses the modal.
    /// </summary>
    public ModalNode WithDismissOnEscape(bool dismiss = true)
    {
        _dismissOnEscape = dismiss;
        return this;
    }

    /// <inheritdoc />
    public void OnFocused()
    {
        _hasFocus = true;

        // Forward focus to content if it's focusable
        if (_content is IFocusable focusableContent)
        {
            focusableContent.OnFocused();
        }

        Invalidate();
    }

    /// <inheritdoc />
    public void OnBlurred()
    {
        _hasFocus = false;

        // Forward blur to content if it's focusable
        if (_content is IFocusable focusableContent)
        {
            focusableContent.OnBlurred();
        }

        Invalidate();
    }

    /// <inheritdoc />
    public bool HandleInput(ConsoleKeyInfo key)
    {
        // Handle Escape to dismiss
        if (_dismissOnEscape && key.Key == ConsoleKey.Escape)
        {
            _dismissed.OnNext(Unit.Default);
            return true;
        }

        // Forward input to content if it's focusable
        if (_content is IFocusable focusable && focusable.CanFocus)
        {
            return focusable.HandleInput(key);
        }

        // Modal consumes all input to prevent it reaching underlying UI
        return true;
    }

    /// <inheritdoc />
    public override Size Measure(Size available)
    {
        // Modal takes full available space (for backdrop)
        return available;
    }

    /// <inheritdoc />
    public override void Render(IRenderContext context, Rect bounds)
    {
        // Draw backdrop
        RenderBackdrop(context, bounds);

        // Calculate modal size based on content
        var contentSize = _content?.Measure(new Size(
            bounds.Width - 4 - (_padding * 2),  // Account for border and padding
            bounds.Height - 4 - (_padding * 2)
        )) ?? new Size(20, 5);

        var modalWidth = Math.Min(
            contentSize.Width + 2 + (_padding * 2),  // +2 for border
            bounds.Width - 4  // Leave some margin
        );
        var modalHeight = Math.Min(
            contentSize.Height + 2 + (_padding * 2),  // +2 for border
            bounds.Height - 2  // Leave some margin
        );

        // Calculate position
        var x = (bounds.Width - modalWidth) / 2;
        var y = _position switch
        {
            ModalPosition.Top => 1,
            ModalPosition.Bottom => bounds.Height - modalHeight - 1,
            _ => (bounds.Height - modalHeight) / 2
        };

        // Draw modal panel
        RenderPanel(context, new Rect(x, y, modalWidth, modalHeight));
    }

    private void RenderBackdrop(IRenderContext context, Rect bounds)
    {
        switch (_backdrop)
        {
            case BackdropStyle.Dim:
                context.SetForeground(_backdropColor);
                context.Fill(0, 0, bounds.Width, bounds.Height, _backdropChar);
                break;

            case BackdropStyle.Solid:
                context.SetBackground(_backdropColor);
                context.Fill(0, 0, bounds.Width, bounds.Height, ' ');
                context.ResetColors();
                break;

            // Transparent - don't render anything
        }
    }

    private void RenderPanel(IRenderContext context, Rect bounds)
    {
        var border = GetBorderChars(_borderStyle);

        // Create a sub-context for this panel's bounds so all coordinates are relative to the panel
        var panelContext = context.CreateSubContext(bounds);

        // Set border color
        if (_borderColor.HasValue)
            panelContext.SetForeground(_borderColor.Value);

        // Draw border
        // Top
        panelContext.WriteAt(0, 0, border.TopLeft.ToString());
        panelContext.WriteAt(1, 0, new string(border.Horizontal, bounds.Width - 2));
        panelContext.WriteAt(bounds.Width - 1, 0, border.TopRight.ToString());

        // Sides and fill interior
        for (var row = 1; row < bounds.Height - 1; row++)
        {
            panelContext.WriteAt(0, row, border.Vertical.ToString());
            // Clear interior
            panelContext.ResetColors();
            panelContext.WriteAt(1, row, new string(' ', bounds.Width - 2));
            if (_borderColor.HasValue)
                panelContext.SetForeground(_borderColor.Value);
            panelContext.WriteAt(bounds.Width - 1, row, border.Vertical.ToString());
        }

        // Bottom
        panelContext.WriteAt(0, bounds.Height - 1, border.BottomLeft.ToString());
        panelContext.WriteAt(1, bounds.Height - 1, new string(border.Horizontal, bounds.Width - 2));
        panelContext.WriteAt(bounds.Width - 1, bounds.Height - 1, border.BottomRight.ToString());

        // Draw title if present
        if (!string.IsNullOrEmpty(_title))
        {
            var maxTitleLen = bounds.Width - 4;
            var displayTitle = _title.Length > maxTitleLen ? _title[..maxTitleLen] : _title;
            var titleText = $" {displayTitle} ";
            var titleX = 2;

            if (_titleColor.HasValue)
                panelContext.SetForeground(_titleColor.Value);
            else
                panelContext.ResetColors();

            panelContext.WriteAt(titleX, 0, titleText);
        }

        panelContext.ResetColors();

        // Render content
        if (_content != null)
        {
            var contentBounds = new Rect(
                1 + _padding,
                1 + _padding,
                bounds.Width - 2 - (_padding * 2),
                bounds.Height - 2 - (_padding * 2)
            );

            if (contentBounds.HasArea)
            {
                var contentContext = panelContext.CreateSubContext(contentBounds);
                var innerBounds = new Rect(0, 0, contentBounds.Width, contentBounds.Height);
                _content.Render(contentContext, innerBounds);
            }
        }
    }

    private void Invalidate()
    {
        if (!_disposed)
            _invalidated.OnNext(Unit.Default);
    }

    /// <inheritdoc />
    public override void OnActivate()
    {
        // Activate content if it's a LayoutNode
        if (_content is LayoutNode contentNode)
        {
            contentNode.OnActivate();
        }
        base.OnActivate();
    }

    /// <inheritdoc />
    public override void OnDeactivate()
    {
        // Deactivate content if it's a LayoutNode
        if (_content is LayoutNode contentNode)
        {
            contentNode.OnDeactivate();
        }
        base.OnDeactivate();
    }

    /// <inheritdoc />
    public override void Dispose()
    {
        if (_disposed)
            return;

        _disposed = true;
        _invalidated.OnCompleted();
        _invalidated.Dispose();
        _dismissed.OnCompleted();
        _dismissed.Dispose();
        _content?.Dispose();
        base.Dispose();
    }
}

Released under the Apache 2.0 License.