├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets └── demo.gif ├── codecov.yml ├── example ├── demo.tape ├── main.go └── prompt.md ├── go.mod ├── pin.go └── pin_test.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | goVersion: [ "1.11", "1.21", "1.23" ] 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: ${{ matrix.goVersion }} 22 | 23 | - name: Run tests with coverage 24 | run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... 25 | 26 | - name: Upload coverage reports to Codecov 27 | if: ${{ matrix.goVersion == '1.23' }} 28 | uses: codecov/codecov-action@v5 29 | with: 30 | token: ${{ secrets.CODECOV_TOKEN }} 31 | slug: yarlson/pin 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage.txt 2 | .idea 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Yar Kravtsov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pin 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/yarlson/pin.svg)](https://pkg.go.dev/github.com/yarlson/pin) 4 | [![codecov](https://codecov.io/gh/yarlson/pin/branch/main/graph/badge.svg)](https://codecov.io/gh/yarlson/pin) 5 | 6 | `pin` is a lightweight, customizable terminal spinner library for Go applications. It provides an elegant way to show progress and status in CLI applications with support for colors, custom symbols, and flexible positioning. 7 | 8 | ![Demo](/assets/demo.gif) 9 | 10 | ## Features 11 | 12 | - 🎨 Customizable colors for all spinner elements via functional options 13 | - 🔄 Smooth braille-pattern animation 14 | - 🎯 Flexible positioning (spinner before/after message) 15 | - 💫 Configurable prefix and separator 16 | - 🔤 UTF-8 symbol support 17 | - ✨ Ability to update the spinner message dynamically 18 | - 🖼️ Customizable spinner frames for unique animation effects 19 | - ⚙️ No external dependencies – uses only the Go standard library 20 | - 🚀 Compatible with Go 1.11 and later 21 | - ⏹ Automatically disables animations in non-interactive (piped) environments to prevent output corruption 22 | 23 | ## Installation 24 | 25 | ```bash 26 | go get github.com/yarlson/pin 27 | ``` 28 | 29 | ## Quick Start 30 | 31 | ```go 32 | p := pin.New("Loading...", 33 | pin.WithSpinnerColor(pin.ColorCyan), 34 | pin.WithTextColor(pin.ColorYellow), 35 | ) 36 | cancel := p.Start(context.Background()) 37 | defer cancel() 38 | // do some work 39 | p.Stop("Done!") 40 | ``` 41 | 42 | ## Custom Output Writer 43 | 44 | You can direct spinner output to an alternative writer (for example, `os.Stderr`) using the `WithWriter` option: 45 | 46 | ```go 47 | p := pin.New("Processing...", 48 | pin.WithSpinnerColor(pin.ColorCyan), 49 | pin.WithTextColor(pin.ColorYellow), 50 | pin.WithWriter(os.Stderr), // output will be written to stderr 51 | ) 52 | cancel := p.Start(context.Background()) 53 | defer cancel() 54 | // perform your work 55 | p.Stop("Done!") 56 | ``` 57 | 58 | ## Non-interactive Behavior 59 | 60 | When the spinner detects that `stdout` is not connected to an interactive terminal (for example, when output is piped), it disables animations and outputs messages as plain text. In this mode: 61 | 62 | - The **initial message** is printed immediately when the spinner starts. 63 | - Any **updated messages** are printed as soon as you call `UpdateMessage()`. 64 | - The **final done message** is printed when you call `Stop()`. 65 | 66 | ## Examples 67 | 68 | ### Basic Progress Indicator 69 | 70 | ```go 71 | p := pin.New("Processing data") 72 | cancel := p.Start(context.Background()) 73 | defer cancel() 74 | // ... do work ... 75 | p.UpdateMessage("Almost there...") 76 | // finish work 77 | p.Stop("Completed!") 78 | ``` 79 | 80 | ### Styled Output 81 | 82 | ```go 83 | p := pin.New("Uploading", 84 | pin.WithPrefix("Transfer"), 85 | pin.WithSeparator("→"), 86 | pin.WithSpinnerColor(pin.ColorBlue), 87 | pin.WithTextColor(pin.ColorCyan), 88 | pin.WithPrefixColor(pin.ColorYellow), 89 | ) 90 | p.Start() 91 | // ... do work ... 92 | p.Stop("Upload complete") 93 | ``` 94 | 95 | ### Right-side Spinner 96 | 97 | ```go 98 | p := pin.New("Downloading", pin.WithPosition(pin.PositionRight)) 99 | cancel := p.Start(context.Background()) 100 | defer cancel() 101 | // ... do work ... 102 | p.Stop("Downloaded") 103 | ``` 104 | 105 | ### Custom Styling & Message Updating 106 | 107 | ```go 108 | p := pin.New("Processing", 109 | pin.WithPrefix("Task"), 110 | pin.WithSeparator(":"), 111 | pin.WithSeparatorColor(pin.ColorWhite), 112 | pin.WithDoneSymbol('✔'), 113 | pin.WithDoneSymbolColor(pin.ColorGreen), 114 | ) 115 | cancel := p.Start(context.Background()) 116 | defer cancel() 117 | 118 | // ... do work ... 119 | p.UpdateMessage("Almost done...") 120 | // finish work 121 | p.Stop("Success") 122 | ``` 123 | 124 | ### Failure Indicator 125 | 126 | You can express a failure state with the spinner using the new `Fail()` method. Customize the failure appearance with `WithFailSymbol`, `WithFailSymbolColor`, and (optionally) `WithFailColor`. 127 | 128 | ```go 129 | p := pin.New("Deploying", 130 | pin.WithFailSymbol('✖'), 131 | pin.WithFailSymbolColor(pin.ColorRed), 132 | ) 133 | cancel := p.Start(context.Background()) 134 | defer cancel() 135 | // ... perform tasks ... 136 | p.Fail("Deployment failed") 137 | ``` 138 | 139 | ## API Reference 140 | 141 | ### Creating a New Spinner 142 | 143 | ```go 144 | p := pin.New("message", /* options... */) 145 | ``` 146 | 147 | ### Available Options 148 | 149 | - `WithSpinnerColor(color Color)` – sets the spinner's animation color. 150 | - `WithTextColor(color Color)` – sets the color of the message text. 151 | - `WithPrefix(prefix string)` – sets text to display before the spinner. 152 | - `WithPrefixColor(color Color)` – sets the color of the prefix text. 153 | - `WithSeparator(separator string)` – sets the separator text between prefix and message. 154 | - `WithSeparatorColor(color Color)` – sets the color of the separator. 155 | - `WithDoneSymbol(symbol rune)` – sets the symbol displayed upon completion. 156 | - `WithDoneSymbolColor(color Color)` – sets the color of the done symbol. 157 | - `WithFailSymbol(symbol rune)` – sets the symbol displayed upon failure. 158 | - `WithFailSymbolColor(color Color)` – sets the color of the failure symbol. 159 | - `WithFailColor(color Color)` – sets the color of the failure message text. 160 | - `WithPosition(pos Position)` – sets the spinner's position relative to the message. 161 | - `WithSpinnerFrames(frames []rune)` – sets the spinner's frames. 162 | - `WithWriter(w io.Writer)` – sets a custom writer for spinner output. 163 | 164 | ### Available Colors 165 | 166 | - `ColorDefault` 167 | - `ColorBlack` 168 | - `ColorRed` 169 | - `ColorGreen` 170 | - `ColorYellow` 171 | - `ColorBlue` 172 | - `ColorMagenta` 173 | - `ColorCyan` 174 | - `ColorGray` 175 | - `ColorWhite` 176 | 177 | ## Development & Compatibility 178 | 179 | This library is written using only the Go standard library and supports Go version 1.11 and later. 180 | 181 | ### Running Tests 182 | 183 | ```bash 184 | go test -v ./... 185 | ``` 186 | 187 | ## Prompt 188 | 189 | The LLM prompt example in [example/prompt.md](example/prompt.md) shows you how to quickly integrate pin into your codebase. 190 | 191 | ### Contributing 192 | 193 | 1. Fork the repository 194 | 2. Create your feature branch: `git checkout -b feature/amazing-feature` 195 | 3. Commit your changes: `git commit -m 'Add amazing feature'` 196 | 4. Push to the branch: `git push origin feature/amazing-feature` 197 | 5. Open a Pull Request 198 | 199 | ## License 200 | 201 | MIT License – see [LICENSE](LICENSE) for details 202 | 203 | ## Acknowledgments 204 | 205 | Inspired by elegant CLI spinners and the need for a simple, flexible progress indicator in Go applications. 206 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yarlson/pin/ba679def7ba77f2c47126c60aaf99289596bbbea/assets/demo.gif -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | ignore: 3 | - "example/*" 4 | status: 5 | project: 6 | default: 7 | target: 90% 8 | -------------------------------------------------------------------------------- /example/demo.tape: -------------------------------------------------------------------------------- 1 | Output ../assets/demo.gif 2 | 3 | Set Shell zsh 4 | Set FontSize 46 5 | Set Width 1200 6 | Set Height 600 7 | 8 | Sleep 1s 9 | Type go run main.go 10 | Enter 1 11 | Sleep 10s 12 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/yarlson/pin" 8 | ) 9 | 10 | func main() { 11 | s := pin.New("Loading...", 12 | pin.WithSpinnerColor(pin.ColorCyan), 13 | pin.WithTextColor(pin.ColorYellow), 14 | pin.WithDoneSymbol('✔'), 15 | pin.WithDoneSymbolColor(pin.ColorGreen), 16 | pin.WithPrefix("pin"), 17 | pin.WithPrefixColor(pin.ColorMagenta), 18 | pin.WithSeparatorColor(pin.ColorGray), 19 | ) 20 | 21 | cancel := s.Start(context.Background()) 22 | defer cancel() 23 | 24 | time.Sleep(4 * time.Second) 25 | 26 | s.UpdateMessage("Still working...") 27 | time.Sleep(4 * time.Second) 28 | 29 | s.Stop("Done!") 30 | } 31 | -------------------------------------------------------------------------------- /example/prompt.md: -------------------------------------------------------------------------------- 1 | You are developing or refactoring a Go project that uses the `pin` library for displaying interactive CLI spinners. The following is a comprehensive description of the library, its public API, and usage examples designed for new projects or refactored code. 2 | 3 | --- 4 | 5 | ## Library Overview: 6 | 7 | The `pin` library is a lightweight and customizable terminal spinner for Go applications. It provides an elegant progress indicator with support for: 8 | 9 | - Custom colors 10 | - Dynamic message updates 11 | - Flexible positioning (spinner before or after the message) 12 | - Custom symbols for success or failure states 13 | - Automatic adjustment in non-interactive environments (animations are disabled when output is piped) 14 | 15 | ## Installation: 16 | 17 | To install the library, run: 18 | 19 | ```bash 20 | go get github.com/yarlson/pin 21 | ``` 22 | 23 | ## Public API: 24 | 25 | 1. **Creating a New Spinner:** 26 | 27 | - **Constructor:** 28 | ```go 29 | func New(message string, opts ...Option) *Pin 30 | ``` 31 | _Description:_ Initializes a new spinner with a base message and an optional list of functional options for customization. 32 | 33 | 2. **Controlling the Spinner:** 34 | 35 | - **Start:** 36 | 37 | ```go 38 | func (p *Pin) Start(ctx context.Context) context.CancelFunc 39 | ``` 40 | 41 | _Description:_ Begins the spinner animation using the provided context. Returns a cancellation function that can be called to stop the spinner. 42 | 43 | - **Stop:** 44 | 45 | ```go 46 | func (p *Pin) Stop(message ...string) 47 | ``` 48 | 49 | _Description:_ Stops the spinner and outputs an optional final message indicating success or normal termination. 50 | 51 | - **Fail:** 52 | 53 | ```go 54 | func (p *Pin) Fail(message ...string) 55 | ``` 56 | 57 | _Description:_ Stops the spinner and displays a failure message with a failure-specific symbol and color. 58 | 59 | - **UpdateMessage:** 60 | ```go 61 | func (p *Pin) UpdateMessage(message string) 62 | ``` 63 | _Description:_ Dynamically updates the spinner's displayed message while it is still active. 64 | 65 | 3. **Functional Options for Customization:** 66 | These functions return an `Option` that customizes various aspects of the spinner. 67 | 68 | - ```go 69 | func WithSpinnerColor(color Color) Option 70 | ``` 71 | _Description:_ Sets the color of the spinner's animation. 72 | - ```go 73 | func WithTextColor(color Color) Option 74 | ``` 75 | _Description:_ Sets the color of the message text. 76 | - ```go 77 | func WithDoneSymbol(symbol rune) Option 78 | ``` 79 | _Description:_ Sets the symbol displayed when the spinner stops successfully. 80 | - ```go 81 | func WithDoneSymbolColor(color Color) Option 82 | ``` 83 | _Description:_ Sets the color of the done symbol. 84 | - ```go 85 | func WithPrefix(prefix string) Option 86 | ``` 87 | _Description:_ Adds a prefix before the spinner and message. 88 | - ```go 89 | func WithPrefixColor(color Color) Option 90 | ``` 91 | _Description:_ Sets the color of the prefix text. 92 | - ```go 93 | func WithSeparator(separator string) Option 94 | ``` 95 | _Description:_ Defines the separator between the prefix and the main message text. 96 | - ```go 97 | func WithSeparatorColor(color Color) Option 98 | ``` 99 | _Description:_ Sets the color for the separator. 100 | - ```go 101 | func WithPosition(pos Position) Option 102 | ``` 103 | _Description:_ Determines the spinner's placement relative to the message text. Use `PositionLeft` (default) or `PositionRight`. 104 | - ```go 105 | func WithSpinnerFrames(frames []rune) Option 106 | ``` 107 | _Description:_ Sets the spinner's frames for custom animations. 108 | - ```go 109 | func WithFailSymbol(symbol rune) Option 110 | ``` 111 | _Description:_ Sets the symbol shown when the spinner indicates a failure. 112 | - ```go 113 | func WithFailSymbolColor(color Color) Option 114 | ``` 115 | _Description:_ Sets the color for the failure symbol. 116 | - ```go 117 | func WithFailColor(color Color) Option 118 | ``` 119 | _Description:_ Sets the color of the failure message text. 120 | - ```go 121 | func WithWriter(w io.Writer) Option 122 | ``` 123 | _Description:_ Redirects spinner output to a custom writer such as `os.Stderr`. 124 | 125 | 4. **Public Constants:** 126 | 127 | **Colors:** 128 | 129 | ```go 130 | const ( 131 | ColorDefault Color = iota 132 | ColorBlack 133 | ColorRed 134 | ColorGreen 135 | ColorYellow 136 | ColorBlue 137 | ColorMagenta 138 | ColorCyan 139 | ColorGray 140 | ColorWhite 141 | ) 142 | ``` 143 | 144 | _Description:_ These constants represent ANSI colors for styling elements of the spinner (text, symbols, animation). 145 | 146 | **Positions:** 147 | 148 | ```go 149 | const ( 150 | PositionLeft Position = iota // Spinner appears before the message (default) 151 | PositionRight // Spinner appears after the message 152 | ) 153 | ``` 154 | 155 | _Description:_ These constants allow you to specify the spinner's placement relative to the message text. 156 | 157 | ## Usage Examples: 158 | 159 | 1. **Basic Spinner Usage:** 160 | 161 | ```go:example/basic.go 162 | package main 163 | 164 | import ( 165 | "context" 166 | "time" 167 | "github.com/yarlson/pin" 168 | ) 169 | 170 | func main() { 171 | // Create a spinner with default settings. 172 | p := pin.New("Loading...", 173 | pin.WithSpinnerColor(pin.ColorCyan), 174 | pin.WithTextColor(pin.ColorYellow), 175 | ) 176 | 177 | // Start the spinner. 178 | cancel := p.Start(context.Background()) 179 | defer cancel() 180 | 181 | // Simulate work. 182 | time.Sleep(3 * time.Second) 183 | 184 | // Stop the spinner with a final message. 185 | p.Stop("Done!") 186 | } 187 | ``` 188 | 189 | 2. **Advanced Customization with Dynamic Updates:** 190 | 191 | ```go:example/advanced.go 192 | package main 193 | 194 | import ( 195 | "context" 196 | "time" 197 | "github.com/yarlson/pin" 198 | ) 199 | 200 | func main() { 201 | // Initialize spinner with custom options. 202 | p := pin.New("Processing", 203 | pin.WithSpinnerColor(pin.ColorBlue), 204 | pin.WithTextColor(pin.ColorCyan), 205 | pin.WithPrefix("Task"), 206 | pin.WithPrefixColor(pin.ColorYellow), 207 | pin.WithSeparator("->"), 208 | pin.WithPosition(pin.PositionRight), 209 | pin.WithDoneSymbol('✔'), 210 | pin.WithDoneSymbolColor(pin.ColorGreen), 211 | ) 212 | 213 | // Start the spinner. 214 | ctx, cancel := context.WithCancel(context.Background()) 215 | defer cancel() 216 | p.Start(ctx) 217 | 218 | // Update the spinner message while processing. 219 | time.Sleep(2 * time.Second) 220 | p.UpdateMessage("Still processing...") 221 | time.Sleep(2 * time.Second) 222 | 223 | // Stop the spinner with a success message. 224 | p.Stop("Success!") 225 | } 226 | ``` 227 | 228 | 3. **Handling Failure States:** 229 | 230 | ```go:example/fail.go 231 | package main 232 | 233 | import ( 234 | "context" 235 | "time" 236 | "github.com/yarlson/pin" 237 | ) 238 | 239 | func main() { 240 | // Configure spinner with failure indicators. 241 | p := pin.New("Deploying", 242 | pin.WithFailSymbol('✖'), 243 | pin.WithFailSymbolColor(pin.ColorRed), 244 | pin.WithFailColor(pin.ColorYellow), 245 | ) 246 | 247 | // Start the spinner. 248 | ctx, cancel := context.WithCancel(context.Background()) 249 | defer cancel() 250 | p.Start(ctx) 251 | 252 | // Simulate a failure scenario. 253 | time.Sleep(2 * time.Second) 254 | p.Fail("Deployment failed") 255 | } 256 | ``` 257 | 258 | 4. **Specifying a Custom Output Destination:** 259 | 260 | ```go:example/custom_writer.go 261 | package main 262 | 263 | import ( 264 | "context" 265 | "os" 266 | "time" 267 | "github.com/yarlson/pin" 268 | ) 269 | 270 | func main() { 271 | // Direct spinner output to os.Stderr. 272 | p := pin.New("Saving Data", 273 | pin.WithSpinnerColor(pin.ColorMagenta), 274 | pin.WithWriter(os.Stderr), 275 | ) 276 | 277 | // Start the spinner. 278 | ctx, cancel := context.WithCancel(context.Background()) 279 | defer cancel() 280 | p.Start(ctx) 281 | 282 | // Simulate work. 283 | time.Sleep(3 * time.Second) 284 | p.Stop("Saved!") 285 | } 286 | ``` 287 | 288 | Usage Context: 289 | 290 | This description is intended for integrating or refactoring the pin spinner in your Go project. It details every aspect of the API (public functions and constants) and provides real-world usage examples to simplify the implementation. Use this comprehensive guide to ensure a consistent and interactive CLI experience in your application. 291 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yarlson/pin 2 | 3 | go 1.11 4 | -------------------------------------------------------------------------------- /pin.go: -------------------------------------------------------------------------------- 1 | // Package pin provides a customizable CLI spinner for showing progress and status in terminal applications. 2 | // 3 | // Example usage: 4 | // 5 | // p := pin.New("Loading...", 6 | // pin.WithSpinnerColor(ColorCyan), 7 | // pin.WithTextColor(ColorYellow), 8 | // ) 9 | // cancel := p.Start(context.Background()) 10 | // defer cancel() 11 | // // ... do some work ... 12 | // p.Stop("Done!") 13 | // 14 | // Example with custom styling: 15 | // 16 | // p := pin.New("Processing", 17 | // WithPrefix("Task"), 18 | // WithSeparator("→"), 19 | // WithSpinnerColor(ColorBlue), 20 | // WithTextColor(ColorCyan), 21 | // WithPrefixColor(ColorYellow), 22 | // ) 23 | // cancel := p.Start(context.Background()) 24 | // defer cancel() 25 | // // ... do some work ... 26 | // p.Stop("Completed successfully") 27 | // 28 | // Example with right-side positioning: 29 | // 30 | // p := pin.New("Uploading", WithPosition(PositionRight)) 31 | // cancel := p.Start(context.Background()) 32 | // defer cancel() 33 | // // ... do some work ... 34 | // p.UpdateMessage("Almost done...") 35 | // // ... do more work ... 36 | // p.Stop("Upload complete") 37 | // 38 | // Example with failure: 39 | // 40 | // p := pin.New("Processing", 41 | // WithFailSymbol('✖'), 42 | // WithFailSymbolColor(ColorRed), 43 | // ) 44 | // cancel := p.Start(context.Background()) 45 | // defer cancel() 46 | // // ... do some work ... 47 | // p.Fail("Error occurred") 48 | // 49 | // Example with custom output writer: 50 | // 51 | // p := pin.New("Saving Data", 52 | // WithSpinnerColor(ColorMagenta), 53 | // WithWriter(os.Stderr), // send output to stderr 54 | // ) 55 | // cancel := p.Start(context.Background()) 56 | // defer cancel() 57 | // // ... do some work ... 58 | // p.Stop("Saved!") 59 | package pin 60 | 61 | import ( 62 | "context" 63 | "fmt" 64 | "io" 65 | "os" 66 | "sync" 67 | "sync/atomic" 68 | "time" 69 | ) 70 | 71 | // Color represents ANSI color codes for terminal output styling. 72 | // Example usage: 73 | // 74 | // p := pin.New("Loading...", WithTextColor(ColorGreen)) 75 | type Color int 76 | 77 | const ( 78 | ColorDefault Color = iota 79 | ColorBlack 80 | ColorRed 81 | ColorGreen 82 | ColorYellow 83 | ColorBlue 84 | ColorMagenta 85 | ColorCyan 86 | ColorGray 87 | ColorWhite 88 | ColorReset 89 | ) 90 | 91 | // Position represents the position of the spinner relative to the message text. 92 | // 93 | // Example usage: 94 | // 95 | // p := pin.New("Loading", WithPosition(PositionRight)) // Spinner after the message 96 | type Position int 97 | 98 | const ( 99 | PositionLeft Position = iota // Before the message (default) 100 | PositionRight // After the message 101 | ) 102 | 103 | // Option is a functional option for configuring a Pin. 104 | type Option func(*Pin) 105 | 106 | // WithSpinnerColor sets the color of the spinning animation. 107 | func WithSpinnerColor(color Color) Option { 108 | return func(p *Pin) { 109 | p.spinnerColor = color 110 | } 111 | } 112 | 113 | // WithTextColor sets the color of the message text. 114 | func WithTextColor(color Color) Option { 115 | return func(p *Pin) { 116 | p.textColor = color 117 | } 118 | } 119 | 120 | // WithDoneSymbol sets the symbol displayed when the spinner completes. 121 | func WithDoneSymbol(symbol rune) Option { 122 | return func(p *Pin) { 123 | p.doneSymbol = symbol 124 | } 125 | } 126 | 127 | // WithDoneSymbolColor sets the color of the completion symbol. 128 | func WithDoneSymbolColor(color Color) Option { 129 | return func(p *Pin) { 130 | p.doneSymbolColor = color 131 | } 132 | } 133 | 134 | // WithPrefix sets the text displayed before the spinner and message. 135 | func WithPrefix(prefix string) Option { 136 | return func(p *Pin) { 137 | p.prefix = prefix 138 | } 139 | } 140 | 141 | // WithPrefixColor sets the color of the prefix text. 142 | func WithPrefixColor(color Color) Option { 143 | return func(p *Pin) { 144 | p.prefixColor = color 145 | } 146 | } 147 | 148 | // WithSeparator sets the separator text between prefix and message. 149 | func WithSeparator(separator string) Option { 150 | return func(p *Pin) { 151 | p.separator = separator 152 | } 153 | } 154 | 155 | // WithSeparatorColor sets the color of the separator. 156 | func WithSeparatorColor(color Color) Option { 157 | return func(p *Pin) { 158 | p.separatorColor = color 159 | } 160 | } 161 | 162 | // WithPosition sets whether the spinner appears before or after the message. 163 | func WithPosition(pos Position) Option { 164 | return func(p *Pin) { 165 | p.position = pos 166 | } 167 | } 168 | 169 | // WithFailSymbol sets the symbol displayed when the spinner fails. 170 | func WithFailSymbol(symbol rune) Option { 171 | return func(p *Pin) { 172 | p.failSymbol = symbol 173 | } 174 | } 175 | 176 | // WithFailSymbolColor sets the color of the failure symbol. 177 | func WithFailSymbolColor(color Color) Option { 178 | return func(p *Pin) { 179 | p.failSymbolColor = color 180 | } 181 | } 182 | 183 | // WithFailColor sets the color of the failure message text. 184 | // If not set, the failure message is printed using the spinner's text color. 185 | func WithFailColor(color Color) Option { 186 | return func(p *Pin) { 187 | p.failColor = color 188 | } 189 | } 190 | 191 | // WithSpinnerFrames sets the frames for the spinner. 192 | // If not set, defaults to the braille symbols. The frames are used from 193 | // beginning to end and then start at the beginning (frames[0]) again 194 | func WithSpinnerFrames(frames []rune) Option { 195 | return func(p *Pin) { 196 | p.frames = frames 197 | } 198 | } 199 | 200 | // WithWriter sets a custom io.Writer for spinner output. 201 | func WithWriter(w io.Writer) Option { 202 | return func(p *Pin) { 203 | p.out = w 204 | } 205 | } 206 | 207 | // Pin represents an animated terminal spinner with customizable appearance and behavior. 208 | // It supports custom colors, symbols, prefixes, and positioning. 209 | // 210 | // Basic usage: 211 | // 212 | // p := pin.New("Loading") 213 | // p.Start() 214 | // time.Sleep(2 * time.Second) 215 | // p.Stop("Done") 216 | // 217 | // Advanced usage: 218 | // 219 | // p := pin.New("Processing") 220 | // p.SetPrefix("Status") 221 | // p.SetSeparator(":") 222 | // p.SetSeparatorColor(pin.ColorWhite) 223 | // p.SetSpinnerColor(pin.ColorCyan) 224 | // p.SetTextColor(pin.ColorYellow) 225 | // p.Start() 226 | // 227 | // // Update message during operation 228 | // p.UpdateMessage("Still working...") 229 | // 230 | // // Complete with success 231 | // p.SetDoneSymbolColor(pin.ColorGreen) 232 | // p.Stop("Completed!") 233 | // 234 | // You can also indicate failure using the Fail method: 235 | // 236 | // p := pin.New("Deploying", 237 | // WithFailSymbol('✖'), 238 | // WithFailSymbolColor(ColorRed), 239 | // ) 240 | // p.Start() 241 | // // ... error occurred ... 242 | // p.Fail("Deployment failed") 243 | type Pin struct { 244 | frames []rune 245 | current int 246 | message string 247 | messageMu sync.RWMutex 248 | stopChan chan struct{} 249 | isRunning int32 250 | spinnerColor Color 251 | textColor Color 252 | doneSymbol rune 253 | doneSymbolColor Color 254 | failSymbol rune 255 | failSymbolColor Color 256 | failColor Color 257 | prefix string 258 | prefixColor Color 259 | separator string 260 | separatorColor Color 261 | position Position 262 | out io.Writer 263 | wg sync.WaitGroup 264 | } 265 | 266 | var defaultFrames = []rune{ 267 | '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', 268 | } 269 | 270 | // New creates a new Pin instance with the given message and optional configuration options. 271 | // It sets default styling and applies any provided options. 272 | func New(message string, opts ...Option) *Pin { 273 | p := &Pin{ 274 | frames: defaultFrames, 275 | message: message, 276 | stopChan: make(chan struct{}, 1), 277 | spinnerColor: ColorDefault, 278 | textColor: ColorDefault, 279 | doneSymbol: '✓', 280 | doneSymbolColor: ColorGreen, 281 | failSymbol: '✖', 282 | failSymbolColor: ColorRed, 283 | failColor: ColorDefault, 284 | prefix: "", 285 | prefixColor: ColorDefault, 286 | separator: "›", 287 | separatorColor: ColorWhite, 288 | position: PositionLeft, 289 | out: os.Stdout, 290 | } 291 | for _, opt := range opts { 292 | opt(p) 293 | } 294 | return p 295 | } 296 | 297 | // Start begins the spinner animation using the provided context. 298 | // It returns a cancel function which, when called, will stop the spinner. 299 | // Note: Canceling the returned function stops the spinner without printing 300 | // a final message. To print a final message, use the Stop() method. 301 | func (p *Pin) Start(ctx context.Context) context.CancelFunc { 302 | if p.IsRunning() { 303 | return func() {} 304 | } 305 | 306 | if !isTerminal(p.out) { 307 | ctx, cancel := context.WithCancel(ctx) 308 | p.setRunning(true) 309 | p.messageMu.RLock() 310 | msg := p.message 311 | p.messageMu.RUnlock() 312 | _, _ = fmt.Fprintln(p.out, msg) 313 | go func() { 314 | <-ctx.Done() 315 | p.setRunning(false) 316 | }() 317 | return cancel 318 | } 319 | 320 | p.setRunning(true) 321 | 322 | ctx, cancel := context.WithCancel(ctx) 323 | ticker := time.NewTicker(100 * time.Millisecond) 324 | p.wg.Add(1) 325 | go func() { 326 | defer ticker.Stop() 327 | defer p.wg.Done() 328 | for { 329 | select { 330 | case <-p.stopChan: 331 | return 332 | case <-ctx.Done(): 333 | p.setRunning(false) 334 | _, _ = fmt.Fprint(p.out, "\r\033[K") 335 | return 336 | case <-ticker.C: 337 | prefixPart := p.buildPrefixPart() 338 | 339 | p.messageMu.RLock() 340 | message := p.message 341 | p.messageMu.RUnlock() 342 | 343 | var format string 344 | var args []interface{} 345 | 346 | if p.position == PositionLeft { 347 | format = "\r\033[K%s%s%c%s %s%s%s" 348 | args = []interface{}{ 349 | prefixPart, 350 | p.spinnerColor, p.frames[p.current], ColorReset, 351 | p.textColor, message, ColorReset, 352 | } 353 | } else { 354 | format = "\r\033[K%s%s%s%s %s%c%s " 355 | args = []interface{}{ 356 | prefixPart, 357 | p.textColor, message, ColorReset, 358 | p.textColor, p.frames[p.current], ColorReset, 359 | } 360 | } 361 | 362 | _, _ = fmt.Fprintf(p.out, format, args...) 363 | p.current = (p.current + 1) % len(p.frames) 364 | } 365 | } 366 | }() 367 | 368 | return cancel 369 | } 370 | 371 | // Stop halts the spinner animation and optionally displays a final message. 372 | func (p *Pin) Stop(message ...string) { 373 | if !p.IsRunning() { 374 | return 375 | } 376 | 377 | if p.handleNonTerminal(message...) { 378 | return 379 | } 380 | 381 | p.setRunning(false) 382 | p.stopChan <- struct{}{} 383 | p.wg.Wait() 384 | 385 | _, _ = fmt.Fprint(p.out, "\r\033[K") 386 | 387 | if len(message) > 0 { 388 | p.printResult(message[0], p.doneSymbol, p.doneSymbolColor) 389 | } 390 | } 391 | 392 | // Fail halts the spinner animation and displays a failure message. 393 | // This method is similar to Stop but uses a distinct symbol and color scheme to indicate an error state. 394 | func (p *Pin) Fail(message ...string) { 395 | if !p.IsRunning() { 396 | return 397 | } 398 | 399 | if p.handleNonTerminal(message...) { 400 | return 401 | } 402 | 403 | p.setRunning(false) 404 | p.stopChan <- struct{}{} 405 | p.wg.Wait() 406 | 407 | fmt.Print("\r\033[K") 408 | 409 | if len(message) > 0 { 410 | p.printResult(message[0], p.failSymbol, p.failSymbolColor) 411 | } 412 | } 413 | 414 | // UpdateMessage changes the message shown next to the spinner. 415 | func (p *Pin) UpdateMessage(message string) { 416 | if !p.IsRunning() { 417 | return 418 | } 419 | 420 | p.messageMu.Lock() 421 | p.message = message 422 | p.messageMu.Unlock() 423 | if !isTerminal(p.out) { 424 | _, _ = fmt.Fprintln(p.out, message) 425 | } 426 | } 427 | 428 | // String returns the ANSI color code for the given color 429 | func (c Color) String() string { 430 | switch c { 431 | case ColorReset: 432 | return "\033[0m" 433 | case ColorBlack: 434 | return "\033[30m" 435 | case ColorRed: 436 | return "\033[31m" 437 | case ColorGreen: 438 | return "\033[32m" 439 | case ColorYellow: 440 | return "\033[33m" 441 | case ColorBlue: 442 | return "\033[34m" 443 | case ColorMagenta: 444 | return "\033[35m" 445 | case ColorCyan: 446 | return "\033[36m" 447 | case ColorGray: 448 | return "\033[90m" 449 | case ColorWhite: 450 | return "\033[37m" 451 | default: 452 | return "" 453 | } 454 | } 455 | 456 | // isTerminal checks if the provided writer is a terminal. 457 | func isTerminal(w io.Writer) bool { 458 | if ForceInteractive { 459 | return true 460 | } 461 | 462 | // Ensure the writer is an *os.File 463 | f, ok := w.(*os.File) 464 | if !ok { 465 | return false 466 | } 467 | 468 | fi, err := f.Stat() 469 | if err != nil { 470 | return false 471 | } 472 | 473 | return (fi.Mode() & os.ModeCharDevice) != 0 474 | } 475 | 476 | var ForceInteractive bool 477 | 478 | // buildPrefixPart constructs the prefix string (including colors) if a prefix is set. 479 | func (p *Pin) buildPrefixPart() string { 480 | if p.prefix == "" { 481 | return "" 482 | } 483 | return fmt.Sprintf("%s%s%s %s%s%s ", p.prefixColor, p.prefix, ColorReset, p.separatorColor, p.separator, ColorReset) 484 | } 485 | 486 | // printResult prints the final message along with a symbol using the appropriate formatting. 487 | func (p *Pin) printResult(msg string, symbol rune, symbolColor Color) { 488 | var msgColorCode Color 489 | if symbol == p.failSymbol && p.failColor != ColorDefault { 490 | msgColorCode = p.failColor 491 | } else { 492 | msgColorCode = p.textColor 493 | } 494 | prefixPart := p.buildPrefixPart() 495 | 496 | if p.position == PositionLeft { 497 | format := "%s%s%c%s %s%s%s\n" 498 | _, _ = fmt.Fprintf(p.out, format, prefixPart, symbolColor, symbol, ColorReset, msgColorCode, msg, ColorReset) 499 | } else { 500 | format := "%s%s%s%s %s%c%s\n" 501 | _, _ = fmt.Fprintf(p.out, format, prefixPart, msgColorCode, msg, ColorReset, symbolColor, symbol, ColorReset) 502 | } 503 | } 504 | 505 | // handleNonTerminal checks if stdout is non-terminal. 506 | // If yes, it prints a plain message (if provided) and returns true. 507 | func (p *Pin) handleNonTerminal(message ...string) bool { 508 | if !isTerminal(p.out) { 509 | if len(message) > 0 { 510 | _, _ = fmt.Fprintln(p.out, message[0]) 511 | } 512 | p.setRunning(false) 513 | return true 514 | } 515 | return false 516 | } 517 | 518 | // Message returns the current spinner message. 519 | func (p *Pin) Message() string { 520 | return p.message 521 | } 522 | 523 | // IsRunning returns whether the spinner is active. 524 | func (p *Pin) IsRunning() bool { 525 | return atomic.LoadInt32(&p.isRunning) == 1 526 | } 527 | 528 | // setRunning sets the running state of the spinner. 529 | func (p *Pin) setRunning(running bool) { 530 | var val int32 531 | if running { 532 | val = 1 533 | } 534 | atomic.StoreInt32(&p.isRunning, val) 535 | } 536 | -------------------------------------------------------------------------------- /pin_test.go: -------------------------------------------------------------------------------- 1 | package pin_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/yarlson/pin" 11 | ) 12 | 13 | func TestNewCreatesSpinner(t *testing.T) { 14 | message := "Loading..." 15 | p := pin.New(message) 16 | if p == nil { 17 | t.Fatal("Expected a non-nil spinner instance") 18 | } 19 | if p.Message() != message { 20 | t.Fatalf("Expected message %q, got %q", message, p.Message()) 21 | } 22 | } 23 | 24 | func TestStartAndCancel(t *testing.T) { 25 | p := pin.New("Loading...") 26 | cancel := p.Start(context.Background()) 27 | // Immediately, the spinner should be running. 28 | if !p.IsRunning() { 29 | t.Fatal("Expected spinner to be running after Start()") 30 | } 31 | // Cancel the spinner. 32 | cancel() 33 | // Allow some time for the cancellation to propagate. 34 | time.Sleep(100 * time.Millisecond) 35 | if p.IsRunning() { 36 | t.Fatal("Expected spinner to have stopped after cancellation") 37 | } 38 | } 39 | 40 | func TestStopPrintsMessage(t *testing.T) { 41 | var buf bytes.Buffer 42 | 43 | // Create a spinner with a custom writer so output can be captured. 44 | p := pin.New("Processing...", pin.WithWriter(&buf)) 45 | // Start the spinner. 46 | cancel := p.Start(context.Background()) 47 | // Cancel to simulate spinner stopping (ensuring any background goroutines complete). 48 | cancel() 49 | 50 | // Now call Stop with a final message. 51 | p.Stop("Done!") 52 | 53 | output := buf.String() 54 | if !strings.Contains(output, "Done!") { 55 | t.Errorf("Expected output to contain final message 'Done!', got %q", output) 56 | } 57 | // Also verify spinner is no longer running. 58 | if p.IsRunning() { 59 | t.Error("Expected spinner to not be running after Stop()") 60 | } 61 | } 62 | 63 | func TestUpdateMessagePrints(t *testing.T) { 64 | var buf bytes.Buffer 65 | // Create a spinner with a custom writer so we can capture output. 66 | p := pin.New("Initial", pin.WithWriter(&buf)) 67 | // Start the spinner. 68 | cancel := p.Start(context.Background()) 69 | // Cancel to simulate spinner stopping (ensuring any background goroutines complete). 70 | cancel() 71 | 72 | // Update the spinner message. 73 | p.UpdateMessage("Updated") 74 | 75 | output := buf.String() 76 | if !strings.Contains(output, "Updated") { 77 | t.Errorf("Expected output to contain 'Updated', got %q", output) 78 | } 79 | } 80 | 81 | func TestSpinnerAnimation(t *testing.T) { 82 | // Force interactive mode for this test. 83 | pin.ForceInteractive = true 84 | defer func() { pin.ForceInteractive = false }() 85 | 86 | var buf bytes.Buffer 87 | // Create a spinner with a custom writer so we can capture output. 88 | p := pin.New("Animating", pin.WithWriter(&buf)) 89 | cancel := p.Start(context.Background()) 90 | defer cancel() 91 | 92 | // Let the spinner animate for a short while. 93 | time.Sleep(150 * time.Millisecond) 94 | p.UpdateMessage("Updated") 95 | time.Sleep(150 * time.Millisecond) 96 | p.Stop("Stopped") 97 | 98 | output := buf.String() 99 | frames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} 100 | found := false 101 | for _, frame := range frames { 102 | if strings.Contains(output, string(frame)) { 103 | found = true 104 | break 105 | } 106 | } 107 | if !found { 108 | t.Errorf("Expected output to contain one of the spinner frames, got %q", output) 109 | } 110 | } 111 | 112 | func TestFailPrintsFailureMessage(t *testing.T) { 113 | // Force interactive mode for this test. 114 | pin.ForceInteractive = true 115 | defer func() { pin.ForceInteractive = false }() 116 | 117 | var buf bytes.Buffer 118 | // Create a spinner with a custom writer. 119 | p := pin.New("Working", pin.WithWriter(&buf)) 120 | cancel := p.Start(context.Background()) 121 | defer cancel() 122 | // Allow some time for animation to start. 123 | time.Sleep(150 * time.Millisecond) 124 | // Call Fail with a failure message. 125 | p.Fail("Failed") 126 | output := buf.String() 127 | if !strings.Contains(output, "Failed") { 128 | t.Errorf("Expected output to contain 'Failed', got %q", output) 129 | } 130 | if !strings.Contains(output, "✖") { 131 | t.Errorf("Expected output to contain default failure symbol '✖', got %q", output) 132 | } 133 | } 134 | 135 | func TestFailHasPrefix(t *testing.T) { 136 | // Force interactive mode. 137 | pin.ForceInteractive = true 138 | defer func() { pin.ForceInteractive = false }() 139 | 140 | var buf bytes.Buffer 141 | // Create a spinner with a custom writer and a prefix configuration. 142 | p := pin.New("Working", 143 | pin.WithWriter(&buf), 144 | pin.WithPrefix("TestPrefix"), 145 | pin.WithSeparator(":"), 146 | ) 147 | cancel := p.Start(context.Background()) 148 | defer cancel() 149 | // Let the spinner animate briefly. 150 | time.Sleep(150 * time.Millisecond) 151 | // Call Fail with a failure message. 152 | p.Fail("Error occurred") 153 | 154 | output := buf.String() 155 | if !strings.Contains(output, "TestPrefix") { 156 | t.Errorf("Expected output to contain prefix 'TestPrefix', got %q", output) 157 | } 158 | if !strings.Contains(output, "Error occurred") { 159 | t.Errorf("Expected output to contain failure message 'Error occurred', got %q", output) 160 | } 161 | if !strings.Contains(output, ":") { 162 | t.Errorf("Expected output to contain separator ':', got %q", output) 163 | } 164 | } 165 | 166 | func TestPrefixAndSeparatorColors(t *testing.T) { 167 | // Force interactive mode. 168 | pin.ForceInteractive = true 169 | defer func() { pin.ForceInteractive = false }() 170 | 171 | var buf bytes.Buffer 172 | 173 | // Define custom prefix, separator, and their colors. 174 | prefix := "MyPrefix" 175 | separator := ">" 176 | prefixColor := pin.ColorCyan 177 | separatorColor := pin.ColorWhite 178 | 179 | // Create a spinner with these custom options. 180 | p := pin.New("TestMessage", 181 | pin.WithWriter(&buf), 182 | pin.WithPrefix(prefix), 183 | pin.WithPrefixColor(prefixColor), 184 | pin.WithSeparator(separator), 185 | pin.WithSeparatorColor(separatorColor), 186 | ) 187 | 188 | // Start the spinner and then invoke Fail to print the final output. 189 | cancel := p.Start(context.Background()) 190 | defer cancel() 191 | time.Sleep(150 * time.Millisecond) 192 | p.Fail("Failure occurred") 193 | 194 | output := buf.String() 195 | 196 | if !strings.Contains(output, prefixColor.String()) { 197 | t.Errorf("Expected output to contain prefix color %q, got %q", prefixColor, output) 198 | } 199 | if !strings.Contains(output, separatorColor.String()) { 200 | t.Errorf("Expected output to contain separator color %q, got %q", separatorColor, output) 201 | } 202 | if !strings.Contains(output, prefix) { 203 | t.Errorf("Expected output to contain prefix %q, got %q", prefix, output) 204 | } 205 | if !strings.Contains(output, separator) { 206 | t.Errorf("Expected output to contain separator %q, got %q", separator, output) 207 | } 208 | } 209 | 210 | func TestStopDisplaysDoneSymbol(t *testing.T) { 211 | // Force interactive mode. 212 | pin.ForceInteractive = true 213 | defer func() { pin.ForceInteractive = false }() 214 | 215 | var buf bytes.Buffer 216 | doneSymbol := '✓' 217 | doneSymbolColor := pin.ColorGreen 218 | 219 | // Create a spinner configured with custom done symbol and done symbol color. 220 | p := pin.New("Processing", 221 | pin.WithWriter(&buf), 222 | pin.WithDoneSymbol(doneSymbol), 223 | pin.WithDoneSymbolColor(doneSymbolColor), 224 | ) 225 | 226 | cancel := p.Start(context.Background()) 227 | defer cancel() 228 | time.Sleep(150 * time.Millisecond) 229 | p.Stop("Completed") 230 | output := buf.String() 231 | if !strings.Contains(output, string(doneSymbol)) { 232 | t.Errorf("Expected output to contain done symbol %q, got %q", string(doneSymbol), output) 233 | } 234 | if !strings.Contains(output, "Completed") { 235 | t.Errorf("Expected output to contain final message 'Completed', got %q", output) 236 | } 237 | if !strings.Contains(output, doneSymbolColor.String()) { 238 | t.Errorf("Expected output to contain done symbol color %q, got %q", doneSymbolColor, output) 239 | } 240 | } 241 | 242 | func TestWithCustomSpinnerFrames(t *testing.T) { 243 | // Force interactive mode. 244 | pin.ForceInteractive = true 245 | defer func() { pin.ForceInteractive = false }() 246 | 247 | var buf bytes.Buffer 248 | // Define custom frames (e.g. a simple sequence: a, b, c). 249 | customFrames := []rune{'a', 'b', 'c'} 250 | 251 | // Create a spinner with custom frames using the new option. 252 | p := pin.New("CustomFrames", pin.WithWriter(&buf), pin.WithSpinnerFrames(customFrames)) 253 | 254 | // Start the spinner to trigger the animation. 255 | cancel := p.Start(context.Background()) 256 | defer cancel() 257 | time.Sleep(200 * time.Millisecond) 258 | p.Stop("Finished") 259 | 260 | output := buf.String() 261 | frameFound := false 262 | // Check that at least one of the custom frames appears in the captured output. 263 | for _, frame := range customFrames { 264 | if strings.Contains(output, string(frame)) { 265 | frameFound = true 266 | break 267 | } 268 | } 269 | if !frameFound { 270 | t.Errorf("Expected output to contain one of the custom spinner frames %q, got %q", customFrames, output) 271 | } 272 | } 273 | 274 | func TestFailWithCustomFailColor(t *testing.T) { 275 | // Force interactive mode. 276 | pin.ForceInteractive = true 277 | defer func() { pin.ForceInteractive = false }() 278 | 279 | var buf bytes.Buffer 280 | customFailColor := pin.ColorRed 281 | 282 | // Create a spinner with a custom failure color. 283 | p := pin.New("Working", 284 | pin.WithWriter(&buf), 285 | pin.WithFailColor(customFailColor), 286 | ) 287 | cancel := p.Start(context.Background()) 288 | defer cancel() 289 | time.Sleep(150 * time.Millisecond) 290 | p.Fail("Failure occurred") 291 | output := buf.String() 292 | if !strings.Contains(output, customFailColor.String()) { 293 | t.Errorf("Expected output to contain custom fail color %q, got %q", customFailColor, output) 294 | } 295 | if !strings.Contains(output, "Failure occurred") { 296 | t.Errorf("Expected output to contain failure message 'Failure occurred', got %q", output) 297 | } 298 | } 299 | 300 | func TestPositionSwitching(t *testing.T) { 301 | // Force interactive mode. 302 | pin.ForceInteractive = true 303 | defer func() { pin.ForceInteractive = false }() 304 | 305 | var bufLeft, bufRight bytes.Buffer 306 | 307 | // Create spinner with PositionLeft (default behavior). 308 | spinnerLeft := pin.New("TestPos", 309 | pin.WithWriter(&bufLeft), 310 | pin.WithPosition(pin.PositionLeft), 311 | ) 312 | cancelLeft := spinnerLeft.Start(context.Background()) 313 | time.Sleep(150 * time.Millisecond) 314 | spinnerLeft.Stop("Left Done") 315 | cancelLeft() 316 | 317 | // Create spinner with PositionRight. 318 | spinnerRight := pin.New("TestPos", 319 | pin.WithWriter(&bufRight), 320 | pin.WithPosition(pin.PositionRight), 321 | ) 322 | cancelRight := spinnerRight.Start(context.Background()) 323 | time.Sleep(150 * time.Millisecond) 324 | spinnerRight.Stop("Right Done") 325 | cancelRight() 326 | 327 | // The outputs should differ because the frame is placed in a different position. 328 | if bufLeft.String() == bufRight.String() { 329 | t.Errorf("Expected different outputs for left and right spinner positions, but both outputs were:\n%q", bufLeft.String()) 330 | } 331 | } 332 | 333 | func TestNonInteractiveStart(t *testing.T) { 334 | // Use a bytes.Buffer to simulate a non-interactive writer. 335 | var buf bytes.Buffer 336 | 337 | // Create a spinner with the custom writer. 338 | p := pin.New("Non-interactive Message", pin.WithWriter(&buf)) 339 | 340 | // Call Start; since buf is not *os.File (or os.Stdout), it will be treated as non-interactive. 341 | cancel := p.Start(context.Background()) 342 | // Allow a short delay to ensure the message is printed. 343 | time.Sleep(100 * time.Millisecond) 344 | // Cancel the spinner. 345 | cancel() 346 | 347 | output := buf.String() 348 | expected := "Non-interactive Message\n" 349 | if output != expected { 350 | t.Errorf("Expected output %q, got %q", expected, output) 351 | } 352 | } 353 | 354 | func TestNonInteractiveFullMessageLogging(t *testing.T) { 355 | // Use a bytes.Buffer to simulate non-interactive output. 356 | var buf bytes.Buffer 357 | 358 | // Create a spinner with the custom writer (non-interactive mode). 359 | p := pin.New("Initial", pin.WithWriter(&buf)) 360 | 361 | // Start the spinner. 362 | cancel := p.Start(context.Background()) 363 | 364 | // Allow a short time for the initial message to be printed. 365 | time.Sleep(50 * time.Millisecond) 366 | 367 | // Update the spinner's message. 368 | p.UpdateMessage("Updated") 369 | 370 | // Allow a short time for the update to be printed. 371 | time.Sleep(50 * time.Millisecond) 372 | 373 | // Stop the spinner with a final message. 374 | p.Stop("Done") 375 | cancel() 376 | 377 | // Split the captured output into lines (ignoring empty lines). 378 | var lines []string 379 | for _, l := range strings.Split(buf.String(), "\n") { 380 | if strings.TrimSpace(l) != "" { 381 | lines = append(lines, l) 382 | } 383 | } 384 | 385 | expected := []string{"Initial", "Updated", "Done"} 386 | if len(lines) != len(expected) { 387 | t.Errorf("Expected %d lines of output, got %d: %v", len(expected), len(lines), lines) 388 | return 389 | } 390 | for i, line := range expected { 391 | if lines[i] != line { 392 | t.Errorf("Line %d mismatch: expected %q, got %q", i+1, line, lines[i]) 393 | } 394 | } 395 | } 396 | 397 | func TestWithSpinnerColorAndTextColor(t *testing.T) { 398 | // Force interactive mode so that the animation branch is executed. 399 | pin.ForceInteractive = true 400 | defer func() { pin.ForceInteractive = false }() 401 | 402 | var buf bytes.Buffer 403 | 404 | // Define desired spinner and text colors. 405 | expectedSpinnerColor := pin.ColorCyan 406 | expectedTextColor := pin.ColorYellow 407 | 408 | // Create a spinner with the custom spinner and text color options. 409 | s := pin.New("TestMessage", 410 | pin.WithWriter(&buf), 411 | pin.WithSpinnerColor(expectedSpinnerColor), 412 | pin.WithTextColor(expectedTextColor), 413 | ) 414 | 415 | // Start the spinner to trigger the animation loop. 416 | cancel := s.Start(context.Background()) 417 | // Allow some time for multiple animation ticks. 418 | time.Sleep(250 * time.Millisecond) 419 | s.Stop("Done") 420 | cancel() 421 | 422 | output := buf.String() 423 | 424 | // Verify that the output contains the expected spinner color and text color. 425 | if !strings.Contains(output, expectedSpinnerColor.String()) { 426 | t.Errorf("Output does not contain the expected spinner color %q. Output: %q", expectedSpinnerColor, output) 427 | } 428 | 429 | if !strings.Contains(output, expectedTextColor.String()) { 430 | t.Errorf("Output does not contain the expected text color %q. Output: %q", expectedTextColor, output) 431 | } 432 | } 433 | 434 | func TestCustomFailSymbolAndColor(t *testing.T) { 435 | // Force interactive mode to exercise the animated branch. 436 | pin.ForceInteractive = true 437 | defer func() { pin.ForceInteractive = false }() 438 | 439 | var buf bytes.Buffer 440 | 441 | customFailSymbol := 'X' // Custom failure symbol. 442 | customFailSymbolColor := pin.ColorBlue // Custom failure symbol color. 443 | 444 | // Create a spinner with custom fail symbol and fail symbol color. 445 | p := pin.New("Working", 446 | pin.WithWriter(&buf), 447 | pin.WithFailSymbol(customFailSymbol), 448 | pin.WithFailSymbolColor(customFailSymbolColor), 449 | ) 450 | 451 | cancel := p.Start(context.Background()) 452 | defer cancel() 453 | 454 | // Allow some time for the spinner to animate. 455 | time.Sleep(150 * time.Millisecond) 456 | // Trigger the failure. 457 | p.Fail("Operation failed") 458 | 459 | output := buf.String() 460 | 461 | // Verify the custom failure symbol appears in the output. 462 | if !strings.Contains(output, string(customFailSymbol)) { 463 | t.Errorf("Output does not contain custom fail symbol %q. Output: %q", string(customFailSymbol), output) 464 | } 465 | 466 | // Verify the custom failure symbol color code appears. 467 | if !strings.Contains(output, customFailSymbolColor.String()) { 468 | t.Errorf("Output does not contain custom fail symbol color %q. Output: %q", customFailSymbolColor.String(), output) 469 | } 470 | 471 | // Also, verify that the failure message is present. 472 | if !strings.Contains(output, "Operation failed") { 473 | t.Errorf("Output does not contain failure message 'Operation failed'. Output: %q", output) 474 | } 475 | } 476 | 477 | // New test: when Start is called while spinner is already running 478 | func TestStartWhenAlreadyRunning(t *testing.T) { 479 | var buf bytes.Buffer 480 | p := pin.New("AlreadyRunning", pin.WithWriter(&buf)) 481 | // Start the spinner. 482 | cancel1 := p.Start(context.Background()) 483 | // Call Start again; as per the code, if already running it should return a no-op. 484 | cancel2 := p.Start(context.Background()) 485 | // Cancel the second (no-op) and then cancel the first. 486 | cancel2() 487 | cancel1() 488 | // Allow some time for cancellations. 489 | time.Sleep(100 * time.Millisecond) 490 | if p.IsRunning() { 491 | t.Errorf("Expected spinner to have stopped after canceling both start invocations") 492 | } 493 | } 494 | 495 | // Test calling Stop and Fail when spinner is not running should be no-ops. 496 | func TestStopAndFailWhenNotRunning(t *testing.T) { 497 | var buf bytes.Buffer 498 | p := pin.New("NotRunning", pin.WithWriter(&buf)) 499 | // Ensure spinner is not running. 500 | if p.IsRunning() { 501 | t.Fatal("Spinner should not be running at test start") 502 | } 503 | // Call Stop and Fail; nothing should be printed. 504 | p.Stop("ShouldNotPrint") 505 | p.Fail("ShouldNotPrint") 506 | output := buf.String() 507 | if output != "" { 508 | t.Errorf("Expected no output when calling Stop/Fail on non-running spinner, got: %q", output) 509 | } 510 | } 511 | 512 | // Test calling Fail when spinner is not running returns immediately. 513 | func TestFailWhenNotRunning(t *testing.T) { 514 | var buf bytes.Buffer 515 | p := pin.New("NotRunningFail", pin.WithWriter(&buf)) 516 | // Ensure spinner is not running. 517 | if p.IsRunning() { 518 | t.Fatal("Spinner should not be running at test start") 519 | } 520 | // Call Fail; should simply return. 521 | p.Fail("NoOutput") 522 | output := buf.String() 523 | if output != "" { 524 | t.Errorf("Expected no output when calling Fail on non-running spinner, got: %q", output) 525 | } 526 | } 527 | --------------------------------------------------------------------------------