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 loop — Init → Update → View. 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 images — ImageWidget 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-programs — IComponent / IComponent<TResult> for self-contained pages with own state, keymaps, and lifecycle.
- Declarative keybindings — KeyMap + 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 & padding — Style.Padding(1) and Style.Margin(1) enforced by the layout engine.
- Content-aware layout — SizeConstraint.Auto sizes to content through IMeasurable; Fixed and Flex cover the rest.
- Pure state reducers — TextInputState, 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 commands — Cmd.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.
- Subscriptions — Sub.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;
{
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),
};
]));
}
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
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
record Theme
Immutable named collection of base styles applied as defaults across all widgets via Style....
Definition Theme.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:
| 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:
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()
theme.AccentStyle()
theme.MutedStyle()
theme.Success()
theme.Warning()
theme.Error()
theme.Bg()
theme.BgStyle()
Layout
Children declare Width and Height as SizeConstraint:
SizeConstraint.Auto
record SizeConstraint
Discriminated union for widget dimension constraints.
Definition SizeConstraint.cs:7
Auto asks the widget how much room its content wants, via IMeasurable:
{
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:
children: [
]);
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)
.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 wheel — MouseMsg with MouseButton.ScrollUp / ScrollDown
- Button events — press, release, motion tracking via MouseMsg
Handle in your model:
@ ScrollDown
Scroll wheel rotated downward (toward user).
Definition Messages.cs:74
KeyMap — Declarative Keybindings
Replace switch statements with composable, context-aware binding maps:
.
On(ConsoleKey.UpArrow, () =>
new NavUpMsg())
.
On(ConsoleKey.DownArrow, () =>
new NavDownMsg())
.
On(ConsoleKey.Enter, () =>
new SelectMsg())
.
On(
'?', () =>
new ShowHelpMsg())
? new NavUpMsg() : new NavDownMsg());
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())
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:
sealed record CounterPage(
int Count = 0) :
IComponent
{
.
On(ConsoleKey.UpArrow, () =>
new IncrMsg())
.
On(ConsoleKey.DownArrow, () =>
new DecrMsg());
public ICmd? Init() =>
null;
{
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),
};
}
}
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
{
{
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>
{
}
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
{
{
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
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);
string doc = next.Text();
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:
{
{
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
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.
Run(async ct => { ...
return msg; })
Cmd.
Tick(TimeSpan, ts =>
new TickMsg(ts))
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:
{
SelectionChangedMsg m => (this with { Index = m.Index },
_ =>
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))),
];
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
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).