Skip to content

Custom Components

Build reusable layout nodes that integrate with Termina's rendering system.

ILayoutNode Interface

All layout nodes implement ILayoutNode:

csharp
public interface ILayoutNode : IDisposable
{
    SizeConstraint WidthConstraint { get; }
    SizeConstraint HeightConstraint { get; }
    Size Measure(Size available);
    void Render(IRenderContext context, Rect bounds);
}

Accessing Dimensions

During rendering, you can access available dimensions from two sources:

The Render method receives a Rect bounds parameter containing the allocated space:

csharp
public void Render(IRenderContext context, Rect bounds)
{
    int width = bounds.Width;   // Allocated width
    int height = bounds.Height; // Allocated height
    int x = bounds.X;           // Left position
    int y = bounds.Y;           // Top position
}

From IRenderContext

The render context also exposes the total region dimensions:

csharp
public void Render(IRenderContext context, Rect bounds)
{
    int regionWidth = context.Width;   // Total region width
    int regionHeight = context.Height; // Total region height
}

From Size available (During Measurement)

The Measure method receives available space to determine desired size:

csharp
public Size Measure(Size available)
{
    int maxWidth = available.Width;
    int maxHeight = available.Height;

    // Return desired size (should not exceed available)
    return new Size(Math.Min(desiredWidth, maxWidth), Math.Min(desiredHeight, maxHeight));
}

Creating a Simple Node

Here's a minimal custom node:

csharp
public class ProgressBarNode : LayoutNode
{
    private double _progress;
    private Color _fillColor = Color.Green;
    private Color _emptyColor = Color.Gray;

    public ProgressBarNode()
    {
        WidthConstraint = new SizeConstraint.Fill();
        HeightConstraint = new SizeConstraint.Fixed(1);
    }

    public ProgressBarNode WithProgress(double value)
    {
        _progress = Math.Clamp(value, 0, 1);
        return this;
    }

    public ProgressBarNode WithColors(Color fill, Color empty)
    {
        _fillColor = fill;
        _emptyColor = empty;
        return this;
    }

    public override Size Measure(Size available)
    {
        // Take full width, 1 row height
        return new Size(available.Width, 1);
    }

    public override void Render(IRenderContext context, Rect bounds)
    {
        var filledWidth = (int)(bounds.Width * _progress);

        // Render filled portion
        context.SetForeground(_fillColor);
        for (int x = 0; x < filledWidth; x++)
        {
            context.WriteAt(bounds.X + x, bounds.Y, '█');
        }

        // Render empty portion
        context.SetForeground(_emptyColor);
        for (int x = filledWidth; x < bounds.Width; x++)
        {
            context.WriteAt(bounds.X + x, bounds.Y, '░');
        }

        context.ResetColors();
    }
}

Usage

csharp
return Layouts.Vertical()
    .WithChild(new TextNode("Download Progress:"))
    .WithChild(
        new ProgressBarNode()
            .WithProgress(0.75)
            .WithColors(Color.Cyan, Color.DarkGray)
            .Height(1));

Fluent Size Methods

The LayoutNode base class provides fluent methods for size constraints:

csharp
// Fixed sizes
node.Width(20);      // Fixed width of 20 columns
node.Height(5);      // Fixed height of 5 rows

// Fill remaining space
node.WidthFill();    // Fill available width
node.Fill();         // Fill available height (note: uses Fill(), not HeightFill())

// Auto-size based on content
node.WidthAuto();
node.HeightAuto();

// Percentage-based
node.WidthPercent(50);   // 50% of available width
node.HeightPercent(25);  // 25% of available height

You can also set constraints directly in your constructor:

csharp
public MyNode()
{
    WidthConstraint = new SizeConstraint.Fixed(20);
    HeightConstraint = new SizeConstraint.Fill { Weight = 1 };
}

Container Nodes

For nodes that contain children:

csharp
public class BorderedContainer : LayoutNode
{
    private ILayoutNode? _content;
    private BorderStyle _style = BorderStyle.Single;

    public BorderedContainer()
    {
        WidthConstraint = new SizeConstraint.Fill();
        HeightConstraint = new SizeConstraint.Fill();
    }

    public BorderedContainer WithContent(ILayoutNode content)
    {
        _content = content;
        return this;
    }

    public override Size Measure(Size available)
    {
        // Account for border (2 chars width, 2 chars height)
        var contentSpace = new Size(
            Math.Max(0, available.Width - 2),
            Math.Max(0, available.Height - 2));

        if (_content != null)
        {
            var contentSize = _content.Measure(contentSpace);
            return new Size(contentSize.Width + 2, contentSize.Height + 2);
        }

        return new Size(2, 2);
    }

    public override void Render(IRenderContext context, Rect bounds)
    {
        // Draw border
        DrawBorder(context, bounds, _style);

        // Render content in inner area
        if (_content != null)
        {
            var innerBounds = new Rect(
                bounds.X + 1,
                bounds.Y + 1,
                bounds.Width - 2,
                bounds.Height - 2);
            _content.Render(context, innerBounds);
        }
    }

    private void DrawBorder(IRenderContext context, Rect bounds, BorderStyle style)
    {
        // Border drawing implementation...
    }
}

Reactive Custom Nodes

For nodes that need to update based on observables:

csharp
public class LiveValueNode : LayoutNode
{
    private string _currentValue = "";
    private IDisposable? _subscription;

    public LiveValueNode()
    {
        WidthConstraint = new SizeConstraint.Fill();
        HeightConstraint = new SizeConstraint.Fixed(1);
    }

    public LiveValueNode BindTo(IObservable<string> source, Action requestRedraw)
    {
        _subscription?.Dispose();
        _subscription = source.Subscribe(value =>
        {
            _currentValue = value;
            requestRedraw();
        });
        return this;
    }

    public override Size Measure(Size available)
    {
        return new Size(
            Math.Min(_currentValue.Length, available.Width),
            1);
    }

    public override void Render(IRenderContext context, Rect bounds)
    {
        var text = _currentValue.Length > bounds.Width
            ? _currentValue[..bounds.Width]
            : _currentValue;

        context.WriteAt(bounds.X, bounds.Y, text);
    }

    public override void Dispose()
    {
        _subscription?.Dispose();
        base.Dispose();
    }
}

Component Lifecycle

When using NavigationBehavior.PreserveState, layout nodes are preserved across navigations and go through activation/deactivation cycles instead of being disposed. This prevents race conditions with in-flight events and allows components to maintain state.

Implementing Lifecycle Methods

Extend LayoutNode and override OnActivate() and OnDeactivate():

csharp
public class AnimatedNode : LayoutNode
{
    private readonly Timer _timer;
    private int _frame;

    public AnimatedNode()
    {
        _timer = new Timer(100);
        _timer.Elapsed += (_, _) =>
        {
            _frame++;
            // Trigger UI update
        };
    }

    public override void OnActivate()
    {
        // Resume animation when page becomes active
        _timer.Start();
        base.OnActivate();
    }

    public override void OnDeactivate()
    {
        // Pause animation when navigating away
        _timer.Stop();
        base.OnDeactivate();
    }

    public override void Dispose()
    {
        // Final cleanup when component is destroyed
        _timer.Dispose();
        base.Dispose();
    }
}

When to Implement Lifecycle

Implement OnActivate() and OnDeactivate() when your component:

  • Uses timers - Stop/start timers to avoid background work
  • Subscribes to observables - Pause subscriptions to prevent processing events while inactive
  • Animates - Pause animations when not visible
  • Holds expensive resources - Temporarily release resources when inactive

Lifecycle vs Disposal

Key differences:

MethodWhen CalledPurposeSubjects/Observables
OnDeactivate()Navigating away (PreserveState)Pause, don't destroyKeep alive, pause subscriptions
Dispose()Page destroyed or app shutdownFinal cleanupComplete and dispose

Important: OnDeactivate() should not dispose Subjects or complete observables. It should only pause active resources like timers and subscriptions.

Example: Reactive Node with Lifecycle

csharp
public class LiveDataNode : LayoutNode, IInvalidatingNode
{
    private readonly IObservable<string> _source;
    private readonly Subject<Unit> _invalidated = new();
    private IDisposable? _subscription;
    private string _currentValue = "";

    public IObservable<Unit> Invalidated => _invalidated;

    public LiveDataNode(IObservable<string> source)
    {
        _source = source;

        // Create initial subscription
        _subscription = source.Subscribe(value =>
        {
            _currentValue = value;
            _invalidated.OnNext(Unit.Default);
        });
    }

    public override void OnActivate()
    {
        // Recreate subscription if it was disposed during deactivation
        if (_subscription == null || _subscription is BooleanDisposable { IsDisposed: true })
        {
            _subscription = _source.Subscribe(value =>
            {
                _currentValue = value;
                _invalidated.OnNext(Unit.Default);
            });
        }
        base.OnActivate();
    }

    public override void OnDeactivate()
    {
        // Pause subscription - don't dispose the Subject!
        _subscription?.Dispose();
        _subscription = null;
        base.OnDeactivate();
    }

    public override void Dispose()
    {
        // Final cleanup
        _subscription?.Dispose();
        _invalidated.OnCompleted();
        _invalidated.Dispose();
        base.Dispose();
    }
}

Container Lifecycle Propagation

If your custom container holds child nodes, propagate lifecycle calls:

csharp
public class CustomContainer : LayoutNode
{
    private readonly List<ILayoutNode> _children = new();

    public override void OnActivate()
    {
        // Activate all children
        foreach (var child in _children)
        {
            if (child is LayoutNode node)
                node.OnActivate();
        }
        base.OnActivate();
    }

    public override void OnDeactivate()
    {
        // Deactivate all children
        foreach (var child in _children)
        {
            if (child is LayoutNode node)
                node.OnDeactivate();
        }
        base.OnDeactivate();
    }

    public override void Dispose()
    {
        // Dispose all children
        foreach (var child in _children)
        {
            child.Dispose();
        }
        base.Dispose();
    }
}

Best Practices

Measurement

  • Return sizes that fit within available
  • Account for borders, padding, and decorations
  • Handle zero-size gracefully

Rendering

  • Only render within your bounds
  • Use context.WriteAt() for character-level control
  • Use context.SetForeground() and context.SetBackground() for colors
  • Call context.ResetColors() after custom styling
  • Check bounds before rendering to avoid overflow

Immutability

  • Fluent methods should return this for chaining
  • Create new instances for fundamentally different configurations
  • Avoid mutation during measure/render

Performance

  • Cache expensive calculations
  • Minimize allocations in hot paths
  • Use Span<char> for string operations when possible

Integrating with Built-in Nodes

Compose with existing nodes:

csharp
public class LabeledProgressBar : LayoutNode
{
    private readonly VerticalLayout _layout;

    public LabeledProgressBar(string label, double progress)
    {
        _layout = Layouts.Vertical()
            .WithChild(new TextNode(label).Height(1))
            .WithChild(new ProgressBarNode().WithProgress(progress).Height(1));

        HeightConstraint = new SizeConstraint.Fixed(2);
    }

    public override Size Measure(Size available) => _layout.Measure(available);

    public override void Render(IRenderContext context, Rect bounds) =>
        _layout.Render(context, bounds);
}

Released under the Apache 2.0 License.