Skip to content

ReactiveLayoutNode

Updates its content based on an observable stream. This is the core building block for reactive UI binding.

Basic Usage

csharp
// From observable of layout nodes
new ReactiveLayoutNode(
    ViewModel.CountChanged
        .Select(c => new TextNode($"Count: {c}")))

// Using the AsLayout() extension method (preferred)
ViewModel.CountChanged
    .Select(c => new TextNode($"Count: {c}"))
    .AsLayout()

How It Works

  1. Subscribe to the source observable
  2. When a new value is emitted, dispose the old child node
  3. Replace with the new layout node from the transform
  4. Signal invalidation to trigger re-render

Extension Method

The AsLayout() extension method provides a cleaner syntax:

csharp
// These are equivalent:
new ReactiveLayoutNode(observable)
observable.AsLayout()

// With transform:
new ReactiveLayoutNode<int>(source, value => new TextNode($"{value}"))
source.Select(value => new TextNode($"{value}")).AsLayout()

Common Patterns

Reactive Text

csharp
ViewModel.StatusChanged
    .Select(status => new TextNode(status)
        .WithForeground(Color.Yellow))
    .AsLayout()

Conditional Content

csharp
ViewModel.HasErrorChanged
    .Select(hasError => hasError
        ? new TextNode("Error occurred!").WithForeground(Color.Red)
        : (ILayoutNode)new EmptyNode())
    .AsLayout()

Dynamic Lists

csharp
ViewModel.ItemsChanged
    .Select(items => Layouts.Vertical(
        items.Select(i => new TextNode(i.Name)).ToArray()))
    .AsLayout()

Styled Based on Value

csharp
ViewModel.HealthChanged
    .Select(health => new TextNode($"Health: {health}%")
        .WithForeground(health > 50 ? Color.Green : Color.Red))
    .AsLayout()

Size Constraints

Apply constraints to the reactive node itself:

csharp
ViewModel.CountChanged
    .Select(c => new TextNode($"{c}"))
    .AsLayout()
    .Height(1)          // Fixed height
    .WidthFill()        // Fill available width

Performance Considerations

WARNING

Each emission creates a new layout node and disposes the old one. For frequently updating content, consider:

  1. Stateful nodes outside reactive - Keep TextInputNode, StreamingTextNode etc. as properties, not inside reactive wrappers
  2. Debounce high-frequency updates - Use .Throttle() or .Sample() operators
  3. Minimize node creation - Update properties rather than recreating entire subtrees

Good: Stateful node as property

csharp
public class MyViewModel : ReactiveViewModel
{
    // Node lives outside reactive wrapper
    public TextInputNode Input { get; } = new TextInputNode();
}

// In page:
new PanelNode()
    .WithContent(ViewModel.Input)  // Not wrapped in reactive

Avoid: Recreating stateful nodes

csharp
// Bad - TextInputNode is recreated on every change
ViewModel.SomethingChanged
    .Select(_ => new TextInputNode())  // State is lost!
    .AsLayout()

API Reference

Constructors

csharp
// From observable of layout nodes
public ReactiveLayoutNode(
    IObservable<ILayoutNode> source,
    ILayoutNode? initialChild = null)

// From observable with transform
public ReactiveLayoutNode<T>(
    IObservable<T> source,
    Func<T, ILayoutNode> transform)

Extension Methods

csharp
// Convert observable of layout nodes
IObservable<ILayoutNode>.AsLayout() -> ReactiveLayoutNode

// Observable transform (via Select) + AsLayout
observable.Select(transform).AsLayout()

Source Code

View ReactiveLayoutNode 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.Disposables;
using System.Reactive.Subjects;
using Termina.Rendering;

namespace Termina.Layout;

/// <summary>
/// A layout node that updates its content based on an observable stream.
/// When the observable emits, the child node is replaced and invalidation is signaled.
/// </summary>
public sealed class ReactiveLayoutNode : LayoutNode, IInvalidatingNode
{
    private readonly IObservable<ILayoutNode> _source;
    private IDisposable? _subscription;
    private IDisposable? _childInvalidationSubscription;
    private readonly Subject<Unit> _invalidated = new();
    private ILayoutNode _currentChild;
    private Size _lastMeasuredSize;
    private bool _isActive = false;

    /// <inheritdoc />
    public IObservable<Unit> Invalidated => _invalidated;

    /// <summary>
    /// Create a reactive layout node from an observable of layout nodes.
    /// </summary>
    public ReactiveLayoutNode(IObservable<ILayoutNode> source, ILayoutNode? initialChild = null)
    {
        _source = source;
        _currentChild = initialChild ?? new EmptyNode();
        SubscribeToChildInvalidation(_currentChild);

        _subscription = source.Subscribe(
            onNext: node =>
            {
                // Deactivate old child instead of disposing (active/inactive pattern)
                if (_currentChild is LayoutNode oldChild)
                {
                    oldChild.OnDeactivate();
                }
                _currentChild = node;
                SubscribeToChildInvalidation(node);

                // Activate the new child if we're currently active
                if (_isActive && node is LayoutNode newChildNode)
                {
                    newChildNode.OnActivate();
                }

                _invalidated.OnNext(Unit.Default);
            },
            onError: _ => { },
            onCompleted: () => { });
    }

    /// <summary>
    /// Subscribe to a child's invalidation events and propagate them upward.
    /// </summary>
    private void SubscribeToChildInvalidation(ILayoutNode child)
    {
        // Dispose previous child invalidation subscription
        _childInvalidationSubscription?.Dispose();
        _childInvalidationSubscription = null;

        if (child is IInvalidatingNode invalidating)
        {
            _childInvalidationSubscription = invalidating.Invalidated.Subscribe(_ => _invalidated.OnNext(Unit.Default));
        }
    }

    /// <inheritdoc />
    public override Size Measure(Size available)
    {
        var childSize = _currentChild.Measure(available);

        // Apply our own constraints
        var width = WidthConstraint.Compute(available.Width, childSize.Width, available.Width);
        var height = HeightConstraint.Compute(available.Height, childSize.Height, available.Height);

        _lastMeasuredSize = new Size(width, height);
        return _lastMeasuredSize;
    }

    /// <inheritdoc />
    public override void Render(IRenderContext context, Rect bounds)
    {
        _currentChild.Render(context, bounds);
    }

    /// <inheritdoc />
    public override void OnActivate()
    {
        _isActive = true;

        // If subscription was disposed during deactivation, recreate it
        if (_subscription == null || _subscription is BooleanDisposable { IsDisposed: true })
        {
            _subscription = _source.Subscribe(
                onNext: node =>
                {
                    // Deactivate old child instead of disposing (active/inactive pattern)
                    if (_currentChild is LayoutNode oldChild)
                    {
                        oldChild.OnDeactivate();
                    }
                    _currentChild = node;
                    SubscribeToChildInvalidation(node);

                    // Activate the new child if we're currently active
                    if (_isActive && node is LayoutNode newChildNode)
                    {
                        newChildNode.OnActivate();
                    }

                    _invalidated.OnNext(Unit.Default);
                },
                onError: _ => { },
                onCompleted: () => { });
        }

        // Re-subscribe to current child's invalidation events (might have been disposed)
        SubscribeToChildInvalidation(_currentChild);

        // Activate current child if it's a LayoutNode
        if (_currentChild is LayoutNode childNode)
        {
            childNode.OnActivate();
        }

        base.OnActivate();
    }

    /// <inheritdoc />
    public override void OnDeactivate()
    {
        _isActive = false;

        // Deactivate current child if it's a LayoutNode
        if (_currentChild is LayoutNode childNode)
        {
            childNode.OnDeactivate();
        }

        // Dispose subscription to pause updates
        _subscription?.Dispose();
        _subscription = null;

        base.OnDeactivate();
    }

    /// <inheritdoc />
    public override void Dispose()
    {
        _subscription?.Dispose();
        _childInvalidationSubscription?.Dispose();
        _invalidated.OnCompleted();
        _invalidated.Dispose();
        _currentChild.Dispose();
        base.Dispose();
    }
}

/// <summary>
/// A layout node that updates based on an observable of values, transformed to layout nodes.
/// </summary>
public sealed class ReactiveLayoutNode<T> : LayoutNode, IInvalidatingNode
{
    private readonly IObservable<T> _source;
    private readonly Func<T, ILayoutNode> _transform;
    private IDisposable? _subscription;
    private IDisposable? _childInvalidationSubscription;
    private readonly Subject<Unit> _invalidated = new();
    private ILayoutNode _currentChild;
    private bool _isActive = false;

    /// <inheritdoc />
    public IObservable<Unit> Invalidated => _invalidated;

    /// <summary>
    /// Create a reactive layout node from an observable with a transform function.
    /// </summary>
    public ReactiveLayoutNode(IObservable<T> source, Func<T, ILayoutNode> transform)
    {
        _source = source;
        _transform = transform;
        _currentChild = new EmptyNode();

        _subscription = source.Subscribe(
            onNext: value =>
            {
                // Deactivate old child instead of disposing (active/inactive pattern)
                if (_currentChild is LayoutNode oldChild)
                {
                    oldChild.OnDeactivate();
                }
                _currentChild = _transform(value);
                SubscribeToChildInvalidation(_currentChild);

                // Activate the new child if we're currently active
                if (_isActive && _currentChild is LayoutNode newChildNode)
                {
                    newChildNode.OnActivate();
                }

                _invalidated.OnNext(Unit.Default);
            },
            onError: _ => { },
            onCompleted: () => { });
    }

    /// <summary>
    /// Subscribe to a child's invalidation events and propagate them upward.
    /// </summary>
    private void SubscribeToChildInvalidation(ILayoutNode child)
    {
        // Dispose previous child invalidation subscription
        _childInvalidationSubscription?.Dispose();
        _childInvalidationSubscription = null;

        if (child is IInvalidatingNode invalidating)
        {
            _childInvalidationSubscription = invalidating.Invalidated.Subscribe(_ => _invalidated.OnNext(Unit.Default));
        }
    }

    /// <inheritdoc />
    public override Size Measure(Size available)
    {
        var childSize = _currentChild.Measure(available);

        var width = WidthConstraint.Compute(available.Width, childSize.Width, available.Width);
        var height = HeightConstraint.Compute(available.Height, childSize.Height, available.Height);

        return new Size(width, height);
    }

    /// <inheritdoc />
    public override void Render(IRenderContext context, Rect bounds)
    {
        _currentChild.Render(context, bounds);
    }

    /// <inheritdoc />
    public override void OnActivate()
    {
        _isActive = true;

        // If subscription was disposed during deactivation, recreate it
        if (_subscription == null || _subscription is BooleanDisposable { IsDisposed: true })
        {
            _subscription = _source.Subscribe(
                onNext: value =>
                {
                    // Deactivate old child instead of disposing (active/inactive pattern)
                    if (_currentChild is LayoutNode oldChild)
                    {
                        oldChild.OnDeactivate();
                    }
                    _currentChild = _transform(value);
                    SubscribeToChildInvalidation(_currentChild);

                    // Activate the new child if we're currently active
                    if (_isActive && _currentChild is LayoutNode newChildNode)
                    {
                        newChildNode.OnActivate();
                    }

                    _invalidated.OnNext(Unit.Default);
                },
                onError: _ => { },
                onCompleted: () => { });
        }

        // Re-subscribe to current child's invalidation events (might have been disposed)
        SubscribeToChildInvalidation(_currentChild);

        // Activate current child if it's a LayoutNode
        if (_currentChild is LayoutNode childNode)
        {
            childNode.OnActivate();
        }

        base.OnActivate();
    }

    /// <inheritdoc />
    public override void OnDeactivate()
    {
        _isActive = false;

        // Deactivate current child if it's a LayoutNode
        if (_currentChild is LayoutNode childNode)
        {
            childNode.OnDeactivate();
        }

        // Dispose subscription to pause updates
        _subscription?.Dispose();
        _subscription = null;

        base.OnDeactivate();
    }

    /// <inheritdoc />
    public override void Dispose()
    {
        _subscription?.Dispose();
        _childInvalidationSubscription?.Dispose();
        _invalidated.OnCompleted();
        _invalidated.Dispose();
        _currentChild.Dispose();
        base.Dispose();
    }
}

Released under the Apache 2.0 License.