Documents
Terminal Rendering
Terminal Rendering
Type
Topic
Status
Published
Created
Feb 27, 2026
Updated
Feb 27, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

Terminal Rendering#

Overview#

Terminal Rendering in opnDossier is a project-specific display subsystem that transforms parsed OPNsense configuration data into human-readable, styled terminal output. The system emphasizes deterministic output, correct Unicode handling, and flexible theming to support diverse terminal environments ranging from modern 24-bit color terminals to automation pipelines requiring plain-text output.

The rendering architecture implements several key design patterns: per-instance renderers on TerminalDisplay (avoiding singleton anti-patterns), rune-based text wrapping using utf8.RuneCountInString to correctly handle multi-byte UTF-8 characters, and ubiquitous deterministic sorting using slices.Sorted(maps.Keys()) to ensure stable output across runs.

The system leverages Charm's Glamour library for markdown rendering with support for four rendering themes (auto, light, dark, none) accessible via the --theme flag on the display command. Error handling follows a graceful degradation pattern where renderer initialization errors are stored but not raised, falling back to plain markdown output when styled rendering is unavailable.

Architecture and Renderer Lifecycle#

Per-Instance Renderer Pattern#

The rendering system uses a per-instance architecture where each TerminalDisplay struct maintains its own Glamour renderer rather than using a global singleton:

type TerminalDisplay struct {
    options *Options
    renderer *glamour.TermRenderer
    rendererErr error // Preserved from construction
    progress *progress.Model
    progressMu sync.Mutex
}

This architectural pattern provides several benefits:

  • Isolation: Each display instance operates independently with its own configuration
  • Testability: Renderers can be easily mocked or stubbed in unit tests
  • Thread Safety: No shared global state concerns or synchronization overhead
  • Flexibility: Multiple display instances can use different themes simultaneously

The renderer is created during construction in NewTerminalDisplayWithOptions:

var renderer *glamour.TermRenderer
var rendererErr error
if opts.EnableColors {
    glamourStyle := DetermineGlamourStyle(&opts)
    glamourOpts := []glamour.TermRendererOption{
        glamour.WithStandardStyle(glamourStyle),
    }
    if opts.WrapWidth > 0 {
        glamourOpts = append(glamourOpts, glamour.WithWordWrap(opts.WrapWidth))
    }
    r, err := glamour.NewTermRenderer(glamourOpts...)
    if err != nil {
        rendererErr = err
    } else {
        renderer = r
    }
}

Error Handling and Graceful Degradation#

The system implements a two-phase error handling approach that ensures the application never fails to produce output:

  1. Construction Phase: Errors during renderer creation are stored in the rendererErr field, but the TerminalDisplay instance is still successfully created and returned.

  2. Rendering Phase: When rendering content, the system checks for renderer availability and falls back to plain markdown output if the renderer is unavailable:

if td.renderer == nil {
    if td.rendererErr != nil {
        fmt.Fprintf(os.Stderr, "Note: Displaying raw markdown due to renderer error: %v\n", td.rendererErr)
    }
    fmt.Print(wrapRenderedOutput(markdownContent, td.options.WrapWidth))
    return nil
}

This graceful degradation ensures that users always receive readable output, even when terminal rendering capabilities are limited or unavailable. The system distinguishes between intentionally disabled colors (rendererErr == nil) and actual rendering failures (rendererErr != nil), providing appropriate feedback in each case.

Rune-Based Text Wrapping#

UTF-8 Character Handling#

Text wrapping in opnDossier uses rune-based counting to correctly handle multi-byte UTF-8 characters. The implementation explicitly documents this design decision:

// wrapMarkdownLine breaks a single line of markdown text at word boundaries to
// fit within width (measured in runes). Leading whitespace (indentation) is
// preserved on continuation lines. Words longer than the remaining space are
// split at the rune boundary with a trailing backslash.

Why Rune-Based Counting is Necessary#

Go's len() function returns byte count, not character count. For multi-byte UTF-8 characters, this creates incorrect measurements that can lead to corrupted output:

CharacterUTF-8 Byteslen()utf8.RuneCountInString()
(ellipsis)0xE2 0x80 0xA631
😀 (emoji)4 bytes41
(Chinese)3 bytes31
é (composed)2 bytes21

Using byte-based slicing operations like line[:6] could split a multi-byte character mid-sequence, creating invalid UTF-8 that renders as replacement characters (�) or causes terminal display corruption. The implementation converts strings to rune slices for safe character-level slicing:

runes := []rune(word)
if len(runes) > remaining {
    part := string(runes[:remaining])
    if needsSpace {
        current += " " + part
    } else {
        current += part
    }
    lines = append(lines, current+`\`)
    current = prefix
    currentLen = prefixLen
    word = string(runes[remaining:])
    continue
}

By converting to []rune before slicing, the code ensures that multi-byte characters are never split, maintaining UTF-8 validity across line breaks.

ANSI Escape Sequence Handling#

For rendered output containing ANSI color codes, wrapRenderedLine uses utf8.DecodeRuneInString to process characters while preserving escape sequences:

r, size := utf8.DecodeRuneInString(line[i:])
builder.WriteRune(r)
visible++
if visible >= width {
    segments = append(segments, builder.String())
    builder.Reset()
    visible = 0
}
i += size // Advance by the number of bytes in this rune

The size return value from utf8.DecodeRuneInString indicates how many bytes constitute the current rune, ensuring the iterator advances correctly through the string without splitting multi-byte characters. This approach maintains both UTF-8 validity and ANSI escape sequence integrity.

Theme Handling#

Theme Configuration Scope#

The --theme flag is available exclusively on the display command, not on the convert command. This design decision reflects the different output contexts:

  • Display command: Renders directly to terminal with Glamour, where theme affects visual presentation
  • Convert command: Writes to files or stdout without terminal rendering, where themes are less relevant

The convert command code explicitly documents this design choice: "no CLI flag for theme in convert command". The convert command can still use theme settings from configuration files, but theme cannot be overridden via CLI flags.

Valid Theme Values#

The four valid theme values support diverse terminal environments:

ThemeDescriptionUse Case
autoAuto-detect based on terminal capabilitiesDefault, intelligent selection based on environment
lightLight theme (dark text on white background)Explicit light terminals or user preference
darkDark theme (light text on dark background)Explicit dark terminals or user preference
noneNo styling (raw output)Piping to files, CI/CD pipelines, limited terminals

Theme validation enforces these values (case-insensitive):

if sharedTheme != "" {
    validThemes := []string{"light", "dark", "auto", "none"}
    if !slices.Contains(validThemes, strings.ToLower(sharedTheme)) {
        return fmt.Errorf("invalid theme %q, must be one of: %s", sharedTheme, strings.Join(validThemes, ", "))
    }
}

Theme Selection Logic#

Theme selection follows a multi-level precedence hierarchy:

  1. Check if colors are disabled → use notty style
  2. Check terminal color capabilities → use ascii if unsupported
  3. Map theme name to appropriate Glamour style:
switch opts.Theme.Name {
case constants.ThemeLight:
    return constants.ThemeLight
case constants.ThemeDark:
    return constants.ThemeDark
case "none":
    return Notty
case "custom":
    return Auto
default: // "auto" or other
    return opts.Theme.GetGlamourStyleName()
}

This cascading logic ensures that the most appropriate rendering style is selected based on both user preferences and terminal capabilities.

Terminal Styling and NO_COLOR Support#

useStylesCheck() Helper#

Terminal styling decisions are centralized in the useStylesCheck() helper function used throughout CLI commands:

const (
    termEnvVar = "TERM"
    noColorEnvVar = "NO_COLOR"
    termDumb = "dumb"
)

func useStylesCheck() bool {
    return os.Getenv(termEnvVar) != termDumb && os.Getenv(noColorEnvVar) == ""
}

This helper respects two standard environment variables that control terminal styling:

  • TERM=dumb: Indicates a non-interactive terminal without styling support (common in CI/CD environments)
  • NO_COLOR: When set to any value, disables all color output (following the NO_COLOR standard)

The display package uses Options.EnableColors and IsTerminalColorCapable() for similar capability detection at the rendering layer.

Deterministic Sorting for Output Stability#

Sorting Requirements#

The project mandates deterministic sorting for all output derived from maps or non-deterministic data structures:

All lists must be sorted for deterministic output — use slices.Sort(), slices.Sorted(maps.Keys()), or sort.Strings() on any slice derived from maps. Non-deterministic order causes flaky tests, unstable golden files, and inconsistent CLI output.

This requirement stems from Go's map iteration order being intentionally randomized. Without explicit sorting, identical configuration data could produce differently ordered output on each run, causing:

  • Flaky tests: Assertions fail intermittently based on iteration order
  • Unstable golden files: Regenerating golden files changes output unnecessarily
  • Inconsistent diffs: Configuration comparisons show spurious differences
  • Poor user experience: Same input produces different visual output

Implementation Patterns#

Pattern 1: slices.Sorted(maps.Keys())

The most common pattern for iterating maps in sorted order:

// Add compliance plugin results if available (deterministic order)
if len(report.Compliance) > 0 {
    md.H3("Plugin Compliance Results")
    for _, pluginName := range slices.Sorted(maps.Keys(report.Compliance)) {
        result := report.Compliance[pluginName]
        md.H4(pluginName)
        // ...
    }
}

This pattern creates a sorted slice of map keys in a single expression, then iterates in predictable order.

Pattern 2: slices.Sort() for In-Place Sorting

Used when comparing or processing existing slices:

ifaces1 := slices.Clone(rule1.Interfaces)
ifaces2 := slices.Clone(rule2.Interfaces)
slices.Sort(ifaces1)
slices.Sort(ifaces2)

Cloning before sorting prevents mutation of original data while enabling deterministic comparison.

Pattern 3: sort.Strings() Legacy Support

Still used in older code sections:

// Sort for deterministic output
sort.Strings(unknown)

While slices.Sort() is preferred for new code, sort.Strings() remains for backward compatibility.

Testing Infrastructure#

The project uses golden file tests to verify output consistency across multiple runs:

// TestGolden_ConsistencyAcrossRuns ensures that multiple runs produce identical output.
func TestGolden_ConsistencyAcrossRuns(t *testing.T) {
    testData := loadTestDataFromFile(t, "complete.json")
    mdBuilder := createDeterministicBuilder(t)

    // Generate the same report multiple times
    outputs := make([]string, 5)
    for i := range 5 {
        output, err := mdBuilder.BuildStandardReport(testData)
        require.NoError(t, err)
        outputs[i] = string(normalizeGoldenOutput([]byte(output)))
        time.Sleep(10 * time.Millisecond)
    }

    // All outputs should be identical
    for i := 1; i < len(outputs); i++ {
        assert.Equal(t, outputs[0], outputs[i])
    }
}

This test generates the same report five times with deliberate delays between runs. All outputs must be byte-for-byte identical, verifying that no non-deterministic ordering leaks into the output.

Usage Examples#

Display Command with Theme#

# Auto-detect theme (default)
opndossier display config.xml

# Explicit dark theme
opndossier display config.xml --theme dark

# Plain text output (no styling)
opndossier display config.xml --theme none

# Light theme with custom wrap width
opndossier display config.xml --theme light --wrap 100

Configuration File#

Theme and display settings can be persisted in ~/.opnDossier.yaml:

# Display settings
theme: auto # or: dark, light, none
wrap: 120 # or: -1 (auto), 0 (off)

display:
  width: -1 # -1 = auto-detect terminal width
  syntax_highlighting: true

Configuration file settings are overridden by CLI flags when both are present, following standard precedence rules.

Environment Variables#

# Disable all colors (respects NO_COLOR standard)
NO_COLOR=1 opndossier display config.xml

# Indicate dumb terminal (no ANSI support)
TERM=dumb opndossier display config.xml

Environment variables affect styling detection but can be overridden by explicit --theme flags.

Relevant Code Files#

FilePurposeKey Components
internal/display/display.goCore rendering logicTerminalDisplay struct, NewTerminalDisplayWithOptions(), Display() method, DetermineGlamourStyle()
internal/display/wrap.goText wrapping utilitieswrapMarkdownLine(), wrapRenderedLine(), rune-based wrapping algorithms
cmd/display.goDisplay command implementationdisplayCmd, validateDisplayFlags(), theme validation
cmd/shared_flags.goShared CLI flagsaddDisplayFlags(), ValidThemes() for shell completion
cmd/config_validate.goTerminal styling checksuseStylesCheck() helper function
  • Configuration Management: Theme and wrapping settings in configuration files and precedence rules
  • Markdown Processing: Integration with Charm Glamour library for markdown rendering and syntax highlighting
  • CLI Architecture: Command-specific flag scoping, validation, and shell completion
  • Testing Strategies: Golden file testing, deterministic output verification, and test infrastructure
  • Unicode Support: UTF-8 character handling, rune-based operations, and international text support
  • Error Handling Patterns: Graceful degradation, fallback mechanisms, and user-facing error messages