From 175ad1e361918ee1c7eeccc3968bbff992f879c0 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 21 Feb 2026 18:10:01 +0000 Subject: [PATCH] feat(cli): add ProgressBar with Increment, Set, SetMessage, Done Co-Authored-By: Virgil --- pkg/cli/progressbar.go | 106 ++++++++++++++++++++++++++++++++++++ pkg/cli/progressbar_test.go | 60 ++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 pkg/cli/progressbar.go create mode 100644 pkg/cli/progressbar_test.go diff --git a/pkg/cli/progressbar.go b/pkg/cli/progressbar.go new file mode 100644 index 0000000..76b488f --- /dev/null +++ b/pkg/cli/progressbar.go @@ -0,0 +1,106 @@ +package cli + +import ( + "fmt" + "strings" + "sync" +) + +// ProgressHandle controls a progress bar. +type ProgressHandle struct { + mu sync.Mutex + current int + total int + message string + width int +} + +// NewProgressBar creates a new progress bar with the given total. +func NewProgressBar(total int) *ProgressHandle { + return &ProgressHandle{ + total: total, + width: 30, + } +} + +// Current returns the current progress value. +func (p *ProgressHandle) Current() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.current +} + +// Total returns the total value. +func (p *ProgressHandle) Total() int { + return p.total +} + +// Increment advances the progress by 1. +func (p *ProgressHandle) Increment() { + p.mu.Lock() + defer p.mu.Unlock() + if p.current < p.total { + p.current++ + } + p.render() +} + +// Set sets the progress to a specific value. +func (p *ProgressHandle) Set(n int) { + p.mu.Lock() + defer p.mu.Unlock() + if n > p.total { + n = p.total + } + if n < 0 { + n = 0 + } + p.current = n + p.render() +} + +// SetMessage sets the message displayed alongside the bar. +func (p *ProgressHandle) SetMessage(msg string) { + p.mu.Lock() + defer p.mu.Unlock() + p.message = msg + p.render() +} + +// Done completes the progress bar and moves to a new line. +func (p *ProgressHandle) Done() { + p.mu.Lock() + defer p.mu.Unlock() + p.current = p.total + p.render() + fmt.Println() +} + +// String returns the rendered progress bar without ANSI cursor control. +func (p *ProgressHandle) String() string { + pct := 0 + if p.total > 0 { + pct = (p.current * 100) / p.total + } + + filled := 0 + if p.total > 0 { + filled = (p.width * p.current) / p.total + } + if filled > p.width { + filled = p.width + } + empty := p.width - filled + + bar := "[" + strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", empty) + "]" + + if p.message != "" { + return fmt.Sprintf("%s %3d%% %s", bar, pct, p.message) + } + return fmt.Sprintf("%s %3d%%", bar, pct) +} + +// render outputs the progress bar, overwriting the current line. +func (p *ProgressHandle) render() { + fmt.Printf("\033[2K\r%s", p.String()) +} diff --git a/pkg/cli/progressbar_test.go b/pkg/cli/progressbar_test.go new file mode 100644 index 0000000..afa621f --- /dev/null +++ b/pkg/cli/progressbar_test.go @@ -0,0 +1,60 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProgressBar_Good_Create(t *testing.T) { + pb := NewProgressBar(100) + require.NotNil(t, pb) + assert.Equal(t, 0, pb.Current()) + assert.Equal(t, 100, pb.Total()) +} + +func TestProgressBar_Good_Increment(t *testing.T) { + pb := NewProgressBar(10) + pb.Increment() + assert.Equal(t, 1, pb.Current()) + pb.Increment() + assert.Equal(t, 2, pb.Current()) +} + +func TestProgressBar_Good_SetMessage(t *testing.T) { + pb := NewProgressBar(10) + pb.SetMessage("Processing file.go") + assert.Equal(t, "Processing file.go", pb.message) +} + +func TestProgressBar_Good_Set(t *testing.T) { + pb := NewProgressBar(100) + pb.Set(50) + assert.Equal(t, 50, pb.Current()) +} + +func TestProgressBar_Good_Done(t *testing.T) { + pb := NewProgressBar(5) + for i := 0; i < 5; i++ { + pb.Increment() + } + pb.Done() + // After Done, Current == Total + assert.Equal(t, 5, pb.Current()) +} + +func TestProgressBar_Bad_ExceedsTotal(t *testing.T) { + pb := NewProgressBar(2) + pb.Increment() + pb.Increment() + pb.Increment() // Should clamp to total + assert.Equal(t, 2, pb.Current()) +} + +func TestProgressBar_Good_Render(t *testing.T) { + pb := NewProgressBar(10) + pb.Set(5) + rendered := pb.String() + assert.Contains(t, rendered, "50%") +}