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:

  1. Change-guards — returns early if the new value equals the old, so setting Count = 3 when it's already 3 does no work and fires no notification.
  2. Raises INotifyPropertyChanged (OnPropertyChanged) — so data-bound consumers see the change.
  3. 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. Invalidate this via SetProperty; 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 chosen ColorRole — so a Danger badge is red in whatever the current theme calls red, and it re-themes for free. This is the identical call SpinnerControl.PaintDOM makes. Never write Color.Red in a control. See THEMES.md.

  • Literal narrow chars use SetNarrowCell. SetNarrowCell assumes 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 of MarkupParser.Parse (which already carries wide-continuation flags). If your text could contain markup or wide glyphs, you'd parse it instead — that's what SpinnerControl does 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 SetNarrowCell per 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 build and dotnet 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 — IColorRoleableControl for a themable surface, no focus/mouse) — the ISP philosophy.
  • The three layout overrides — ContentWidth, GetLogicalContentSize, and MeasureDOM(LayoutConstraints) → LayoutSize — modeled on SpinnerControl.
  • PaintDOM(CharacterBuffer, LayoutRect bounds, LayoutRect clipRect, Color defaultForeground, Color defaultBackground) — the exact IDOMPaintable signature — plus SetActualBounds and clip-rect guarding.
  • The reactive property contract: Count via SetProperty, which change-guards, notifies, and self-invalidates — so you never call Invalidate by hand, and never Container?.Invalidate in a setter.
  • Role-driven color via ColorResolver.ColorRoleForeground — no hardcoded Color. literals; the badge re-themes for free.
  • Unicode-correct width with UnicodeWidth.GetStringWidth, and writing literal narrow cells with SetNarrowCell (vs. SetCell for parsed/wide cells).
  • A Controls.Badge(int count) factory + BadgeControlBuilder, modeled on SpinnerBuilder.
  • A "real thing" test that renders, reads cells back, changes Count, and asserts the update survives the re-render.

Composite Controls | Next → Dialogs