Contributor Tutorial 2: Adding a Control
July 8, 2026 · View on GitHub
Difficulty: Intermediate (contributor) | Prerequisites: Read Composite Controls first | Estimated reading time: ~25 minutes
← Composite Controls | Next → Dialogs
What you'll build: BadgeControl — a small primitive that renders a colored count pill like [ 3 ]. It's tiny, but it exercises every piece the framework requires of a real control: layout, the reactive property contract, color roles, correct Unicode width, a builder, and a test.
The previous tutorial built a composite — a control that only arranges other controls and never touches the render engine. That is the safest first contribution precisely because it can't introduce a paint bug. This tutorial goes one level deeper: a primitive that paints its own cells and measures its own size. That means you now own the two hardest parts of a terminal UI — measuring text width correctly and writing cells to the buffer — so we'll be careful about both.
Read SpinnerControl.cs alongside this tutorial. SharpConsoleUI/Controls/SpinnerControl.cs is the simplest real primitive in the codebase, and it is the model for everything below: what it inherits, how it measures, how it paints, and how it resolves its color role. Every structural claim in this tutorial matches what SpinnerControl actually does — keep it open in a split pane and compare as you go.
Step 1: Decide where the file goes
A primitive control lives beside its peers:
SharpConsoleUI/Controls/BadgeControl.cs
SharpConsoleUI/Builders/BadgeControlBuilder.cs
Start every source file with the project's license banner (copy it verbatim from the top of SpinnerControl.cs):
// -----------------------------------------------------------------------
// ConsoleEx - A simple console window system for .NET Core
//
// Author: Nikolaos Protopapas
// Email: nikolaos.protopapas@gmail.com
// License: MIT
// -----------------------------------------------------------------------
Step 2: The interfaces — implement only what the badge needs
SharpConsoleUI follows Interface Segregation: a control implements one small interface per capability it actually has, rather than one broad interface with methods it must stub out. A badge is not focusable and does not react to the mouse, so it implements neither IFocusableControl nor IMouseAwareControl. It does have a themable surface (the pill color), so — exactly like SpinnerControl — it implements IColorRoleableControl. See patterns.md for the full interface catalog and the ISP rationale.
The base class BaseControl already supplies the layout plumbing (ActualWidth, Margin, Invalidate, SetProperty, and the abstract ContentWidth / MeasureDOM / PaintDOM / GetLogicalContentSize members you'll override). So the declaration mirrors SpinnerControl : BaseControl, IColorRoleableControl:
using System.Drawing;
using SharpConsoleUI.Helpers;
using SharpConsoleUI.Layout;
using SharpConsoleUI.Parsing;
using SharpConsoleUI.Themes;
using Size = System.Drawing.Size;
namespace SharpConsoleUI.Controls;
/// <summary>
/// A small count badge that renders a colored pill such as <c>[ 3 ]</c>. The pill color
/// is driven by the control's <see cref="ColorRole"/> (e.g. Primary for a normal count,
/// Danger for a warning count).
/// </summary>
public class BadgeControl : BaseControl, IColorRoleableControl
{
}
IColorRoleableControl requires three members — ColorRole, ColorRoleMode, and Outline. SpinnerControl implements them as a small region; copy that shape verbatim (all three go through SetProperty, which we cover in Step 4):
#region ColorRole
private ColorRole _role = ColorRole.Default;
private ThemeMode? _colorRoleMode;
private bool _outline;
/// <inheritdoc/>
public ColorRole ColorRole
{
get => _role;
set => SetProperty(ref _role, value);
}
/// <inheritdoc/>
public ThemeMode? ColorRoleMode
{
get => _colorRoleMode;
set => SetProperty(ref _colorRoleMode, value);
}
/// <inheritdoc/>
public bool Outline
{
get => _outline;
set => SetProperty(ref _outline, value);
}
#endregion
Step 3: The Count property via the reactive contract
Here is the part a previous tutorial got wrong — and it was caught in review. A control property setter must go through SetProperty, never a hand-rolled set { _count = value; ... }:
private int _count;
/// <summary>Gets or sets the number shown inside the pill.</summary>
public int Count
{
get => _count;
set => SetProperty(ref _count, value);
}
Why this exact form matters. SetProperty (defined on BaseControl, SharpConsoleUI/Controls/BaseControl.cs) does three things in one call:
- Change-guards — returns early if the new value equals the old, so setting
Count = 3when it's already3does no work and fires no notification. - Raises
INotifyPropertyChanged(OnPropertyChanged) — so data-bound consumers see the change. - Self-invalidates — calls
Invalidate(Invalidation.Relayout)on the control itself, which schedules a re-measure and repaint.
That third step is why the framework is reactive at the property boundary: assigning badge.Count = 5 makes the UI update on its own. You never write an Invalidate call for a property change. See patterns.md for the full property/invalidation contract.
Do NOT hand-roll
Container?.Invalidate(...)in a setter. A setter that pokes the container directly (set { _count = value; Container?.Invalidate(true); }) bypasses the change-guard and the notification, and invalidates the wrong node. It is rejected in review. InvalidatethisviaSetProperty; the framework forwards identity for you.
SetProperty issues a Relayout (the count can change the pill's width, so layout must re-run). If you were adding an appearance-only property that can't change size — a color, say — you'd use the granular Invalidate(Invalidation.Repaint) form (with a change-guard and OnPropertyChanged), exactly as SpinnerControl's Color setter does. Count affects width, so SetProperty/Relayout is correct.
Step 4: Measure → Arrange → Paint
Every primitive answers two questions for the layout engine: how big am I? (measure) and draw yourself here (paint). Arrange happens in between and is done for you by the container — it hands your PaintDOM the final bounds. Read DOM_LAYOUT_SYSTEM.md for the full measure/arrange/paint walk.
First, a small helper for the rendered pill text and its display width — we'll use it in both measure and paint so the two never disagree:
/// <summary>The literal pill text, e.g. "[ 3 ]".</summary>
private string PillText() => $"[ {_count} ]";
/// <summary>The pill's display width in terminal columns.</summary>
private int PillWidth() => UnicodeWidth.GetStringWidth(PillText());
Note we measure with UnicodeWidth.GetStringWidth (SharpConsoleUI/Helpers/UnicodeWidth.cs), not PillText().Length. string.Length counts UTF-16 code units, not terminal columns — it's wrong for wide and combining characters (see the Unicode-aware rendering rules in CODE_QUALITY.md). For an all-ASCII pill the two happen to agree, but using the correct API from the start means the control stays correct if the format ever gains a wide glyph.
Now the three BaseControl overrides. ContentWidth reports the intrinsic width; GetLogicalContentSize reports intrinsic size including margins; MeasureDOM clamps that into the constraints. These mirror SpinnerControl exactly:
/// <inheritdoc/>
public override int? ContentWidth => PillWidth();
/// <inheritdoc/>
public override Size GetLogicalContentSize()
{
int width = PillWidth();
int height = 1 + Margin.Top + Margin.Bottom;
return new Size(width + Margin.Left + Margin.Right, height);
}
/// <inheritdoc/>
public override LayoutSize MeasureDOM(LayoutConstraints constraints)
{
int width = PillWidth() + Margin.Left + Margin.Right;
int height = 1 + Margin.Top + Margin.Bottom;
return new LayoutSize(
Math.Clamp(width, constraints.MinWidth, constraints.MaxWidth),
Math.Clamp(height, constraints.MinHeight, constraints.MaxHeight)
);
}
MeasureDOM(LayoutConstraints constraints) returning a LayoutSize is the exact signature from IDOMPaintable / IDOMMeasurable (SharpConsoleUI/Layout/IDOMPaintable.cs). The badge is a one-line control, so height is always 1 (plus vertical margin). Math.Clamp is safe here because the constraints always carry MinWidth ≤ MaxWidth.
Step 5: Paint — resolve the role color, then write cells
The paint signature is fixed by IDOMPaintable — copy it character-for-character:
/// <inheritdoc/>
public override void PaintDOM(CharacterBuffer buffer, LayoutRect bounds, LayoutRect clipRect, Color defaultForeground, Color defaultBackground)
{
SetActualBounds(bounds);
int x = bounds.X + Margin.Left;
int y = bounds.Y + Margin.Top;
if (y < clipRect.Y || y >= clipRect.Bottom || y >= bounds.Bottom) return;
// Resolve the pill color from the semantic role via the active theme —
// never a hardcoded Color literal. Same call SpinnerControl uses.
Color fg = ColorResolver.ColorRoleForeground(ColorRole, Container, Outline, mode: ColorRoleMode)
?? defaultForeground;
Color bg = Container?.BackgroundColor ?? defaultBackground;
// The pill is literal narrow ASCII ("[ 3 ]"), so write each cell with
// SetNarrowCell — it assumes width-1 and is the correct API for literal
// narrow characters.
string text = PillText();
foreach (var rune in text.EnumerateRunes())
{
if (x >= bounds.Right || x >= clipRect.Right) break;
if (x >= clipRect.X)
buffer.SetNarrowCell(x, y, rune, fg, bg);
x++;
}
}
Two rules from the buffer-write contract (see the Unicode-aware rendering section of CODE_QUALITY.md) are load-bearing here:
-
Color comes from the role, not a literal.
ColorResolver.ColorRoleForeground(...)(SharpConsoleUI/Helpers/ColorResolver.cs) derives the color from the active theme's palette for the chosenColorRole— so aDangerbadge is red in whatever the current theme calls red, and it re-themes for free. This is the identical callSpinnerControl.PaintDOMmakes. Never writeColor.Redin a control. See THEMES.md. -
Literal narrow chars use
SetNarrowCell.SetNarrowCellassumes a width-1 character and clears cell flags — correct for the brackets, spaces, and ASCII digits of the pill. The other API,buffer.SetCell(x, y, cell), is for cells that came out ofMarkupParser.Parse(which already carries wide-continuation flags). If your text could contain markup or wide glyphs, you'd parse it instead — that's whatSpinnerControldoes for its (possibly marked-up) frames:var cells = MarkupParser.Parse(text, fg, bg); foreach (var cell in cells) { buffer.SetCell(x, y, cell); x++; }A count pill is plain ASCII, so
SetNarrowCellper rune is the right, simplest choice.
SetActualBounds(bounds) (inherited from BaseControl) records where the control was painted so hit-testing and the reactive layer know its real position — every PaintDOM calls it first, just like SpinnerControl.
Step 6: A Controls.Badge(int count) builder
Users create controls through the fluent Controls.<Name>() factory, not new. Add a builder modeled on SpinnerBuilder (SharpConsoleUI/Builders/SpinnerBuilder.cs); see BUILDERS.md for the convention.
SharpConsoleUI/Builders/BadgeControlBuilder.cs:
using SharpConsoleUI.Controls;
using SharpConsoleUI.Themes;
namespace SharpConsoleUI.Builders
{
/// <summary>Fluent builder for <see cref="BadgeControl"/>.</summary>
public class BadgeControlBuilder : IControlBuilder<BadgeControl>
{
private readonly BadgeControl _control = new();
/// <summary>Sets the count shown in the pill.</summary>
public BadgeControlBuilder WithCount(int count) { _control.Count = count; return this; }
/// <summary>Sets the semantic colour role (e.g. Primary, Danger).</summary>
public BadgeControlBuilder WithColorRole(ColorRole role, ThemeMode? mode = null)
{ _control.ColorRole = role; _control.ColorRoleMode = mode; return this; }
/// <summary>Renders the badge in outline style.</summary>
public BadgeControlBuilder Outline(bool outline = true) { _control.Outline = outline; return this; }
/// <summary>Sets the control name.</summary>
public BadgeControlBuilder WithName(string name) { _control.Name = name; return this; }
/// <summary>Builds the configured <see cref="BadgeControl"/>.</summary>
public BadgeControl Build() => _control;
}
}
Then add the factory to Controls (SharpConsoleUI/Builders/Controls.cs), right beside the existing Spinner() factory:
/// <summary>Creates a new badge builder seeded with a count.</summary>
/// <param name="count">The initial count shown in the pill.</param>
/// <returns>A new badge builder.</returns>
public static BadgeControlBuilder Badge(int count) =>
new BadgeControlBuilder().WithCount(count);
Now a caller writes Controls.Badge(3).WithColorRole(ColorRole.Danger).Build().
Step 7: The "real thing" test
The rule for this codebase (see CODE_QUALITY.md, the "Real thing" test required section) is that a test must exercise the actual usage path end to end and, crucially, assert that a change survives a re-render. Isolated asserts have passed green while the live control was visibly broken — so we build the real control, paint it, read the cells back, change Count, paint again, and confirm the new value is what's on screen.
Tests paint into a CharacterBuffer and read cells back with buffer.GetCell(x, y) — the recipe used throughout SharpConsoleUI.Tests/Controls/. A tiny paint helper mirrors SpinnerControlTests:
using System.Drawing;
using System.Text;
using SharpConsoleUI.Controls;
using SharpConsoleUI.Layout;
using SharpConsoleUI.Themes;
using Xunit;
public class BadgeControlTests
{
private static CharacterBuffer Paint(BadgeControl b, int w = 20, int h = 1)
{
var buffer = new CharacterBuffer(w, h);
var rect = new LayoutRect(0, 0, w, h);
b.PaintDOM(buffer, rect, rect, Color.White, Color.Black);
return buffer;
}
private static string RowText(CharacterBuffer buffer, int y, int width)
{
var sb = new StringBuilder();
for (int x = 0; x < width; x++)
sb.Append(buffer.GetCell(x, y).Character.ToString());
return sb.ToString();
}
[Fact]
public void Badge_renders_pill_and_survives_count_change()
{
var badge = new BadgeControl { ColorRole = ColorRole.Primary, Count = 3 };
// Measure reports the pill's true display width, not string.Length.
var size = badge.MeasureDOM(new LayoutConstraints(0, 100, 0, 100));
Assert.Equal(UnicodeWidth.GetStringWidth("[ 3 ]"), size.Width);
// First render: the pill text is on screen.
var b1 = Paint(badge);
Assert.StartsWith("[ 3 ]", RowText(b1, 0, 20));
// Change the reactive property, re-render, assert the update SURVIVED.
badge.Count = 42;
var b2 = Paint(badge);
Assert.StartsWith("[ 42 ]", RowText(b2, 0, 20));
}
}
The second half is the "real thing" part: it drives the reactive property (badge.Count = 42), re-renders, and asserts the new value is the one painted — proving the SetProperty-based setter actually reaches the screen, not just that the field changed.
For layout-sensitive controls, CODE_QUALITY.md also asks for a test nested in the real container at boundary sizes, driven via the real input path. A count badge has no input and a fixed one-row height, so the render-and-survive assertion above carries the weight; add a nested-container test if you later give the badge interactive behavior.
Step 8: Open the PR
You added a new control, a new builder, and a new factory — all strictly additive, so you're safely inside the no-breaking-changes rule (SharpConsoleUI has real NuGet users; we never remove or rename existing public API — we only add). Before opening the PR:
- Run
dotnet buildanddotnet test— the new test must be green. - Run
dotnet format SharpConsoleUI/SharpConsoleUI.csproj— CI has a blocking format gate (tabs). - Give every new public member an XML
<summary>(the compiler warns otherwise). - Fill in the PR checklist in
.github/pull_request_template.md(tick New feature) and re-read the No breaking changes section of CONTRIBUTING.md.
That's a complete primitive control — measure, paint, the reactive property contract, role-driven color, correct Unicode width, a builder, and a survives-a-re-render test — merged as a purely additive change.
What you learned
- Which interfaces a primitive implements (
BaseControl+ only the capability interfaces it needs —IColorRoleableControlfor a themable surface, no focus/mouse) — the ISP philosophy. - The three layout overrides —
ContentWidth,GetLogicalContentSize, andMeasureDOM(LayoutConstraints) → LayoutSize— modeled onSpinnerControl. PaintDOM(CharacterBuffer, LayoutRect bounds, LayoutRect clipRect, Color defaultForeground, Color defaultBackground)— the exactIDOMPaintablesignature — plusSetActualBoundsand clip-rect guarding.- The reactive property contract:
CountviaSetProperty, which change-guards, notifies, and self-invalidates — so you never callInvalidateby hand, and neverContainer?.Invalidatein a setter. - Role-driven color via
ColorResolver.ColorRoleForeground— no hardcodedColor.literals; the badge re-themes for free. - Unicode-correct width with
UnicodeWidth.GetStringWidth, and writing literal narrow cells withSetNarrowCell(vs.SetCellfor parsed/wide cells). - A
Controls.Badge(int count)factory +BadgeControlBuilder, modeled onSpinnerBuilder. - A "real thing" test that renders, reads cells back, changes
Count, and asserts the update survives the re-render.
← Composite Controls | Next → Dialogs