ConsoleForge 0.3.0
Elm-architecture TUI framework for .NET 8
Loading...
Searching...
No Matches
ConsoleForge

An Elm-architecture TUI framework for .NET 8. Immutable model → pure update → declarative view.

Docs: popplywop.github.io/ConsoleForge  | NuGet: ConsoleForge  | SourceGen: ConsoleForge.SourceGen

Built for developers who want the predictability of Bubble Tea in C#: no mutable widget state, no hidden side effects, and a render pipeline that only touches the cells that actually changed.

Features

  • Elm loopInitUpdateView. Your model is an immutable record. Update returns a new copy. View is pure.
  • 15 built-in widgets — TextBlock, TextInput, TextArea, List, Table, Checkbox, Tabs, ProgressBar, Spinner, BorderBox, Container, Modal, ZStack, ImageWidget, HorizontalShelf
  • 6 named themes — Dark, Light, Dracula, Nord, Monokai, Tokyo Night. Switch at runtime with one message.
  • Mouse support — SGR 1006 extended mouse tracking. Click-to-focus, scroll wheel, button/motion events.
  • Unicode-aware layout — CJK, emoji, and full-width characters render at correct column widths, from a table generated out of the Unicode Character Database.
  • Inline imagesImageWidget draws PNGs through the Kitty graphics protocol where the terminal supports it, and falls back to half-block cells with 24-bit colour everywhere else.
  • Composable sub-programsIComponent / IComponent<TResult> for self-contained pages with own state, keymaps, and lifecycle.
  • Declarative keybindingsKeyMap + KeyPattern replace giant switch statements. Composable, context-aware, and layout-independent for printable keys via KeyPattern.OfChar.
  • Virtualized scrolling — List and Table render only visible rows. 1,000 items costs the same as 20.
  • Double-buffered renderer — Cell-level diff with per-widget dirty tracking. Only changed cells hit the terminal.
  • Margin & paddingStyle.Padding(1) and Style.Margin(1) enforced by the layout engine.
  • Content-aware layoutSizeConstraint.Auto sizes to content through IMeasurable; Fixed and Flex cover the rest.
  • Pure state reducersTextInputState, TextAreaState, ListState: editing, cursor movement and selection as values in your model, not stateful sub-widgets. The widgets delegate to them, so the two cannot drift.
  • Async commandsCmd.Run, Cmd.Batch, Cmd.Sequence, Cmd.Tick, and Cmd.Debounce / Cmd.Throttle, which rate-limit by key so a cmd built fresh in Update still coalesces.
  • SubscriptionsSub.Interval, Sub.FromAsyncEnumerable, Sub.FromObservable for continuous data streams.

Quick Start

dotnet add package ConsoleForge
sealed record HelloModel(int Count = 0) : IModel
{
public ICmd? Init() => null;
public (IModel Model, ICmd? Cmd) Update(IMsg msg) => msg switch
{
KeyMsg { Key: ConsoleKey.UpArrow } => (this with { Count = Count + 1 }, null),
KeyMsg { Key: ConsoleKey.DownArrow } => (this with { Count = Count - 1 }, null),
KeyMsg { Key: ConsoleKey.Q } => (this, Cmd.Quit()),
_ => (this, null),
};
public IWidget View() =>
new BorderBox("ConsoleForge",
body: new Container(Axis.Vertical, [
new TextBlock($"Count: {Count}"),
new TextBlock("↑↓ to change, Q to quit",
style: Style.Default.Faint(true)),
]));
}
await App.Run(new HelloModel(), theme: Theme.Dark);
Main event loop for a ConsoleForge application.
Definition App.cs:20
static Task Run(IModel model, ITerminal? terminal=null, Theme? theme=null, int targetFps=30, bool enableMouse=false, TimeProvider? timeProvider=null)
Run the application asynchronously.
Definition App.cs:41
Factory for creating common command values.
Definition Cmd.cs:5
static ICmd Quit()
Returns QuitMsg immediately, ending the program loop.
Definition Cmd.cs:10
The root interface for all ConsoleForge application models.
Definition IModel.cs:10
Marker interface for all messages flowing through the event loop.
Definition IMsg.cs:7
Base interface for all visual elements in the widget tree.
Definition IWidget.cs:9
Definition App.cs:8
delegate Task< IMsg > ICmd()
A command: an async function that produces one message when complete.
Definition CursorDescriptor.cs:1
Axis
Axis for container layout direction.
Definition LayoutEngine.cs:6
Definition Borders.cs:1
record Theme
Immutable named collection of base styles applied as defaults across all widgets via Style....
Definition Theme.cs:11
Definition BorderBox.cs:4
record TextBlock
A widget that renders a single string, wrapping at region width.
Definition TextBlock.cs:10
record BorderBox
A widget that renders a bordered box with an optional title and a body child widget.
Definition BorderBox.cs:11
record Container
A layout container that arranges child widgets along a given axis.
Definition Container.cs:11
Immutable value type carrying visual style properties.
Definition Style.cs:12
Style Faint(bool value=true)
Returns a new style with faint (dim) intensity enabled or disabled.
Definition Style.cs:106
static readonly Style Default
The empty style (no properties set). Fast path: Render returns text unchanged.
Definition Style.cs:66

Widgets

Widget Description
TextBlock Text display with word-wrap. Supports \n, padding, and alignment.
TextInput Single-line input with cursor, word jumps, and grapheme-aware editing. Delegates to TextInputState.
TextArea Multi-line editor with cursor navigation, line splitting, and scroll. Delegates to TextAreaState.
List Scrollable list with selection highlight. Virtualized — only visible rows render. Delegates to ListState.
Table Columnar data with headers, selection, separators. Virtualized scrolling.
Checkbox Toggle [✓] Label / [ ] Label. Customizable indicator characters.
Tabs Tab bar + body content. Left/Right arrows, number keys 1–9.
ProgressBar Horizontal fill bar with optional percentage label.
Spinner Animated spinner with braille, ASCII, and arc frame sets.
BorderBox Bordered box with title. 6 border styles: Normal, Rounded, Thick, Double, ASCII, Hidden.
Container Flex layout along horizontal or vertical axis. Supports scrolling.
Modal Centered dialog overlay. Compose with ZStack for layered UIs.
ZStack Renders layers back-to-front. The foundation for overlays and modals.
ImageWidget Inline image. Kitty graphics protocol when available, half-block cells otherwise.
HorizontalShelf Paged, virtualized strip of cover art for rows wider than the terminal.

Themes

Six built-in themes with background colours, accent styles, and semantic colour slots:

await App.Run(model, theme: Theme.Dracula);
Theme Background Accent Focus
Theme.Dark #1C1C1C Teal Gold
Theme.Light #F0F0F0 Blue Red
Theme.Dracula #282A36 Purple Cyan
Theme.Nord #2E3440 Teal Green
Theme.Monokai #272822 Green Yellow
Theme.TokyoNight #1A1B26 Blue Orange

Switch at runtime:

// In Update:
return (this with { ThemeIdx = next }, Cmd.Msg(new ThemeChangedMsg(newTheme)));
static ICmd Msg(IMsg msg)
Returns the given message immediately (synchronous, no async gap).
Definition Cmd.cs:17

Access theme colours with extension methods:

theme.Accent() // IColor? — border brand colour
theme.AccentStyle() // Style — accent as foreground, bold-ready
theme.MutedStyle() // Style — dim secondary text
theme.Success() // Style — green/equivalent
theme.Warning() // Style — yellow/equivalent
theme.Error() // Style — red/equivalent
theme.Bg() // IColor? — background from BaseStyle
theme.BgStyle() // Style — background only

Layout

Children declare Width and Height as SizeConstraint:

SizeConstraint.Fixed(24) // exact columns/rows
SizeConstraint.Flex(1) // proportional share of free space
SizeConstraint.Auto // shrink to content
SizeConstraint.Min(10, inner) // minimum bound
SizeConstraint.Max(40, inner) // maximum bound
record SizeConstraint
Discriminated union for widget dimension constraints.
Definition SizeConstraint.cs:7

Auto asks the widget how much room its content wants, via IMeasurable:

public interface IMeasurable : IWidget
{
Size Measure(int availableWidth, int availableHeight);
}
Implemented by widgets that can report how much space their content wants, which is what makes SizeCo...
Definition IMeasurable.cs:32
readonly record struct Size(int Width, int Height)
A widget's desired size in terminal cells.
Definition IMeasurable.cs:6

TextBlock, Spinner, Container, BorderBox, and ZStack implement it — a one-line TextBlock is one row, a Container sums its children along its axis. Implement it on your own widgets to make Auto work for them; a widget that doesn't gets flex weight 1, the older behaviour. Measure runs during layout, so it must be pure and cheap, and must never ask for more than it was offered.

Two edges worth knowing: a flex child contributes nothing along the stacking axis (flex fills leftovers, which is not a content size), so an Auto container of only flex children collapses — give that container a Fixed or Flex size. And Min/Max fold over the measurement, so Max(10, Auto) is a content size capped at 10.

Container runs a two-pass layout: fixed children first, then flex children share the remainder. Supports padding and margin:

new Container(Axis.Horizontal,
style: Style.Default.Padding(1), // insets child region
children: [
new TextBlock("A") { Style = Style.Default.Margin(0, 1, 0, 1) }, // spacing around widget
new TextBlock("B"),
]);
Style Margin(int all)
Returns a new style with equal margin on all four sides.
Definition Style.cs:124
Style Padding(int all)
Returns a new style with equal padding on all four sides.
Definition Style.cs:114

Styling

Style is an immutable value type. All methods return a new Style:

.Foreground(Color.FromHex("#FF5733"))
.Background(Color.Blue)
.Bold()
.Italic()
.Underline()
.Padding(1, 2)
.Border(Borders.Rounded)
.BorderForeground(Color.Cyan)
Pre-defined border character sets.
Definition Borders.cs:5
static readonly BorderSpec Rounded
Rounded corners: ╭─╮│╰╯├┤┬┴┼
Definition Borders.cs:16

Styles inherit from the active theme when properties are unset — set only what you need.

Mouse Support

await App.Run(model, theme: Theme.Dark, enableMouse: true);
  • Click-to-focus — give a widget a FocusKey, and a left-click on it sends FocusRequestedMsg(key) to your model. The framework hit-tests; your model decides what focus means. A widget without a key is not a click-focus target.
  • Scroll wheelMouseMsg with MouseButton.ScrollUp / ScrollDown
  • Button events — press, release, motion tracking via MouseMsg

Handle in your model:

case MouseMsg { Button: MouseButton.ScrollDown } => // scroll handler
@ ScrollDown
Scroll wheel rotated downward (toward user).
Definition Messages.cs:74

KeyMap — Declarative Keybindings

Replace switch statements with composable, context-aware binding maps:

static readonly KeyMap SidebarKeys = new KeyMap()
.On(ConsoleKey.UpArrow, () => new NavUpMsg())
.On(ConsoleKey.DownArrow, () => new NavDownMsg())
.On(ConsoleKey.Enter, () => new SelectMsg())
.On(ConsoleKey.Escape, () => new QuitMsg())
.On('?', () => new ShowHelpMsg())
.On(KeyPattern.WithCtrl(ConsoleKey.C), () => new QuitMsg())
.OnScroll(m => m.Button == MouseButton.ScrollUp
? new NavUpMsg() : new NavDownMsg());
// In Update:
if (SidebarKeys.Handle(msg) is { } action) msg = action;
Declarative input binding map.
Definition KeyMap.cs:28
KeyMap On(ConsoleKey key, Func< IMsg > handler)
Bind a key (any modifiers) to a message factory.
Definition KeyMap.cs:35
KeyMap OnScroll(Func< MouseMsg, IMsg > handler)
Bind scroll-wheel events (both up and down).
Definition KeyMap.cs:94
record QuitMsg
Signals the program loop to exit cleanly.
Definition Messages.cs:6
MouseButton
Dispatched by List when the user presses Enter on a selected item.
Definition Messages.cs:64
Pattern for matching keyboard events.
Definition KeyPattern.cs:36
static KeyPattern WithCtrl(ConsoleKey key)
Match Ctrl + key (Shift and Alt are wildcards).
Definition KeyPattern.cs:71

KeyPattern matches a key two ways. Of(key), WithCtrl(key), WithAlt(key), WithShift(key) and Plain(key) match a ConsoleKey with the named modifiers — the right choice for keys that produce no character, such as arrows, Enter, Tab and function keys.

OfChar(char) matches the character the terminal produced, which is what printable bindings want, because it does not assume a keyboard layout. ? sits on Shift+Oem2 on a US layout and somewhere else entirely on a German or French one, but it is '?' on all of them:

.On('?', () => new ShowHelpMsg()) // shorthand for KeyPattern.OfChar('?')
.On(KeyPattern.OfChar('N'), () => ...) // capital N only — case-sensitive
static KeyPattern OfChar(char c)
Match a printable character, whatever key produced it — the layout-independent way to bind ?...
Definition KeyPattern.cs:68

Shift stays a wildcard for a character pattern, since it has already been consumed producing the glyph; Ctrl and Alt must be absent, because Ctrl+letter arrives as a control character and Alt+key is a separate binding. Every KeyPattern field is optional and null means "don't care", so default(KeyPattern) matches every key — useful as a trailing catch-all, and worth avoiding by accident.

Compose maps: globalKeys.Merge(pageKeys) — first map takes priority.

IComponent — Sub-Programs

Self-contained pages with own state, keybindings, and view. The Bubble Tea tea.Model-per-page pattern:

// Pages/CounterPage.cs
sealed record CounterPage(int Count = 0) : IComponent
{
static readonly KeyMap Keys = new KeyMap()
.On(ConsoleKey.UpArrow, () => new IncrMsg())
.On(ConsoleKey.DownArrow, () => new DecrMsg());
public ICmd? Init() => null;
public (IModel Model, ICmd? Cmd) Update(IMsg msg)
{
if (Keys.Handle(msg) is { } action) msg = action;
return msg switch
{
IncrMsg => (this with { Count = Count + 1 }, null),
DecrMsg => (this with { Count = Count - 1 }, null),
_ => (this, null),
};
}
public IWidget View() => new TextBlock($"Count: {Count}");
}
A self-contained sub-program: owns its own state, update logic, and view.
Definition IComponent.cs:53

Embed in a parent model:

record AppModel(CounterPage Counter) : IModel
{
public (IModel, ICmd?) Update(IMsg msg)
{
var (next, cmd) = Component.Delegate(Counter, msg);
return (this with { Counter = next! }, cmd);
}
public IWidget View() => Counter.View();
}
Static helpers for working with IComponent and IComponent<TResult> in parent model Update methods.
Definition Component.cs:22

For components that return results (file pickers, confirm dialogs):

sealed record FilePicker(string? Result = null) : IComponent<string>
{
string IComponent<string>.Result => Result!;
// ... Update sets Result when user picks a file
}
// Parent checks completion:
var (next, cmd) = Component.Delegate(picker, msg);
if (next.IsCompleted())
return (this with { Picker = null, ChosenFile = next.Result }, cmd);

State Reducers — TextInputState, TextAreaState, ListState

Editing and selection rules as pure values, so a model owns its state and never needs a stateful sub-program to manage it. Each widget delegates to its reducer, so storing the widget and storing the state behave identically — they cannot drift:

TextInputState — single-line

sealed record Model(TextInputState Filter) : IModel
{
public (IModel, ICmd?) Update(IMsg msg) => msg switch
{
KeyMsg k => (this with { Filter = Filter.HandleKey(k) }, null),
_ => (this, null),
};
public IWidget View() => new TextInput(Filter.Value, cursorPosition: Filter.Cursor);
}
record TextInputState
Single-line text editing as a pure value: a string, a cursor, and one function from a KeyMsg to the n...
Definition TextInputState.cs:48
record TextInput
A single-line text input widget that accepts keyboard input when focused.
Definition TextInput.cs:14

HandleKey covers insert, Backspace/Delete, Left/Right, Ctrl+←/Ctrl+→ word jumps, Home/End (and Ctrl+A/Ctrl+E), Ctrl+W delete-word, and Ctrl+U/Ctrl+K kill-to-edge. Insert(text) handles paste. It returns the same instance when a key changes nothing, so ReferenceEquals tells you whether to do more work.

Cursor is an index into Value, always on a grapheme cluster boundary — an emoji or an accented letter moves and deletes as one unit, and a with expression that would leave the cursor mid-cluster is normalised instead. It is not a column: a wide glyph is two columns but one cursor step.

TextAreaState — multi-line

Same editing rules, because every key that stays on one line is handed to TextInputState. TextAreaState owns only what crosses lines: Up/Down, Enter splitting a line, Backspace at column 0 joining onto the previous line, and Delete at end-of-line pulling the next one up. Insert(text) splits a paste on newlines.

var next = Body.HandleKey(key); // Lines, CursorRow, CursorCol
string doc = next.Text(); // joined with \n
IWidget? Body
Content widget to render below the tab bar.
Definition Tabs.cs:59

Lines is never empty — an empty document is one empty line — and the cursor is re-normalised on every with, so no copy lands off the end or inside a cluster.

ListState — selection and scroll

Holds the item count, not the items, so it serves an array, a filtered view, or virtualized pages equally. The model keeps the data and indexes it:

sealed record Model(ListState Rows, string[] Items) : IModel
{
public (IModel, ICmd?) Update(IMsg msg) => msg switch
{
WindowResizeMsg r => (this with { Rows = Rows.WithViewport(r.Height - Chrome) }, null),
KeyMsg k => (this with { Rows = Rows.HandleKey(k) }, null),
_ => (this, null),
};
}
record ListState
Selection and scroll as a pure value: a count, an index, an offset, a viewport, and one function from...
Definition ListState.cs:50
IReadOnlyList< string > Items
The display strings shown in the list.
Definition List.cs:26
IReadOnlyList< IReadOnlyList< string > > Rows
Data rows.
Definition Table.cs:49

HandleKey covers Up/Down, Home/End, and PageUp/PageDown. Because ViewportHeight lives in the state, ScrollOffset is maintained on every move rather than being something you remember to recompute — a selection cannot scroll out of sight, and an offset that would hide it is overridden. Set the viewport to 0 when nothing knows it yet: scroll is then left alone and paging does nothing, since a page has no size.

VisibleRange gives the (offset, length) slice a view should draw.

Note: the List widget cannot use the viewport half — a widget learns its region only at render time — so it leaves ScrollOffset to the model via List.ComputeScrollOffset. A model holding a ListState gets both.

Commands

Cmd.Quit() // exit the program
Cmd.Msg(new SomeMsg()) // synchronous follow-up message
Cmd.Run(async ct => { ... return msg; }) // async work → message
Cmd.Batch(cmd1, cmd2, cmd3) // run concurrently
Cmd.Sequence(cmd1, cmd2, cmd3) // run serially
Cmd.Tick(TimeSpan, ts => new TickMsg(ts)) // delayed single fire
Cmd.Debounce(key, TimeSpan, ts => msg) // debounced by key (last wins)
Cmd.Throttle(key, TimeSpan, ts => msg) // throttled by key (first wins)
static ? ICmd Sequence(params ICmd?[] cmds)
Run commands serially: each waits for the previous to complete.
Definition Cmd.cs:56
static ? ICmd Batch(params ICmd?[] cmds)
Run all commands concurrently.
Definition Cmd.cs:41
static ICmd Tick(TimeSpan interval, Func< DateTimeOffset, IMsg > fn, CancellationToken cancellationToken=default)
Fire once after interval .
Definition Cmd.cs:75
static ICmd Throttle(string key, TimeSpan interval, Func< DateTimeOffset, IMsg > fn)
Invokes fn at most once per interval under key , on the leading edge.
Definition Cmd.cs:116
static ICmd Run(Func< CancellationToken, Task< IMsg > > fn)
Wrap an async function as a command.
Definition Cmd.cs:24
static ICmd Debounce(string key, TimeSpan interval, Func< DateTimeOffset, IMsg > fn)
Invokes fn once interval has passed without another dispatch under key .
Definition Cmd.cs:101

Debounce and Throttle are keyed because Update builds a new command on every call. The rate-limit state belongs to the key, held by the running program, so re-dispatching under the same key supersedes the pending window no matter how many command instances were built along the way:

public (IModel, ICmd?) Update(IMsg msg) => msg switch
{
// One fetch after the selection settles, not one per keypress. The URL differs
// per row, so the closure differs per call — only the key is stable.
SelectionChangedMsg m => (this with { Index = m.Index },
Cmd.Debounce("poster", TimeSpan.FromMilliseconds(150),
_ => new LoadPosterMsg(Items[m.Index].PosterUrl))),
_ => (this, null),
};

Keys are plain strings, namespaced by the application the same way subscription keys are ("shelf/poster"). A superseded or throttled call produces no message at all.

Subscriptions

For continuous data streams, implement IHasSubscriptions:

public IReadOnlyList<(string Key, ISub Sub)> Subscriptions() =>
[
("timer", Sub.Interval(TimeSpan.FromSeconds(1), ts => new TickMsg(ts))),
("data", Sub.FromAsyncEnumerable(ct => GetDataStream(ct))),
];
Factory helpers for creating common ISub values.
Definition Sub.cs:5
static ISub Interval(TimeSpan interval, Func< DateTimeOffset, IMsg > fn)
A subscription that fires fn every interval .
Definition Sub.cs:10
static ISub FromAsyncEnumerable(Func< CancellationToken, IAsyncEnumerable< IMsg > > factory)
Wrap an arbitrary IAsyncEnumerable<IMsg> factory as a subscription.
Definition Sub.cs:35
delegate IAsyncEnumerable< IMsg > ISub(CancellationToken cancellationToken)
A subscription: a long-running async function that produces messages continuously until the supplied ...

Samples

Sample Description
ConsoleForge.Gallery Widget showcase — widgets, 6 themes, mouse support, IComponent pages
ConsoleForge.TodoApp Todo list — browse, add, toggle, delete
ConsoleForge.SysMonitor Live system stats via async subscriptions
dotnet run --project samples/ConsoleForge.Gallery

Project Structure

src/ConsoleForge/
Core/ App, IModel, IMsg, ICmd, IComponent, KeyMap, KeyPattern, Component,
Cmd, CmdDispatcher, Sub, FocusManager, Renderer, ViewDescriptor,
Messages, TextInputState, TextAreaState, ListState, Attributes
Layout/ IWidget, IContainer, IFocusable, IMeasurable, ISingleBodyWidget,
ILayeredContainer, IRawEscapePayload, LayoutEngine, LayoutSolver,
RenderContext, SubRenderContext, TextUtils, SizeConstraint, Region
Styling/ Style, Theme, ThemeExtensions, Color, Borders, BorderSpec, ColorProfile
Terminal/ ITerminal, AnsiTerminal, TerminalCapabilities, KittyProtocol,
Termios (Unix), WindowsConsole
Widgets/ TextBlock, TextInput, TextArea, List, Table, Checkbox, Tabs,
ProgressBar, Spinner, BorderBox, Container, Modal, ZStack,
ImageWidget, HorizontalShelf
src/ConsoleForge.SourceGen/ Roslyn incremental generator (netstandard2.0)
tests/ConsoleForge.Tests/ 662 unit tests (xUnit v3)
tests/ConsoleForge.SourceGen.Tests/ 12 generator snapshot tests
tests/ConsoleForge.Benchmarks/ BenchmarkDotNet render + cmd benchmarks
samples/
ConsoleForge.Gallery/ Widget browser with IComponent page architecture
ConsoleForge.SysMonitor/ System monitor dashboard
ConsoleForge.TodoApp/ Todo list app

Building

dotnet build ConsoleForge.slnx
dotnet test ConsoleForge.slnx
dotnet run --project samples/ConsoleForge.Gallery

Requires .NET 8 SDK. Single dependency: System.Reactive.

Performance

Double-buffered cell diff + per-widget render cache. Cold is a first frame — full layout, render and diff. Warm is a steady-state redraw of the same tree, where the widget cache and the cell diff do their work.

RenderBenchmarks, Release, 80×24. Absolute figures track the machine; the ratios are the point.

Scenario Time Allocations
20 widgets, cold 28.2 µs 64 KB
20 widgets, warm 21.2 µs 7.8 KB
BorderBox, cold 36.1 µs 75 KB
BorderBox, warm 29.6 µs 18 KB
Single TextBlock, cold 1.29 µs 2.9 KB
Single TextBlock, warm 0.81 µs 416 B
Dirty-skip (model unchanged) 3.7 ns 0

Reproduce with:

dotnet run --project tests/ConsoleForge.Benchmarks -c Release -- --filter "*RenderBenchmarks*"

License

MIT — see [LICENSE](LICENSE).