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/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= diff --git a/stloader/loader.go b/stloader/loader.go new file mode 100644 index 0000000..eb9e4b3 --- /dev/null +++ b/stloader/loader.go @@ -0,0 +1,254 @@ +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 + // Writer is the output writer (default: os.Stdout) + Writer io.Writer + // HideCursor hides the cursor while loading (default: true) + // 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 +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 if not explicitly set + if cfg.HideCursor == nil { + hideCursor := true + cfg.HideCursor = &hideCursor + } + + 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: + // 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 { + textLen := len([]rune(text)) + if textLen > 0 { + l.highlightIndex = (l.highlightIndex + 1) % textLen + } + } + + // Update spinner symbol at appropriate interval + spinCounter++ + if spinCounter >= spinThreshold { + l.symbolIdx = (l.symbolIdx + 1) % len(l.config.Symbols) + spinCounter = 0 + } + l.mu.Unlock() + + // Render outside the lock since it only reads local copies + l.renderWithValues(symbol, text, highlightIdx) + } + } +} + +// 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) + + if text != "" { + output.WriteString(" ") + if l.config.EnableShining { + l.renderShiningText(&output, text, highlightIdx) + } else { + output.WriteString(text) + } + } + + fmt.Fprint(l.config.Writer, output.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 + } + + baseColor := l.config.BaseColor + highlightColor := lightenColor(baseColor) + + for i, r := range runes { + if r == ' ' { + w.WriteRune(r) + continue + } + + if i == highlightIdx { + fmt.Fprintf(w, "\033[38;2;%d;%d;%dm%c", highlightColor.R, highlightColor.G, highlightColor.B, r) + } else { + fmt.Fprintf(w, "\033[38;2;%d;%d;%dm%c", baseColor.R, baseColor.G, baseColor.B, r) + } + } + // Reset color at the end + w.WriteString("\033[0m") +} + +// 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..24cf2bd --- /dev/null +++ b/stloader/loader_test.go @@ -0,0 +1,267 @@ +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 == 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) + + 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") + } +}