From f672e15b1db3e6d3359a2bf234f7b1e720f5a90f Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 29 Jun 2026 21:12:11 +0200 Subject: [PATCH 1/6] feat(tui): add DSL node model and layout/bind-mode enums - Add BindMode enum (OneWay/TwoWay) under SquidStd.Tui.Types.Tui - Add StackOrientation enum (Vertical/Horizontal) under SquidStd.Tui.Types.Tui - Add abstract TuiNode base class under SquidStd.Tui.Dsl - Add LabelNode: one-way string property binding - Add TextFieldNode: string property binding with BindMode - Add ButtonNode: caption + ICommand expression binding - Add StackNode: orientation + typed children list - Add TuiNodeTests covering all four node types (4 tests, all green) --- src/SquidStd.Tui/Dsl/ButtonNode.cs | 23 ++++++++ src/SquidStd.Tui/Dsl/LabelNode.cs | 17 ++++++ src/SquidStd.Tui/Dsl/StackNode.cs | 21 ++++++++ src/SquidStd.Tui/Dsl/TextFieldNode.cs | 22 ++++++++ src/SquidStd.Tui/Dsl/TuiNode.cs | 7 +++ src/SquidStd.Tui/Types/Tui/BindMode.cs | 11 ++++ .../Types/Tui/StackOrientation.cs | 11 ++++ tests/SquidStd.Tests/Tui/Dsl/TuiNodeTests.cs | 53 +++++++++++++++++++ 8 files changed, 165 insertions(+) create mode 100644 src/SquidStd.Tui/Dsl/ButtonNode.cs create mode 100644 src/SquidStd.Tui/Dsl/LabelNode.cs create mode 100644 src/SquidStd.Tui/Dsl/StackNode.cs create mode 100644 src/SquidStd.Tui/Dsl/TextFieldNode.cs create mode 100644 src/SquidStd.Tui/Dsl/TuiNode.cs create mode 100644 src/SquidStd.Tui/Types/Tui/BindMode.cs create mode 100644 src/SquidStd.Tui/Types/Tui/StackOrientation.cs create mode 100644 tests/SquidStd.Tests/Tui/Dsl/TuiNodeTests.cs diff --git a/src/SquidStd.Tui/Dsl/ButtonNode.cs b/src/SquidStd.Tui/Dsl/ButtonNode.cs new file mode 100644 index 00000000..0ce44f00 --- /dev/null +++ b/src/SquidStd.Tui/Dsl/ButtonNode.cs @@ -0,0 +1,23 @@ +using System.Linq.Expressions; +using System.Windows.Input; + +namespace SquidStd.Tui.Dsl; + +/// A button whose activation runs a command resolved from the ViewModel. +public sealed class ButtonNode : TuiNode +{ + /// The button caption. + public string Caption { get; } + + /// The source property providing the command to run. + public Expression> Command { get; } + + public ButtonNode(string caption, Expression> command) + { + ArgumentException.ThrowIfNullOrEmpty(caption); + ArgumentNullException.ThrowIfNull(command); + + Caption = caption; + Command = command; + } +} diff --git a/src/SquidStd.Tui/Dsl/LabelNode.cs b/src/SquidStd.Tui/Dsl/LabelNode.cs new file mode 100644 index 00000000..b3686a1a --- /dev/null +++ b/src/SquidStd.Tui/Dsl/LabelNode.cs @@ -0,0 +1,17 @@ +using System.Linq.Expressions; + +namespace SquidStd.Tui.Dsl; + +/// A label whose text is one-way bound to a string property. +public sealed class LabelNode : TuiNode +{ + /// The source string property bound to the label text. + public Expression> Text { get; } + + public LabelNode(Expression> text) + { + ArgumentNullException.ThrowIfNull(text); + + Text = text; + } +} diff --git a/src/SquidStd.Tui/Dsl/StackNode.cs b/src/SquidStd.Tui/Dsl/StackNode.cs new file mode 100644 index 00000000..336118ab --- /dev/null +++ b/src/SquidStd.Tui/Dsl/StackNode.cs @@ -0,0 +1,21 @@ +using SquidStd.Tui.Types.Tui; + +namespace SquidStd.Tui.Dsl; + +/// A container that arranges its children vertically or horizontally. +public sealed class StackNode : TuiNode +{ + /// The direction children are arranged. + public StackOrientation Orientation { get; } + + /// The child nodes, in arrangement order. + public IReadOnlyList> Children { get; } + + public StackNode(StackOrientation orientation, IReadOnlyList> children) + { + ArgumentNullException.ThrowIfNull(children); + + Orientation = orientation; + Children = children; + } +} diff --git a/src/SquidStd.Tui/Dsl/TextFieldNode.cs b/src/SquidStd.Tui/Dsl/TextFieldNode.cs new file mode 100644 index 00000000..b37dd35b --- /dev/null +++ b/src/SquidStd.Tui/Dsl/TextFieldNode.cs @@ -0,0 +1,22 @@ +using System.Linq.Expressions; +using SquidStd.Tui.Types.Tui; + +namespace SquidStd.Tui.Dsl; + +/// An editable text field bound to a string property (two-way by default). +public sealed class TextFieldNode : TuiNode +{ + /// The source string property bound to the field text. + public Expression> Text { get; } + + /// The binding direction. + public BindMode Mode { get; } + + public TextFieldNode(Expression> text, BindMode mode) + { + ArgumentNullException.ThrowIfNull(text); + + Text = text; + Mode = mode; + } +} diff --git a/src/SquidStd.Tui/Dsl/TuiNode.cs b/src/SquidStd.Tui/Dsl/TuiNode.cs new file mode 100644 index 00000000..30a46f5c --- /dev/null +++ b/src/SquidStd.Tui/Dsl/TuiNode.cs @@ -0,0 +1,7 @@ +namespace SquidStd.Tui.Dsl; + +/// Immutable description of a UI element bound to . +/// The ViewModel the node's bindings target. +public abstract class TuiNode +{ +} diff --git a/src/SquidStd.Tui/Types/Tui/BindMode.cs b/src/SquidStd.Tui/Types/Tui/BindMode.cs new file mode 100644 index 00000000..2f76fe67 --- /dev/null +++ b/src/SquidStd.Tui/Types/Tui/BindMode.cs @@ -0,0 +1,11 @@ +namespace SquidStd.Tui.Types.Tui; + +/// Binding direction for a DSL node that supports both. +public enum BindMode +{ + /// Source-to-target only. + OneWay, + + /// Source-to-target and target-to-source. + TwoWay +} diff --git a/src/SquidStd.Tui/Types/Tui/StackOrientation.cs b/src/SquidStd.Tui/Types/Tui/StackOrientation.cs new file mode 100644 index 00000000..e0a0467d --- /dev/null +++ b/src/SquidStd.Tui/Types/Tui/StackOrientation.cs @@ -0,0 +1,11 @@ +namespace SquidStd.Tui.Types.Tui; + +/// Direction a Stack arranges its children. +public enum StackOrientation +{ + /// Children stacked top-to-bottom. + Vertical, + + /// Children placed left-to-right. + Horizontal +} diff --git a/tests/SquidStd.Tests/Tui/Dsl/TuiNodeTests.cs b/tests/SquidStd.Tests/Tui/Dsl/TuiNodeTests.cs new file mode 100644 index 00000000..72ea2eba --- /dev/null +++ b/tests/SquidStd.Tests/Tui/Dsl/TuiNodeTests.cs @@ -0,0 +1,53 @@ +using System.Windows.Input; +using CommunityToolkit.Mvvm.Input; +using SquidStd.Tui.Dsl; +using SquidStd.Tui.Internal; +using SquidStd.Tui.Types.Tui; + +namespace SquidStd.Tests.Tui.Dsl; + +public class TuiNodeTests +{ + private sealed class SampleViewModel + { + public string Title { get; set; } = string.Empty; + public ICommand Save { get; } = new RelayCommand(() => { }); + } + + [Fact] + public void LabelNode_CapturesTextExpression() + { + var node = new LabelNode(x => x.Title); + + Assert.Equal(nameof(SampleViewModel.Title), PropertyPath.NameOf(node.Text)); + } + + [Fact] + public void TextFieldNode_DefaultsToTwoWay() + { + var node = new TextFieldNode(x => x.Title, BindMode.TwoWay); + + Assert.Equal(BindMode.TwoWay, node.Mode); + Assert.Equal(nameof(SampleViewModel.Title), PropertyPath.NameOf(node.Text)); + } + + [Fact] + public void ButtonNode_CapturesCaptionAndCommand() + { + var node = new ButtonNode("Save", x => x.Save); + + Assert.Equal("Save", node.Caption); + Assert.NotNull(node.Command); + } + + [Fact] + public void StackNode_HoldsOrientationAndChildren() + { + var child = new LabelNode(x => x.Title); + var node = new StackNode(StackOrientation.Vertical, new TuiNode[] { child }); + + Assert.Equal(StackOrientation.Vertical, node.Orientation); + Assert.Single(node.Children); + Assert.Same(child, node.Children[0]); + } +} From be4b12b158070e332e69341812bceade3f8bbfb6 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 29 Jun 2026 21:17:00 +0200 Subject: [PATCH 2/6] feat(tui): add UiFactory for building DSL nodes from typed lambdas --- src/SquidStd.Tui/Dsl/UiFactory.cs | 43 +++++++++++++++ .../SquidStd.Tests/Tui/Dsl/UiFactoryTests.cs | 54 +++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/SquidStd.Tui/Dsl/UiFactory.cs create mode 100644 tests/SquidStd.Tests/Tui/Dsl/UiFactoryTests.cs diff --git a/src/SquidStd.Tui/Dsl/UiFactory.cs b/src/SquidStd.Tui/Dsl/UiFactory.cs new file mode 100644 index 00000000..e6ad22ea --- /dev/null +++ b/src/SquidStd.Tui/Dsl/UiFactory.cs @@ -0,0 +1,43 @@ +using System.Linq.Expressions; +using System.Windows.Input; +using SquidStd.Tui.Types.Tui; + +namespace SquidStd.Tui.Dsl; + +/// +/// Builds DSL nodes from typed lambdas. The ViewModel type is fixed by the factory instance so the +/// member-expression lambdas (x => x.Property) infer without annotations. +/// +/// The ViewModel the built nodes bind to. +public sealed class UiFactory +{ + /// A label one-way bound to a string property. + public LabelNode Label(Expression> text) + { + return new LabelNode(text); + } + + /// A text field bound to a string property (two-way by default). + public TextFieldNode TextField(Expression> text, BindMode mode = BindMode.TwoWay) + { + return new TextFieldNode(text, mode); + } + + /// A button that runs the command resolved from the ViewModel. + public ButtonNode Button(string caption, Expression> command) + { + return new ButtonNode(caption, command); + } + + /// A vertical stack of child nodes. + public StackNode VStack(params TuiNode[] children) + { + return new StackNode(StackOrientation.Vertical, children); + } + + /// A horizontal stack of child nodes. + public StackNode HStack(params TuiNode[] children) + { + return new StackNode(StackOrientation.Horizontal, children); + } +} diff --git a/tests/SquidStd.Tests/Tui/Dsl/UiFactoryTests.cs b/tests/SquidStd.Tests/Tui/Dsl/UiFactoryTests.cs new file mode 100644 index 00000000..7d897068 --- /dev/null +++ b/tests/SquidStd.Tests/Tui/Dsl/UiFactoryTests.cs @@ -0,0 +1,54 @@ +using System.Windows.Input; +using CommunityToolkit.Mvvm.Input; +using SquidStd.Tui.Dsl; +using SquidStd.Tui.Internal; +using SquidStd.Tui.Types.Tui; + +namespace SquidStd.Tests.Tui.Dsl; + +public class UiFactoryTests +{ + private sealed class SampleViewModel + { + public string Title { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public ICommand Save { get; } = new RelayCommand(() => { }); + } + + private readonly UiFactory _ui = new(); + + [Fact] + public void Label_ProducesLabelNode() + { + var node = _ui.Label(x => x.Title); + + Assert.Equal(nameof(SampleViewModel.Title), PropertyPath.NameOf(node.Text)); + } + + [Fact] + public void TextField_DefaultsToTwoWay_AndOneWayIsExplicit() + { + Assert.Equal(BindMode.TwoWay, _ui.TextField(x => x.Name).Mode); + Assert.Equal(BindMode.OneWay, _ui.TextField(x => x.Name, BindMode.OneWay).Mode); + } + + [Fact] + public void Button_ProducesButtonNode() + { + var node = _ui.Button("Save", x => x.Save); + + Assert.Equal("Save", node.Caption); + } + + [Fact] + public void VStack_And_HStack_SetOrientationAndChildren() + { + var v = _ui.VStack(_ui.Label(x => x.Title), _ui.Label(x => x.Name)); + var h = _ui.HStack(_ui.Label(x => x.Title)); + + Assert.Equal(StackOrientation.Vertical, v.Orientation); + Assert.Equal(2, v.Children.Count); + Assert.Equal(StackOrientation.Horizontal, h.Orientation); + Assert.Single(h.Children); + } +} From 1e05947fbc84213437b3a1a742be5b7795b954b8 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 29 Jun 2026 21:19:09 +0200 Subject: [PATCH 3/6] refactor(tui): add OnInitialize hook to TuiView for declarative composition --- src/SquidStd.Tui/TuiView.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/SquidStd.Tui/TuiView.cs b/src/SquidStd.Tui/TuiView.cs index 02926395..e7b1a306 100644 --- a/src/SquidStd.Tui/TuiView.cs +++ b/src/SquidStd.Tui/TuiView.cs @@ -30,9 +30,18 @@ void ITuiView.Bind(object viewModel) } void ITuiView.Initialize() + { + OnInitialize(_binder); + } + + /// + /// Builds the view. The default runs then ; the + /// declarative base overrides this to materialise a node tree instead. + /// + protected virtual void OnInitialize(ViewBinder binder) { BuildLayout(); - Bind(_binder); + Bind(binder); } /// Creates the Terminal.Gui widgets for this view. From 01da1b4516fdd3eb23ea1817fcddbb5d4e0f6c65 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 29 Jun 2026 21:28:08 +0200 Subject: [PATCH 4/6] feat(tui): add materializer turning DSL nodes into bound Terminal.Gui views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add TuiNodeMaterializer in SquidStd.Tui.Dsl; walks TuiNode trees and produces View graphs - wire LabelNode via binder.OneWayText, TextFieldNode via TwoWay/OneWay binder overloads, ButtonNode via binder.Command - StackNode (Vertical/Horizontal) produces a container View using Pos.Bottom/Pos.Right and Dim.Fill for layout - TViewModel constrained to INotifyPropertyChanged so existing binder overloads resolve without new binding logic - add TuiNodeMaterializerTests covering VStack+Label one-way propagation and TextField two-way VM→widget; both pass headless - outer test class marked partial because nested ObservableObject partial class requires it (CommunityToolkit source generator) --- src/SquidStd.Tui/Dsl/TuiNodeMaterializer.cs | 92 +++++++++++++++++++ .../Tui/Dsl/TuiNodeMaterializerTests.cs | 49 ++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/SquidStd.Tui/Dsl/TuiNodeMaterializer.cs create mode 100644 tests/SquidStd.Tests/Tui/Dsl/TuiNodeMaterializerTests.cs diff --git a/src/SquidStd.Tui/Dsl/TuiNodeMaterializer.cs b/src/SquidStd.Tui/Dsl/TuiNodeMaterializer.cs new file mode 100644 index 00000000..b9abeba6 --- /dev/null +++ b/src/SquidStd.Tui/Dsl/TuiNodeMaterializer.cs @@ -0,0 +1,92 @@ +using System.ComponentModel; +using SquidStd.Tui.Binding; +using SquidStd.Tui.Types.Tui; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace SquidStd.Tui.Dsl; + +/// Turns a tree into a Terminal.Gui view graph, wiring each +/// node's binding through the supplied . +public sealed class TuiNodeMaterializer +{ + /// Materialises against , registering + /// bindings on , and returns the produced view. + public View Materialize(TuiNode node, TViewModel viewModel, ViewBinder binder) + where TViewModel : INotifyPropertyChanged + { + ArgumentNullException.ThrowIfNull(node); + ArgumentNullException.ThrowIfNull(viewModel); + ArgumentNullException.ThrowIfNull(binder); + + switch (node) + { + case LabelNode labelNode: + var label = new Label(); + binder.OneWayText(viewModel, labelNode.Text, label); + + return label; + + case TextFieldNode fieldNode: + var field = new TextField(); + + if (fieldNode.Mode == BindMode.TwoWay) + { + binder.TwoWay(viewModel, fieldNode.Text, field); + } + else + { + binder.OneWay(viewModel, fieldNode.Text, field, f => f.Text); + } + + return field; + + case ButtonNode buttonNode: + var button = new Button { Text = buttonNode.Caption }; + binder.Command(button, buttonNode.Command.Compile()(viewModel)); + + return button; + + case StackNode stackNode: + return MaterializeStack(stackNode, viewModel, binder); + + default: + throw new NotSupportedException($"Unsupported node type '{node.GetType().Name}'."); + } + } + + private View MaterializeStack(StackNode stack, TViewModel viewModel, ViewBinder binder) + where TViewModel : INotifyPropertyChanged + { + var container = new View + { + Width = Dim.Fill(), + Height = Dim.Fill() + }; + + View? previous = null; + + foreach (var childNode in stack.Children) + { + var child = Materialize(childNode, viewModel, binder); + + if (stack.Orientation == StackOrientation.Vertical) + { + child.X = 0; + child.Y = previous is null ? 0 : Pos.Bottom(previous); + child.Width = Dim.Fill(); + } + else + { + child.X = previous is null ? 0 : Pos.Right(previous); + child.Y = 0; + child.Height = Dim.Fill(); + } + + container.Add(child); + previous = child; + } + + return container; + } +} diff --git a/tests/SquidStd.Tests/Tui/Dsl/TuiNodeMaterializerTests.cs b/tests/SquidStd.Tests/Tui/Dsl/TuiNodeMaterializerTests.cs new file mode 100644 index 00000000..72f367cc --- /dev/null +++ b/tests/SquidStd.Tests/Tui/Dsl/TuiNodeMaterializerTests.cs @@ -0,0 +1,49 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using SquidStd.Tui.Binding; +using SquidStd.Tui.Dsl; +using Terminal.Gui.Views; + +namespace SquidStd.Tests.Tui.Dsl; + +public partial class TuiNodeMaterializerTests +{ + private sealed partial class SampleViewModel : ObservableObject + { + [ObservableProperty] + private string _title = "start"; + + [ObservableProperty] + private string _name = string.Empty; + } + + [Fact] + public void Materialize_VStack_ProducesChildrenAndAppliesOneWay() + { + var ui = new UiFactory(); + var vm = new SampleViewModel(); + var binder = new ViewBinder(); + var materializer = new TuiNodeMaterializer(); + + var root = materializer.Materialize(ui.VStack(ui.Label(x => x.Title)), vm, binder); + + var label = Assert.IsType