| name | widget-creator |
| description | Step-by-step guide for creating new widgets in the Hex1b TUI library. Use when implementing new widgets from scratch, including widget records, nodes, extension methods, theming, reconciliation, and tests. |
Widget Creator Skill
This skill provides a comprehensive step-by-step guide for AI coding agents to create new widgets in the Hex1b TUI library. Widgets are the building blocks of Hex1b applications, following a declarative pattern inspired by React.
Overview
Creating a widget in Hex1b involves several coordinated files:
| File | Purpose |
|---|
src/Hex1b/Widgets/{Name}Widget.cs | Immutable widget record (describes what to render) |
src/Hex1b/Nodes/{Name}Node.cs | Mutable node class (manages state, renders) |
src/Hex1b/{Name}Extensions.cs | Fluent API extension methods |
src/Hex1b/Theming/{Name}Theme.cs | Theme elements (colors, characters) |
tests/Hex1b.Tests/{Name}NodeTests.cs | Unit tests |
Step-by-Step Process
Step 1: Define the Widget Record
Widgets are immutable record types that describe the desired UI. They capture configuration and event handlers but contain no rendering logic.
Location: src/Hex1b/Widgets/{Name}Widget.cs
Key principles:
- Use primary constructor parameters for required data
- Use
internal properties with init for optional configuration
- Event handlers use
Func<TEventArgs, Task>? pattern
- Provide sync and async overloads for event handlers using
this with { } pattern
- Implement
Reconcile() to create/update the corresponding node
- Implement
GetExpectedNodeType() to return the node type
Template:
using Hex1b.Events;
using Hex1b.Nodes;
namespace Hex1b.Widgets;
public sealed record MyWidget(string PrimaryProperty) : Hex1bWidget
{
public static readonly ActionId Activate = new($"{nameof(MyWidget)}.{nameof(Activate)}");
internal bool SomeOption { get; init; }
internal Func<MyEventArgs, Task>? ActionHandler { get; init; }
public MyWidget OnAction(Action<MyEventArgs> handler)
=> this with { ActionHandler = args => { handler(args); return Task.CompletedTask; } };
public MyWidget OnAction(Func<MyEventArgs, Task> handler)
=> this with { ActionHandler = handler };
internal override Hex1bNode Reconcile(Hex1bNode? existingNode, ReconcileContext context)
{
var node = existingNode as MyNode ?? new MyNode();
if (node.PrimaryProperty != PrimaryProperty || node.SomeOption != SomeOption)
{
node.MarkDirty();
}
node.PrimaryProperty = PrimaryProperty;
node.SomeOption = SomeOption;
node.SourceWidget = this;
if (ActionHandler != null)
{
node.ActionCallback = async ctx =>
{
var args = new MyEventArgs(this, node, ctx);
await ActionHandler(args);
};
}
else
{
node.ActionCallback = null;
}
return node;
}
internal override Type GetExpectedNodeType() => typeof(MyNode);
}
Step 2: Create Event Args (if needed)
If your widget has event handlers, create a typed event args class.
Location: src/Hex1b/Events/{Name}EventArgs.cs
Template:
using Hex1b.Input;
using Hex1b.Widgets;
namespace Hex1b.Events;
public sealed class MyEventArgs
{
public MyWidget Widget { get; }
public MyNode Node { get; }
public InputBindingActionContext Context { get; }
internal MyEventArgs(MyWidget widget, MyNode node, InputBindingActionContext context)
{
Widget = widget;
Node = node;
Context = context;
}
}
Step 3: Create the Node Class
Nodes are mutable classes that hold render state and perform actual rendering. They receive updates during reconciliation and implement layout and rendering.
Location: src/Hex1b/Nodes/{Name}Node.cs
Key principles:
- Properties are mutable (set from widget during reconciliation)
- Track state that must survive re-renders (focus, cursor position, etc.)
- Call
MarkDirty() when internal state changes
- Implement
Measure() to calculate size
- Implement
Render() to draw to terminal
- Override
IsFocusable if the widget can receive focus
- Override
ConfigureDefaultBindings() for keyboard/mouse handling
Lift state up when mutable state would benefit composites. If your widget owns non-trivial mutable state that a parent might want to read, write, or coordinate with — buffer text, selection index, navigation history, checked value — promote that state to a public class and have your widget implement IStatefulWidget<TSelf, TState> so callers can pass an external instance via .State(...). See TextBoxWidget / TextBoxState, EditorWidget / EditorState, NavigatorWidget / NavigatorState, CheckboxWidget / CheckboxState for the canonical pattern. The composition guide has a worked example. Pure visual state (focus, hover, animation phase) stays internal to the node — only mutable user-facing data needs lifting.
Template:
using Hex1b.Input;
using Hex1b.Layout;
using Hex1b.Theming;
using Hex1b.Widgets;
namespace Hex1b;
public sealed class MyNode : Hex1bNode
{
public string PrimaryProperty { get; set; } = "";
public bool SomeOption { get; set; }
public MyWidget? SourceWidget { get; set; }
public Func<InputBindingActionContext, Task>? ActionCallback { get; set; }
private bool _isFocused;
public override bool IsFocused
{
get => _isFocused;
set
{
if (_isFocused != value)
{
_isFocused = value;
MarkDirty();
}
}
}
public override bool IsFocusable => true;
public override void ConfigureDefaultBindings(InputBindingsBuilder bindings)
{
if (ActionCallback != null)
{
bindings.Key(Hex1bKey.Enter).Triggers(MyWidget.Activate, ActionCallback, "Activate");
}
}
public override Size Measure(Constraints constraints)
{
var width = PrimaryProperty.Length;
var height = 1;
return constraints.Constrain(new Size(width, height));
}
public override void Render(Hex1bRenderContext context)
{
var theme = context.Theme;
var fg = theme.Get(MyTheme.ForegroundColor);
var bg = theme.Get(MyTheme.BackgroundColor);
var output = $"{fg.ToForegroundAnsi()}{bg.ToBackgroundAnsi()}{PrimaryProperty}{theme.GetResetToGlobalCodes()}";
if (context.CurrentLayoutProvider != null)
{
context.WriteClipped(Bounds.X, Bounds.Y, output);
}
else
{
context.Write(output);
}
}
}
Step 4: Create Theme Elements
Theme elements allow users to customize the widget's appearance.
Location: src/Hex1b/Theming/{Name}Theme.cs
Template:
namespace Hex1b.Theming;
public static class MyTheme
{
public static readonly Hex1bThemeElement<Hex1bColor> ForegroundColor =
new($"{nameof(MyTheme)}.{nameof(ForegroundColor)}", () => Hex1bColor.Default);
public static readonly Hex1bThemeElement<Hex1bColor> BackgroundColor =
new($"{nameof(MyTheme)}.{nameof(BackgroundColor)}", () => Hex1bColor.Default);
public static readonly Hex1bThemeElement<Hex1bColor> FocusedForegroundColor =
new($"{nameof(MyTheme)}.{nameof(FocusedForegroundColor)}", () => Hex1bColor.Black);
public static readonly Hex1bThemeElement<Hex1bColor> FocusedBackgroundColor =
new($"{nameof(MyTheme)}.{nameof(FocusedBackgroundColor)}", () => Hex1bColor.White);
public static readonly Hex1bThemeElement<char> SomeCharacter =
new($"{nameof(MyTheme)}.{nameof(SomeCharacter)}", () => '█');
}
Step 5: Create Extension Methods
Extension methods provide the fluent API for creating widgets.
Location: src/Hex1b/{Name}Extensions.cs
Key principles:
- Extend
WidgetContext<TParent> for widgets that can be children
- Provide overloads for common patterns
- Use XML documentation for IntelliSense
Template:
namespace Hex1b;
using Hex1b.Widgets;
public static class MyExtensions
{
public static MyWidget My<TParent>(
this WidgetContext<TParent> ctx,
string primaryProperty)
where TParent : Hex1bWidget
=> new(primaryProperty);
public static MyWidget My<TParent>(
this WidgetContext<TParent> ctx,
string primaryProperty,
bool someOption)
where TParent : Hex1bWidget
=> new(primaryProperty) { SomeOption = someOption };
}
Step 6: Write Unit Tests
Tests verify that the node behaves correctly. Test files use MSTest 4 (MSTest.Sdk/4.2.3 with OutputType=Exe) and import Microsoft.VisualStudio.TestTools.UnitTesting; Hex1b.Testing is global-using'd for TestSeq helpers.
Location: tests/Hex1b.Tests/{Name}NodeTests.cs
Key test scenarios:
- Measure returns correct size
- Render outputs expected content
- Input handling works correctly
- Property changes mark node dirty
- Focus state changes work
Template:
using Hex1b;
using Hex1b.Input;
using Hex1b.Layout;
using Hex1b.Theming;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Hex1b.Tests;
[TestClass]
public class MyNodeTests
{
[TestMethod]
public void Measure_ReturnsCorrectSize()
{
var node = new MyNode { PrimaryProperty = "Hello" };
var constraints = new Constraints(0, 100, 0, 10);
var size = node.Measure(constraints);
Assert.AreEqual(5, size.Width);
Assert.AreEqual(1, size.Height);
}
[TestMethod]
public void PropertyChange_MarksDirty()
{
var node = new MyNode { PrimaryProperty = "Initial" };
node.ClearDirty();
node.PrimaryProperty = "Changed";
}
[TestMethod]
public void IsFocused_WhenSet_MarksDirty()
{
var node = new MyNode();
node.ClearDirty();
node.IsFocused = true;
Assert.IsTrue(node.IsDirty);
}
}
Step 7: Build and Test
After creating all files:
dotnet build src/Hex1b
dotnet test
dotnet run --project tests/Hex1b.Tests/
dotnet test --filter "MyNodeTests"
Common Patterns
Fill Width (Horizontal Fill)
For widgets that should fill available horizontal space by default:
public override Size Measure(Constraints constraints)
{
var width = constraints.MaxWidth;
var height = 1;
return constraints.Constrain(new Size(width, height));
}
Animation / Indeterminate State
For widgets with animation (like indeterminate progress), you need to:
- Track animation state in the node
- Call
MarkDirty() when animation changes
- Use
Hex1bApp.Invalidate() from the widget builder to trigger re-renders
Container Widgets
For widgets that contain children, see VStackWidget/VStackNode as examples:
- Store children in
Hex1bWidget[] (widget) / List<Hex1bNode> (node)
- Use
ReconcileContext.ReconcileChildren() in widget's Reconcile()
- Implement
GetChildren() in node for focus traversal
Theming Best Practices
- Use
Hex1bColor.Default for colors that should inherit from parent
- Provide focused/hovered variants for interactive widgets
- Use characters for customizable borders/bullets so users can theme them
Testing Best Practices
Testing widgets requires two layers: unit tests for isolated node behavior, and integration tests for real-world scenarios using Hex1bApp. Integration tests should export evidence in multiple formats for verification and documentation.
Test File Organization
| File | Purpose |
|---|
tests/Hex1b.Tests/{Name}NodeTests.cs | Unit tests for node behavior |
tests/Hex1b.Tests/{Name}IntegrationTests.cs | Integration tests with Hex1bApp |
Unit Tests (NodeTests)
Unit tests verify isolated node behavior without running a full app.
Key scenarios to cover:
- Measure behavior - Size calculations for various constraints
- Render output - Correct ANSI sequences and text
- Property dirty tracking - Changes trigger re-render
- Input handling - Key/mouse events processed correctly
- Focus state - Focus changes mark dirty and render differently
- Reconciliation - Widget updates preserve/update node state
Example pattern:
[TestMethod]
public void Measure_FillsAvailableWidth()
{
var node = new ProgressNode { Value = 50, Maximum = 100 };
var size = node.Measure(new Constraints(0, 80, 0, 10));
Assert.AreEqual(80, size.Width);
Assert.AreEqual(1, size.Height);
}
[TestMethod]
public void PropertyChange_MarksDirty()
{
var node = new ProgressNode { Value = 50 };
node.ClearDirty();
var widget = new ProgressWidget { Value = 75 };
widget.Reconcile(node, new ReconcileContext(...));
Assert.IsTrue(node.IsDirty);
}
Integration Tests (IntegrationTests)
Integration tests spin up a real Hex1bApp and test the widget in various layout scenarios. These tests must export evidence files for verification.
Required export formats:
- SVG - Vector graphics for documentation
- HTML - Styled HTML output
- ANSI - Raw terminal sequences
- Asciinema (.cast) - Animated recordings for dynamic behavior
Test Infrastructure Setup
using Hex1b;
using Hex1b.Input;
using Hex1b.Theming;
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class MyWidgetIntegrationTests : IDisposable
{
private readonly List<string> _tempFiles = new();
private string GetTempFile()
{
var path = Path.Combine(Path.GetTempPath(), $"hex1b_test_{Guid.NewGuid()}.cast");
_tempFiles.Add(path);
return path;
}
public void Dispose()
{
foreach (var file in _tempFiles)
{
try { File.Delete(file); } catch { }
}
}
}
Basic Integration Test Pattern
[TestMethod]
public async Task MyWidget_RendersCorrectly()
{
using var workload = new Hex1bAppWorkloadAdapter();
using var terminal = new Hex1bTerminal(workload, 60, 10);
using var app = new Hex1bApp(
ctx => ctx.VStack(v => [
v.Text("Label:"),
v.My("Hello World")
]),
new Hex1bAppOptions { WorkloadAdapter = workload }
);
var runTask = app.RunAsync(TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Label:"), TimeSpan.FromSeconds(2))
.Capture("mywidget-basic")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await runTask;
}
Layout Scenario Tests
Test the widget in all common layout containers:
[TestMethod]
public async Task MyWidget_InBorder()
{
using var workload = new Hex1bAppWorkloadAdapter();
using var terminal = new Hex1bTerminal(workload, 60, 10);
using var app = new Hex1bApp(
ctx => ctx.Border(b => [
b.Text("Content"),
b.My("Inside border")
], title: "Panel"),
new Hex1bAppOptions { WorkloadAdapter = workload }
);
var runTask = app.RunAsync(TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Panel"), TimeSpan.FromSeconds(2))
.Capture("mywidget-in-border")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await runTask;
}
[TestMethod]
public async Task MyWidget_InHStackWithFill()
{
using var workload = new Hex1bAppWorkloadAdapter();
using var terminal = new Hex1bTerminal(workload, 80, 10);
using var app = new Hex1bApp(
ctx => ctx.HStack(h => [
h.Text("Label: "),
h.My("Content").Fill()
]),
new Hex1bAppOptions { WorkloadAdapter = workload }
);
var runTask = app.RunAsync(TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Label:"), TimeSpan.FromSeconds(2))
.Capture("mywidget-hstack-fill")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await runTask;
}
[TestMethod]
[DataRow(40)]
[DataRow(60)]
[DataRow(80)]
[DataRow(120)]
public async Task MyWidget_RespondsToTerminalWidth(int width)
{
using var workload = new Hex1bAppWorkloadAdapter();
using var terminal = new Hex1bTerminal(workload, width, 10);
using var app = new Hex1bApp(
ctx => ctx.My("Responsive content"),
new Hex1bAppOptions { WorkloadAdapter = workload }
);
var runTask = app.RunAsync(TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Responsive"), TimeSpan.FromSeconds(2))
.Capture($"mywidget-width-{width}")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await runTask;
}
Asciinema Recording Tests
For widgets with animation or dynamic behavior, record asciinema sessions:
[TestMethod]
public async Task MyWidget_RecordsAnimation()
{
var tempFile = GetTempFile();
using var workload = new Hex1bAppWorkloadAdapter();
var terminalOptions = new Hex1bTerminalOptions
{
Width = 60,
Height = 10,
WorkloadAdapter = workload
};
var recorder = terminalOptions.AddAsciinemaRecorder(tempFile, new AsciinemaRecorderOptions
{
Title = "MyWidget Animation Demo",
IdleTimeLimit = 0.5f
});
using var terminal = new Hex1bTerminal(terminalOptions);
var animationValue = 0.0;
using var app = new Hex1bApp(
ctx => ctx.My($"Value: {animationValue:F1}"),
new Hex1bAppOptions { WorkloadAdapter = workload }
);
using var cts = new CancellationTokenSource();
var runTask = app.RunAsync(cts.Token);
recorder.AddMarker("Animation Start");
for (int i = 0; i < 40; i++)
{
animationValue = (i % 20) / 20.0;
app.Invalidate();
await Task.Delay(50, TestContext.Current.CancellationToken);
}
recorder.AddMarker("Animation End");
var snapshot = terminal.CreateSnapshot();
TestCaptureHelper.Capture(snapshot, "mywidget-animated");
await TestCaptureHelper.CaptureCastAsync(recorder, "mywidget-animation", TestContext.Current.CancellationToken);
cts.Cancel();
await runTask;
}
Resize Scenario Tests
Test how widgets respond to terminal resizing:
[TestMethod]
public async Task MyWidget_RecordsResizeScenario()
{
var tempFile = GetTempFile();
using var workload = new Hex1bAppWorkloadAdapter();
var terminalOptions = new Hex1bTerminalOptions
{
Width = 100,
Height = 10,
WorkloadAdapter = workload
};
var recorder = terminalOptions.AddAsciinemaRecorder(tempFile, new AsciinemaRecorderOptions
{
Title = "MyWidget Resize Behavior",
IdleTimeLimit = 1.0f
});
using var terminal = new Hex1bTerminal(terminalOptions);
using var app = new Hex1bApp(
ctx => ctx.My("Resize me"),
new Hex1bAppOptions { WorkloadAdapter = workload }
);
using var cts = new CancellationTokenSource();
var runTask = app.RunAsync(cts.Token);
recorder.AddMarker("Initial Size (100 cols)");
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Resize"), TimeSpan.FromSeconds(2))
.Wait(TimeSpan.FromMilliseconds(500))
.Build()
.ApplyAsync(terminal, TestContext.Current.CancellationToken);
recorder.AddMarker("Resize to 60 cols");
await ((IHex1bTerminalWorkloadFilter)recorder).OnResizeAsync(60, 10, TimeSpan.FromSeconds(1));
terminal.Resize(60, 10);
await workload.ResizeAsync(60, 10, TestContext.Current.CancellationToken);
await Task.Delay(300, TestContext.Current.CancellationToken);
TestCaptureHelper.Capture(terminal, "mywidget-resize-medium");
recorder.AddMarker("Resize to 40 cols");
await ((IHex1bTerminalWorkloadFilter)recorder).OnResizeAsync(40, 10, TimeSpan.FromSeconds(2));
terminal.Resize(40, 10);
await workload.ResizeAsync(40, 10, TestContext.Current.CancellationToken);
await Task.Delay(300, TestContext.Current.CancellationToken);
TestCaptureHelper.Capture(terminal, "mywidget-resize-narrow");
await TestCaptureHelper.CaptureCastAsync(recorder, "mywidget-resize-demo", TestContext.Current.CancellationToken);
cts.Cancel();
await runTask;
}
Theming Tests
Verify custom themes are applied correctly:
[TestMethod]
public async Task MyWidget_RespectsCustomTheme()
{
using var workload = new Hex1bAppWorkloadAdapter();
using var terminal = new Hex1bTerminal(workload, 60, 10);
var customTheme = new Hex1bTheme("CustomTest")
.Set(MyTheme.ForegroundColor, Hex1bColor.Blue)
.Set(MyTheme.BackgroundColor, Hex1bColor.Yellow);
using var app = new Hex1bApp(
ctx => ctx.My("Themed content"),
new Hex1bAppOptions
{
WorkloadAdapter = workload,
Theme = customTheme
}
);
var runTask = app.RunAsync(TestContext.Current.CancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Themed"), TimeSpan.FromSeconds(2))
.Capture("mywidget-custom-theme")
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
await runTask;
}
Integration Test Checklist
Every widget should have integration tests covering:
Export Evidence Requirements
All integration tests must generate evidence files via TestCaptureHelper:
| Method | Output | Purpose |
|---|
.Capture("name") | SVG, HTML, ANSI | Static snapshot evidence |
TestCaptureHelper.Capture(terminal, "name") | SVG, HTML, ANSI | Manual capture |
TestCaptureHelper.CaptureCastAsync(recorder, "name", ct) | .cast file | Asciinema recording |
These files are attached to test results and can be viewed in CI artifacts for debugging and documentation purposes.
Visual Regression Testing
For complex widgets like TableWidget, consider adding visual regression tests that capture baselines and compare rendered output.
Baseline Test Infrastructure
Visual regression tests use the full Hex1bTerminal stack to render widgets and capture output for comparison:
public static async Task<(string Ansi, string Text)> RenderTableAsync(
TableVisualTestCase testCase,
CancellationToken cancellationToken = default)
{
using var terminal = Hex1bTerminal.CreateBuilder()
.WithHeadless()
.WithDimensions(testCase.Width, testCase.Height)
.WithHex1bApp((app, options) => ctx => BuildWidget(ctx, testCase))
.Build();
var runTask = terminal.RunAsync(cancellationToken);
await new Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText("Expected text"), TimeSpan.FromSeconds(2))
.Wait(TimeSpan.FromMilliseconds(50))
.Build()
.ApplyAsync(terminal, cancellationToken);
using var snapshot = terminal.CreateSnapshot();
var text = snapshot.GetScreenText();
await new Hex1bTerminalInputSequenceBuilder()
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyAsync(terminal, cancellationToken);
await runTask;
return (ansi, text);
}
Baseline Storage
Baselines are stored in tests/Hex1b.Tests/Baselines/{Widget}/ with:
.ansi files containing ANSI escape sequences (for color verification)
.txt files containing plain text (for structure verification)
Updating Baselines
When widget rendering changes intentionally:
UPDATE_BASELINES=1 dotnet test --filter "{Widget}VisualRegressionTests"
Test Matrix Example
For TableWidget, the visual regression test matrix covers:
- Data sizes: 0, 1, 5, 50, 1000 rows
- Render modes: Compact, Full
- Terminal sizes: 80×24, 160×48
- Selection states: None, some, all selected
- Focus states: Row focus, table focus indicator
See tests/Hex1b.Tests/TableVisualRegressionTests.cs for the complete implementation.
Checklist
Before considering a widget complete:
Example: ProgressWidget
The ProgressWidget was created following this skill. It demonstrates:
- Determinate mode: Shows progress from min to max value
- Indeterminate mode: Animated spinner for unknown completion
- Fill width: Uses all available horizontal space by default
- Theming: Customizable fill and track characters
- Comprehensive tests: Unit tests and integration tests with full export evidence
See the implementation files: