renderer

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 29, 2025 License: MIT Imports: 7 Imported by: 0

README

Renderer Pattern Deep Dive

The Renderer pattern is the core abstraction for CLI output in Tracks. It separates what to display from how to display it.

Design Philosophy

Separation of Concerns
Command Logic          Renderer Implementation
┌──────────────┐      ┌────────────────────┐
│              │      │                    │
│ • Validation │──────▶│ • Format output   │
│ • Business   │ data │ • Apply styles    │
│ • Data fetch │──────▶│ • Handle errors   │
│              │      │                    │
└──────────────┘      └────────────────────┘

Commands produce data (titles, sections, tables). Renderers format that data for different contexts (terminal, JSON, TUI).

Benefits
  1. Testability - Mock renderers for unit tests
  2. Flexibility - Add new output formats without changing commands
  3. Consistency - All commands use the same output primitives
  4. Maintainability - Output logic isolated from business logic

Interface

type Renderer interface {
    // Title displays a prominent heading
    Title(string)

    // Section displays titled content blocks
    Section(Section)

    // Table displays structured data in rows/columns
    Table(Table)

    // Progress creates a tracker for long-running operations
    Progress(ProgressSpec) Progress

    // Flush writes all accumulated output
    Flush() error
}
Supporting Types
// Section represents a content block with optional title
type Section struct {
    Title string  // Optional heading
    Body  string  // Main content
}

// Table represents structured tabular data
type Table struct {
    Headers []string    // Column headers
    Rows    [][]string  // Data rows
}

// ProgressSpec configures a progress tracker
type ProgressSpec struct {
    Label string   // Display label
    Total int64    // Total items
}

// Progress tracks incremental updates
type Progress interface {
    Increment(int64)  // Update progress
    Done()            // Mark complete
}

Implementations

ConsoleRenderer

Human-readable terminal output using Lip Gloss for styling.

Features:

  • Colored output (respects NO_COLOR)
  • Themed styles (Title, Success, Error, Warning, Muted)
  • Table alignment with lipgloss.Table
  • Progress bars using Bubbles progress component
  • Direct writes (no buffering)

Usage:

r := renderer.NewConsoleRenderer(os.Stdout)

r.Title("Installation Complete")
r.Section(renderer.Section{
    Body: "Successfully installed 15 packages",
})
r.Table(renderer.Table{
    Headers: []string{"Package", "Version"},
    Rows: [][]string{
        {"cobra", "1.10.1"},
        {"viper", "1.20.1"},
    },
})

progress := r.Progress(renderer.ProgressSpec{
    Label: "Downloading",
    Total: 100,
})
for i := 0; i <= 100; i++ {
    progress.Increment(1)
    time.Sleep(10 * time.Millisecond)
}
progress.Done()

if err := r.Flush(); err != nil {
    log.Fatal(err)
}

Output:

Installation Complete

Successfully installed 15 packages

Package         Version
──────────────────────
cobra           1.10.1
viper           1.20.1

Downloading [████████████████████] 100%
JSONRenderer

Machine-readable JSON for scripting and automation.

Features:

  • Structured JSON output
  • Accumulates all data before writing
  • Pretty-printed with 2-space indentation
  • No-op progress (JSON not suitable for incremental updates)

Usage:

r := renderer.NewJSONRenderer(os.Stdout)

r.Title("Installation Complete")
r.Section(renderer.Section{
    Body: "Successfully installed 15 packages",
})
r.Table(renderer.Table{
    Headers: []string{"Package", "Version"},
    Rows: [][]string{
        {"cobra", "1.10.1"},
        {"viper", "1.20.1"},
    },
})

if err := r.Flush(); err != nil {
    log.Fatal(err)
}

Output:

{
  "title": "Installation Complete",
  "sections": [
    {
      "title": "",
      "body": "Successfully installed 15 packages"
    }
  ],
  "tables": [
    {
      "headers": ["Package", "Version"],
      "rows": [
        ["cobra", "1.10.1"],
        ["viper", "1.20.1"]
      ]
    }
  ]
}
TUIRenderer (Phase 4)

Interactive Bubble Tea interface for immersive experiences.

Planned Features:

  • Full-screen TUI application
  • Keyboard navigation
  • Real-time updates
  • Form inputs
  • File browsers
  • Confirmation dialogs

Implementation Guide

Creating a New Renderer

Let's create a Markdown renderer as an example.

1. Create File
// internal/cli/renderer/markdown.go
package renderer

import (
    "fmt"
    "io"
    "strings"
)

type MarkdownRenderer struct {
    w io.Writer
}

func NewMarkdownRenderer(w io.Writer) *MarkdownRenderer {
    return &MarkdownRenderer{w: w}
}
2. Implement Title
func (r *MarkdownRenderer) Title(s string) {
    fmt.Fprintf(r.w, "# %s\n\n", s)
}
3. Implement Section
func (r *MarkdownRenderer) Section(sec Section) {
    if sec.Title != "" {
        fmt.Fprintf(r.w, "## %s\n\n", sec.Title)
    }
    if sec.Body != "" {
        fmt.Fprintf(r.w, "%s\n\n", sec.Body)
    }
}
4. Implement Table
func (r *MarkdownRenderer) Table(t Table) {
    if len(t.Headers) == 0 {
        return
    }

    // Headers
    fmt.Fprintf(r.w, "| %s |\n", strings.Join(t.Headers, " | "))

    // Separator
    sep := make([]string, len(t.Headers))
    for i := range sep {
        sep[i] = "---"
    }
    fmt.Fprintf(r.w, "| %s |\n", strings.Join(sep, " | "))

    // Rows
    for _, row := range t.Rows {
        cells := make([]string, len(t.Headers))
        for i := range t.Headers {
            if i < len(row) {
                cells[i] = row[i]
            }
        }
        fmt.Fprintf(r.w, "| %s |\n", strings.Join(cells, " | "))
    }
    fmt.Fprintln(r.w)
}
5. Implement Progress
type markdownProgress struct{}

func (p *markdownProgress) Increment(n int64) {}
func (p *markdownProgress) Done()             {}

func (r *MarkdownRenderer) Progress(spec ProgressSpec) Progress {
    // Markdown doesn't support progress bars
    return &markdownProgress{}
}
6. Implement Flush
func (r *MarkdownRenderer) Flush() error {
    // Markdown writes immediately, nothing to flush
    return nil
}
7. Add Tests
// internal/cli/renderer/markdown_test.go
package renderer_test

import (
    "bytes"
    "strings"
    "testing"

    "github.com/anomalousventures/tracks/internal/cli/renderer"
)

func TestMarkdownRenderer(t *testing.T) {
    var buf bytes.Buffer
    r := renderer.NewMarkdownRenderer(&buf)

    r.Title("Test Title")
    r.Section(renderer.Section{
        Title: "Section",
        Body:  "Content",
    })
    r.Table(renderer.Table{
        Headers: []string{"A", "B"},
        Rows:    [][]string{{"1", "2"}},
    })

    if err := r.Flush(); err != nil {
        t.Fatalf("Flush failed: %v", err)
    }

    output := buf.String()

    if !strings.Contains(output, "# Test Title") {
        t.Error("Missing title")
    }
    if !strings.Contains(output, "## Section") {
        t.Error("Missing section title")
    }
    if !strings.Contains(output, "| A | B |") {
        t.Error("Missing table")
    }
}
8. Integrate with CLI

Update internal/cli/root.go:

// Add mode constant in ui/mode.go
const (
    ModeAuto UIMode = iota
    ModeConsole
    ModeJSON
    ModeTUI
    ModeMarkdown  // New
)

// Update DetectMode() in ui/mode.go
func DetectMode(cfg UIConfig) UIMode {
    // ... existing logic ...
    if cfg.Markdown {  // Add Markdown flag
        return ModeMarkdown
    }
    // ... rest of detection ...
}

// Update NewRendererFromCommand() in root.go
func NewRendererFromCommand(cmd *cobra.Command) renderer.Renderer {
    cfg := GetConfig(cmd)
    uiMode := ui.DetectMode(...)

    switch uiMode {
    case ui.ModeJSON:
        return renderer.NewJSONRenderer(cmd.OutOrStdout())
    case ui.ModeMarkdown:
        return renderer.NewMarkdownRenderer(cmd.OutOrStdout())
    default:
        return renderer.NewConsoleRenderer(cmd.OutOrStdout())
    }
}

// Add flag in NewRootCmd()
rootCmd.PersistentFlags().Bool("markdown", false, "Output in Markdown format")

Design Patterns

Accumulate-Then-Flush

Some renderers need to see all data before rendering (e.g., JSON needs complete structure).

type AccumulatingRenderer struct {
    data  OutputData
    w     io.Writer
}

func (r *AccumulatingRenderer) Title(s string) {
    r.data.Title = s  // Store, don't write yet
}

func (r *AccumulatingRenderer) Flush() error {
    // Now write everything as complete structure
    return json.NewEncoder(r.w).Encode(r.data)
}
Immediate Write

Other renderers write immediately (e.g., Console for interactive feedback).

type ImmediateRenderer struct {
    w io.Writer
}

func (r *ImmediateRenderer) Title(s string) {
    fmt.Fprintln(r.w, s)  // Write immediately
}

func (r *ImmediateRenderer) Flush() error {
    return nil  // Nothing buffered
}
No-Op Progress

If a renderer can't support progress bars, provide a no-op implementation:

type noOpProgress struct{}

func (p *noOpProgress) Increment(n int64) {}
func (p *noOpProgress) Done()             {}

func (r *MyRenderer) Progress(spec ProgressSpec) Progress {
    return &noOpProgress{}
}

Testing Strategies

Test All Implementations

Use table-driven tests to ensure all renderers work:

func TestRendererImplementations(t *testing.T) {
    tests := []struct {
        name string
        r    renderer.Renderer
    }{
        {"console", renderer.NewConsoleRenderer(&bytes.Buffer{})},
        {"json", renderer.NewJSONRenderer(&bytes.Buffer{})},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            tt.r.Title("Test")
            if err := tt.r.Flush(); err != nil {
                t.Errorf("Flush failed: %v", err)
            }
        })
    }
}
Mock Renderer for Command Tests
type MockRenderer struct {
    Titles   []string
    Sections []renderer.Section
    Flushed  bool
}

func (m *MockRenderer) Title(s string) {
    m.Titles = append(m.Titles, s)
}

func (m *MockRenderer) Section(s renderer.Section) {
    m.Sections = append(m.Sections, s)
}

func (m *MockRenderer) Flush() error {
    m.Flushed = true
    return nil
}

// Use in tests
func TestMyCommand(t *testing.T) {
    mock := &MockRenderer{}
    myCommandLogic(mock)

    if len(mock.Titles) != 1 {
        t.Error("Expected 1 title")
    }
    if !mock.Flushed {
        t.Error("Renderer not flushed")
    }
}
Integration Tests

Test complete flow with real renderers:

func TestConsoleOutput(t *testing.T) {
    var buf bytes.Buffer
    r := renderer.NewConsoleRenderer(&buf)

    r.Title("Test")
    r.Section(renderer.Section{Body: "Content"})
    r.Flush()

    output := buf.String()
    if !strings.Contains(output, "Test") {
        t.Error("Title missing from output")
    }
}

Common Pitfalls

1. Forgetting Flush
// BAD - no output appears
r.Title("Hello")
// Forgot r.Flush()!
2. Flushing Too Early
// BAD - JSON would be incomplete
r.Title("Test")
r.Flush()       // Too early!
r.Section(...)  // Won't be included
3. Assuming IO Never Fails
// BAD - ignoring error
r.Flush()

// GOOD - checking error
if err := r.Flush(); err != nil {
    return fmt.Errorf("output failed: %w", err)
}
4. Testing Output Format
// BAD - brittle test
if output != "exact string" { ... }

// GOOD - semantic test
if !strings.Contains(output, "key content") { ... }
5. Not Implementing All Methods
// BAD - panic at runtime
func (r *MyRenderer) Progress(...) Progress {
    panic("not implemented")
}

// GOOD - no-op implementation
func (r *MyRenderer) Progress(...) Progress {
    return &noOpProgress{}
}

Performance Considerations

Buffering

For network/file writes, use buffering:

func NewFileRenderer(filename string) (*FileRenderer, error) {
    f, err := os.Create(filename)
    if err != nil {
        return nil, err
    }
    return &FileRenderer{
        w: bufio.NewWriter(f),  // Buffered!
        f: f,
    }, nil
}

func (r *FileRenderer) Flush() error {
    // Flush buffer first
    if err := r.w.(*bufio.Writer).Flush(); err != nil {
        return err
    }
    // Then sync file
    return r.f.Sync()
}
Large Tables

Stream large tables row-by-row instead of buffering:

func (r *StreamRenderer) Table(t Table) {
    r.writeHeaders(t.Headers)
    for _, row := range t.Rows {
        r.writeRow(row)  // Write immediately
    }
}

Future Enhancements

Planned Features (Phase 4)
  • TUIRenderer - Full-screen Bubble Tea interface
  • HTMLRenderer - For generated documentation
  • MarkdownRenderer - For README generation
  • Streaming renderers - For long-running commands
Extension Points

The Renderer interface may grow to support:

  • Prompts/Input (Prompt(question string) (answer string))
  • Confirmations (Confirm(message string) bool)
  • Multi-select (Select(options []string) []int)

Documentation

Overview

Package renderer provides output formatting implementations for CLI commands.

ConsoleRenderer outputs human-readable formatted text to a terminal using Lip Gloss styles from the Theme. It automatically respects NO_COLOR and other accessibility environment variables through the Theme system.

Package renderer provides output formatting implementations for CLI commands.

JSONRenderer outputs machine-readable JSON for scripting and automation. It accumulates all output (titles, sections, tables) and writes formatted JSON when Flush() is called.

Package renderer provides implementations of CLI output formatting.

This package provides concrete implementations of the interfaces.Renderer interface per ADR-002 (Interface Placement in Consumer Packages).

The Renderer pattern separates business logic from output formatting, enabling multiple output modes (console, JSON, TUI) without duplicating code. Commands produce data, Renderers display it in the appropriate format.

Available implementations:

  • ConsoleRenderer: Human-friendly output with colors and formatting
  • JSONRenderer: Machine-readable JSON output for scripting
  • TUIRenderer: Interactive terminal UI (future implementation)

Package renderer provides type definitions for renderer implementations.

All interface types (Renderer, Section, Table, ProgressSpec, Progress) have been moved to internal/cli/interfaces per ADR-002.

This file is retained for any future renderer-specific types that are not part of the public interface.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type ConsoleProgress

type ConsoleProgress struct {
	// contains filtered or unexported fields
}

ConsoleProgress implements Progress interface using Bubbles progress component.

Renders a progress bar using ViewAs for standalone rendering without Bubble Tea event loop. Updates are written in-place using \r prefix.

func (*ConsoleProgress) Done

func (p *ConsoleProgress) Done()

Done completes the progress bar and adds a newline.

Marks the progress as complete and writes a final newline to move to the next line. Subsequent calls are idempotent (no additional output).

func (*ConsoleProgress) Increment

func (p *ConsoleProgress) Increment(n int64)

Increment updates the progress bar by the specified amount.

Calculates the new percentage and renders the progress bar in-place using \r (carriage return). Handles edge cases like zero total and overflow gracefully. If a label was provided, it is displayed before the progress bar using the Muted theme style.

type ConsoleRenderer

type ConsoleRenderer struct {
	// contains filtered or unexported fields
}

ConsoleRenderer implements the Renderer interface for human-readable terminal output.

ConsoleRenderer writes formatted output to an io.Writer using Lip Gloss styles from the Theme. All styling automatically respects NO_COLOR and other accessibility environment variables.

Example usage:

renderer := NewConsoleRenderer(os.Stdout)
renderer.Title("Project Created")
renderer.Section(Section{
    Title: "Configuration",
    Body:  "Using Chi router with templ templates",
})
renderer.Flush()

ConsoleRenderer is safe for concurrent use from multiple goroutines as long as the underlying io.Writer is thread-safe.

func NewConsoleRenderer

func NewConsoleRenderer(out io.Writer) *ConsoleRenderer

NewConsoleRenderer creates a new ConsoleRenderer that writes to the provided io.Writer.

The writer parameter is typically os.Stdout or os.Stderr, but can be any io.Writer for testing purposes (e.g., bytes.Buffer).

Example:

renderer := NewConsoleRenderer(os.Stdout)

func (*ConsoleRenderer) Flush

func (r *ConsoleRenderer) Flush() error

Flush ensures all buffered output is written.

For ConsoleRenderer, this is a no-op since fmt.Fprintln writes directly to the underlying io.Writer without buffering. This method exists to satisfy the Renderer interface and maintain consistency with other renderer implementations that may need flushing (like JSONRenderer).

Always returns nil.

func (*ConsoleRenderer) Progress

Progress creates a progress bar for tracking long-running operations.

Returns a ConsoleProgress instance that renders using Bubbles progress component with gradient colors. Uses ViewAs for standalone rendering without requiring a Bubble Tea event loop.

The progress bar updates in-place using \r (carriage return) and completes with a newline when Done() is called.

Example:

progress := renderer.Progress(interfaces.ProgressSpec{Label: "Downloading", Total: 100})
progress.Increment(25)  // 25%
progress.Increment(50)  // 75%
progress.Increment(25)  // 100%
progress.Done()         // Adds newline

func (*ConsoleRenderer) Section

func (r *ConsoleRenderer) Section(sec interfaces.Section)

Section displays a titled section with body content.

If the section has a title, it is rendered using Theme.Title style. The body is rendered as plain text below the title. Both title and body are optional and will only be rendered if non-empty.

Example:

renderer.Section(interfaces.Section{
    Title: "Database Configuration",
    Body:  "Using LibSQL with migrations enabled",
})

func (*ConsoleRenderer) Table

func (r *ConsoleRenderer) Table(t interfaces.Table)

Table displays structured tabular data with aligned columns.

Headers are rendered using Theme.Title style. All columns are automatically sized to fit their content with proper alignment. Empty tables produce no output.

Example:

renderer.Table(interfaces.Table{
    Headers: []string{"File", "Status", "Lines"},
    Rows: [][]string{
        {"user.go", "created", "42"},
        {"user_test.go", "created", "128"},
    },
})

func (*ConsoleRenderer) Title

func (r *ConsoleRenderer) Title(s string)

Title displays a prominent title using the Theme.Title style.

The title is rendered with bold purple styling (when colors are enabled) and written on its own line.

Example:

renderer.Title("Welcome to Tracks")

type JSONRenderer

type JSONRenderer struct {
	// contains filtered or unexported fields
}

JSONRenderer implements the Renderer interface for machine-readable JSON output.

JSONRenderer accumulates all CLI output (titles, sections, tables) in memory and writes formatted JSON when Flush() is called. This enables CLI integration with scripts, CI/CD pipelines, and other tools that need structured data.

Example usage:

renderer := NewJSONRenderer(os.Stdout)
renderer.Title("Project Created")
renderer.Section(Section{
    Title: "Configuration",
    Body:  "Using Chi router with templ templates",
})
renderer.Table(Table{
    Headers: []string{"File", "Status"},
    Rows:    [][]string{{"user.go", "created"}},
})
renderer.Flush()

Output:

{
  "title": "Project Created",
  "sections": [
    {"title": "Configuration", "body": "Using Chi router with templ templates"}
  ],
  "tables": [
    {"headers": ["File", "Status"], "rows": [["user.go", "created"]]}
  ]
}

JSONRenderer is not safe for concurrent use from multiple goroutines. Each command should use a single JSONRenderer instance sequentially.

func NewJSONRenderer

func NewJSONRenderer(out io.Writer) *JSONRenderer

NewJSONRenderer creates a new JSONRenderer that writes to the provided io.Writer.

The writer parameter is typically os.Stdout or os.Stderr, but can be any io.Writer for testing purposes (e.g., bytes.Buffer).

Example:

renderer := NewJSONRenderer(os.Stdout)

func (*JSONRenderer) Flush

func (r *JSONRenderer) Flush() error

Flush writes all accumulated data as formatted JSON.

The JSON output is indented with 2 spaces for readability. After flushing, the renderer's internal data is not cleared, so subsequent calls to Flush will output the same data (potentially with additions from new Title/Section/Table calls).

Always returns nil unless the underlying io.Writer returns an error.

Example:

renderer.Title("Project Created")
renderer.Flush()  // Writes JSON to output

func (*JSONRenderer) Progress

Progress returns a no-op Progress implementation for JSON output.

JSON output is not suitable for incremental progress updates, so this method returns a Progress instance that discards all updates. Progress is designed for interactive terminals, not machine-readable output.

Example:

progress := renderer.Progress(interfaces.ProgressSpec{Label: "Downloading", Total: 100})
progress.Increment(50)  // No output
progress.Done()         // No output

func (*JSONRenderer) Section

func (r *JSONRenderer) Section(sec interfaces.Section)

Section adds a titled section to the JSON output.

Sections are accumulated and rendered as an array in the "sections" field. Multiple calls to Section will append to the array.

Example:

renderer.Section(interfaces.Section{
    Title: "Database Configuration",
    Body:  "Using LibSQL with migrations enabled",
})

func (*JSONRenderer) Table

func (r *JSONRenderer) Table(t interfaces.Table)

Table adds structured tabular data to the JSON output.

Tables are accumulated and rendered as an array in the "tables" field. Multiple calls to Table will append to the array.

Example:

renderer.Table(interfaces.Table{
    Headers: []string{"File", "Status", "Lines"},
    Rows: [][]string{
        {"user.go", "created", "42"},
        {"user_test.go", "created", "128"},
    },
})

func (*JSONRenderer) Title

func (r *JSONRenderer) Title(s string)

Title stores a prominent title for the JSON output.

The title is rendered as the "title" field in the JSON output. Multiple calls to Title will overwrite previous values.

Example:

renderer.Title("Welcome to Tracks")

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL