Jumbee.Console
0.2.1
dotnet add package Jumbee.Console --version 0.2.1
NuGet\Install-Package Jumbee.Console -Version 0.2.1
<PackageReference Include="Jumbee.Console" Version="0.2.1" />
<PackageVersion Include="Jumbee.Console" Version="0.2.1" />
<PackageReference Include="Jumbee.Console" />
paket add Jumbee.Console --version 0.2.1
#r "nuget: Jumbee.Console, 0.2.1"
#:package Jumbee.Console@0.2.1
#addin nuget:?package=Jumbee.Console&version=0.2.1
#tool nuget:?package=Jumbee.Console&version=0.2.1
Jumbee.Console

About
Jumbee.Console is a .NET library for advanced TUIs that focuses on performance and usability. Inspired by libs like ratatui and Textual, it tries to provide a high-performance retained-mode library that is easy-to-use with idiomatic .NET GUI and Task patterns, while flexible enough to create different types of TUI applications from news readers to animated dashboards to IDEs to agent harnesses to graphics apps.
Features
- 100% managed AOT-compatible code.
- Retained-mode GUI framework with an API designed to be easy to use and extend.
- Sub-ms frame rendering times and minimal CPU consumption even with complex displays like multi-tab document editing and syntax highlighting.
- Uses modern terminal features: ANSI/VT control sequences, 24-bit colour, SGR-encoded mouse with motion tracking, bracketed paste, focus reporting, and the alternate screen buffer.
- Also support legacy non-ANSI terminal emulators like the classic Windows console.
- Uses Spectre.Console-compatible markup, styles, text rendering, and widgets in a retained-mode rendering pipeline.
- Supports both fixed-width layouts like
Gridand flexible, resizable layouts likeDockPanel,HorizontalStack,VerticalStack, resizableSplitPanel. - Large set of common GUI controls: menus, buttons, trees, text inputs with autocomplete, modal dialog windows, etc...., supports easy composition of controls.
- Control frames support adornments like titles, borders, margins, and scrollbars.
- Cross-platform 100% managed code terminal-emulator.
- Multi-tab editor that supports C#, JavaScript, C++, Markdown + a dozen other languages.
- Split-pane interactive editors with preview for Markdown, AsciiDoc, Mermaid documents, Mermaid embedded in Markdown.
- Visualization and graphics: Many different types of plots and graphs like candlestick & heatmap plots & bar/run charts, world maps, sub-cell drawing canvas and shapes, 3D texture rendering, and support for animation.
- Flexible themes that support styling both colors and glyphs independently.
- Headless snapshot testing: render any control or layout to text or PNG without a real terminal.
Getting Started
dotnet add package Jumbee.Console
A first app — a label and a button that increments a counter:
using Jumbee.Console;
using static Jumbee.Console.Color; // import the static color names
var count = 0;
var label = new TextLabel(TextLabelOrientation.Horizontal, "Count: 0", Cyan1);
var button = new Button("Increment");
// Change the label text when the button is clicked or pressed
button.Activated += (_, _) => label.Text = $"Count: {++count}";
// One column, two rows: the label above the button.
var root = new Grid(
columnWidths: [30],
rowHeights: [1, 3],
controls:
[
[label],
[button],
]);
UI.RegisterHotKey(UI.HotKeys.Escape, UI.Stop); // Esc quits (Ctrl+Q also quits by default)
UI.SetFocus(button); // focus it so Enter/Space activates
// Start the UI. Mouse/hover need a VtInputSource or omit for keyboard only.
// Returns a task.
var t = UI.Start(root, width: 34, height: 6, input: new VtInputSource(anyMotion: true));
// Wait for the UI to stop.
t.Wait();
See GETTING-STARTED.md and the documentation. To find the right control for a job, start with the control guides — a decision table over the whole library, then a guide per category.
For LLMs and coding agents
llms.txt (what this is) is a curated index of every doc and API page in this project, as absolute links to raw Markdown. Fetch it first to find the page you need, then fetch that page directly.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- NTokenizers (>= 6.1.1)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Jumbee.Console:
| Package | Downloads |
|---|---|
|
Jumbee.Console.Documents
Document viewers and editors for Jumbee.Console: Markdown, AsciiDoc, and Mermaid diagram rendering and interactive editing controls for TUIs. |
|
|
Jumbee.Console.Snapshot
Headless snapshot testing for Jumbee.Console TUIs: render controls to text or PNG without a real terminal, for unit tests and visual verification. |
GitHub repositories
This package is not used by any popular GitHub repositories.
0.2.1
-----
ADDED
* `protected Control.Job<T>(produce, apply, onError)`, which returns a `JobHandle` and is the on-demand counterpart to
Control.Feed, for control authors. A feed runs on a timer; a job runs when something asks it to. The
producer runs off the UI thread and hands back a finished object, which the apply callback installs on
the UI thread, so the expensive half never blocks a frame. Requests coalesce: asking again while a run
is in flight does not queue a second run, it rides the pending one, so a control cannot accumulate an
unbounded backlog of stale work. JobHandle exposes Request, Cancel, StopAsync and Completion, plus
Completed and Coalesced as diagnostics - a steadily climbing Coalesced means the callers are outrunning
the producer, which is the job doing its job.
* Grid accepts empty cells. The controls parameter is IFocusable?[][], so a cell can be null and a whole
row can be [], instead of needing a filler control to hold a gap open. (This surfaced a latent null
dereference in Layout.Controls, fixed with it.)
CHANGED
* UI.Start asks the console whether it speaks ANSI instead of assuming it does. A Windows console does not
interpret escape sequences until an application enables VT processing, and a console with the machine-wide
"Use legacy console" option ticked refuses to enable it at all - so there was no picture rather than a
degraded one: every sequence landed on screen as literal text from the first frame. Enabling and detecting
are the same call, so Start now makes it before anything is drawn and falls back to the Win32 render path
when the console refuses. It is a downgrade only: passing isAnsiTerminal: false still forces the legacy
path, and a caller-supplied IConsole (a test stub or a headless render) is never probed. A redirected
stdout is deliberately NOT treated as legacy - GetConsoleMode fails on a pipe, which says nothing about
what will eventually read the bytes.
* Autowrap (DECAWM) is turned off for the duration of an ANSI session, and restored on exit. A full-screen
renderer writes every cell including the bottom-right one, which leaves the terminal in "pending wrap" -
and a terminal that resolves that eagerly rather than deferring it scrolls the screen by one row. That is
not one bad frame: the renderer diffs against its own model of the screen, so after an unnoticed scroll
every cell it believes unchanged is displayed one row off and never rewritten, smearing stale content
across the whole screen. Nothing here needs wrapping - every discontiguous cell is positioned with an
explicit CUP and no run crosses a row boundary.
FIXED
* Input was dead on a legacy Windows console. Such a console refuses VT mode on both handles; rendering
correctly fell back to the Win32 path, but the input source did not follow, because UI.Start only chooses
one when the caller supplied none - and VtInputSource then ignored its own SetConsoleMode failure and kept
reading stdin. Without VT input mode the console never encodes arrows, function keys, mouse or paste as
bytes at all, while plain characters and Ctrl+letter still arrive as the real control characters they are.
So the app came up looking healthy and navigated for nothing: every arrow and every Ctrl+arrow silently
did nothing while Ctrl+letter hotkeys worked. Start now detects a VT source the terminal refused and
swaps in the keyboard-only ConsoleInputSource. Mouse and hover are unavailable there, which is inherent -
such a console cannot report them.
Disposing the refused source is not enough on its own, and the reason generalises: the reader keeps one
outstanding read on the console handle and a console read cannot be cancelled, so Dispose ends the thread
but leaves the read pending for the life of the process. A replacement source then shares one input queue
with that orphan and keys alternate between the two readers - roughly every other press disappears, which
shows up as a hotkey that only works on the second try. VtInputSource therefore no longer starts its
reader at all in that state, and no longer emits the mouse/paste/focus enable sequences to a console that
would print rather than obey them.
* Setting Control.Width or Control.Height at runtime left a composite drawing a stale picture. The setters
resized directly, which changes Size but skips what Initialize does around it: the backing console buffer
was never re-sized, so the control reported an area its own buffer could not serve, and OnInitialization
never fired - which is what tells a CompositeControl to re-lay-out its children. They therefore kept the
old size indefinitely until some unrelated event forced a real layout pass. Setting Width had a second
bug of its own: it paired the new width with the REQUESTED height, which is 0 in the common "fill the
parent" case, so setting a width could collapse the height to nothing.
* A composite could throw NullReferenceException out of a paint on terminal resize. Its cell reads were
bounds-checked against Size but not against the buffer behind it, and the two diverge for a composite
that changes its own size in response to a resize - it can be read after Size has grown but before the
buffer has followed. An out-of-range read now yields a transparent cell, which is what a not-yet-painted
cell means everywhere else, and the next layout fills it in. A blank cell for one pass is a recoverable
glitch; a throw out of a paint is not.
* A Plot point just outside the top or left edge of its area threw inside the braille brush. The bounds check
ran after dividing the sub-pixel coordinate into a cell, and integer division truncates toward zero - so a
point less than one cell outside the edge came out as cell 0 with a negative sub-pixel, passed the check,
and BrailleBrush threw ArgumentOutOfRangeException for it. The exception was swallowed upstream, so it never
surfaced as an error; it surfaced as one throw per clipped point, per frame - a waveform just touching the
top of its box pays that on every sample that touches it. Negative coordinates are now rejected before the
division, as the deeper overshoots always were.
* Updated ConsolePlot and Spectre.Console dependencies which contained a vulnerable package.
EXAMPLES
* New Wolf3D demo - the Wolfenstein 3D engine ported to the terminal, with a software raycaster, sprites,
working doors, weapons, and the original status bar. It needs the game data, which it locates or installs
itself; the shareware episode is enough.
* The 3D sandbox reads .STL and .PLY alongside .OBJ, with per-facet colour where the format carries it.
Anti-aliasing is now a single quadrant-glyph path (the switch is labelled "Quadrant glyphs"), there is a
Shade level slider (the shaded renderer defaults to 16 levels, up to 32) and a Texel level slider beside it
for a texture's own colours (also 16, up to 32), and the camera pad is docked bottom-right. Its rasteriser runs on a Control.Job, so
loading a large model no longer blocks the UI. --model takes a directory as well as files, making every
model in it spawnable (it previously crashed on one).
* The 3D model viewer comes up at once and loads its folder behind a modal progress dialog, instead of
parsing every model before the UI appeared - which, on a folder of large models, was seconds of blank
terminal. The viewer keeps turning its generated knot, dimmed, under the dialog, which names each file as
it parses. Esc stops the load and keeps what has loaded. A file that fails no longer ends the app: the rest
of the folder loads and the failures are listed afterwards. The same dialog covers opening a folder from
inside the viewer, which used to freeze it for the length of the parse.
* The model viewer's command is now "3dsandbox view" rather than "3dsandbox obj", since it reads .STL and
.PLY as well and takes a directory as readily as a file. "obj" still works as an alias.
* The 3D model viewer draws textures. An OBJ's .mtl materials are read - diffuse colour and map_Kd, from PNG
or JPEG - and drawn by the shaded renderer, with a Texture off/auto/on drop-down in the viewer. Auto, the
default, draws a map only when it will read at terminal resolution: a busy one (fine print, dense noise)
renders as noise worse than the shading it replaces, so it falls back to the material's flat colour. Maps
are reduced and quantised once at load, so a texture costs nothing per frame beyond its sampling. Damaged
image files fail the load with the file named rather than drawing wrongly: the PNG decoder is guarded
against a truncation that would otherwise hang it, and JPEG decoding treats every warning as an error. A new slider allpws you
to set the number of Texel color levels.
* The AudioScope demo gained --play, which plays the file through the default output device and scopes the
samples on their way there, so the picture and the sound are the same audio at the same moment. File input
now plays at real time - each frame advances by the audio actually elapsed - and the status bar reports the
overlap achieved rather than the one requested. On Windows, loopback capture opens a sized, event-driven
WASAPI stream instead of the legacy default, so the waveform slides rather than stepping at the device's
callback rate.
* The 3D sandbox harness and the other demo harnesses moved to tests/*.Harness and joined the solution, so a
solution build compiles them instead of letting them rot.
* THIRD-PARTY-NOTICES.TXT lists the demos' NuGet dependencies as well as the vendored code, and both Docker
images ship it alongside LICENSE. The images carry every model file the 3D sandbox can read, with their
materials and texture maps.
* One Docker image now, and every app in it is a NativeAOT binary. allisterb/jumbee-console IS the NativeAOT
image - published under that name and as allisterb/jumbee-console-aot, one image under two names - so every
documented docker run command keeps working, IDE demo included. It gained the IDE demo and the Wolf3D
walkthrough, which were never AOT-compiled before (both trim with no warnings; the IDE's terminal pane was
proven under AOT in a Linux container). There is no .NET runtime in it, so it is about 490 MB against the
old JIT image's 680 MB, and every app starts at once. Mount Wolf3D's game data at /app/wolf3d/GameData, or
anywhere and pass the path. The old JIT image's Dockerfile has been removed.
* The image is multi-arch: linux/amd64 and linux/arm64, so Apple Silicon Macs run native arm64 binaries
instead of emulating amd64. The arm64 build is cross-compiled rather than built under emulation, and
build-docker verifies it by running it under QEMU.
* The IDE demo's Build, Run and Clean check for a .NET SDK first, and where there is none - the Docker image
carries no .NET at all - offer to install one, running apt-get install dotnet-sdk-10.0 in the IDE's own
terminal pane. They used to type a dotnet command into the pane that could only fail: the published image
had already dropped the SDK, so the Build menu had been broken there.
* Wolf3D crashed under NativeAOT when started without a data folder: it located its GameData folder through
Assembly.Location, which is empty under NativeAOT, and threw before drawing anything. It uses
AppContext.BaseDirectory now, and trims with no warnings.
* build.cmd and examples.cmd take a target (browser, agent-harness, ide, audio-scope, 3dsandbox, wolf3d,
core, examples, all), and --verify runs each demo's headless smoke check - 47 example screens plus five
demo checks, all rendering, in one command.
DOCS
* docs/internal/Input.md gained a section on what the legacy input source can and cannot deliver, and on
the silent-failure mode above: how a VT input source that was refused still looks alive, why swapping it
out is not enough on its own, and the field signature of the orphaned read (a hotkey that works on the
second press). JUMBEE_INPUT_LOG is documented as the way into that layer, and now announces itself on
stderr at startup and shutdown - it appends on every read, so a forgotten environment variable is a cost
you should be told about rather than discover.
0.2.0
-----
ADDED
* UI.RestoreBuiltInHotKeys() - drops every hotkey an app registered with UI.RegisterHotKey, keeping the
library's own (Ctrl+Q, the Ctrl focus tier, F1). UI.Stop now calls it, so hotkeys are scoped to the
session that registered them, alongside the control list Stop already cleared.
* IScrollable - the opt-in that makes a framed control scroll. A ControlFrame owns the viewport, the
scrollbar and the offset; the control's side of the bargain is one honest answer, "how tall is my content
at this width", which is now IScrollable.MeasureHeight. Implemented by ListBox, Tree, TextEditor,
TextPanel, CodeEditor, MarkdownViewer, AsciiDocViewer and MermaidViewer.
* IScrollable.FocusRowChanged, with the RowSpan struct it carries. A control with a moving point of
interest - a selected item, a caret - raises it when that point moves, and the enclosing frame scrolls the
rows into view. It has a do-nothing default implementation, so a viewer with nothing to report simply
omits it. Declared as a plain field-like event, the compiler reports CS0067 if it is never raised, which
catches the mistake worth catching: a control that claims a moving selection and then lets it scroll off
screen.
* ControlFrame.ScrollIntoView(start, height) and the protected Control.ScrollIntoView, replacing three
private copies of the same clamp in ListBox, Tree and CodeEditor. Scrolls the minimum needed, does nothing
when the rows are already visible, and aligns a span taller than the viewport to its top.
* A frame now reveals a newly focused descendant automatically. Tab into a tall sidebar and the frame
scrolls the focused control into view with no code in the composite: the row is recovered by walking the
drawing-context chain, so it reads the layout's real positions and cannot drift from them the way
arithmetic in a composite would.
* Terminal-write telemetry. The console write runs off the render loop, so frame time never included it and
nothing reported it at all. UI.AverageWriteTime, AverageWriteWaitTime, WriteQueueDepth/Peak,
AverageFrameLatency and PeakFrameLatency now do, fed by ConsoleManager's Emit and paired back to their own
frame by ordinal. PerfHud gained write, wait and latency rows.
* ProcessMetrics.RenderTimeMsDrawnAvg, FrameLatencyMsAvg/Peak, LockContentionsPerSecond, RecordWrite and
ConsoleManager.ResetOutputTelemetry.
CHANGED
* BREAKING: Control.FillsFrameViewport is removed, and frame scrolling is now opt-in rather than opt-out.
Previously a frame gave EVERY child an unbounded height unless it set FillsFrameViewport, and a control
that overrode neither that nor MeasureHeight resolved to the 1000-row size clamp - a scrollbar with a
thumb too small to draw over ~1000 rows of blank space, with nothing thrown, nothing logged, the content
drawn correctly at the top, and a snapshot of the visible area passing. The wrong answer was the one you
got by doing nothing.
To migrate:
Was Now
--------------------------------------------------------------------------------
override FillsFrameViewport => true delete the override (it is the default)
override MeasureHeight, wanted scrolling implement IScrollable
neither, but relied on frame scrolling implement IScrollable
A control that implemented neither and did not want scrolling needs no change. A third-party control that
relied on the old implicit default stops scrolling rather than scrolling wrongly, which is the better
direction to fail, but it is a real break.
* BREAKING: Control.MeasureHeight moved from a protected virtual to IScrollable.MeasureHeight, and is public
on the implementing control. It is deliberately not an explicit interface implementation: those are not
virtual, so the first subclass wanting its own measurement would have had to re-declare the interface.
* Control.OnMouseWheel's default now scrolls the nearest ENCLOSING scrolling frame, not just Frame. The
control under the pointer is almost never the one being scrolled - a button inside a panel of framed
sections has no frame of its own - so a wheel notch over it used to be dropped entirely.
* Slider takes the wheel only when it has focus, or when nothing enclosing it can scroll. A standalone
slider behaves as before; one inside a scrolling panel now passes the notch through instead of making the
panel nearly unscrollable and changing a value the pointer was only passing over.
* PerfHud: the frame row is renamed render and now averages the frames that actually drew (the old figure
was diluted by cheap idle frames, so it did not add up with the write/wait rows); locks is a contention
RATE over the rolling window rather than the lifetime total, so it returns to zero once contention stops;
the timing rows are grouped together; and the panel derives its height from the rows it emits rather than
a hand-kept constant that silently clipped the last metric when it fell behind.
CHANGED
* Grid cells accept null, and a whole row can be written as []. A sparse arrangement - a cross, a keypad,
controls at the corners - no longer needs a filler control per empty cell. A null cell is simply never given
a child, so it costs nothing; the blank TextLabel it replaces was an allocation whose only job was to be
invisible. A row is either fully specified or empty: a row that is merely SHORT still throws, since a
miscounted row is the mistake the arity check exists to catch. Source-compatible - existing call sites pass
non-null arrays and are unaffected.
FIXED
* Control.Width and Control.Height called Resize() directly, which changed the control's Size but skipped
everything Initialize does around it: the console buffer was never re-sized, and OnInitialization never
fired - which is what tells a CompositeControl to re-lay-out its content. So setting either at runtime left
a composite's children at the OLD size indefinitely, drawing a stale picture into a correctly-sized control
until some unrelated event forced a real layout pass. The undersized buffer was worse: the composite
reported an area its own buffer could not serve, and reading a cell there threw NullReferenceException out
of a paint. Both setters now go through Initialize, guarded so an unchanged value costs one comparison.
CompositeControl's indexer also bounds-checks against the buffer as well as against Size, so an
out-of-range read yields an empty cell rather than throwing.
Found in the Wolf3D demo's status bar, which sets its own Height on a terminal resize: control 237x15 over
content 237x50, so it drew the top 15 rows of a 50-row picture. It did not reproduce through
ConsoleSnapshot, because ToText re-lays-out the whole tree on every call and papers the missing
propagation over - the regression test therefore docks a composite and sets Height with no intervening
layout trigger.
* Tree reported no content height, so a framed Tree scrolled over the 1000-row clamp rather than its own
rows - a sliver of a thumb, and a scroll range with nothing in most of it. It had been scrolling by
accident for as long as it has existed, which is why nothing caught it; the test asserting that navigation
scrolls passed for the wrong reason. Tree now reports its real row count.
* A framed DataTable drew a dead scroll track and lost its own bottom border. It owns its viewport and draws
its own scrollbar, but declared neither of the old opt-outs, so the frame gave it the unbounded height,
took a column for a scrollbar whose thumb was too small to render, and clipped the table's last row out of
reach - the wheel is consumed by the table, so nothing could scroll it back. Framing a table for a border
and a title is the common case and now works.
* Tree's scroll-into-view lacked the guard ListBox had for a node taller than the viewport, so navigating to
one scrolled past its own first row and pushed the just-selected node off the top. Both now share one
implementation.
* Control.Dispose leaked the frame's UI.FocusChanged subscription. The Frame setter attached the frame to
both UI.ThemeChanged and UI.FocusChanged; Dispose detached only the first, so every framed control ever
created kept handling focus moves for the life of the process.
* ConsoleManager no longer commands the terminal in response to a terminal resize. AdjustBufferSize called
Resize, whose StandardConsole setter drives SetBufferSize/SetWindowSize - and those can fail or clamp
silently, so the size read back differed again next frame and the whole UI re-laid-out every frame. That
feedback loop is why AnsiTerminalConsole existed to route around it. Observing a resize now adopts it; the
console still syncs its own scroll buffer, so a full-screen UI keeps a host scrollbar off the app.
* The legacy console no longer collapses to a single cell. StandardConsole's Size setter shrank the window
to 1x1 as an intermediate step before resizing the buffer, and SafeConsole swallows failures - so if
either following call failed, the window was simply left at 1x1 and the app looked like it had vanished
while the process kept running. It now shrinks only to the target size.
* ChatPrompt is one row tall in a plain layout cell, and one column wider inside a frame. It reported its
single row as an IScrollable content height, which is honoured only under an unbounded parent, so unframed
it filled its cell; and being scrollable cost it a column of every frame's interior for a scrollbar that
could never appear. It states the row as an intrinsic height instead.
EXAMPLES
* The 3D sandbox model viewer's sidebar scrolls, so it stays usable on a short terminal. It also gained a
"Scale All" slider that drives the three axis sliders together, a Reset in each of the Scale and Shear
sections (each undoing only its own section - the whole-transform reset is still Model > Reset transform
and the 0 key), and the mouse-wheel zoom direction is corrected: wheel up now zooms in.
* The agent harness demo's task list stopped round-tripping every style through markup text. It built a
markup STRING with Style.ToMarkup()/Color.ToMarkup() and let Markup parse it straight back through the
tokenizer, style parser and hex-colour parser - 57% of the whole demo's allocations. Emitting styled
Segments directly, and computing the pane height arithmetically rather than rendering it to count lines,
cut the demo's total allocations from ~482k to ~123k and its per-frame allocation by roughly half.
DOCS
* New "Scrolling" section in docs/controls/Control Model.md - the canonical recipe, in the guide someone
framing a control actually opens rather than filed under composites. It names the failure mode outright
("a control that doesn't implement IScrollable is sized to the viewport and never scrolled") and covers
the two things a frame will not do for you: focus within a control, and the column the scrollbar costs.
* The PerfHud counter table in docs/controls/Live Data.md is rewritten for the new metrics, with a section
on why latency is not a frame budget: render and write overlap, so throughput is bounded by whichever is
slower while latency is their sum.
* New internal design note at docs/internal/Scrolling.md recording why the old default was the broken one,
why the focus row is an event rather than a polled property, and what CS0067 does and does not enforce.