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.
  • 13 built-in widgets — TextBlock, TextInput, TextArea, List, Table, Checkbox, Tabs, ProgressBar, Spinner, BorderBox, Container, Modal, ZStack
  • 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.
  • 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.
  • 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.
  • Async commandsCmd.Run, Cmd.Batch, Cmd.Sequence, Cmd.Tick, Cmd.Debounce, Cmd.Throttle.
  • 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 Program.Run(new HelloModel(), theme: Theme.Dark);
Factory for creating common command values.
Definition Cmd.cs:5
static ICmd Quit()
Returns QuitMsg immediately, ending the program loop.
Definition Cmd.cs:10
A widget that renders a bordered box with an optional title and a body child widget.
Definition BorderBox.cs:12
A layout container that arranges child widgets along a given axis.
Definition Container.cs:12
A widget that renders a single string, wrapping at region width.
Definition TextBlock.cs:11
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:6
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:7
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
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, backspace, delete, and arrow keys.
TextArea Multi-line editor with cursor navigation, line splitting, and scroll.
List Scrollable list with selection highlight. Virtualized — only visible rows render.
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.

Themes

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

await Program.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

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 Program.Run(model, theme: Theme.Dark, enableMouse: true);
  • Click-to-focus — left-click moves focus to the clicked widget (automatic)
  • 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:72

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(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:72
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:62
Pattern for matching keyboard events.
Definition KeyPattern.cs:19
static KeyPattern WithCtrl(ConsoleKey key)
Match Ctrl + key (Shift and Alt are wildcards).
Definition KeyPattern.cs:33

KeyPattern supports modifier wildcards: Of(key), WithCtrl(key), WithAlt(key), Plain(key).

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);

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(TimeSpan, ts => msg) // debounced (last wins)
Cmd.Throttle(TimeSpan, ts => msg) // throttled (first wins)
static ? ICmd Sequence(params ICmd?[] cmds)
Run commands serially: each waits for the previous to complete.
Definition Cmd.cs:55
static ICmd Throttle(TimeSpan interval, Func< DateTimeOffset, IMsg > fn)
Returns a cmd that forwards at most one invocation per interval .
Definition Cmd.cs:119
static ICmd Debounce(TimeSpan interval, Func< DateTimeOffset, IMsg > fn)
Returns a cmd that, when dispatched, waits interval after the last dispatch before invoking fn .
Definition Cmd.cs:91
static ? ICmd Batch(params ICmd?[] cmds)
Run all commands concurrently.
Definition Cmd.cs:38
static ICmd Tick(TimeSpan interval, Func< DateTimeOffset, IMsg > fn, CancellationToken cancellationToken=default)
Fire once after interval .
Definition Cmd.cs:74
static ICmd Run(Func< CancellationToken, Task< IMsg > > fn)
Wrap an async function as a command.
Definition Cmd.cs:24

Subscriptions

For continuous data streams, implement IHasSubscriptions:

public IEnumerable<(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 — all 13 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/ Program, IModel, IMsg, ICmd, IComponent, KeyMap, KeyPattern,
Component, Cmd, Sub, FocusManager, Renderer, Messages
Layout/ IWidget, IContainer, IFocusable, ISingleBodyWidget, ILayeredContainer,
LayoutEngine, RenderContext, TextUtils, SizeConstraint, Region
Styling/ Style, Theme, ThemeExtensions, Color, Borders, BorderSpec, ColorProfile
Terminal/ ITerminal, AnsiTerminal, Termios (Unix), WindowsConsole
Widgets/ TextBlock, TextInput, TextArea, List, Table, Checkbox, Tabs,
ProgressBar, Spinner, BorderBox, Container, Modal, ZStack
tests/ConsoleForge.Tests/ 398 unit tests (xUnit v3)
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. Benchmarks (80×24 terminal):

Scenario Time Allocations
20-widget cold render ~20 µs 68 KB
20-widget warm (no changes) ~21 µs 14 KB
1,000-row Table (24 visible) ~30 µs 67 KB
Dirty-skip (model unchanged) 3 ns 0

License

MIT — see [LICENSE](LICENSE).