Skip to content

feat(tui): add fluent declarative UI DSL for SquidStd.Tui#15

Merged
tgiachi merged 6 commits into
developfrom
feature/tui-dsl
Jun 30, 2026
Merged

feat(tui): add fluent declarative UI DSL for SquidStd.Tui#15
tgiachi merged 6 commits into
developfrom
feature/tui-dsl

Conversation

@tgiachi

@tgiachi tgiachi commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Summary

A type-safe fluent C# DSL (markup-in-code) for building declarative UIs in SquidStd.Tui, where one node = widget + binding. Reduces the imperative BuildLayout + Bind boilerplate while keeping full type-safety (typed lambdas, no magic strings) and KISS.

Architecture

  • Pure node tree (Dsl/TuiNode<TVm> + LabelNode/TextFieldNode/ButtonNode/StackNode) — immutable, Terminal.Gui-free, fully unit-tested as plain data.
  • UiFactory<TVm> — builds nodes from typed lambdas; fixes TVm per instance so Ui.Label(x => x.Value) infers without annotations.
  • TuiNodeMaterializer — the only Terminal.Gui-coupled piece; walks the tree and wires bindings exclusively through the existing ViewBinder (no new binding logic, no extra subscriptions).
  • TuiComposedView<TVm> — declarative view base: implement Compose() and return a node tree. Integrated via a new non-breaking OnInitialize(ViewBinder) hook on TuiView; imperative TuiView<TVm> is unchanged.
  • Direction is inferred from widget type (Label one-way, TextField two-way, Button command); BindMode.OneWay overrides a TextField. Enums under Types.Tui.

Usage

public sealed class CounterView : TuiComposedView<CounterViewModel>
{
    protected override TuiNode<CounterViewModel> Compose() =>
        Ui.VStack(
            Ui.Label(x => x.Title),
            Ui.Label(x => x.Value),
            Ui.Button("+1", x => x.IncrementCommand));
}

Notes

  • The node layer has zero Terminal.Gui references — coupling is isolated to the materializer + composed-view base.
  • No new leak surface: the materializer registers all bindings on the view-owned ViewBinder, disposed with the view (identical lifecycle to the imperative path).
  • Sample samples/SquidStd.Samples.Tui ported to the DSL; README documents both styles.

Test plan

  • Full solution builds (0 errors)
  • Full suite green — 916 tests, 0 failures (906 prior + 10 new DSL tests)
  • Pure node layer + UiFactory unit-tested without Terminal.Gui
  • Materializer headless smoke (VM→widget propagation for Label one-way and TextField two-way)
  • Sample builds against Terminal.Gui 2.4.16
  • Manual dotnet run of the DSL Counter sample in a real terminal (interactive render)

tgiachi added 6 commits June 29, 2026 21:12
- Add BindMode enum (OneWay/TwoWay) under SquidStd.Tui.Types.Tui
- Add StackOrientation enum (Vertical/Horizontal) under SquidStd.Tui.Types.Tui
- Add abstract TuiNode<TViewModel> base class under SquidStd.Tui.Dsl
- Add LabelNode<TViewModel>: one-way string property binding
- Add TextFieldNode<TViewModel>: string property binding with BindMode
- Add ButtonNode<TViewModel>: caption + ICommand expression binding
- Add StackNode<TViewModel>: orientation + typed children list
- Add TuiNodeTests covering all four node types (4 tests, all green)
… views

- add TuiNodeMaterializer in SquidStd.Tui.Dsl; walks TuiNode<TViewModel> 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)
Comment on lines +69 to +88
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;
}
private sealed partial class SampleViewModel : ObservableObject
{
[ObservableProperty]
private string _title = "start";
private string _title = "start";

[ObservableProperty]
private string _name = string.Empty;
@tgiachi
tgiachi merged commit ec5c6c5 into develop Jun 30, 2026
3 checks passed
@tgiachi
tgiachi deleted the feature/tui-dsl branch June 30, 2026 06:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants