Files
ollama/cmd/launch/spinner.go

110 lines
3.6 KiB
Go

package launch
import (
"fmt"
"os"
"sync"
"time"
)
// SpinnerFrames are the braille spinner frames used by the bubbletea TUIs in
// this codebase (sign-in, upgrade). StartSpinner uses the same frames for its
// fallback so the restart spinner matches the look of those flows.
var SpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// DefaultSpinner, when set, starts an animated spinner displaying message and
// returns a *Spinner. cmd/cmd.go registers a bubbletea implementation from
// cmd/tui; when unset (or when it returns nil, e.g. no TTY) StartSpinner falls
// back to a simple ANSI spinner using SpinnerFrames.
var DefaultSpinner func(message string) *Spinner
// Spinner is a handle on a running animated spinner. Stop halts the spinner
// and clears its line (it blocks until the spinner has fully stopped and is
// safe to call multiple times). Cancelled returns a channel that is closed if
// the user interrupts the spinner (e.g. with Ctrl+C); wait loops can select on
// it to abort early. For the non-interactive ANSI fallback the channel is never
// closed because Ctrl+C raises SIGINT and terminates the process directly.
type Spinner struct {
stop func()
cancelled chan struct{}
}
// NewSpinner builds a Spinner from a stop function and a cancellation channel.
// It is intended for implementations of DefaultSpinner (e.g. the bubbletea
// spinner in cmd/tui). stop must be safe to call multiple times; cancelled is
// closed by the implementation when the user interrupts the spinner, or left
// open when interruption is handled another way (e.g. SIGINT).
func NewSpinner(stop func(), cancelled chan struct{}) *Spinner {
return &Spinner{stop: stop, cancelled: cancelled}
}
// Stop halts the spinner and clears its line. It is a no-op when the spinner
// already stopped (for example after the user cancelled it).
func (s *Spinner) Stop() {
if s != nil && s.stop != nil {
s.stop()
}
}
// Cancelled returns a channel that is closed when the user interrupts the
// spinner. Callers may select on it to abort a blocking wait.
func (s *Spinner) Cancelled() <-chan struct{} {
if s == nil {
return nil
}
return s.cancelled
}
// StartSpinner begins an animated spinner displaying message and returns a
// *Spinner handle. It uses DefaultSpinner when available, otherwise a simple
// ANSI fallback that renders SpinnerFrames to stderr without requiring a TTY.
func StartSpinner(message string) *Spinner {
if DefaultSpinner != nil {
if s := DefaultSpinner(message); s != nil {
return s
}
}
return defaultSpinner(message)
}
// defaultSpinner renders SpinnerFrames to stderr without requiring a TTY. It
// runs in its own goroutine so it can animate while a caller polls; Stop
// signals the goroutine to exit, waits for it, and clears the spinner line.
func defaultSpinner(message string) *Spinner {
frames := SpinnerFrames
frame := 0
fmt.Fprintf(os.Stderr, "\r\033[90m%s %s\033[0m", message, frames[0])
done := make(chan struct{})
exited := make(chan struct{})
var once sync.Once
go func() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-done:
close(exited)
return
case <-ticker.C:
frame++
fmt.Fprintf(os.Stderr, "\r\033[90m%s %s\033[0m", message, frames[frame%len(frames)])
}
}
}()
stop := func() {
once.Do(func() {
close(done)
<-exited
fmt.Fprintf(os.Stderr, "\r\033[K")
})
}
// Ctrl+C in non-raw mode raises SIGINT and terminates the process by
// default (the launch flow installs no SIGINT handler), so this cancelled
// channel is intentionally never closed.
return &Spinner{stop: stop, cancelled: make(chan struct{})}
}