From 736dc83059447ce55985f82b0887d895e1a71cf0 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sun, 4 Jan 2026 00:46:49 +0800 Subject: [PATCH 1/3] feat(stloader): add terminal spinner package with shining text effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new stloader package for displaying loading spinners with optional animated text effects. Features include: - Braille dots spinner animation (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) - Optional "shining" text effect with left-to-right color sweep - Configurable base color with 20% lighter highlight - Thread-safe Start/Stop/UpdateText methods - Standard library only (no third-party dependencies) - Uses ANSI 24-bit color escape codes for terminal colors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/loadertest/main.go | 29 +++++ stloader/loader.go | 259 ++++++++++++++++++++++++++++++++++++++++ stloader/loader_test.go | 253 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 541 insertions(+) create mode 100644 cmd/loadertest/main.go create mode 100644 stloader/loader.go create mode 100644 stloader/loader_test.go diff --git a/cmd/loadertest/main.go b/cmd/loadertest/main.go new file mode 100644 index 0000000..afe5da9 --- /dev/null +++ b/cmd/loadertest/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "fmt" + "time" + + "github.com/malamtime/cli/stloader" +) + +func main() { + fmt.Println("Testing stloader package...") + fmt.Println() + + // Test with shining effect + l := stloader.NewLoader(stloader.LoaderConfig{ + Text: "Processing your request, please wait...", + EnableShining: true, + BaseColor: stloader.RGB{R: 100, G: 180, B: 255}, // Light blue + ShineInterval: 100 * time.Millisecond, + SpinInterval: 150 * time.Millisecond, + }) + + l.Start() + time.Sleep(10 * time.Second) + l.Stop() + + fmt.Println() + fmt.Println("Done!") +} diff --git a/stloader/loader.go b/stloader/loader.go new file mode 100644 index 0000000..35fbff5 --- /dev/null +++ b/stloader/loader.go @@ -0,0 +1,259 @@ +package stloader + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" +) + +// RGB represents an RGB color +type RGB struct { + R, G, B uint8 +} + +// DefaultSymbols are the spinner symbols used by default (Braille dots) +var DefaultSymbols = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// Default configuration values +const ( + DefaultSpinInterval = 200 * time.Millisecond + DefaultShineInterval = 32 * time.Millisecond +) + +// LoaderConfig holds configuration options for the loader +type LoaderConfig struct { + // Symbols to rotate through for the spinner (default: ["/", "*", "\\", "|", "-"]) + Symbols []string + // SpinInterval is the time between spinner symbol changes (default: 200ms) + SpinInterval time.Duration + // Text is the optional text to display after the spinner + Text string + // EnableShining enables the color sweep effect on text + EnableShining bool + // ShineInterval is the time between color sweep updates (default: 32ms) + ShineInterval time.Duration + // BaseColor is the base text color (user-defined) + BaseColor RGB + // DarkTheme indicates if the terminal is in dark theme (highlighted char is 20% lighter) + DarkTheme bool + // Writer is the output writer (default: os.Stdout) + Writer io.Writer + // HideCursor hides the cursor while loading (default: true) + HideCursor bool +} + +// Loader represents a terminal spinner with optional shining text effect +type Loader struct { + config LoaderConfig + mu sync.Mutex + running bool + stopChan chan struct{} + doneChan chan struct{} + symbolIdx int + highlightIndex int +} + +// NewLoader creates a new Loader with the given configuration +func NewLoader(cfg LoaderConfig) *Loader { + // Apply defaults for zero values + if cfg.Symbols == nil || len(cfg.Symbols) == 0 { + cfg.Symbols = DefaultSymbols + } + if cfg.SpinInterval == 0 { + cfg.SpinInterval = DefaultSpinInterval + } + if cfg.ShineInterval == 0 { + cfg.ShineInterval = DefaultShineInterval + } + if cfg.Writer == nil { + cfg.Writer = os.Stdout + } + // HideCursor defaults to true (we check if explicitly set to false) + // Since bool zero value is false, we need a different approach + // For simplicity, we'll always hide cursor by default + cfg.HideCursor = true + + return &Loader{ + config: cfg, + } +} + +// NewLoaderWithText creates a simple loader with default settings and the given text +func NewLoaderWithText(text string) *Loader { + return NewLoader(LoaderConfig{ + Text: text, + }) +} + +// Start begins the loading animation +func (l *Loader) Start() { + l.mu.Lock() + if l.running { + l.mu.Unlock() + return + } + l.running = true + l.stopChan = make(chan struct{}) + l.doneChan = make(chan struct{}) + l.symbolIdx = 0 + l.highlightIndex = 0 + l.mu.Unlock() + + if l.config.HideCursor { + fmt.Fprint(l.config.Writer, "\033[?25l") // Hide cursor + } + + go l.animate() +} + +// Stop stops the loading animation and clears the line +func (l *Loader) Stop() { + l.mu.Lock() + if !l.running { + l.mu.Unlock() + return + } + l.running = false + l.mu.Unlock() + + close(l.stopChan) + <-l.doneChan // Wait for animation to finish + + // Clear the line + fmt.Fprint(l.config.Writer, "\r\033[K") + + if l.config.HideCursor { + fmt.Fprint(l.config.Writer, "\033[?25h") // Show cursor + } +} + +// UpdateText changes the displayed text while running +func (l *Loader) UpdateText(text string) { + l.mu.Lock() + defer l.mu.Unlock() + l.config.Text = text + l.highlightIndex = 0 // Reset highlight position for new text +} + +// animate runs the animation loop in a goroutine +func (l *Loader) animate() { + defer close(l.doneChan) + + // Use the faster interval for smooth animation + interval := l.config.SpinInterval + if l.config.EnableShining && l.config.ShineInterval < interval { + interval = l.config.ShineInterval + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + // Calculate how many ticks before updating the spinner symbol + spinThreshold := 1 + if l.config.EnableShining && l.config.ShineInterval > 0 { + spinThreshold = int(l.config.SpinInterval / l.config.ShineInterval) + if spinThreshold < 1 { + spinThreshold = 1 + } + } + + spinCounter := 0 + + for { + select { + case <-l.stopChan: + return + case <-ticker.C: + l.render() + + // Update highlight index for shining effect + if l.config.EnableShining { + l.mu.Lock() + textLen := len([]rune(l.config.Text)) + if textLen > 0 { + l.highlightIndex = (l.highlightIndex + 1) % textLen + } + l.mu.Unlock() + } + + // Update spinner symbol at appropriate interval + spinCounter++ + if spinCounter >= spinThreshold { + l.mu.Lock() + l.symbolIdx = (l.symbolIdx + 1) % len(l.config.Symbols) + l.mu.Unlock() + spinCounter = 0 + } + } + } +} + +// render draws the current state to the terminal +func (l *Loader) render() { + l.mu.Lock() + symbol := l.config.Symbols[l.symbolIdx] + text := l.config.Text + highlightIdx := l.highlightIndex + l.mu.Unlock() + + var output strings.Builder + output.WriteString("\r\033[K") // Clear line first + output.WriteString(symbol) + + if text != "" { + output.WriteString(" ") + if l.config.EnableShining { + output.WriteString(l.renderShiningText(text, highlightIdx)) + } else { + output.WriteString(text) + } + } + + fmt.Fprint(l.config.Writer, output.String()) +} + +// renderShiningText renders text with the shining effect +func (l *Loader) renderShiningText(text string, highlightIdx int) string { + runes := []rune(text) + if len(runes) == 0 { + return "" + } + + baseColor := l.config.BaseColor + highlightColor := lightenColor(baseColor) + + var result strings.Builder + for i, r := range runes { + if r == ' ' { + result.WriteRune(r) + continue + } + + if i == highlightIdx { + result.WriteString(colorize(string(r), highlightColor)) + } else { + result.WriteString(colorize(string(r), baseColor)) + } + } + // Reset color at the end + result.WriteString("\033[0m") + + return result.String() +} + +// lightenColor returns a color 20% lighter +func lightenColor(c RGB) RGB { + return RGB{ + R: uint8(min(255, int(c.R)+51)), // 255 * 0.2 ≈ 51 + G: uint8(min(255, int(c.G)+51)), + B: uint8(min(255, int(c.B)+51)), + } +} + +// colorize wraps text with ANSI 24-bit color escape codes +func colorize(text string, c RGB) string { + return fmt.Sprintf("\033[38;2;%d;%d;%dm%s", c.R, c.G, c.B, text) +} diff --git a/stloader/loader_test.go b/stloader/loader_test.go new file mode 100644 index 0000000..8830d9d --- /dev/null +++ b/stloader/loader_test.go @@ -0,0 +1,253 @@ +package stloader + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func TestNewLoader(t *testing.T) { + l := NewLoader(LoaderConfig{}) + + if l == nil { + t.Fatal("NewLoader returned nil") + } + + // Check defaults + if len(l.config.Symbols) != len(DefaultSymbols) { + t.Errorf("Expected %d symbols, got %d", len(DefaultSymbols), len(l.config.Symbols)) + } + if l.config.SpinInterval != DefaultSpinInterval { + t.Errorf("Expected SpinInterval %v, got %v", DefaultSpinInterval, l.config.SpinInterval) + } + if l.config.ShineInterval != DefaultShineInterval { + t.Errorf("Expected ShineInterval %v, got %v", DefaultShineInterval, l.config.ShineInterval) + } + if l.config.Writer == nil { + t.Error("Expected Writer to be set") + } + if !l.config.HideCursor { + t.Error("Expected HideCursor to be true by default") + } +} + +func TestNewLoaderWithText(t *testing.T) { + text := "Loading..." + l := NewLoaderWithText(text) + + if l == nil { + t.Fatal("NewLoaderWithText returned nil") + } + if l.config.Text != text { + t.Errorf("Expected text %q, got %q", text, l.config.Text) + } +} + +func TestLoaderStartStop(t *testing.T) { + var buf bytes.Buffer + l := NewLoader(LoaderConfig{ + Writer: &buf, + Text: "Test", + SpinInterval: 20 * time.Millisecond, + }) + + l.Start() + time.Sleep(100 * time.Millisecond) + l.Stop() + + output := buf.String() + // Should contain at least one spinner symbol + found := false + for _, sym := range DefaultSymbols { + if strings.Contains(output, sym) { + found = true + break + } + } + if !found { + t.Error("Expected output to contain at least one spinner symbol") + } + + // Should contain the text + if !strings.Contains(output, "Test") { + t.Error("Expected output to contain the text 'Test'") + } +} + +func TestLoaderDoubleStart(t *testing.T) { + var buf bytes.Buffer + l := NewLoader(LoaderConfig{ + Writer: &buf, + }) + + // Should not panic when starting twice + l.Start() + l.Start() // Second start should be ignored + time.Sleep(50 * time.Millisecond) + l.Stop() +} + +func TestLoaderDoubleStop(t *testing.T) { + var buf bytes.Buffer + l := NewLoader(LoaderConfig{ + Writer: &buf, + }) + + l.Start() + time.Sleep(50 * time.Millisecond) + + // Should not panic when stopping twice + l.Stop() + l.Stop() // Second stop should be ignored +} + +func TestLoaderStopWithoutStart(t *testing.T) { + var buf bytes.Buffer + l := NewLoader(LoaderConfig{ + Writer: &buf, + }) + + // Should not panic when stopping without starting + l.Stop() +} + +func TestLoaderUpdateText(t *testing.T) { + var buf bytes.Buffer + l := NewLoader(LoaderConfig{ + Writer: &buf, + Text: "Initial", + SpinInterval: 20 * time.Millisecond, + }) + + l.Start() + time.Sleep(100 * time.Millisecond) + + l.UpdateText("Updated") + time.Sleep(100 * time.Millisecond) + + l.Stop() + + output := buf.String() + if !strings.Contains(output, "Initial") { + t.Error("Expected output to contain 'Initial'") + } + if !strings.Contains(output, "Updated") { + t.Error("Expected output to contain 'Updated'") + } +} + +func TestLoaderShiningEffect(t *testing.T) { + var buf bytes.Buffer + l := NewLoader(LoaderConfig{ + Writer: &buf, + Text: "Test", + EnableShining: true, + BaseColor: RGB{R: 100, G: 150, B: 200}, + ShineInterval: 10 * time.Millisecond, + }) + + l.Start() + time.Sleep(100 * time.Millisecond) + l.Stop() + + output := buf.String() + // Should contain ANSI color codes + if !strings.Contains(output, "\033[38;2;") { + t.Error("Expected output to contain ANSI 24-bit color codes") + } +} + +func TestLightenColor(t *testing.T) { + tests := []struct { + name string + input RGB + expected RGB + }{ + { + name: "normal color", + input: RGB{R: 100, G: 100, B: 100}, + expected: RGB{R: 151, G: 151, B: 151}, + }, + { + name: "near max color", + input: RGB{R: 220, G: 220, B: 220}, + expected: RGB{R: 255, G: 255, B: 255}, // Should cap at 255 + }, + { + name: "zero color", + input: RGB{R: 0, G: 0, B: 0}, + expected: RGB{R: 51, G: 51, B: 51}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := lightenColor(tt.input) + if result != tt.expected { + t.Errorf("lightenColor(%v) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestColorize(t *testing.T) { + result := colorize("X", RGB{R: 255, G: 128, B: 64}) + expected := "\033[38;2;255;128;64mX" + + if result != expected { + t.Errorf("colorize('X', RGB{255, 128, 64}) = %q, expected %q", result, expected) + } +} + +func TestLoaderCustomSymbols(t *testing.T) { + var buf bytes.Buffer + customSymbols := []string{"a", "b", "c"} + l := NewLoader(LoaderConfig{ + Writer: &buf, + Symbols: customSymbols, + SpinInterval: 20 * time.Millisecond, + }) + + l.Start() + time.Sleep(100 * time.Millisecond) + l.Stop() + + output := buf.String() + // Should contain at least one custom symbol + found := false + for _, sym := range customSymbols { + if strings.Contains(output, sym) { + found = true + break + } + } + if !found { + t.Error("Expected output to contain at least one custom symbol") + } +} + +func TestLoaderNoText(t *testing.T) { + var buf bytes.Buffer + l := NewLoader(LoaderConfig{ + Writer: &buf, + SpinInterval: 20 * time.Millisecond, + }) + + l.Start() + time.Sleep(100 * time.Millisecond) + l.Stop() + + // Should still work without text + output := buf.String() + found := false + for _, sym := range DefaultSymbols { + if strings.Contains(output, sym) { + found = true + break + } + } + if !found { + t.Error("Expected output to contain at least one spinner symbol") + } +} From 5f336b84c4e7d2b2b602ef39ed2613b9fd84d8f5 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sun, 4 Jan 2026 00:58:17 +0800 Subject: [PATCH 2/3] refactor(commands): replace briandowns/spinner with stloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all usages of github.com/briandowns/spinner with the new stloader package. All commands now have consistent visual feedback with the shining text effect. Changes: - grep.go: Use stloader with "Searching commands..." text - auth.go: Use stloader with "Waiting for authentication..." text - query.go: Use stloader with "Querying AI..." text - Remove unused briandowns/spinner dependency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- commands/auth.go | 15 ++++++++++----- commands/grep.go | 13 ++++++++----- commands/query.go | 17 ++++++++++------- go.mod | 1 - go.sum | 2 -- 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index e87a043..7da635c 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -8,7 +8,7 @@ import ( "os" "time" - "github.com/briandowns/spinner" + "github.com/malamtime/cli/stloader" "github.com/gookit/color" "github.com/invopop/jsonschema" "github.com/malamtime/cli/model" @@ -118,22 +118,27 @@ func ApplyTokenByHandshake(_ctx context.Context, config model.ShellTimeConfig) ( color.Green.Println(fmt.Sprintf("Open %s to continue", feLink)) - s := spinner.New(spinner.CharSets[35], 200*time.Millisecond) - s.Start() + l := stloader.NewLoader(stloader.LoaderConfig{ + Text: "Waiting for authentication...", + EnableShining: true, + BaseColor: stloader.RGB{R: 100, G: 180, B: 255}, + }) + l.Start() for { if time.Since(startedAt) > 10*time.Minute { + l.Stop() color.Red.Println(" ❌ Failed to authenticate. Please retry with `shelltime init` or contact shelltime team (annatar.he+shelltime.xyz@gmail.com)") - s.Stop() return "", fmt.Errorf("authentication timeout") } token, err := hs.Check(ctx, hid) if err != nil { + l.Stop() return "", err } if token != "" { + l.Stop() color.Green.Println(" ✅ You are ready to go!") - s.Stop() return token, nil } diff --git a/commands/grep.go b/commands/grep.go index 50eb008..9e05aa7 100644 --- a/commands/grep.go +++ b/commands/grep.go @@ -8,7 +8,7 @@ import ( "strconv" "time" - "github.com/briandowns/spinner" + "github.com/malamtime/cli/stloader" "github.com/gookit/color" "github.com/malamtime/cli/model" "github.com/olekukonko/tablewriter" @@ -136,13 +136,16 @@ func commandGrep(c *cli.Context) error { slog.Int("lastId", pagination.LastID)) // Show loading spinner - s := spinner.New(spinner.CharSets[35], 200*time.Millisecond) - s.Suffix = " Searching commands..." - s.Start() + l := stloader.NewLoader(stloader.LoaderConfig{ + Text: "Searching commands...", + EnableShining: true, + BaseColor: stloader.RGB{R: 100, G: 180, B: 255}, + }) + l.Start() // Fetch commands from server result, err := model.FetchCommandsFromServer(ctx, endpoint, filter, pagination) - s.Stop() + l.Stop() if err != nil { if format == "json" { errOutput := struct { diff --git a/commands/query.go b/commands/query.go index 4a85701..b924265 100644 --- a/commands/query.go +++ b/commands/query.go @@ -8,9 +8,8 @@ import ( "os/exec" "runtime" "strings" - "time" - "github.com/briandowns/spinner" + "github.com/malamtime/cli/stloader" "github.com/gookit/color" "github.com/malamtime/cli/model" "github.com/urfave/cli/v2" @@ -54,9 +53,13 @@ func commandQuery(c *cli.Context) error { slog.Warn("Failed to get system context", slog.Any("err", err)) } - s := spinner.New(spinner.CharSets[35], 200*time.Millisecond) - s.Start() - defer s.Stop() + l := stloader.NewLoader(stloader.LoaderConfig{ + Text: "Querying AI...", + EnableShining: true, + BaseColor: stloader.RGB{R: 100, G: 180, B: 255}, + }) + l.Start() + defer l.Stop() // skip userId for now userId := "" @@ -64,12 +67,12 @@ func commandQuery(c *cli.Context) error { // Query the AI newCommand, err := aiService.QueryCommand(ctx, systemContext, userId) if err != nil { - s.Stop() + l.Stop() color.Red.Printf("❌ Failed to query AI: %v\n", err) return err } - s.Stop() + l.Stop() // Trim the command newCommand = strings.TrimSpace(newCommand) diff --git a/go.mod b/go.mod index 96f3643..5bacf2c 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.25 require ( github.com/PromptPal/go-sdk v0.4.2 github.com/ThreeDotsLabs/watermill v1.5.1 - github.com/briandowns/spinner v1.23.2 github.com/go-git/go-git/v5 v5.16.4 github.com/google/uuid v1.6.0 github.com/gookit/color v1.6.0 diff --git a/go.sum b/go.sum index 756263c..6c62b77 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,6 @@ github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892 github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= -github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= -github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= From 5d37a523ab9ca01ccb9feff2361e87f7068757ad Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Sun, 4 Jan 2026 01:17:21 +0800 Subject: [PATCH 3/3] fix(stloader): address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use *bool for HideCursor to distinguish unset (nil, defaults to true) from explicitly false - Remove unused DarkTheme field from LoaderConfig - Consolidate mutex locks in animate() to single lock-unlock per tick - Use fmt.Fprintf with %c in renderShiningText to avoid string(r) allocations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- stloader/loader.go | 63 +++++++++++++++++++---------------------- stloader/loader_test.go | 16 ++++++++++- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/stloader/loader.go b/stloader/loader.go index 35fbff5..eb9e4b3 100644 --- a/stloader/loader.go +++ b/stloader/loader.go @@ -37,12 +37,11 @@ type LoaderConfig struct { ShineInterval time.Duration // BaseColor is the base text color (user-defined) BaseColor RGB - // DarkTheme indicates if the terminal is in dark theme (highlighted char is 20% lighter) - DarkTheme bool // Writer is the output writer (default: os.Stdout) Writer io.Writer // HideCursor hides the cursor while loading (default: true) - HideCursor bool + // Use pointer to distinguish between unset (nil, defaults to true) and explicitly false + HideCursor *bool } // Loader represents a terminal spinner with optional shining text effect @@ -71,10 +70,11 @@ func NewLoader(cfg LoaderConfig) *Loader { if cfg.Writer == nil { cfg.Writer = os.Stdout } - // HideCursor defaults to true (we check if explicitly set to false) - // Since bool zero value is false, we need a different approach - // For simplicity, we'll always hide cursor by default - cfg.HideCursor = true + // HideCursor defaults to true if not explicitly set + if cfg.HideCursor == nil { + hideCursor := true + cfg.HideCursor = &hideCursor + } return &Loader{ config: cfg, @@ -102,7 +102,7 @@ func (l *Loader) Start() { l.highlightIndex = 0 l.mu.Unlock() - if l.config.HideCursor { + if *l.config.HideCursor { fmt.Fprint(l.config.Writer, "\033[?25l") // Hide cursor } @@ -125,7 +125,7 @@ func (l *Loader) Stop() { // Clear the line fmt.Fprint(l.config.Writer, "\r\033[K") - if l.config.HideCursor { + if *l.config.HideCursor { fmt.Fprint(l.config.Writer, "\033[?25h") // Show cursor } } @@ -167,38 +167,36 @@ func (l *Loader) animate() { case <-l.stopChan: return case <-ticker.C: - l.render() + // Consolidate all shared state access within a single mutex lock + l.mu.Lock() + symbol := l.config.Symbols[l.symbolIdx] + text := l.config.Text + highlightIdx := l.highlightIndex // Update highlight index for shining effect if l.config.EnableShining { - l.mu.Lock() - textLen := len([]rune(l.config.Text)) + textLen := len([]rune(text)) if textLen > 0 { l.highlightIndex = (l.highlightIndex + 1) % textLen } - l.mu.Unlock() } // Update spinner symbol at appropriate interval spinCounter++ if spinCounter >= spinThreshold { - l.mu.Lock() l.symbolIdx = (l.symbolIdx + 1) % len(l.config.Symbols) - l.mu.Unlock() spinCounter = 0 } + l.mu.Unlock() + + // Render outside the lock since it only reads local copies + l.renderWithValues(symbol, text, highlightIdx) } } } -// render draws the current state to the terminal -func (l *Loader) render() { - l.mu.Lock() - symbol := l.config.Symbols[l.symbolIdx] - text := l.config.Text - highlightIdx := l.highlightIndex - l.mu.Unlock() - +// renderWithValues draws the current state to the terminal with pre-fetched values +func (l *Loader) renderWithValues(symbol, text string, highlightIdx int) { var output strings.Builder output.WriteString("\r\033[K") // Clear line first output.WriteString(symbol) @@ -206,7 +204,7 @@ func (l *Loader) render() { if text != "" { output.WriteString(" ") if l.config.EnableShining { - output.WriteString(l.renderShiningText(text, highlightIdx)) + l.renderShiningText(&output, text, highlightIdx) } else { output.WriteString(text) } @@ -215,33 +213,30 @@ func (l *Loader) render() { fmt.Fprint(l.config.Writer, output.String()) } -// renderShiningText renders text with the shining effect -func (l *Loader) renderShiningText(text string, highlightIdx int) string { +// renderShiningText renders text with the shining effect directly to the builder +func (l *Loader) renderShiningText(w *strings.Builder, text string, highlightIdx int) { runes := []rune(text) if len(runes) == 0 { - return "" + return } baseColor := l.config.BaseColor highlightColor := lightenColor(baseColor) - var result strings.Builder for i, r := range runes { if r == ' ' { - result.WriteRune(r) + w.WriteRune(r) continue } if i == highlightIdx { - result.WriteString(colorize(string(r), highlightColor)) + fmt.Fprintf(w, "\033[38;2;%d;%d;%dm%c", highlightColor.R, highlightColor.G, highlightColor.B, r) } else { - result.WriteString(colorize(string(r), baseColor)) + fmt.Fprintf(w, "\033[38;2;%d;%d;%dm%c", baseColor.R, baseColor.G, baseColor.B, r) } } // Reset color at the end - result.WriteString("\033[0m") - - return result.String() + w.WriteString("\033[0m") } // lightenColor returns a color 20% lighter diff --git a/stloader/loader_test.go b/stloader/loader_test.go index 8830d9d..24cf2bd 100644 --- a/stloader/loader_test.go +++ b/stloader/loader_test.go @@ -27,11 +27,25 @@ func TestNewLoader(t *testing.T) { if l.config.Writer == nil { t.Error("Expected Writer to be set") } - if !l.config.HideCursor { + if l.config.HideCursor == nil || !*l.config.HideCursor { t.Error("Expected HideCursor to be true by default") } } +func TestNewLoaderWithHideCursorFalse(t *testing.T) { + hideCursor := false + l := NewLoader(LoaderConfig{ + HideCursor: &hideCursor, + }) + + if l.config.HideCursor == nil { + t.Fatal("Expected HideCursor to be set") + } + if *l.config.HideCursor != false { + t.Error("Expected HideCursor to be false when explicitly set") + } +} + func TestNewLoaderWithText(t *testing.T) { text := "Loading..." l := NewLoaderWithText(text)