MentalDesk.Tui
0.2.0
dotnet add package MentalDesk.Tui --version 0.2.0
NuGet\Install-Package MentalDesk.Tui -Version 0.2.0
<PackageReference Include="MentalDesk.Tui" Version="0.2.0" />
<PackageVersion Include="MentalDesk.Tui" Version="0.2.0" />
<PackageReference Include="MentalDesk.Tui" />
paket add MentalDesk.Tui --version 0.2.0
#r "nuget: MentalDesk.Tui, 0.2.0"
#:package MentalDesk.Tui@0.2.0
#addin nuget:?package=MentalDesk.Tui&version=0.2.0
#tool nuget:?package=MentalDesk.Tui&version=0.2.0
MentalDesk TUI style guide
How terminal UIs in this org should look and behave, so that requirements, implementations and reviews all start from the same assumptions.
Read this before writing a requirement that describes UI, and before building one. It is deliberately small: it covers the things that keep coming back in review, not everything a UI can do. It grows by pull request as new ones come back (see Extending this guide).
The framework is Terminal.Gui v2. Rules that are really framework mechanics say so; the rest are design rules that would hold in any TUI.
Using this guide. The rules are here. Each one is also built, once, in
MentalDesk.Tui, the shared library, and shown working in
Swatch, a theme editor that exists to exercise them
(dotnet run --project src/Swatch). When you build something a rule covers, start from the
library type its section names, not from TuiCode's or a-team's own version: those are the older
copies the library was taken from. Where a section still names TuiCode or a-team code, the library
doesn't have that piece yet. Apps reference the library as the
MentalDesk.Tui package rather than copying from it.
Contents
- Build from the framework's widgets
- One affordance per action
- Errors and status
- Icons and glyphs
- Keys and focus
- Scrolling
- Writing requirements
1. Build from the framework's widgets
Reach for a built-in view before writing your own. A built-in already has the focus handling, mouse handling, theming and keyboard conventions that a bespoke control has to reinvent and usually gets half right.
Pick the control that matches the shape of the input, not the one that is easiest to draw:
| The input is | Use |
|---|---|
| On or off | CheckBox |
| One of a few choices, all worth showing | OptionSelector<T> |
| Several independent on/off flags | FlagSelector<T> |
| A number in a range | NumericUpDown<T>, with a minimum and maximum |
| One of many choices | DropDownList<T> or ListView |
| One of many, with hierarchy | TreeView |
| A short free-text value | TextField |
| Multi-line free text | TextView |
| Progress of a known-length job | ProgressBar |
Style a built-in before you replace it. Most of what looks like "we need a custom control" is a
property: NoDecorations, NoPadding, ShadowStyle, SchemeName, Orientation, TabBehavior.
A custom view needs a reason, stated in the PR — name the built-in you rejected and what it
could not do. TuiCode's LogView is custom only because TextView cannot scroll without moving its
cursor; that sentence is the whole bar to clear.
2. One affordance per action
Never give the same action two affordances in the same view. A dialog with a Submit button
and a hint reading Ctrl+Enter submit is telling the user the same thing twice and asking them to
work out whether it is one thing or two.
Prefer the hint, and make it clickable. Hint text is more compact than a button, it teaches the
keyboard shortcut, and it can still be clicked. A Terminal.Gui Button with its decorations turned
off is a clickable hint:
private static Button Hint(string text, Pos x) => new()
{
Text = text,
X = x,
Y = Pos.AnchorEnd(1),
NoDecorations = true,
NoPadding = true,
ShadowStyle = ShadowStyles.None,
HotKeySpecifier = (Rune)0xffff, // the hint names its own key; don't also claim a hotkey
};
Use a real, decorated Button only where there is no key to name — a toolbar action, or a choice
that has no sensible shortcut.
The hint bar
Hints belong on the last row of the view, anchored with Pos.AnchorEnd(1), separated by
• (two spaces, U+2022, two spaces).
For the app's main screen, that row is the status bar: the full width of the screen's last row,
never the window title or the foot of one pane. Give it its own colour, a StatusBar scheme
whose background differs from every region above it, in every theme. In the content's colours it
reads as one more line of content. The same row carries the focus word (section 5) and errors
outside a dialog (section 3).
- Framework mechanic: TG's built-in
StatusBarpaints in theMenuscheme and draws a border between its items, so the status bar is a plain one-rowViewin theStatusBarscheme instead.
The library's AppStatusBar, with its hints laid out by
HintRow, and the StatusBar scheme in each of its
themes are the reference implementation.
Write each hint as key first, then a lower-case verb phrase: the key is what the user is scanning for.
Type to filter • Up/Down/PgUp/PgDn • Enter compare • Esc cancel
Ctrl+Enter submit • Esc cancel
- Order by how often the hint is used, with cancel last.
- Name keys as the terminal reports them:
Ctrl+Enter,Esc,Up/Down,PgUp/PgDn. - Leave out keys that every view has (
Tabto move focus). Name the ones specific to this view. - Keep it to one row. If the hints don't fit, the view is doing too much.
3. Errors and status
An error must never be interleaved with the controls. Growing a Label in place pushes it over
whatever sits below, and an error that lands between the buttons reads as part of them.
Give the dialog a dedicated message block at its foot, below the hints, spanning the full width, occupying zero rows while there is nothing to say. When a message arrives, the dialog grows and the content above it shrinks — the message never steals the hint row.
The library's AlertView is the reference implementation: it
word-wraps to as many rows as the message needs, reports that count as Lines so the dialog can
re-lay itself out (AppDialog grows by it), and picks its colours from
the theme by severity rather than hard-coding them.
| Severity | Scheme | For |
|---|---|---|
Error |
Error |
The action was refused or failed |
Info |
Accent |
Progress, or a fact the user needs before acting |
Rules that matter more than the widget:
- Keep what the user typed. A refused action leaves the dialog open with its input intact. Never clear a form because the server said no.
- Say what happened, in the app's terms. One line. The first line of the tool's stderr beats an exception type; a stack trace belongs in the log, never on screen.
- Put focus where the fix is. A missing summary focuses the summary field.
- Show busy, and refuse a second go.
Submitting…in the message block, with the confirm disabled until it resolves. - Outside a dialog, errors go to the status bar — same wording rules, one line. Don't open a modal to report something the user did not just ask for.
4. Icons and glyphs
Use Nerd Font glyphs where an icon does real work. An icon earns its place when it classifies (this row is a folder, this one a C# file) or disambiguates at a glance. Decoration does not earn its place — a glyph on every label makes the ones that mean something invisible.
- One vocabulary per app. Reuse a glyph already in use before picking a new one, and pick from
one Nerd Font set (
nf-md-*, say) rather than mixing. - Always have a fallback. No terminal reports its font, so assume some users have no Nerd Font: fall back to emoji, then to plain text. Make it a user-visible setting where icons are prominent.
- Draw the icon; don't put it in the text. Prepend the glyph's cells at draw time so the item's
text stays the bare name — otherwise filtering, sorting and type-to-jump all match against the
glyph. See TuiCode's
FileIcons. - Budget the cells. Nerd Font glyphs are one cell; emoji are two. Lay out for the fallback you actually ship, or columns shift when the style changes.
- Colour comes from the theme, and the icon keeps its row's background so selection still reads.
5. Keys and focus
Esccancels. Always. It never does anything else.Enterconfirms a single-line dialog;Ctrl+Enterconfirms one with a multi-line field, where plainEnterhas to insert a newline.- Every action is reachable from the keyboard. The mouse is a convenience, never the only way.
- Tab moves focus, everywhere. In a
TextViewinside a dialog, setTabKeyAddsTab = falseso Tab leaves the field instead of typing into it. - Focus lands where work starts when a view opens — the filter field, the summary, the first row.
- A container
Viewthat hosts focusable children needsCanFocus = true;SetFocus()silently returns false when any ancestor has it off.
Showing focus
Focus has one owner, and that owner is not the framework's HasFocus. Keep the focused
region in one place and re-read it from the view the framework reports as focused, on every
iteration and before every key — a mouse click moves focus between iterations. TG leaves HasFocus
set on a view that focus has moved on from, so reading it view by view gives you two views that
both claim the keyboard. Navigation.GetFocused() can also return an ancestor of the view
actually holding keys, so walk down to the innermost with View.MostFocused.
Put it on screen twice: in colour and in a word. The focused pane draws its border in the theme's focus colour, and a word naming the region sits at the far left of the status bar, always present. The colour is what you notice; the word is what still works when the theme is pale, the terminal profile overrides, or the user can't tell the two colours apart. Without either, a focus move that went somewhere unintended is invisible until the user types.
- Framework mechanic: TG draws border lines in
VisualRole.Normalwhatever has focus. Swap the role as the attribute is resolved — aGettingAttributeForRolehook mappingNormal→FocusandHotNormal→HotFocus— rather than overriding the pane's scheme, which has to be reapplied on every theme change. - Don't read the framework's button painting as the answer either: TG draws the first
Buttonin theFocusattribute whether or not it has focus, so the thing that looks focused is often the wrong one.
The library's FocusTracker and
FocusBorder, wired up in AppShell, are the
reference implementation. FocusTracker is framework-free and unit-tested directly: the host
registers each region with a move and an ownership test and supplies the focused view.
What a command acts on
A command acts on the selection, and the selection stays put while the menu or the palette has focus. Opening either one takes focus from the content. A command that finds its target by asking what has focus finds nothing there, so its menu item greys out a moment after the menu opens, just as the user reaches for it.
- Read the target, and
isEnabled, from state the content keeps: the list's selected row, the pane last selected. Never fromHasFocusorNavigation.GetFocused(). - Where selecting a thing is focusing it, as in a grid of panes, remember the last one focus was in, and keep answering with it while focus is outside them.
- Test it with the menu open. Select something, open the menu, refresh it, and check the item is still enabled. Refreshing matters: an app that refreshes its menu on a timer greys the item on the first tick, not when the menu opens.
FocusTracker already does this for regions: while focus is in a view no region owns, such as the
open menu, it keeps the region it had. Swatch's Theme › Use for Swatch is the reference for a
selection. It acts on the theme selected in the tree, and is enabled only while that isn't the theme
already in use.
The caret
The caret is the terminal's own cursor. Colour it from the theme with OSC 12 whenever the
theme is applied (the theme's Cursor scheme, which nothing draws with) and restore the terminal's
own with OSC 112 on exit. Terminals that don't support it ignore the sequence.
Paint a caret only where the terminal cannot draw one — a second caret, say, in a terminal without kitty's multiple cursors protocol. Then three rules, all of them things that have come back in review:
- Paint every caret and hide the terminal cursor. One painted caret beside one real one is two different-looking things on screen claiming to be the same thing.
- A caret is a bar before the insertion point, everywhere. The terminal cursor is a bar, so a painted one is a bar too — the shape a user sees must not depend on which terminal they have, on how many carets are on screen, or on whether they are in the editor or a dialog. An underline vanishes under an underscore; a reversed cell reads as a selection.
- Invalidate the view whenever a caret moves. A painted caret only moves when the view redraws.
A caret that snaps into place only once the user types is a missing
SetNeedsDraw(), not a drawing bug — the terminal cursor hid this, because the framework moves that one without a repaint.
The library's TerminalCursor sets, restores and reads back the
cursor colour, and the Diagnostics dialog shows what was asked for beside what the terminal reports.
Painted carets aren't in the library: TuiCode's
EditorTextView.Carets.cs
and TerminalCursors
are the reference implementation. A dialog's field gets this by reusing them, not by writing a
second caret — which is also how it stays one shape.
6. Scrolling
Anything that scrolls shows a scroll bar while its content doesn't fit. It is the one cue that
says, at a glance, where you are and how much is left. Without it, a pane that scrolls on
PgUp/PgDn looks like it ends at its last visible row.
- Auto: never always, never off. The bar appears when the content outgrows the view and goes when it fits again, so content that fits keeps every column. No setting to hide it.
- Horizontal too, where lines don't wrap. A line running past the right edge gets a bar along the bottom on the same terms. A view that wraps never needs one.
- Focus doesn't matter. A pane that never takes focus, scrolled by its parent's keys, still shows its bar.
- One bar for panes that scroll together, such as the two sides of a diff.
Framework mechanics:
- Every
Viewhas aVerticalScrollBarand aHorizontalScrollBar, hidden until you setViewportSettingsFlags.HasVerticalScrollBar/HasHorizontalScrollBar(orVisibilityMode = ScrollBarVisibilityMode.Auto).TreeView<T>andMarkdownturn theirs on;TextViewships with them off, so setScrollBars = true. - These bars track the view's content size and
Viewport, and the mouse can drag them. A custom view that keeps its own scroll offset gets a bar that never moves: scroll it withSetContentSizeandViewportinstead.
a-team's WorkView is the
reference implementation of a custom view scrolled that way.
7. Writing requirements
A requirement that describes UI is not done until a reader can build it without guessing.
Name the control for every element, using the names in section 1, and sketch it. The sketch carries the layout; the control names carry the behaviour.
┌─ Submit review on #187 ────────────────────────────────┐
│ (•) Comment ( ) Approve ( ) Request changes │ OptionSelector<T>, horizontal
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Summary… │ │ TextView, word wrap; focus starts here
│ └────────────────────────────────────────────────────┘ │
│ Ctrl+Enter submit • Esc cancel │ clickable hints, ` • ` separated
└────────────────────────────────────────────────────────┘
Sketch conventions: [x] / [ ] checkbox, (•) / ( ) option, [ 2 ▲▼] numeric up/down,
[ Button ] button, ▸ collapsed tree node, … placeholder text, ▲ █ ▼ down the right edge
a vertical scroll bar.
Then say, in prose:
- The hint bar, verbatim — it is part of the design, not a detail for the implementer.
- Where focus starts, and what
EnterandEscdo. - What happens when it fails. Which message, shown where, and what survives. A requirement that only describes the happy path gets an error path invented in review.
- Which state is remembered across opens, if any.
Extending this guide
Add a rule when a review comment has had to make the same point twice. A rule here should be short, imperative, and about a decision someone will actually face — if it cannot be violated, it does not need writing down.
Open a pull request. Include the review comment that prompted it in the PR description, not in the guide: the guide says what to do, the PR says why. Change the library and Swatch in the same pull request, so a rule and its reference implementation never disagree.
Repos that follow this guide link to it from their AGENTS.md:
mentaldesk/TuiCode,
mentaldesk/a-team.
| 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
- Terminal.Gui (>= 2.5.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.