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
- Subscribe to the source observable
- When a new value is emitted, dispose the old child node
- Replace with the new layout node from the transform
- 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 widthPerformance Considerations
WARNING
Each emission creates a new layout node and disposes the old one. For frequently updating content, consider:
- Stateful nodes outside reactive - Keep
TextInputNode,StreamingTextNodeetc. as properties, not inside reactive wrappers - Debounce high-frequency updates - Use
.Throttle()or.Sample()operators - 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 reactiveAvoid: 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();
}
}