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.
- 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-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.
- 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.
- Async commands — Cmd.Run, Cmd.Batch, Cmd.Sequence, Cmd.Tick, Cmd.Debounce, Cmd.Throttle.
- 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),
};
]));
}
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 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
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
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, 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:
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
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 Program.Run(model, theme:
Theme.Dark, enableMouse:
true);
- Click-to-focus — left-click moves focus to the clicked widget (automatic)
- 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:72
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())
? 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: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:
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);
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: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))),
];
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/ 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).