This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
dotnet build # Build the solution
dotnet test # Run all tests
dotnet test --filter "FullyQualifiedName~TestName" # Run specific test
dotnet test TUI.Test --logger "console;verbosity=detailed" # Detailed test outputdotnet run --project TUI.App # Sample app (default)
dotnet run --project TUI.App -- --interactive # Interactive demo
dotnet run --project TUI.App -- --showcase # Showcase demoktsu.TUI is a Text User Interface library built on Spectre.Console with this structure:
Contracts → Models → Services
↓ ↓ ↓
Elements → Layouts → Primitives
↓
Provider Abstraction (SpectreConsoleProvider)
Key namespaces:
ktsu.TUI.Core.Contracts- Interfaces (IUIElement,IUIContainer,IConsoleProvider,IUIApplication)ktsu.TUI.Core.Elements- Base classes (UIElementBase,UIContainerBase)ktsu.TUI.Core.Elements.Primitives- UI components (TextElement,BorderElement)ktsu.TUI.Core.Elements.Layouts- Layout containers (StackPanel)ktsu.TUI.Core.Models- Data structures (Position,Dimensions,Padding,TextStyle,InputResult)ktsu.TUI.Core.Services- Application services (UIApplication,SpectreConsoleProvider)
Rendering flow:
UIApplicationmanages the main loop and input processing- Elements are arranged via
ArrangeChildren()which sets childPositionandDimensions. It reaches one level only — a container arranges its own children, not theirs — so anything that resizes a subtree has to walk it - Each pass is a full clear followed by a full redraw:
UIApplication.Render()clears the console and every visible element draws again.Invalidate()marks an element as changed and raisesInvalidated, but it does not gate drawing — a full clear combined with a dirty-only redraw erases static elements rather than preserving them (#109). WhatInvalidateddoes drive is whether a pass happens:UIApplicationsubscribes to the root's event and flags a redraw, which the input loop's poll tick draws, so a change made off the loop thread (a timer, a background task) appears without a keypress. The handler only sets a flag, never draws, and ignores invalidations a pass raises on its own thread while arranging.UIApplication.RequestRender()sets the same flag for a host (#154). The tree itself is not synchronised. Setting a property an element only reads while drawing (TextElement.Text) is safe from any thread, but adding or removing children races the pass that walks them, so a host does that throughUIApplication.Post(Action), which runs the change on the loop thread at the start of the next pass (#225) - Each pass also re-reads
ConsoleProvider.Dimensions. When the terminal has changed size the root takes the new size and the whole tree is re-arranged, so a resized window is laid out on the next frame instead of staying pinned to the size at launch (#111). The input loop wakes on a timer (UIApplication.ResizePollInterval) as well as on input, because a resize delivers no keypress to wake it — each tick compares the size and draws only when it changed The tree is arranged on every pass, not only on a resize: showing an element, changing text, or a size the host assigned to the root all change layout without one (#133) UIContainerBase.Render()renders itself then all visible children
Adding children to containers - Use AddChild() or collection initializer (not direct assignment):
var panel = new StackPanel
{
new TextElement { Text = "Hello" },
new BorderElement { /* ... */ }
};
// or
panel.AddChild(new TextElement { Text = "World" });Creating a UI application:
var app = UIApplication.CreateBuilder(new SpectreConsoleProvider())
.UseRootElement(myRootElement)
.Build();
await app.RunAsync();Custom UI elements - Extend UIElementBase and implement OnRender():
protected override void OnRender(IConsoleProvider provider)
{
// Use provider to draw content
}- Framework: MSTest with Moq for mocking
- When mocking
IUIElement, explicitly setIsVisibletotrue(Moq defaults bools to false):
var mockChild = new Mock<IUIElement>();
mockChild.Setup(c => c.IsVisible).Returns(true);Property round-tripping is not enough on its own. Every alignment combination of TextElement,
every titled BorderElement with the default TitleAlignment, and every BorderStyle.None
element once threw NotImplementedException from the render path, and the suite stayed green
throughout because no test called Render.
RecordingConsoleProvider (in TUI.Test) is the test double for this. It implements
IConsoleProvider and records every WriteAt call rather than drawing, so a test can assert
what was drawn and where:
RecordingConsoleProvider provider = new();
element.Render(provider);
Assert.AreEqual(0, provider.WritesOf("abc").Single().Position.X);Rendering to the real SpectreConsoleProvider in a test writes to the runner's console and
leaves the output unobservable, so use the recorder instead.
Any new element needs render coverage across the full matrix of whatever enum drives its
layout — that is exactly where the bug above lived. TextElementTests and
BorderElementRenderTests use [DynamicData] over Enum.GetValues<T>() so a newly added
enum member is covered automatically rather than silently skipped.
Two sizing traps when writing these:
Dimensions.WithoutPaddingfloors at zero, and an element with an empty content area returns fromOnRenderbefore drawing. An element must be larger than its own padding or it draws nothing and the assertion fails for a reason unrelated to what is under test.BorderElementonly draws its title whenWidth > 4, and draws no border at all below 2x2.
All source files require this copyright header:
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.