Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions samples/SquidStd.Samples.Tui/CounterComposedView.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using SquidStd.Tui;
using SquidStd.Tui.Dsl;

namespace SquidStd.Samples.Tui;

public sealed class CounterComposedView : TuiComposedView<CounterViewModel>
{
protected override TuiNode<CounterViewModel> Compose()
{
return Ui.VStack(
Ui.Label(x => x.Title),
Ui.Label(x => x.Value),
Ui.Button("+1", x => x.IncrementCommand));
}
}
25 changes: 0 additions & 25 deletions samples/SquidStd.Samples.Tui/CounterView.cs

This file was deleted.

2 changes: 1 addition & 1 deletion samples/SquidStd.Samples.Tui/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@

var container = new Container();
container.RegisterTui();
container.RegisterView<CounterView, CounterViewModel>();
container.RegisterView<CounterComposedView, CounterViewModel>();

await container.Resolve<TuiApplicationHost>().RunAsync<CounterViewModel>();
23 changes: 23 additions & 0 deletions src/SquidStd.Tui/Dsl/ButtonNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Linq.Expressions;
using System.Windows.Input;

namespace SquidStd.Tui.Dsl;

/// <summary>A button whose activation runs a command resolved from the ViewModel.</summary>
public sealed class ButtonNode<TViewModel> : TuiNode<TViewModel>
{
/// <summary>The button caption.</summary>
public string Caption { get; }

/// <summary>The source property providing the command to run.</summary>
public Expression<Func<TViewModel, ICommand>> Command { get; }

public ButtonNode(string caption, Expression<Func<TViewModel, ICommand>> command)
{
ArgumentException.ThrowIfNullOrEmpty(caption);
ArgumentNullException.ThrowIfNull(command);

Caption = caption;
Command = command;
}
}
17 changes: 17 additions & 0 deletions src/SquidStd.Tui/Dsl/LabelNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Linq.Expressions;

namespace SquidStd.Tui.Dsl;

/// <summary>A label whose text is one-way bound to a string property.</summary>
public sealed class LabelNode<TViewModel> : TuiNode<TViewModel>
{
/// <summary>The source string property bound to the label text.</summary>
public Expression<Func<TViewModel, string>> Text { get; }

public LabelNode(Expression<Func<TViewModel, string>> text)
{
ArgumentNullException.ThrowIfNull(text);

Text = text;
}
}
21 changes: 21 additions & 0 deletions src/SquidStd.Tui/Dsl/StackNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using SquidStd.Tui.Types.Tui;

namespace SquidStd.Tui.Dsl;

/// <summary>A container that arranges its children vertically or horizontally.</summary>
public sealed class StackNode<TViewModel> : TuiNode<TViewModel>
{
/// <summary>The direction children are arranged.</summary>
public StackOrientation Orientation { get; }

/// <summary>The child nodes, in arrangement order.</summary>
public IReadOnlyList<TuiNode<TViewModel>> Children { get; }

public StackNode(StackOrientation orientation, IReadOnlyList<TuiNode<TViewModel>> children)
{
ArgumentNullException.ThrowIfNull(children);

Orientation = orientation;
Children = children;
}
}
22 changes: 22 additions & 0 deletions src/SquidStd.Tui/Dsl/TextFieldNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Linq.Expressions;
using SquidStd.Tui.Types.Tui;

namespace SquidStd.Tui.Dsl;

/// <summary>An editable text field bound to a string property (two-way by default).</summary>
public sealed class TextFieldNode<TViewModel> : TuiNode<TViewModel>
{
/// <summary>The source string property bound to the field text.</summary>
public Expression<Func<TViewModel, string>> Text { get; }

/// <summary>The binding direction.</summary>
public BindMode Mode { get; }

public TextFieldNode(Expression<Func<TViewModel, string>> text, BindMode mode)
{
ArgumentNullException.ThrowIfNull(text);

Text = text;
Mode = mode;
}
}
7 changes: 7 additions & 0 deletions src/SquidStd.Tui/Dsl/TuiNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SquidStd.Tui.Dsl;

/// <summary>Immutable description of a UI element bound to <typeparamref name="TViewModel" />.</summary>
/// <typeparam name="TViewModel">The ViewModel the node's bindings target.</typeparam>
public abstract class TuiNode<TViewModel>
{
}
92 changes: 92 additions & 0 deletions src/SquidStd.Tui/Dsl/TuiNodeMaterializer.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Turns a <see cref="TuiNode{TViewModel}" /> tree into a Terminal.Gui view graph, wiring each
/// node's binding through the supplied <see cref="ViewBinder" />.</summary>
public sealed class TuiNodeMaterializer
{
/// <summary>Materialises <paramref name="node" /> against <paramref name="viewModel" />, registering
/// bindings on <paramref name="binder" />, and returns the produced view.</summary>
public View Materialize<TViewModel>(TuiNode<TViewModel> node, TViewModel viewModel, ViewBinder binder)
where TViewModel : INotifyPropertyChanged
{
ArgumentNullException.ThrowIfNull(node);
ArgumentNullException.ThrowIfNull(viewModel);
ArgumentNullException.ThrowIfNull(binder);

switch (node)
{
case LabelNode<TViewModel> labelNode:
var label = new Label();
binder.OneWayText(viewModel, labelNode.Text, label);

return label;

case TextFieldNode<TViewModel> 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<TViewModel> buttonNode:
var button = new Button { Text = buttonNode.Caption };
binder.Command(button, buttonNode.Command.Compile()(viewModel));

return button;

case StackNode<TViewModel> stackNode:
return MaterializeStack(stackNode, viewModel, binder);

default:
throw new NotSupportedException($"Unsupported node type '{node.GetType().Name}'.");
}
}

private View MaterializeStack<TViewModel>(StackNode<TViewModel> 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;
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Select Note

This foreach loop immediately
maps its iteration variable to another variable
- consider mapping the sequence explicitly using '.Select(...)'.
Comment on lines +69 to +88

return container;
}
}
43 changes: 43 additions & 0 deletions src/SquidStd.Tui/Dsl/UiFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Linq.Expressions;
using System.Windows.Input;
using SquidStd.Tui.Types.Tui;

namespace SquidStd.Tui.Dsl;

/// <summary>
/// Builds DSL nodes from typed lambdas. The ViewModel type is fixed by the factory instance so the
/// member-expression lambdas (<c>x =&gt; x.Property</c>) infer without annotations.
/// </summary>
/// <typeparam name="TViewModel">The ViewModel the built nodes bind to.</typeparam>
public sealed class UiFactory<TViewModel>
{
/// <summary>A label one-way bound to a string property.</summary>
public LabelNode<TViewModel> Label(Expression<Func<TViewModel, string>> text)
{
return new LabelNode<TViewModel>(text);
}

/// <summary>A text field bound to a string property (two-way by default).</summary>
public TextFieldNode<TViewModel> TextField(Expression<Func<TViewModel, string>> text, BindMode mode = BindMode.TwoWay)
{
return new TextFieldNode<TViewModel>(text, mode);
}

/// <summary>A button that runs the command resolved from the ViewModel.</summary>
public ButtonNode<TViewModel> Button(string caption, Expression<Func<TViewModel, ICommand>> command)
{
return new ButtonNode<TViewModel>(caption, command);
}

/// <summary>A vertical stack of child nodes.</summary>
public StackNode<TViewModel> VStack(params TuiNode<TViewModel>[] children)
{
return new StackNode<TViewModel>(StackOrientation.Vertical, children);
}

/// <summary>A horizontal stack of child nodes.</summary>
public StackNode<TViewModel> HStack(params TuiNode<TViewModel>[] children)
{
return new StackNode<TViewModel>(StackOrientation.Horizontal, children);
}
}
20 changes: 20 additions & 0 deletions src/SquidStd.Tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ container.RegisterView<CounterView, CounterViewModel>();
await container.Resolve<TuiApplicationHost>().RunAsync<CounterViewModel>();
```

## Declarative UI (DSL)

Instead of imperative `BuildLayout` + `Bind`, derive from `TuiComposedView<TViewModel>` and return a node
tree from `Compose()`. One node = widget + binding; direction is inferred (Label one-way, TextField
two-way, Button command) and bindings are typed lambdas — no magic strings.

```csharp
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));
}
```

`Ui.VStack`/`Ui.HStack` arrange children automatically; `Ui.TextField(x => x.Name)` is two-way by default
(pass `BindMode.OneWay` to override).

## Key types

| Type | Purpose |
Expand Down
36 changes: 36 additions & 0 deletions src/SquidStd.Tui/TuiComposedView.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using SquidStd.Tui.Binding;
using SquidStd.Tui.Dsl;

namespace SquidStd.Tui;

/// <summary>
/// Declarative view base: implement <see cref="Compose" /> to return a node tree built with <see cref="Ui" />.
/// The tree is materialised into widgets and bindings during initialisation. For imperative views use
/// <see cref="TuiView{TViewModel}" /> directly.
/// </summary>
/// <typeparam name="TViewModel">The ViewModel type for this view.</typeparam>
public abstract class TuiComposedView<TViewModel> : TuiView<TViewModel>
where TViewModel : TuiViewModel
{
private readonly TuiNodeMaterializer _materializer = new();

/// <summary>Factory for building the node tree from typed lambdas.</summary>
protected UiFactory<TViewModel> Ui { get; } = new();

/// <summary>Returns the declarative node tree for this view.</summary>
protected abstract TuiNode<TViewModel> Compose();

protected sealed override void BuildLayout()
{
}

protected sealed override void Bind(ViewBinder binder)
{
}

protected override void OnInitialize(ViewBinder binder)
{
var root = _materializer.Materialize(Compose(), ViewModel, binder);
Add(root);
}
}
11 changes: 10 additions & 1 deletion src/SquidStd.Tui/TuiView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,18 @@ void ITuiView.Bind(object viewModel)
}

void ITuiView.Initialize()
{
OnInitialize(_binder);
}

/// <summary>
/// Builds the view. The default runs <see cref="BuildLayout" /> then <see cref="Bind" />; the
/// declarative base overrides this to materialise a node tree instead.
/// </summary>
protected virtual void OnInitialize(ViewBinder binder)
{
BuildLayout();
Bind(_binder);
Bind(binder);
}

/// <summary>Creates the Terminal.Gui widgets for this view.</summary>
Expand Down
11 changes: 11 additions & 0 deletions src/SquidStd.Tui/Types/Tui/BindMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace SquidStd.Tui.Types.Tui;

/// <summary>Binding direction for a DSL node that supports both.</summary>
public enum BindMode
{
/// <summary>Source-to-target only.</summary>
OneWay,

/// <summary>Source-to-target and target-to-source.</summary>
TwoWay
}
11 changes: 11 additions & 0 deletions src/SquidStd.Tui/Types/Tui/StackOrientation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace SquidStd.Tui.Types.Tui;

/// <summary>Direction a <c>Stack</c> arranges its children.</summary>
public enum StackOrientation
{
/// <summary>Children stacked top-to-bottom.</summary>
Vertical,

/// <summary>Children placed left-to-right.</summary>
Horizontal
}
Loading
Loading