| 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
{
internal bool SomeOption { get; init; }
internal Func<MyEventArgs, Task>? ActionHandler { get; init; }
MyWidget ()
=> { ActionHandler = => { handler(); Task.CompletedTask; } };
=> { ActionHandler = handler };
{
node = existingNode MyNode ?? MyNode();
(node.PrimaryProperty != PrimaryProperty || node.SomeOption != SomeOption)
{
node.MarkDirty();
}
node.PrimaryProperty = PrimaryProperty;
node.SomeOption = SomeOption;
node.SourceWidget = ;
(ActionHandler != )
{
node.ActionCallback = ctx =>
{
= MyEventArgs(, node, ctx);
ActionHandler();
};
}
{
node.ActionCallback = ;
}
node;
}
=> (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
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;
IsFocused
{
=> _isFocused;
{
(_isFocused != )
{
_isFocused = ;
MarkDirty();
}
}
}
IsFocusable => ;
{
(ActionCallback != )
{
bindings.Key(Hex1bKey.Enter).Action(ActionCallback, );
}
}
{
width = PrimaryProperty.Length;
height = ;
constraints.Constrain( Size(width, height));
}
{
theme = context.Theme;
fg = theme.Get(MyTheme.ForegroundColor);
bg = theme.Get(MyTheme.BackgroundColor);
output = ;
(context.CurrentLayoutProvider != )
{
context.WriteClipped(Bounds.X, Bounds.Y, output);
}
{
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);
Hex1bThemeElement<> 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.
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;
namespace Hex1b.Tests;
public class MyNodeTests
{
[Fact]
public void Measure_ReturnsCorrectSize()
{
var node = new MyNode { PrimaryProperty = "Hello" };
var constraints = new Constraints(0, 100, 0, 10);
var size = node.Measure(constraints);
Assert.Equal(5, size.Width);
Assert.Equal(1, size.Height);
}
[Fact]
public void PropertyChange_MarksDirty()
{
var node = new MyNode { PrimaryProperty = "Initial" };
node.ClearDirty();
node.PrimaryProperty = "Changed";
}
[Fact]
public void ()
{
node = MyNode();
node.ClearDirty();
node.IsFocused = ;
Assert.True(node.IsDirty);
}
}
Step 7: Build and Test
After creating all files:
dotnet build src/Hex1b
dotnet test
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:
[Fact]
public void Measure_FillsAvailableWidth()
{
var node = new ProgressNode { Value = 50, Maximum = 100 };
var size = node.Measure(new Constraints(0, 80, 0, 10));
Assert.Equal(80, size.Width);
Assert.Equal(1, size.Height);
}
[Fact]
public void PropertyChange_MarksDirty()
{
var node = new ProgressNode { Value = 50 };
node.ClearDirty();
var widget = new ProgressWidget { Value = 75 };
widget.Reconcile(node, new ReconcileContext(...));
Assert.True(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.Terminal;
using Hex1b.Terminal.Automation;
using Hex1b.Theming;
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
[Fact]
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:
[Fact]
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;
}
[Fact]
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().Fill()
]),
Hex1bAppOptions { WorkloadAdapter = workload }
);
runTask = app.RunAsync(TestContext.Current.CancellationToken);
Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText(), TimeSpan.FromSeconds())
.Capture()
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
runTask;
}
[]
[]
[]
[]
[]
{
workload = Hex1bAppWorkloadAdapter();
terminal = Hex1bTerminal(workload, width, );
app = Hex1bApp(
ctx => ctx.My(),
Hex1bAppOptions { WorkloadAdapter = workload }
);
runTask = app.RunAsync(TestContext.Current.CancellationToken);
Hex1bTerminalInputSequenceBuilder()
.WaitUntil(s => s.ContainsText(), TimeSpan.FromSeconds())
.Capture()
.Ctrl().Key(Hex1bKey.C)
.Build()
.ApplyWithCaptureAsync(terminal, TestContext.Current.CancellationToken);
runTask;
}
Asciinema Recording Tests
For widgets with animation or dynamic behavior, record asciinema sessions:
[Fact]
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");
snapshot = terminal.CreateSnapshot();
TestCaptureHelper.Capture(snapshot, );
TestCaptureHelper.CaptureCastAsync(recorder, , TestContext.Current.CancellationToken);
cts.Cancel();
runTask;
}
Resize Scenario Tests
Test how widgets respond to terminal resizing:
[Fact]
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(, );
workload.ResizeAsync(, , TestContext.Current.CancellationToken);
Task.Delay(, TestContext.Current.CancellationToken);
TestCaptureHelper.Capture(terminal, );
recorder.AddMarker();
((IHex1bTerminalWorkloadFilter)recorder).OnResizeAsync(, , TimeSpan.FromSeconds());
terminal.Resize(, );
workload.ResizeAsync(, , TestContext.Current.CancellationToken);
Task.Delay(, TestContext.Current.CancellationToken);
TestCaptureHelper.Capture(terminal, );
TestCaptureHelper.CaptureCastAsync(recorder, , TestContext.Current.CancellationToken);
cts.Cancel();
runTask;
}
Theming Tests
Verify custom themes are applied correctly:
[Fact]
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.
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: