Please purchase the course to watch this video.

Full Course
Creating a progress bar is a crucial aspect of enhancing user experience in command-line interface (CLI) applications, providing real-time feedback on ongoing operations. Unlike a spinner, the progress bar visually indicates the completion percentage, helping users gauge remaining time or tasks. The implementation begins by defining a simple public interface that aligns with user needs, utilizing a constructor for initialization and methods to start, stop, and set progress. Key considerations include using floating-point numbers for precise percentage representation and ensuring the progress bar responds to internal state changes like starting and stopping. The progress is dynamically calculated and visually updated in the terminal, while additional challenges such as error handling, width customization based on terminal size, and responsiveness to window resizing events provide opportunities for further enhancement. This framework serves as a robust foundation for building sophisticated UI components within CLI applications.
Another common UI element when it comes to CLI applications, in addition to the progress spinner, is the progress bar. This is also used to communicate to the user that something is happening.
However, unlike the progress spinner, which just spins until it stops, the progress bar will display the amount of progress that has been completed, which gives a user an estimation of how much work there is left to be done.
Progress Bars vs Spinners
Feature | Spinner | Progress Bar |
---|---|---|
Use Case | Unknown duration tasks | Known progress tasks |
Information | "Something is happening" | "X% complete, Y% remaining" |
User Experience | Indicates activity | Shows progress and ETA |
Examples | Network requests, searching | File downloads, data processing |
The progress bar is also very typical when it comes to web applications, and is a really useful component to implement.
Design-First Approach
In this lesson, we're going to create our own progress bar in a way that explains how I normally think about building more complex components when it comes to code.
User-Centered Design Philosophy
When building components, I always like to think about how a user will use my individual components as a way of leading my design. This approach:
- Focuses on the public interface - What methods will users call?
- Hides implementation details - Users don't need to know internal workings
- Leads to better APIs - Natural, intuitive interfaces
- Similar to TDD - Think about usage before implementation
Key Principle: Design the interface first, then implement the details.
Project Setup
Let's create our progress bar component within a UI package:
Project Structure:
progress-demo/
├── main.go
├── ui/
│ ├── spinner.go # From previous lesson
│ └── progress.go # New progress bar
└── go.mod
Designing the Public Interface
Step 1: Define the Bar Type
ui/progress.go (initial structure):
package ui
// Bar represents a terminal progress bar
type Bar struct {
width int // Total width of the progress bar
// Other internal fields will be added as needed
}
Step 2: Design the Public Methods
Based on how we want to use the progress bar, let's define the interface:
// Constructor
func NewBar() *Bar {
return &Bar{
width: 60, // Default width
}
}
// Start initializes the progress bar display
func (b *Bar) Start() {
// TODO: Set up terminal output
}
// Stop cleans up the progress bar display
func (b *Bar) Stop() {
// TODO: Clean up terminal artifacts
}
// SetProgress updates the progress (0.0 to 1.0)
func (b *Bar) SetProgress(progress float64) {
// TODO: Update the visual progress
}
Understanding Progress Representation
Why float64 between 0.0 and 1.0?
// ✅ Good: Standard mathematical representation
progress := 0.5 // 50%
progress := 0.25 // 25%
progress := 0.2345 // 23.45%
// ❌ Avoid: Integer percentages are less flexible
progress := 50 // Limited to whole percentages
This is the standard convention in software development for representing percentages.
Testing the Interface Design
Before implementing, let's write the usage code to validate our design:
main.go (testing our interface):
package main
import (
"fmt"
"time"
"./ui" // Replace with your module path
)
func main() {
fmt.Println("Starting progress demo...")
// Create and start progress bar
bar := ui.NewBar()
bar.Start()
// Simulate work with progress updates
steps := 10
for i := 0; i < steps; i++ {
// Calculate progress (0.1, 0.2, 0.3, ... 1.0)
progress := float64(i+1) / float64(steps)
bar.SetProgress(progress)
// Simulate work
time.Sleep(500 * time.Millisecond)
}
// Clean up
bar.Stop()
fmt.Println("Finished!")
}
Expected Behavior:
Starting progress demo...
[████████████████████████████████████████████████████████████] 100%
Finished!
The bar should fill up incrementally over 5 seconds (10 steps × 500ms each).
Implementation: Step by Step
Now let's implement our progress bar based on the interface we designed:
Step 1: Implement the Start Method
ui/progress.go (Start method):
package ui
import (
"fmt"
"strings"
)
type Bar struct {
width int
}
func NewBar() *Bar {
return &Bar{
width: 60, // 60 characters wide
}
}
func (b *Bar) Start() {
// Create empty bar (all spaces)
emptyBar := strings.Repeat(" ", b.width)
// Print initial empty bar
fmt.Printf("\r%s", emptyBar)
}
Step 2: Implement the Stop Method
func (b *Bar) Stop() {
// Clear the current line
b.clearLine()
// Reset cursor to beginning
fmt.Printf("\r")
}
// Helper method to clear the current line
func (b *Bar) clearLine() {
emptyLine := strings.Repeat(" ", b.width)
fmt.Printf("\r%s", emptyLine)
}
Step 3: Implement SetProgress Method
func (b *Bar) SetProgress(progress float64) {
// Calculate how many characters should be filled
filledCount := int(float64(b.width) * progress)
emptyCount := b.width - filledCount
// Create filled and empty portions
filled := strings.Repeat("#", filledCount)
empty := strings.Repeat(" ", emptyCount)
// Print the progress bar
fmt.Printf("\r%s%s", filled, empty)
}
Understanding the Math
// Example: width=60, progress=0.3 (30%)
filledCount := int(float64(60) * 0.3) // int(18.0) = 18
emptyCount := 60 - 18 // 42
// Result: 18 '#' characters + 42 ' ' characters = 60 total
// Visual: ##################
// ↑ 18 filled ↑ 42 empty
Testing the Basic Implementation
go run main.go
You should see:
- An empty bar appears initially
- Bar fills progressively:
#
,##
,###
, etc. - When complete, the bar disappears and "Finished!" appears
Common Issues and Solutions
Issue 1: Math Error - Always Zero Progress
Problem:
filledCount := int(progress * b.width) // This truncates to 0!
Solution:
filledCount := int(float64(b.width) * progress) // Cast width to float64 first
Issue 2: Visual Artifacts After Completion
Problem: The filled bar remains visible after "Finished!" prints.
Solution: Ensure Stop()
properly clears the line before resetting cursor.
Complete Working Implementation
ui/progress.go (complete version):
package ui
import (
"fmt"
"strings"
)
type Bar struct {
width int
}
func NewBar() *Bar {
return &Bar{
width: 60,
}
}
func (b *Bar) Start() {
b.clearLine()
}
func (b *Bar) Stop() {
b.clearLine()
fmt.Printf("\r")
}
func (b *Bar) SetProgress(progress float64) {
// Calculate filled and empty counts
filledCount := int(float64(b.width) * progress)
emptyCount := b.width - filledCount
// Create visual components
filled := strings.Repeat("#", filledCount)
empty := strings.Repeat(" ", emptyCount)
// Display progress bar
fmt.Printf("\r%s%s", filled, empty)
}
func (b *Bar) clearLine() {
emptyLine := strings.Repeat(" ", b.width)
fmt.Printf("\r%s", emptyLine)
}
Testing the Complete Implementation
go run main.go
# Should show smooth progress bar animation over 5 seconds
Advanced Features and Homework
Now let's improve our progress bar with practical enhancements:
Assignment 1: Bug Fixes and Input Validation
Problem 1: Panic with Invalid Progress Values
// This will cause a panic!
bar.SetProgress(2.0) // 200% progress
Error: panic: strings: negative repeat count
Solution: Constrain progress to valid range (0.0 to 1.0):
import "math"
func (b *Bar) SetProgress(progress float64) {
// Constrain progress to 0.0-1.0 range
progress = math.Max(0.0, math.Min(1.0, progress))
// Rest of implementation...
}
Problem 2: State Management
Prevent methods from being called in the wrong order:
type Bar struct {
width int
started bool
stopped bool
}
func (b *Bar) Start() {
if b.started {
return // Already started
}
b.started = true
b.clearLine()
}
func (b *Bar) Stop() {
if !b.started || b.stopped {
return // Not started or already stopped
}
b.stopped = true
b.clearLine()
fmt.Printf("\r")
}
func (b *Bar) SetProgress(progress float64) {
if !b.started || b.stopped {
return // Bar not active
}
// Implementation...
}
Assignment 2: Customizable Characters
Allow users to customize the progress bar appearance:
type BarConfig struct {
Width int
FilledChar string
EmptyChar string
}
type Bar struct {
config BarConfig
started bool
stopped bool
}
func NewBar() *Bar {
return NewBarWithConfig(BarConfig{
Width: 60,
FilledChar: "#",
EmptyChar: " ",
})
}
func NewBarWithConfig(config BarConfig) *Bar {
return &Bar{config: config}
}
func (b *Bar) SetProgress(progress float64) {
// Use b.config.FilledChar and b.config.EmptyChar
filled := strings.Repeat(b.config.FilledChar, filledCount)
empty := strings.Repeat(b.config.EmptyChar, emptyCount)
// ...
}
Usage Examples:
// Different visual styles
classicBar := ui.NewBarWithConfig(ui.BarConfig{
Width: 50,
FilledChar: "█",
EmptyChar: "░",
})
dotsBar := ui.NewBarWithConfig(ui.BarConfig{
Width: 40,
FilledChar: "●",
EmptyChar: "○",
})
Assignment 3: Percentage Display
Show the current percentage alongside the bar:
func (b *Bar) SetProgress(progress float64) {
progress = math.Max(0.0, math.Min(1.0, progress))
filledCount := int(float64(b.config.Width) * progress)
emptyCount := b.config.Width - filledCount
filled := strings.Repeat(b.config.FilledChar, filledCount)
empty := strings.Repeat(b.config.EmptyChar, emptyCount)
// Add percentage display
percentage := int(progress * 100)
fmt.Printf("\r%s%s %3d%%", filled, empty, percentage)
}
Result:
████████████████████████████████████████████████████████████ 85%
Assignment 4: Dynamic Terminal Width
Use the terminal's actual width instead of a fixed value:
import (
"os"
"golang.org/x/term"
)
func getTerminalWidth() int {
// Get terminal size
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
return 60 // Fallback to default width
}
// Leave some margin for percentage display
return width - 10
}
func NewBar() *Bar {
return &Bar{
config: BarConfig{
Width: getTerminalWidth(),
FilledChar: "#",
EmptyChar: " ",
},
}
}
Assignment 5: Window Resize Handling (Advanced)
Handle terminal window resizing dynamically:
import (
"os"
"os/signal"
"syscall"
)
type Bar struct {
config BarConfig
started bool
stopped bool
lastProgress float64 // Track last progress value
winChange chan os.Signal
}
func (b *Bar) Start() {
if b.started {
return
}
b.started = true
// Set up window change signal handling
b.winChange = make(chan os.Signal, 1)
signal.Notify(b.winChange, syscall.SIGWINCH)
// Handle window resize in goroutine
go b.handleWindowResize()
b.clearLine()
}
func (b *Bar) handleWindowResize() {
for {
select {
case <-b.winChange:
if b.stopped {
return
}
// Recalculate width and redraw
b.config.Width = getTerminalWidth()
b.SetProgress(b.lastProgress)
}
}
}
func (b *Bar) SetProgress(progress float64) {
if !b.started || b.stopped {
return
}
b.lastProgress = progress // Store for resize handling
// Rest of implementation...
}
func (b *Bar) Stop() {
if !b.started || b.stopped {
return
}
b.stopped = true
// Clean up signal handling
signal.Stop(b.winChange)
close(b.winChange)
b.clearLine()
fmt.Printf("\r")
}
Real-World Usage Examples
File Download Progress
func downloadFile(url, filename string) error {
bar := ui.NewBar()
bar.Start()
defer bar.Stop()
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
size := resp.ContentLength
downloaded := int64(0)
buffer := make([]byte, 1024)
for {
n, err := resp.Body.Read(buffer)
if n > 0 {
file.Write(buffer[:n])
downloaded += int64(n)
// Update progress
progress := float64(downloaded) / float64(size)
bar.SetProgress(progress)
}
if err != nil {
break
}
}
return nil
}
Data Processing Progress
func processData(items []DataItem) []ProcessedItem {
bar := ui.NewBar()
bar.Start()
defer bar.Stop()
results := make([]ProcessedItem, len(items))
for i, item := range items {
// Process item
results[i] = processItem(item)
// Update progress
progress := float64(i+1) / float64(len(items))
bar.SetProgress(progress)
}
return results
}
Batch Operations
func runBatchOperations(operations []Operation) {
bar := ui.NewBarWithConfig(ui.BarConfig{
Width: 50,
FilledChar: "▓",
EmptyChar: "░",
})
bar.Start()
defer bar.Stop()
for i, op := range operations {
op.Execute()
progress := float64(i+1) / float64(len(operations))
bar.SetProgress(progress)
time.Sleep(100 * time.Millisecond) // Rate limiting
}
}
Testing Your Implementation
Create comprehensive tests for your progress bar:
// progress_test.go
package ui
import (
"bytes"
"testing"
)
func TestProgressBar(t *testing.T) {
// Capture output for testing
var buf bytes.Buffer
// Test basic functionality
bar := NewBar()
bar.Start()
bar.SetProgress(0.5)
bar.Stop()
// Verify output contains expected characters
// (This is complex due to carriage returns - consider mock writers)
}
func TestProgressConstraints(t *testing.T) {
bar := NewBar()
bar.Start()
// Test edge cases
bar.SetProgress(-0.5) // Should not panic
bar.SetProgress(2.0) // Should not panic
bar.SetProgress(0.0) // Minimum
bar.SetProgress(1.0) // Maximum
bar.Stop()
}
Comparison: Different Progress Bar Styles
// Style 1: Classic
[████████████████████████████████████████████████████████████] 100%
// Style 2: Blocks
[▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 75%
// Style 3: Dots
[●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●○○○○○○○○○○○○○○○○○○○○○○○○○○○○○○] 50%
// Style 4: ASCII
[####################...........................................] 25%
Performance Considerations
Efficient String Building
For high-frequency updates, consider using strings.Builder
:
func (b *Bar) SetProgress(progress float64) {
progress = math.Max(0.0, math.Min(1.0, progress))
filledCount := int(float64(b.config.Width) * progress)
emptyCount := b.config.Width - filledCount
var builder strings.Builder
builder.WriteString("\r")
// Write filled portion
for i := 0; i < filledCount; i++ {
builder.WriteString(b.config.FilledChar)
}
// Write empty portion
for i := 0; i < emptyCount; i++ {
builder.WriteString(b.config.EmptyChar)
}
fmt.Print(builder.String())
}
Rate Limiting Updates
For very frequent updates, consider rate limiting:
type Bar struct {
// ... other fields
lastUpdate time.Time
updateDelay time.Duration
}
func (b *Bar) SetProgress(progress float64) {
now := time.Now()
if now.Sub(b.lastUpdate) < b.updateDelay {
return // Skip update if too frequent
}
b.lastUpdate = now
// ... rest of implementation
}
Summary
We've successfully created a progress bar that provides visual feedback for CLI tasks with known progress:
Key Concepts Learned:
- Design-First Approach - Define the interface before implementation
- Mathematical Progress - Using float64 for precise percentage representation
- Terminal Control - Carriage return for overwriting display
- State Management - Preventing invalid method call sequences
- Customization - Configurable appearance and behavior
Benefits:
- User Experience - Clear indication of progress and remaining work
- Flexibility - Configurable appearance and size
- Robustness - Input validation and error handling
- Responsive Design - Adapts to terminal size changes
Next Steps:
Complete the homework assignments to create a robust, production-ready progress bar component. In the next lesson, we'll expand our UI library even further by adding the ability to write colored output to the terminal.
Additional Resources
- Reference Implementation - Complete progress bar with all features (check lesson resources)
- Terminal Control Sequences - Advanced cursor control techniques
- golang.org/x/term - Terminal size detection and control
- Signal Handling - Window resize event handling
Remember: Start with the basic implementation and gradually add features. Each assignment builds upon the previous one, creating a more sophisticated and robust progress bar component! 🚀
Bug Fixes and Input Validation
Fix critical bugs and add proper input validation to make the progress bar robust and production-ready.
Problem 1: Panic with Invalid Progress Values
// This will cause a panic!
bar.SetProgress(2.0) // 200% progress
Error: panic: strings: negative repeat count
Requirements:
- Constrain progress to valid range (0.0 to 1.0) using
math.Max
andmath.Min
- Handle negative progress values gracefully
- Handle progress values greater than 1.0 gracefully
Problem 2: State Management
Prevent methods from being called in the wrong order:
type Bar struct {
width int
started bool
stopped bool
}
Requirements:
- Prevent multiple calls to
Start()
- Prevent
SetProgress()
calls beforeStart()
or afterStop()
- Prevent multiple calls to
Stop()
- Add appropriate guards to each method
Customizable Characters
Allow users to customize the progress bar appearance with different characters and configurations.
Requirements:
- Create a
BarConfig
struct withWidth
,FilledChar
, andEmptyChar
fields - Implement
NewBarWithConfig(config BarConfig)
constructor - Update existing
NewBar()
to use default configuration - Modify
SetProgress()
to use configurable characters
type BarConfig struct {
Width int
FilledChar string
EmptyChar string
}
Usage Examples:
// Different visual styles
classicBar := ui.NewBarWithConfig(ui.BarConfig{
Width: 50,
FilledChar: "█",
EmptyChar: "░",
})
dotsBar := ui.NewBarWithConfig(ui.BarConfig{
Width: 40,
FilledChar: "●",
EmptyChar: "○",
})
Percentage Display
Show the current percentage alongside the progress bar for better user feedback.
Requirements:
- Calculate percentage from progress value (0.0-1.0 → 0-100%)
- Display percentage with proper formatting (right-aligned, 3 digits)
- Update the progress bar format to include percentage
- Ensure the percentage updates smoothly as progress changes
Expected Result:
████████████████████████████████████████████████████████████ 85%
Implementation Hint:
percentage := int(progress * 100)
fmt.Printf("\r%s%s %3d%%", filled, empty, percentage)
Dynamic Terminal Width
Make the progress bar adapt to the terminal's actual width instead of using a fixed value.
Requirements:
- Use
golang.org/x/term
package to detect terminal width - Implement
getTerminalWidth()
function with fallback to default width - Leave margin for percentage display (subtract ~10 characters)
- Handle cases where terminal width cannot be detected
- Update
NewBar()
to use dynamic width by default
Dependencies:
go get golang.org/x/term
Implementation Guide:
import (
"os"
"golang.org/x/term"
)
func getTerminalWidth() int {
// Get terminal size
width, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
return 60 // Fallback to default width
}
// Leave some margin for percentage display
return width - 10
}
Window Resize Handling (Advanced)
Handle terminal window resizing dynamically to maintain proper progress bar appearance.
Requirements:
- Set up signal handling for
SIGWINCH
(window change) usingos/signal
andsyscall
- Track the last progress value to redraw after resize
- Implement goroutine-based window resize handling
- Properly clean up signal handling in
Stop()
method - Recalculate width and redraw progress bar on window resize
Advanced Implementation:
import (
"os"
"os/signal"
"syscall"
)
type Bar struct {
config BarConfig
started bool
stopped bool
lastProgress float64 // Track last progress value
winChange chan os.Signal
}
func (b *Bar) handleWindowResize() {
for {
select {
case <-b.winChange:
if b.stopped {
return
}
// Recalculate width and redraw
b.config.Width = getTerminalWidth()
b.SetProgress(b.lastProgress)
}
}
}
Testing: Resize your terminal window while the progress bar is running to verify it adapts correctly.