pax_global_header00006660000000000000000000000064145441456470014530gustar00rootroot0000000000000052 comment=cd533858e30c90455adea62f9cb916a5cd347bef golang-github-theckman-yacspin-0.13.12/000077500000000000000000000000001454414564700176575ustar00rootroot00000000000000golang-github-theckman-yacspin-0.13.12/.github/000077500000000000000000000000001454414564700212175ustar00rootroot00000000000000golang-github-theckman-yacspin-0.13.12/.github/workflows/000077500000000000000000000000001454414564700232545ustar00rootroot00000000000000golang-github-theckman-yacspin-0.13.12/.github/workflows/tests.yaml000066400000000000000000000012731454414564700253050ustar00rootroot00000000000000on: push: branches: - master tags: - '*' pull_request: {} name: tests jobs: test: strategy: matrix: go-version: [1.16.x, 1.17.x] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - name: Install Go uses: actions/setup-go@v2 with: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v2 - name: Run golangci-lint uses: golangci/golangci-lint-action@v2 - name: Run tests run: 'go test -v ./... -coverprofile="coverage.txt" -covermode=atomic' - name: Print coverage report run: 'go tool cover -func="coverage.txt"' golang-github-theckman-yacspin-0.13.12/.gitignore000066400000000000000000000005051454414564700216470ustar00rootroot00000000000000# Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # Test binary, built with `go test -c` *.test # Output of the go coverage tool *.out coverage.txt # Dependency directories (remove the comment below to include it) # vendor/ # README GIF generation files *.mov *.mp4 *.gif *.prproj # OS files .DS_Store golang-github-theckman-yacspin-0.13.12/.golangci.yaml000066400000000000000000000033371454414564700224120ustar00rootroot00000000000000run: tests: true # all available settings of specific linters linters-settings: govet: # report about shadowed variables check-shadowing: true gofmt: # simplify code: gofmt with `-s` option, true by default simplify: true dupl: # tokens count to trigger issue, 150 by default threshold: 100 goconst: # minimal length of string constant, 3 by default min-len: 3 # minimal occurrences count to trigger, 3 by default min-occurrences: 3 misspell: # Correct spellings using locale preferences for US or UK. # Default is to use a neutral variety of English. # Setting locale to US will correct the British spelling of 'colour' to 'color'. locale: US staticcheck: checks: [ "all" ] revive: confidence: 0.8 ignore-generated-header: true rules: - name: context-keys-type - name: time-naming - name: var-declaration - name: unexported-return - name: errorf - name: blank-imports - name: context-as-argument - name: dot-imports - name: error-return - name: error-strings - name: error-naming - name: exported - name: increment-decrement - name: var-naming - name: package-comments - name: range - name: receiver-naming - name: indent-error-flow - name: superfluous-else - name: struct-tag - name: modifies-value-receiver - name: range-val-in-closure - name: range-val-address - name: atomic - name: empty-lines - name: early-return - name: useless-break linters: enable: - revive - govet - gosec - staticcheck - typecheck fast: false issues: exclude-use-default: false exclude: - G104 golang-github-theckman-yacspin-0.13.12/LICENSE000066400000000000000000000261351454414564700206730ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. golang-github-theckman-yacspin-0.13.12/README.md000066400000000000000000000500411454414564700211360ustar00rootroot00000000000000# Yet Another CLi Spinner (for Go) [![License](https://img.shields.io/github/license/theckman/yacspin.svg)](https://github.com/theckman/yacspin/blob/master/LICENSE) [![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat)](https://godoc.org/github.com/theckman/yacspin) [![Latest Git Tag](https://img.shields.io/github/tag/theckman/yacspin.svg)](https://github.com/theckman/yacspin/releases) [![GitHub Actions master Build Status](https://github.com/theckman/yacspin/actions/workflows/tests.yaml/badge.svg?branch=master)](https://github.com/theckman/yacspin/actions/workflows/tests.yaml) [![Go Report Card](https://goreportcard.com/badge/github.com/theckman/yacspin)](https://goreportcard.com/report/github.com/theckman/yacspin) [![Codecov](https://img.shields.io/codecov/c/github/theckman/yacspin)](https://codecov.io/gh/theckman/yacspin) Package `yacspin` provides yet another CLi spinner for Go, taking inspiration (and some utility code) from the https://github.com/briandowns/spinner project. Specifically `yacspin` borrows the default character sets, and color mappings to github.com/fatih/color colors, from that project. ## License Because this package adopts the spinner character sets from https://github.com/briandowns/spinner, this package is released under the Apache 2.0 License. ## Yet Another CLi Spinner? This project was created after it was realized that the most popular spinner library for Go had some limitations, that couldn't be fixed without a massive overhaul of the API. The other spinner ties the ability to show updated messages to the spinner's animation, meaning you can't always show all the information you want to the end user without changing the animation speed. This means you need to trade off animation aesthetics to show "realtime" information. It was a goal to avoid this problem. In addition, there were also some API design choices that have made it unsafe for concurrent use, which presents challenges when trying to update the text in the spinner while it's animating. This could result in undefined behavior due to data races. There were also some variable-width spinners in that other project that did not render correctly. Because the width of the spinner animation would change, so would the position of the message on the screen. `yacspin` uses a dynamic width when animating, so your message should appear static relative to the animating spinner. Finally, there was an interest in the spinner being able to represent a task, and to indicate whether it failed or was successful. This would have further compounded the API changes needed above to support in an intuitive way. This project takes inspiration from that other project, and takes a new approach to address the challenges above. ## Features #### Provided Spinners There are over 90 spinners available in the `CharSets` package variable. They were borrowed from [github.com/briandowns/spinner](https://github.com/briandowns/spinner). There is a table with most of the spinners [at the bottom of this README](#Spinners). #### Dynamic Width of Animation Because of how some spinners are animated, they may have different widths are different times in the animation. `yacspin` calculates the maximum width, and pads the animation to ensure the text's position on the screen doesn't change. This results in a smoother looking animation. ##### yacspin ![yacspin animation with dynamic width](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/features/width_good.gif) ##### other spinners ![other spinners' animation with dynamic width](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/features/width_bad.gif) #### Success and Failure Results The spinner has both a `Stop()` and `StopFail()` method, which allows the spinner to result in a success message or a failure message. The messages, colors, and even the character used to denote success or failure are customizable in either the initial config or via the spinner's methods. By doing this you can use a single `yacspin` spinner to display the status of a list of tasks being executed serially. ##### Stop ![Animation with Success](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/features/stop.gif) ##### StopFail ![Animation with Failure](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/features/stop_fail.gif) #### Animation At End of Line The `SpinnerAtEnd` field of the `Config` struct allows you to specify whether the spinner is rendered at the end of the line instead of the beginning. The default value (`false`) results in the spinner being rendered at the beginning of the line. #### Concurrency The spinner is safe for concurrent use, so you can update any of its settings via methods whether the spinner is stopped or is currently animating. #### Live Updates Most spinners tie the ability to show new messages with the animation of the spinner. So if the spinner animates every 200ms, you can only show updated information every 200ms. If you wanted more frequent updates, you'd need to tradeoff the asthetics of the animation to display more data. This spinner updates the printed information of the spinner immediately on change, without the animation updating. This allows you to use an animation speed that looks astheticaly pleasing, while also knowing the data presented to the user will be updated live. You can see this in action in the following gif, where the filenames being uploaded are rendered independent of the spinner being animated: ![Animation with Success](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/features/stop.gif) #### Pausing for Updates Sometimes you want to change a few settings, and don't want the spinner to render your partially applied configuration. If your spinner is running, and you want to change a few configuration items via method calls, you can `Pause()` the spinner first. After making the changes you can call `Unpause()`, and it will continue rendering like normal with the newly applied configuration. #### Supporting Non-Interactive (TTY) Output Targets `yacspin` also has native support for non-interactive (TTY) output targets. By default this is detected in the constructor, or can be overriden via the `TerminalMode` `Config` struct field. When detecting the application is not running withn a TTY session, the behavior of the spinner is different. Specifically, when this is automatically detected the spinner no longer uses colors, disables the automatic spinner animation, and instead only animates the spinner when updating the message. In addition, each animation is rendered on a new line instead of overwriting the current line. This should result in human-readable output without any changes needed by consumers, even when the system is writing to a non-TTY destination. #### Manually Stepping Animation If you'd like to manually animate the spinner, you can do so by setting the `TerminalMode` to `ForceNoTTYMode | ForceSmartTerminalMode`. In this mode the spinner will still use colors and other text stylings, but the animation only happens when data is updated and on individual lines. You can accomplish this by calling the `Message()` method with the same used previously. ## Usage ``` go get github.com/theckman/yacspin ``` Within the `yacspin` package there are some default spinners stored in the `yacspin.CharSets` variable, and you can also provide your own. There is also a list of known colors in the `yacspin.ValidColors` variable. ### Example There are runnable examples in the [examples/](https://github.com/theckman/yacspin/tree/master/examples) directory, with one simple example and one more advanced one. Here is a quick snippet showing usage from a very high level, with error handling omitted: ```Go cfg := yacspin.Config{ Frequency: 100 * time.Millisecond, CharSet: yacspin.CharSets[59], Suffix: " backing up database to S3", SuffixAutoColon: true, Message: "exporting data", StopCharacter: "✓", StopColors: []string{"fgGreen"}, } spinner, err := yacspin.New(cfg) // handle the error err = spinner.Start() // doing some work time.Sleep(2 * time.Second) spinner.Message("uploading data") // upload... time.Sleep(2 * time.Second) err = spinner.Stop() ``` ## Spinners The spinner animations below are recorded at a refresh frequency of 200ms. Some animations may look better at a different speed, so play around with the frequency until you find a value you find aesthetically pleasing. yacspin.CharSets index | sample gif (Frequency: 200ms) -----------------------|------------------------------ 0 | ![0 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/0.gif) 1 | ![1 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/1.gif) 2 | ![2 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/2.gif) 3 | ![3 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/3.gif) 4 | ![4 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/4.gif) 5 | ![5 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/5.gif) 6 | ![6 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/6.gif) 7 | ![7 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/7.gif) 8 | ![8 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/8.gif) 9 | ![9 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/9.gif) 10 | ![10 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/10.gif) 11 | ![11 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/11.gif) 12 | ![12 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/12.gif) 13 | ![13 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/13.gif) 14 | ![14 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/14.gif) 15 | ![15 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/15.gif) 16 | ![16 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/16.gif) 17 | ![17 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/17.gif) 18 | ![18 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/18.gif) 19 | ![19 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/19.gif) 20 | ![20 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/20.gif) 21 | ![21 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/21.gif) 22 | ![22 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/22.gif) 23 | ![23 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/23.gif) 24 | ![24 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/24.gif) 25 | ![25 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/25.gif) 26 | ![26 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/26.gif) 27 | ![27 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/27.gif) 28 | ![28 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/28.gif) 29 | ![29 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/29.gif) 30 | ![30 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/30.gif) 31 | ![31 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/31.gif) 32 | ![32 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/32.gif) 33 | ![33 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/33.gif) 34 | ![34 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/34.gif) 35 | ![35 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/35.gif) 36 | ![36 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/36.gif) 37 | ![37 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/37.gif) 38 | ![38 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/38.gif) 39 | ![39 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/39.gif) 40 | ![40 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/40.gif) 41 | ![41 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/41.gif) 42 | ![42 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/42.gif) 43 | ![43 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/43.gif) 44 | ![44 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/44.gif) 45 | ![45 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/45.gif) 46 | ![46 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/46.gif) 47 | ![47 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/47.gif) 48 | ![48 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/48.gif) 49 | ![49 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/49.gif) 50 | ![50 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/50.gif) 51 | ![51 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/51.gif) 52 | ![52 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/52.gif) 53 | ![53 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/53.gif) 54 | ![54 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/54.gif) 55 | ![55 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/55.gif) 56 | ![56 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/56.gif) 57 | ![57 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/57.gif) 58 | ![58 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/58.gif) 59 | ![59 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/59.gif) 60 | ![60 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/60.gif) 61 | ![61 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/61.gif) 62 | ![62 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/62.gif) 63 | ![63 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/63.gif) 64 | ![64 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/64.gif) 65 | ![65 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/65.gif) 66 | ![66 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/66.gif) 67 | ![67 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/67.gif) 68 | ![68 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/68.gif) 69 | ![69 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/69.gif) 70 | ![70 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/70.gif) 71 | ![71 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/71.gif) 72 | ![72 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/72.gif) 73 | ![73 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/73.gif) 74 | ![74 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/74.gif) 75 | ![75 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/75.gif) 76 | ![76 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/76.gif) 77 | ![77 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/77.gif) 78 | ![78 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/78.gif) 79 | ![79 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/79.gif) 80 | ![80 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/80.gif) 81 | ![81 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/81.gif) 82 | ![82 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/82.gif) 83 | ![83 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/83.gif) 84 | ![84 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/84.gif) 85 | ![85 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/85.gif) 86 | ![86 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/86.gif) 87 | ![87 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/87.gif) 88 | ![88 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/88.gif) 89 | ![89 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/89.gif) 90 | ![90 gif](https://raw.githubusercontent.com/theckman/yacspin-gifs/11953a4f12560eaf4a27054d3adad471eb19193c/spinners/90.gif) golang-github-theckman-yacspin-0.13.12/character_sets.go000066400000000000000000000220511454414564700232000ustar00rootroot00000000000000// Copyright (c) 2021 Brian J. Downs // Copyright (c) 2019-2021 Tim Heckman // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // Please see the LICENSE file for the copy of the Apache 2.0 License. // // This file was copied from: https://github.com/briandowns/spinner // // Modifications: // // - removed runtime generation of CharSets 37 and 38; made them literals // - fixed pipe spinner (32) animation, by adding missing frame package yacspin // CharSets contains the default character sets from // https://github.com/briandowns/spinner. var CharSets = map[int][]string{ 0: {"←", "↖", "↑", "↗", "→", "↘", "↓", "↙"}, 1: {"▁", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▁"}, 2: {"▖", "▘", "▝", "▗"}, 3: {"┤", "┘", "┴", "└", "├", "┌", "┬", "┐"}, 4: {"◢", "◣", "◤", "◥"}, 5: {"◰", "◳", "◲", "◱"}, 6: {"◴", "◷", "◶", "◵"}, 7: {"◐", "◓", "◑", "◒"}, 8: {".", "o", "O", "@", "*"}, 9: {"|", "/", "-", "\\"}, 10: {"◡◡", "⊙⊙", "◠◠"}, 11: {"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}, 12: {">))'>", " >))'>", " >))'>", " >))'>", " >))'>", " <'((<", " <'((<", " <'((<"}, 13: {"⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"}, 14: {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, 15: {"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"}, 16: {"▉", "▊", "▋", "▌", "▍", "▎", "▏", "▎", "▍", "▌", "▋", "▊", "▉"}, 17: {"■", "□", "▪", "▫"}, 18: {"←", "↑", "→", "↓"}, 19: {"╫", "╪"}, 20: {"⇐", "⇖", "⇑", "⇗", "⇒", "⇘", "⇓", "⇙"}, 21: {"⠁", "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈", "⠈"}, 22: {"⠈", "⠉", "⠋", "⠓", "⠒", "⠐", "⠐", "⠒", "⠖", "⠦", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈"}, 23: {"⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠴", "⠲", "⠒", "⠂", "⠂", "⠒", "⠚", "⠙", "⠉", "⠁"}, 24: {"⠋", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋"}, 25: {"ヲ", "ァ", "ィ", "ゥ", "ェ", "ォ", "ャ", "ュ", "ョ", "ッ", "ア", "イ", "ウ", "エ", "オ", "カ", "キ", "ク", "ケ", "コ", "サ", "シ", "ス", "セ", "ソ", "タ", "チ", "ツ", "テ", "ト", "ナ", "ニ", "ヌ", "ネ", "ノ", "ハ", "ヒ", "フ", "ヘ", "ホ", "マ", "ミ", "ム", "メ", "モ", "ヤ", "ユ", "ヨ", "ラ", "リ", "ル", "レ", "ロ", "ワ", "ン"}, 26: {".", "..", "..."}, 27: {"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▉", "▊", "▋", "▌", "▍", "▎", "▏", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁"}, 28: {".", "o", "O", "°", "O", "o", "."}, 29: {"+", "x"}, 30: {"v", "<", "^", ">"}, 31: {">>--->", " >>--->", " >>--->", " >>--->", " >>--->", " <---<<", " <---<<", " <---<<", " <---<<", "<---<<"}, 32: {"|", "||", "|||", "||||", "|||||", "||||||", "|||||||", "||||||||", "|||||||", "||||||", "|||||", "||||", "|||", "||", "|"}, 33: {"[ ]", "[= ]", "[== ]", "[=== ]", "[==== ]", "[===== ]", "[====== ]", "[======= ]", "[======== ]", "[========= ]", "[==========]"}, 34: {"(*---------)", "(-*--------)", "(--*-------)", "(---*------)", "(----*-----)", "(-----*----)", "(------*---)", "(-------*--)", "(--------*-)", "(---------*)"}, 35: {"█▒▒▒▒▒▒▒▒▒", "███▒▒▒▒▒▒▒", "█████▒▒▒▒▒", "███████▒▒▒", "██████████"}, 36: {"[ ]", "[=> ]", "[===> ]", "[=====> ]", "[======> ]", "[========> ]", "[==========> ]", "[============> ]", "[==============> ]", "[================> ]", "[==================> ]", "[===================>]"}, 37: {"🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚", "🕛"}, // clock emoji: one per hour for hours 1~12 38: {"🕐", "🕜", "🕑", "🕝", "🕒", "🕞", "🕓", "🕟", "🕔", "🕠", "🕕", "🕡", "🕖", "🕢", "🕗", "🕣", "🕘", "🕤", "🕙", "🕥", "🕚", "🕦", "🕛", "🕧"}, // clock emoji: one per half hour for hours 1~12 39: {"🌍", "🌎", "🌏"}, 40: {"◜", "◝", "◞", "◟"}, 41: {"⬒", "⬔", "⬓", "⬕"}, 42: {"⬖", "⬘", "⬗", "⬙"}, 43: {"[>>> >]", "[]>>>> []", "[] >>>> []", "[] >>>> []", "[] >>>> []", "[] >>>>[]", "[>> >>]"}, 44: {"♠", "♣", "♥", "♦"}, 45: {"➞", "➟", "➠", "➡", "➠", "➟"}, 46: {" | ", ` \ `, "_ ", ` \ `, " | ", " / ", " _", " / "}, 47: {" . . . .", ". . . .", ". . . .", ". . . .", ". . . . ", ". . . . ."}, 48: {" | ", " / ", " _ ", ` \ `, " | ", ` \ `, " _ ", " / "}, 49: {"⎺", "⎻", "⎼", "⎽", "⎼", "⎻"}, 50: {"▹▹▹▹▹", "▸▹▹▹▹", "▹▸▹▹▹", "▹▹▸▹▹", "▹▹▹▸▹", "▹▹▹▹▸"}, 51: {"[ ]", "[ =]", "[ ==]", "[ ===]", "[====]", "[=== ]", "[== ]", "[= ]"}, 52: {"( ● )", "( ● )", "( ● )", "( ● )", "( ●)", "( ● )", "( ● )", "( ● )", "( ● )"}, 53: {"✶", "✸", "✹", "✺", "✹", "✷"}, 54: {"▐|\\____________▌", "▐_|\\___________▌", "▐__|\\__________▌", "▐___|\\_________▌", "▐____|\\________▌", "▐_____|\\_______▌", "▐______|\\______▌", "▐_______|\\_____▌", "▐________|\\____▌", "▐_________|\\___▌", "▐__________|\\__▌", "▐___________|\\_▌", "▐____________|\\▌", "▐____________/|▌", "▐___________/|_▌", "▐__________/|__▌", "▐_________/|___▌", "▐________/|____▌", "▐_______/|_____▌", "▐______/|______▌", "▐_____/|_______▌", "▐____/|________▌", "▐___/|_________▌", "▐__/|__________▌", "▐_/|___________▌", "▐/|____________▌"}, 55: {"▐⠂ ▌", "▐⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂▌", "▐ ⠠▌", "▐ ⡀▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐⠠ ▌"}, 56: {"¿", "?"}, 57: {"⢹", "⢺", "⢼", "⣸", "⣇", "⡧", "⡗", "⡏"}, 58: {"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"}, 59: {". ", ".. ", "...", " ..", " .", " "}, 60: {".", "o", "O", "°", "O", "o", "."}, 61: {"▓", "▒", "░"}, 62: {"▌", "▀", "▐", "▄"}, 63: {"⊶", "⊷"}, 64: {"▪", "▫"}, 65: {"□", "■"}, 66: {"▮", "▯"}, 67: {"-", "=", "≡"}, 68: {"d", "q", "p", "b"}, 69: {"∙∙∙", "●∙∙", "∙●∙", "∙∙●", "∙∙∙"}, 70: {"🌑 ", "🌒 ", "🌓 ", "🌔 ", "🌕 ", "🌖 ", "🌗 ", "🌘 "}, 71: {"☗", "☖"}, 72: {"⧇", "⧆"}, 73: {"◉", "◎"}, 74: {"㊂", "㊀", "㊁"}, 75: {"⦾", "⦿"}, 76: {"ဝ", "၀"}, 77: {"▌", "▀", "▐▄"}, 78: {"⠈⠁", "⠈⠑", "⠈⠱", "⠈⡱", "⢀⡱", "⢄⡱", "⢄⡱", "⢆⡱", "⢎⡱", "⢎⡰", "⢎⡠", "⢎⡀", "⢎⠁", "⠎⠁", "⠊⠁"}, 79: {"________", "-_______", "_-______", "__-_____", "___-____", "____-___", "_____-__", "______-_", "_______-", "________", "_______-", "______-_", "_____-__", "____-___", "___-____", "__-_____", "_-______", "-_______", "________"}, 80: {"|_______", "_/______", "__-_____", "___\\____", "____|___", "_____/__", "______-_", "_______\\", "_______|", "______\\_", "_____-__", "____/___", "___|____", "__\\_____", "_-______"}, 81: {"□", "◱", "◧", "▣", "■"}, 82: {"□", "◱", "▨", "▩", "■"}, 83: {"░", "▒", "▓", "█"}, 84: {"░", "█"}, 85: {"⚪", "⚫"}, 86: {"◯", "⬤"}, 87: {"▱", "▰"}, 88: {"➊", "➋", "➌", "➍", "➎", "➏", "➐", "➑", "➒", "➓"}, 89: {"½", "⅓", "⅔", "¼", "¾", "⅛", "⅜", "⅝", "⅞"}, 90: {"↞", "↟", "↠", "↡"}, } golang-github-theckman-yacspin-0.13.12/character_sets_test.go000066400000000000000000000005361454414564700242430ustar00rootroot00000000000000package yacspin import ( "fmt" "testing" "time" ) func TestCharSets(t *testing.T) { spinner, err := New(Config{Frequency: time.Second}) testErrCheck(t, "New()", "", err) for i := 0; i < len(CharSets); i++ { name := fmt.Sprintf("spinner.CharSet(CharSets[%d])", i) err := spinner.CharSet(CharSets[i]) testErrCheck(t, name, "", err) } } golang-github-theckman-yacspin-0.13.12/colors.go000066400000000000000000000101471454414564700215120ustar00rootroot00000000000000// This file is available under the Apache 2.0 License // This file was copied from: https://github.com/briandowns/spinner // // Please see the LICENSE file for the copy of the Apache 2.0 License. // // Modifications: // // - made validColors set map more idiomatic with an empty struct value // - added a function for creating color functions from color list package yacspin import ( "fmt" "github.com/fatih/color" ) // ValidColors holds the list of the strings that are mapped to // github.com/fatih/color color attributes. Any of these colors / attributes can // be used with the *Spinner type, and it should be reflected in the output. var ValidColors = map[string]struct{}{ // default colors for backwards compatibility "black": {}, "red": {}, "green": {}, "yellow": {}, "blue": {}, "magenta": {}, "cyan": {}, "white": {}, // attributes "reset": {}, "bold": {}, "faint": {}, "italic": {}, "underline": {}, "blinkslow": {}, "blinkrapid": {}, "reversevideo": {}, "concealed": {}, "crossedout": {}, // foreground text "fgBlack": {}, "fgRed": {}, "fgGreen": {}, "fgYellow": {}, "fgBlue": {}, "fgMagenta": {}, "fgCyan": {}, "fgWhite": {}, // foreground Hi-Intensity text "fgHiBlack": {}, "fgHiRed": {}, "fgHiGreen": {}, "fgHiYellow": {}, "fgHiBlue": {}, "fgHiMagenta": {}, "fgHiCyan": {}, "fgHiWhite": {}, // background text "bgBlack": {}, "bgRed": {}, "bgGreen": {}, "bgYellow": {}, "bgBlue": {}, "bgMagenta": {}, "bgCyan": {}, "bgWhite": {}, // background Hi-Intensity text "bgHiBlack": {}, "bgHiRed": {}, "bgHiGreen": {}, "bgHiYellow": {}, "bgHiBlue": {}, "bgHiMagenta": {}, "bgHiCyan": {}, "bgHiWhite": {}, } // returns a valid color's foreground text color attribute var colorAttributeMap = map[string]color.Attribute{ // default colors for backwards compatibility "black": color.FgBlack, "red": color.FgRed, "green": color.FgGreen, "yellow": color.FgYellow, "blue": color.FgBlue, "magenta": color.FgMagenta, "cyan": color.FgCyan, "white": color.FgWhite, // attributes "reset": color.Reset, "bold": color.Bold, "faint": color.Faint, "italic": color.Italic, "underline": color.Underline, "blinkslow": color.BlinkSlow, "blinkrapid": color.BlinkRapid, "reversevideo": color.ReverseVideo, "concealed": color.Concealed, "crossedout": color.CrossedOut, // foreground text colors "fgBlack": color.FgBlack, "fgRed": color.FgRed, "fgGreen": color.FgGreen, "fgYellow": color.FgYellow, "fgBlue": color.FgBlue, "fgMagenta": color.FgMagenta, "fgCyan": color.FgCyan, "fgWhite": color.FgWhite, // foreground Hi-Intensity text colors "fgHiBlack": color.FgHiBlack, "fgHiRed": color.FgHiRed, "fgHiGreen": color.FgHiGreen, "fgHiYellow": color.FgHiYellow, "fgHiBlue": color.FgHiBlue, "fgHiMagenta": color.FgHiMagenta, "fgHiCyan": color.FgHiCyan, "fgHiWhite": color.FgHiWhite, // background text colors "bgBlack": color.BgBlack, "bgRed": color.BgRed, "bgGreen": color.BgGreen, "bgYellow": color.BgYellow, "bgBlue": color.BgBlue, "bgMagenta": color.BgMagenta, "bgCyan": color.BgCyan, "bgWhite": color.BgWhite, // background Hi-Intensity text colors "bgHiBlack": color.BgHiBlack, "bgHiRed": color.BgHiRed, "bgHiGreen": color.BgHiGreen, "bgHiYellow": color.BgHiYellow, "bgHiBlue": color.BgHiBlue, "bgHiMagenta": color.BgHiMagenta, "bgHiCyan": color.BgHiCyan, "bgHiWhite": color.BgHiWhite, } // validColor will make sure the given color is actually allowed func validColor(c string) bool { _, ok := ValidColors[c] return ok } func colorFunc(colors ...string) (func(format string, a ...interface{}) string, error) { if len(colors) == 0 { return fmt.Sprintf, nil } attrib := make([]color.Attribute, len(colors)) for i, color := range colors { if !validColor(color) { return nil, fmt.Errorf("%s is not a valid color", color) } attrib[i] = colorAttributeMap[color] } return color.New(attrib...).SprintfFunc(), nil } golang-github-theckman-yacspin-0.13.12/colors_test.go000066400000000000000000000045641454414564700225570ustar00rootroot00000000000000package yacspin import ( "fmt" "testing" "github.com/fatih/color" ) func Test_validColor(t *testing.T) { validColors := []string{ "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "reset", "bold", "faint", "italic", "underline", "blinkslow", "blinkrapid", "reversevideo", "concealed", "crossedout", "fgBlack", "fgRed", "fgGreen", "fgYellow", "fgBlue", "fgMagenta", "fgCyan", "fgWhite", "fgHiBlack", "fgHiRed", "fgHiGreen", "fgHiYellow", "fgHiBlue", "fgHiMagenta", "fgHiCyan", "fgHiWhite", "bgBlack", "bgRed", "bgGreen", "bgYellow", "bgBlue", "bgMagenta", "bgCyan", "bgWhite", "bgHiBlack", "bgHiRed", "bgHiGreen", "bgHiYellow", "bgHiBlue", "bgHiMagenta", "bgHiCyan", "bgHiWhite", } tests := []struct { name string color string want bool }{ { name: "invalid", color: "invalid", want: false, }, } for _, c := range validColors { tests = append(tests, struct { name string color string want bool }{ name: c, color: c, want: true, }) } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := validColor(tt.color); got != tt.want { t.Fatalf("validColor(%q) = %t, want %t", tt.color, got, tt.want) } }) } } func Test_colorFunc(t *testing.T) { tests := []struct { name string colors []string err string }{ { name: "no_color", }, { name: "color", colors: []string{"fgHiGreen"}, }, { name: "colors", colors: []string{"fgHiGreen", "bgRed"}, }, { name: "invalid_color", colors: []string{"fgHiGreen", "invalid", "bgRed"}, err: "invalid is not a valid color", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var tfn func(format string, a ...interface{}) string if len(tt.colors) == 0 { tfn = fmt.Sprintf } else { a := make([]color.Attribute, len(tt.colors)) for i, c := range tt.colors { ca, ok := colorAttributeMap[c] if !ok { continue } a[i] = ca } tfn = color.New(a...).SprintfFunc() } fn, err := colorFunc(tt.colors...) if cont := testErrCheck(t, "colorFunc()", tt.err, err); !cont { return } if fn == nil { t.Fatal("fn is nil") } got, want := fn("%s: %d", "test value", 42), tfn("%s: %d", "test value", 42) if got != want { t.Fatalf(`fn("%%s: %%d", "test value", 42) = %q, want %q`, got, want) } }) } } golang-github-theckman-yacspin-0.13.12/examples/000077500000000000000000000000001454414564700214755ustar00rootroot00000000000000golang-github-theckman-yacspin-0.13.12/examples/advanced/000077500000000000000000000000001454414564700232425ustar00rootroot00000000000000golang-github-theckman-yacspin-0.13.12/examples/advanced/main.go000066400000000000000000000074061454414564700245240ustar00rootroot00000000000000package main import ( "fmt" "os" "os/signal" "syscall" "time" "github.com/theckman/yacspin" ) func main() { // createSpinnerFromStruct() useSpinner(createSpinnerFromStruct(), true) // failure message useSpinner(createSpinnerFromStruct(), false) // success message // createSpinnerFromMethods() useSpinner(createSpinnerFromMethods(), true) // failure message useSpinner(createSpinnerFromMethods(), false) // success message } // createSpinnerFromStruct shows configuring the spinner mostly from the Config // struct. func createSpinnerFromStruct() *yacspin.Spinner { cfg := yacspin.Config{ Frequency: 100 * time.Millisecond, Colors: []string{"fgYellow"}, CharSet: yacspin.CharSets[11], Suffix: " ", SuffixAutoColon: true, Message: "only spinner is colored", StopCharacter: "✓", StopColors: []string{"fgGreen"}, StopMessage: "done", StopFailCharacter: "✗", StopFailColors: []string{"fgRed"}, StopFailMessage: "failed", } s, err := yacspin.New(cfg) if err != nil { exitf("failed to make spinner from struct: %v", err) } return s } // createSpinnerFromMethods shows configuring the spinner mostly from its // methods. func createSpinnerFromMethods() *yacspin.Spinner { cfg := yacspin.Config{ Frequency: 100 * time.Millisecond, ColorAll: true, SuffixAutoColon: true, Message: "spinner and text is colored", } s, err := yacspin.New(cfg) if err != nil { exitf("failed to generate spinner from methods: %v", err) } if err := s.CharSet(yacspin.CharSets[11]); err != nil { exitf("failed to set charset: %v", err) } if err := s.Colors("fgYellow"); err != nil { exitf("failed to set color: %v", err) } if err := s.StopColors("fgGreen"); err != nil { exitf("failed to set stop colors: %v", err) } if err := s.StopFailColors("fgRed"); err != nil { exitf("failed to set stop fail colors: %v", err) } s.Suffix(" ") s.StopCharacter("✓") s.StopMessage("done") s.StopFailCharacter("✗") s.StopFailMessage("failed") return s } // useSpinner utilizes the differently configured spinners, if shouldFail is // true it uses the .StopFail method instead of .Stop. func useSpinner(spinner *yacspin.Spinner, shouldFail bool) { // handle spinner cleanup on interrupts sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) defer signal.Stop(sigCh) go func() { // this is just an example signal handler, should be more robust <-sigCh spinner.StopFailMessage("interrupted") // ignoring error intentionally _ = spinner.StopFail() os.Exit(0) }() // start animating the spinner if err := spinner.Start(); err != nil { exitf("failed to start spinner: %v", err) } // let spinner animation render for a bit time.Sleep(2 * time.Second) // pause spinner to do an "atomic" config update if err := spinner.Pause(); err != nil { exitf("failed to pause spinner: %v", err) } spinner.Suffix(" uploading files") spinner.Message("") // start to animate the spinner again if err := spinner.Unpause(); err != nil { exitf("failed to unpause spinner: %v", err) } // let spinner animation render for a bit time.Sleep(time.Second) // simulate uploading a series of different files for _, f := range []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"} { spinner.Message(fmt.Sprintf("%s.zip", f)) time.Sleep(150 * time.Millisecond) } // let spinner animation render for a bit time.Sleep(1500 * time.Millisecond) if shouldFail { if err := spinner.StopFail(); err != nil { exitf("failed to stopfail: %v", err) } } else { if err := spinner.Stop(); err != nil { exitf("failed to stop: %v", err) } } } func exitf(format string, a ...interface{}) { fmt.Printf(format, a...) os.Exit(1) } golang-github-theckman-yacspin-0.13.12/examples/demo/000077500000000000000000000000001454414564700224215ustar00rootroot00000000000000golang-github-theckman-yacspin-0.13.12/examples/demo/README.md000066400000000000000000000036651454414564700237120ustar00rootroot00000000000000# demo This program is the helper used to create the gifs in the README. What follows are notes for when I do this in the future. ### Overview The software in this `demo` folder is used to demonstrate all of the spinners that exist, and to help generate the GIFs used for the README.md file in the root of this repo. There are two helper scripts in the `scripts/` folder. `ffmpeg.sh` helps with converting the h.264 .mp4 files into .gif files. The `rename.go` program then helps rename those .gif files into the right name for committing to the repository. The GIFs are stored in a sister GitHub repository, [github.com/theckman/yacspin-gifs](https://github.com/theckman/yacspin-gifs) to avoid cluttering up this repo with binary files (images). #### Checklist - [ ] start a screen recording of the terminal using Apple QuickTime Player - [ ] start the demo program (`main.go`) and let it run entirely before stopping the QuickTime recording - [ ] load the .mov file into Adobe Preimere, and cut the clip down into the individual 10 second clips of each spinner - [ ] turn the clips in the original Sequence into Subsequences - [ ] use media encoder to export those subsequences to their own files, matching source with Adaptive High Bitrate - [ ] use the `scripts/ffmpeg.sh` script to convert the `.mp4` files into `.gif` files - [ ] `go run` the `scripts/rename.go` program, to rename the `.gif` files to match the names we expect #### QuickTime Capture Sizes When using the Apple QuickTime Player to capture the screen recording, you can have it only capture a specific area of the screen. These are the capture sizes I played with on my Apple 16" M1 Max MBP laptop. Please note, that the video files rendered from these captures tend to have about 2x the resolution. Keep that in mind when deciding how large of an area you want to capture. Capture Size (pixels) | Font Size ----------------------|---------- 650w x 24h | 18 340w x 12h | 10 1300w x 47h | 38 golang-github-theckman-yacspin-0.13.12/examples/demo/main.go000066400000000000000000000023611454414564700236760ustar00rootroot00000000000000// This program was used to generate the gifs in the README file package main import ( "fmt" "os" "os/signal" "runtime" "runtime/debug" "syscall" "time" "github.com/theckman/yacspin" ) func main() { // disable GC debug.SetGCPercent(-1) cfg := yacspin.Config{ Frequency: 200 * time.Millisecond, CharSet: yacspin.CharSets[36], SuffixAutoColon: true, Suffix: " example spinner", Message: "initial message", Colors: []string{"fgYellow"}, } spinner, err := yacspin.New(cfg) if err != nil { fmt.Fprintf(os.Stderr, "failed to create spinner: %v\n", err) os.Exit(1) } // handle SIGINT / SIGTERM without needing terminal reset sigc := make(chan os.Signal, 2) signal.Notify(sigc, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigc _ = spinner.Stop() os.Exit(0) }() // run GC once before we start to render runtime.GC() time.Sleep(3 * time.Second) for i := 0; i < len(yacspin.CharSets); i++ { spinner.Message("initial message") // interesting charsets for recording sizing: 19, 36 _ = spinner.CharSet(yacspin.CharSets[i]) _ = spinner.Start() time.Sleep(5 * time.Second) spinner.Message("updated message") time.Sleep(5 * time.Second) _ = spinner.Stop() } } golang-github-theckman-yacspin-0.13.12/examples/demo/scripts/000077500000000000000000000000001454414564700241105ustar00rootroot00000000000000golang-github-theckman-yacspin-0.13.12/examples/demo/scripts/ffmpeg.sh000066400000000000000000000005251454414564700257120ustar00rootroot00000000000000#!/bin/bash #### # # Convert *.mp4 files in the current directory to GIFs at 10 FPS # #### function main() { for file in *.mp4; do basename=$(echo "${file}" | sed -e 's/\.mp4$//') ffmpeg -i "${basename}.mp4" -vf "fps=10,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse" -loop 0 "${basename}.gif" done } main golang-github-theckman-yacspin-0.13.12/examples/demo/scripts/rename.go000066400000000000000000000022731454414564700257120ustar00rootroot00000000000000package main import ( "flag" "fmt" "os" "path/filepath" "strconv" "strings" "syscall" ) func main() { var sim bool var glob string flag.BoolVar(&sim, "sim", false, "simulate") flag.StringVar(&glob, "glob", "", "glob to use for matching files, must end in *.gif") flag.Parse() if !strings.HasSuffix(glob, "*.gif") { fmt.Fprintln(os.Stderr, "-glob flag required and must end in *.gif") os.Exit(int(syscall.EINVAL)) } m, err := filepath.Glob(glob) if err != nil { fmt.Fprintf(os.Stderr, "failed to glob %s: %v\n", glob, err) os.Exit(1) } if len(m) == 0 { fmt.Fprintf(os.Stderr, "no files matched glob %s\n", glob) os.Exit(1) } idx := strings.Index(glob, "*") for _, match := range m { num := strings.TrimSuffix(match[idx:], ".gif") ni, err := strconv.Atoi(num) if err != nil { fmt.Fprintf(os.Stderr, "failed to convert number in %s to integer: %v\n", match, err) os.Exit(1) } ni-- newName := fmt.Sprintf("%d.gif", ni) if sim { fmt.Printf("would rename %s to %s\n", match, newName) continue } if err := os.Rename(match, newName); err != nil { fmt.Fprintf(os.Stderr, "failed to rename %s to %s\n", match, newName) os.Exit(1) } } } golang-github-theckman-yacspin-0.13.12/examples/simple/000077500000000000000000000000001454414564700227665ustar00rootroot00000000000000golang-github-theckman-yacspin-0.13.12/examples/simple/main.go000066400000000000000000000042301454414564700242400ustar00rootroot00000000000000package main import ( "fmt" "os" "os/signal" "syscall" "time" "github.com/theckman/yacspin" ) func main() { spinner, err := createSpinner() if err != nil { fmt.Printf("failed to make spinner from config struct: %v\n", err) os.Exit(1) } stopOnSignal(spinner) err = renderSpinner(spinner) if err != nil { fmt.Println(err.Error()) os.Exit(1) } } func createSpinner() (*yacspin.Spinner, error) { // build the configuration, each field is documented cfg := yacspin.Config{ Frequency: 100 * time.Millisecond, CharSet: yacspin.CharSets[11], Suffix: " ", // puts a least one space between the animating spinner and the Message Message: "collecting files", SuffixAutoColon: true, ColorAll: true, Colors: []string{"fgYellow"}, StopCharacter: "✓", StopColors: []string{"fgGreen"}, StopMessage: "done", StopFailCharacter: "✗", StopFailColors: []string{"fgRed"}, StopFailMessage: "failed", } s, err := yacspin.New(cfg) if err != nil { return nil, fmt.Errorf("failed to make spinner from struct: %w", err) } return s, nil } func stopOnSignal(spinner *yacspin.Spinner) { // ensure we stop the spinner before exiting, otherwise cursor will remain // hidden and terminal will require a `reset` sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) go func() { <-sigCh spinner.StopFailMessage("interrupted") // ignoring error intentionally _ = spinner.StopFail() os.Exit(0) }() } func renderSpinner(spinner *yacspin.Spinner) error { // start the spinner animation if err := spinner.Start(); err != nil { return fmt.Errorf("failed to start spinner: %w", err) } // let spinner render time.Sleep(5 * time.Second) // update message spinner.Message("uploading files") // let spinner render some more time.Sleep(5 * time.Second) // if you wanted to print a failure message... // // if err := spinner.StopFail(); err != nil { // return fmt.Errorf("failed to stop spinner: %w", err) // } if err := spinner.Stop(); err != nil { return fmt.Errorf("failed to stop spinner: %w", err) } return nil } golang-github-theckman-yacspin-0.13.12/go.mod000066400000000000000000000005451454414564700207710ustar00rootroot00000000000000module github.com/theckman/yacspin go 1.17 require ( github.com/fatih/color v1.13.0 github.com/google/go-cmp v0.5.6 github.com/mattn/go-colorable v0.1.12 github.com/mattn/go-isatty v0.0.14 github.com/mattn/go-runewidth v0.0.13 ) require ( github.com/rivo/uniseg v0.2.0 // indirect golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 // indirect ) golang-github-theckman-yacspin-0.13.12/go.sum000066400000000000000000000036371454414564700210230ustar00rootroot00000000000000github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang-github-theckman-yacspin-0.13.12/spinner.go000066400000000000000000001002041454414564700216610ustar00rootroot00000000000000// Package yacspin provides Yet Another CLi Spinner for Go, taking inspiration // (and some utility code) from the https://github.com/briandowns/spinner // project. Specifically this project borrows the default character sets, and // color mappings to github.com/fatih/color colors, from that project. // // This spinner should support all major operating systems, and is tested // against Linux, MacOS, and Windows. // // This spinner also supports an alternate mode of operation when the TERM // environment variable is set to "dumb". This is discovered automatically when // constructing the spinner. // // Within the yacspin package there are some default spinners stored in the // yacspin.CharSets variable, and you can also provide your own. There is also a // list of known colors in the yacspin.ValidColors variable, if you'd like to // see what's supported. If you've used github.com/fatih/color before, they // should look familiar. // // cfg := yacspin.Config{ // Frequency: 100 * time.Millisecond, // CharSet: yacspin.CharSets[59], // Suffix: " backing up database to S3", // Message: "exporting data", // StopCharacter: "✓", // StopColors: []string{"fgGreen"}, // } // // spinner, err := yacspin.New(cfg) // // handle the error // // spinner.Start() // // // doing some work // time.Sleep(2 * time.Second) // // spinner.Message("uploading data") // // // upload... // time.Sleep(2 * time.Second) // // spinner.Stop() // // Check out the Config struct to see all of the possible configuration options // supported by the Spinner. package yacspin import ( "bytes" "errors" "fmt" "io" "math" "os" "strings" "sync" "sync/atomic" "time" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" "github.com/mattn/go-runewidth" ) type character struct { Value string Size int } func setToCharSlice(ss []string) ([]character, int) { if len(ss) == 0 { return nil, 0 } var maxWidth int c := make([]character, len(ss)) for i, s := range ss { n := runewidth.StringWidth(s) if n > maxWidth { maxWidth = n } c[i] = character{ Value: s, Size: n, } } return c, maxWidth } // TerminalMode is a type to represent the bit flag controlling the terminal // mode of the spinner, accepted as a field on the Config struct. See the // comments on the exported constants for more info. type TerminalMode uint32 const ( // AutomaticMode configures the constructor function to try and determine if // the application using yacspin is being executed within a interactive // (teletype [TTY]) session. AutomaticMode TerminalMode = 1 << iota // ForceTTYMode configures the spinner to operate as if it's running within // a TTY session. ForceTTYMode // ForceNoTTYMode configures the spinner to operate as if it's not running // within a TTY session. This mode causes the spinner to only animate when // data is being updated. Each animation is rendered on a new line. You can // trigger an animation by calling the Message() method, including with the // last value it was called with. ForceNoTTYMode // ForceDumbTerminalMode configures the spinner to operate as if it's // running within a dumb terminal. This means the spinner will not use ANSI // escape sequences to print colors or to erase each line. Line erasure to // animate the spinner is accomplished by overwriting the line with space // characters. ForceDumbTerminalMode // ForceSmartTerminalMode configures the spinner to operate as if it's // running within a terminal that supports ANSI escape sequences (VT100). // This includes printing of stylized text, and more better line erasure to // animate the spinner. ForceSmartTerminalMode ) func termModeAuto(t TerminalMode) bool { return t&AutomaticMode > 0 } func termModeForceTTY(t TerminalMode) bool { return t&ForceTTYMode > 0 } func termModeForceNoTTY(t TerminalMode) bool { return t&ForceNoTTYMode > 0 } func termModeForceDumb(t TerminalMode) bool { return t&ForceDumbTerminalMode > 0 } func termModeForceSmart(t TerminalMode) bool { return t&ForceSmartTerminalMode > 0 } // Config is the configuration structure for the Spinner type, which you provide // to the New() function. Some of the fields can be updated after the *Spinner // is constructed, others can only be set when calling the constructor. Please // read the comments for those details. type Config struct { // Frequency specifies how often to animate the spinner. Optimal value // depends on the character set you use. Frequency time.Duration // Writer is the place where we are outputting the spinner, and can't be // changed after the *Spinner has been constructed. If omitted (nil), this // defaults to os.Stdout. Writer io.Writer // ShowCursor specifies that the cursor should be shown by the spinner while // animating. If it is not shown, the cursor will be restored when the // spinner stops. This can't be changed after the *Spinner has been // constructed. // // Please note, if you do not set this to true and the program crashes or is // killed, you may need to reset your terminal for the cursor to appear // again. ShowCursor bool // HideCursor describes whether the cursor should be hidden by the spinner // while animating. If it is hidden, it will be restored when the spinner // stops. This can't be changed after the *Spinner has been constructed. // // Please note, if the program crashes or is killed you may need to reset // your terminal for the cursor to appear again. // // Deprecated: use ShowCursor instead. HideCursor bool // SpinnerAtEnd configures the spinner to render the animation at the end of // the line instead of the beginning. The default behavior is to render the // animated spinner at the beginning of the line. SpinnerAtEnd bool // ColorAll describes whether to color everything (all) or just the spinner // character(s). This cannot be changed after the *Spinner has been // constructed. ColorAll bool // Colors are the colors used for the different printed messages. This // respects the ColorAll field. Colors []string // CharSet is the list of characters to iterate through to draw the spinner. CharSet []string // Prefix is the string printed immediately before the spinner. // // If SpinnerAtEnd is set to true, it's recommended that this string start // with a space character (` `). Prefix string // Suffix is the string printed immediately after the spinner and before the // message. // // If SpinnerAtEnd is set to false, it's recommended that this string starts // with an space character (` `). Suffix string // SuffixAutoColon configures whether the spinner adds a colon after the // suffix automatically. If there is a message, a colon followed by a space // is added to the suffix. Otherwise, if there is no message, or the suffix // is only space characters, the colon is omitted. // // If SpinnerAtEnd is set to true, this option is ignored. SuffixAutoColon bool // Message is the message string printed by the spinner. If SpinnerAtEnd is // set to false and SuffixAutoColon is set to true, the printed line will // look like: // // : // // If SpinnerAtEnd is set to true, the printed line will instead look like // this: // // // // In this case, it may be preferred to set the Prefix to empty space (` `). Message string // StopMessage is the message used when Stop() is called. StopMessage string // StopCharacter is spinner character used when Stop() is called. // Recommended character is ✓, and can be more than just one character. StopCharacter string // StopColors are the colors used for the Stop() printed line. This respects // the ColorAll field. StopColors []string // StopFailMessage is the message used when StopFail() is called. StopFailMessage string // StopFailCharacter is the spinner character used when StopFail() is // called. Recommended character is ✗, and can be more than just one // character. StopFailCharacter string // StopFailColors are the colors used for the StopFail() printed line. This // respects the ColorAll field. StopFailColors []string // TerminalMode is a bitflag field to control how the internal TTY / "dumb // terminal" detection works, to allow consumers to override the internal // behaviors. To set this value, it's recommended to use the TerminalMode // constants exported by this package. // // If not set, the New() function implicitly sets it to AutomaticMode. The // New() function also returns an error if you have conflicting flags, such // as setting ForceTTYMode and ForceNoTTYMode, or if you set AutomaticMode // and any other flags set. // // When in AutomaticMode, the New() function attempts to determine if the // current application is running within an interactive (teletype [TTY]) // session. If it does not appear to be within a TTY, it sets this field // value to ForceNoTTYMode | ForceDumbTerminalMode. // // If this does appear to be a TTY, the ForceTTYMode bitflag will bet set. // Similarly, if it's a TTY and the TERM environment variable isn't set to // "dumb" the ForceSmartTerminalMode bitflag will also be set. // // If the deprecated NoTTY Config struct field is set to true, and this // field is AutomaticMode, the New() function sets field to the value of // ForceNoTTYMode | ForceDumbTerminalMode. TerminalMode TerminalMode // NotTTY tells the spinner that the Writer should not be treated as a TTY. // This results in the animation being disabled, with the animation only // happening whenever the data is updated. This mode also renders each // update on new line, versus reusing the current line. // // Deprecated: use TerminalMode field instead by setting it to: // ForceNoTTYMode | ForceDumbTerminalMode. This will be removed in a future // release. NotTTY bool } // Spinner is a type representing an animated CLi terminal spinner. The Spinner // is constructed by the New() function of this package, which accepts a Config // struct as the only argument. Some of the configuration values cannot be // changed after the spinner is constructed, so be sure to read the comments // within the Config type. // // Please note, by default the spinner will hide the terminal cursor when // animating the spinner. If you do not set Config.ShowCursor to true, you need // to make sure to call the Stop() or StopFail() method to reset the cursor in // the terminal. Otherwise, after the program exits the cursor will be hidden // and the user will need to `reset` their terminal. type Spinner struct { writer io.Writer buffer *bytes.Buffer colorAll bool cursorHidden bool suffixAutoColon bool termMode TerminalMode spinnerAtEnd bool status *uint32 lastPrintLen int cancelCh chan struct{} // send: Stop(), close: StopFail(); both stop painter doneCh chan struct{} pauseCh chan struct{} unpauseCh chan struct{} unpausedCh chan struct{} // mutex hat and the fields wearing it mu *sync.Mutex frequency time.Duration chars []character maxWidth int index int prefix string suffix string message string colorFn func(format string, a ...interface{}) string stopMsg string stopChar character stopColorFn func(format string, a ...interface{}) string stopFailMsg string stopFailChar character stopFailColorFn func(format string, a ...interface{}) string frequencyUpdateCh chan time.Duration dataUpdateCh chan struct{} } const ( statusStopped uint32 = iota statusStarting statusRunning statusStopping statusPausing statusPaused statusUnpausing ) // New creates a new unstarted spinner. If stdout does not appear to be a TTY, // this constructor implicitly sets cfg.NotTTY to true. func New(cfg Config) (*Spinner, error) { if cfg.ShowCursor && cfg.HideCursor { return nil, errors.New("cfg.ShowCursor and cfg.HideCursor cannot be true") } if cfg.TerminalMode == 0 { cfg.TerminalMode = AutomaticMode } // AutomaticMode flag has been set, but so have others if termModeAuto(cfg.TerminalMode) && cfg.TerminalMode != AutomaticMode { return nil, errors.New("cfg.TerminalMode cannot have AutomaticMode flag set if others are set") } if termModeForceTTY(cfg.TerminalMode) && termModeForceNoTTY(cfg.TerminalMode) { return nil, errors.New("cfg.TerminalMode cannot have both ForceTTYMode and ForceNoTTYMode flags set") } if termModeForceDumb(cfg.TerminalMode) && termModeForceSmart(cfg.TerminalMode) { return nil, errors.New("cfg.TerminalMode cannot have both ForceDumbTerminalMode and ForceSmartTerminalMode flags set") } if cfg.HideCursor { cfg.ShowCursor = false } // cfg.NotTTY compatibility if cfg.TerminalMode == AutomaticMode && cfg.NotTTY { cfg.TerminalMode = ForceNoTTYMode | ForceDumbTerminalMode } // is this a dumb terminal / not a TTY? if cfg.TerminalMode == AutomaticMode && !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()) { cfg.TerminalMode = ForceNoTTYMode | ForceDumbTerminalMode } // if cfg.TerminalMode is still equal to AutomaticMode, this is a TTY if cfg.TerminalMode == AutomaticMode { cfg.TerminalMode = ForceTTYMode if os.Getenv("TERM") == "dumb" { cfg.TerminalMode |= ForceDumbTerminalMode } else { cfg.TerminalMode |= ForceSmartTerminalMode } } buf := bytes.NewBuffer(make([]byte, 2048)) buf.Reset() s := &Spinner{ buffer: buf, mu: &sync.Mutex{}, frequency: cfg.Frequency, status: uint32Ptr(0), frequencyUpdateCh: make(chan time.Duration), // use unbuffered for now to avoid .Frequency() panic dataUpdateCh: make(chan struct{}), colorAll: cfg.ColorAll, cursorHidden: !cfg.ShowCursor, spinnerAtEnd: cfg.SpinnerAtEnd, suffixAutoColon: cfg.SuffixAutoColon, termMode: cfg.TerminalMode, colorFn: fmt.Sprintf, stopColorFn: fmt.Sprintf, stopFailColorFn: fmt.Sprintf, } if err := s.Colors(cfg.Colors...); err != nil { return nil, err } if err := s.StopColors(cfg.StopColors...); err != nil { return nil, err } if err := s.StopFailColors(cfg.StopFailColors...); err != nil { return nil, err } if len(cfg.CharSet) == 0 { cfg.CharSet = CharSets[9] } // can only error if the charset is empty, and we prevent that above _ = s.CharSet(cfg.CharSet) if termModeForceNoTTY(s.termMode) { // hack to prevent the animation from running if not a TTY s.frequency = time.Duration(math.MaxInt64) } if cfg.Writer == nil { cfg.Writer = colorable.NewColorableStdout() } s.writer = cfg.Writer if len(cfg.Prefix) > 0 { s.Prefix(cfg.Prefix) } if len(cfg.Suffix) > 0 { s.Suffix(cfg.Suffix) } if len(cfg.Message) > 0 { s.Message(cfg.Message) } if len(cfg.StopMessage) > 0 { s.StopMessage(cfg.StopMessage) } if len(cfg.StopCharacter) > 0 { s.StopCharacter(cfg.StopCharacter) } if len(cfg.StopFailMessage) > 0 { s.StopFailMessage(cfg.StopFailMessage) } if len(cfg.StopFailCharacter) > 0 { s.StopFailCharacter(cfg.StopFailCharacter) } return s, nil } func (s *Spinner) notifyDataChange() { // non-blocking notification select { case s.dataUpdateCh <- struct{}{}: default: } } // SpinnerStatus describes the status of the spinner. See the package constants // for the list of all possible statuses type SpinnerStatus uint32 const ( // SpinnerStopped is a stopped spinner SpinnerStopped SpinnerStatus = iota // SpinnerStarting is a starting spinner SpinnerStarting // SpinnerRunning is a running spinner SpinnerRunning // SpinnerStopping is a stopping spinner SpinnerStopping // SpinnerPausing is a pausing spinner SpinnerPausing // SpinnerPaused is a paused spinner SpinnerPaused // SpinnerUnpausing is an unpausing spinner SpinnerUnpausing ) func (s SpinnerStatus) String() string { switch s { case SpinnerStopped: return "stopped" case SpinnerStarting: return "starting" case SpinnerRunning: return "running" case SpinnerStopping: return "stopping" case SpinnerPausing: return "pausing" case SpinnerPaused: return "paused" case SpinnerUnpausing: return "unpausing" default: return fmt.Sprintf("unknown (%d)", s) } } // Status returns the current status of the spinner. The returned value is of // type SpinnerStatus, which can be compared against the exported Spinner* // package-level constants (e.g., SpinnerRunning). func (s *Spinner) Status() SpinnerStatus { return SpinnerStatus(atomic.LoadUint32(s.status)) } // Start begins the spinner on the Writer in the Config provided to New(). Only // possible error is if the spinner is already runninng. func (s *Spinner) Start() error { // move us to the starting state if !atomic.CompareAndSwapUint32(s.status, statusStopped, statusStarting) { return errors.New("spinner already running or shutting down") } // we now have atomic guarantees of no other goroutines starting or running s.mu.Lock() if s.frequency < 1 && termModeForceTTY(s.termMode) { return errors.New("spinner Frequency duration must be greater than 0 when used within a TTY") } if len(s.chars) == 0 { s.mu.Unlock() // move us to the stopped state if !atomic.CompareAndSwapUint32(s.status, statusStarting, statusStopped) { panic("atomic invariant encountered") } return errors.New("before starting the spinner a CharSet must be set") } s.frequencyUpdateCh = make(chan time.Duration, 4) s.dataUpdateCh, s.cancelCh = make(chan struct{}, 1), make(chan struct{}, 1) s.mu.Unlock() // because of the atomic swap above, we know it's safe to mutate these // values outside of mutex s.doneCh = make(chan struct{}) s.pauseCh = make(chan struct{}) // unbuffered since we want this to be synchronous go s.painter(s.cancelCh, s.dataUpdateCh, s.pauseCh, s.doneCh, s.frequencyUpdateCh) // move us to the running state if !atomic.CompareAndSwapUint32(s.status, statusStarting, statusRunning) { panic("atomic invariant encountered") } return nil } // Pause puts the spinner in a state where it no longer animates or renders // updates to data. This function blocks until the spinner's internal painting // goroutine enters a paused state. // // If you want to make a few configuration changes and have them to appear at // the same time, like changing the suffix, message, and color, you can Pause() // the spinner first and then Unpause() after making the changes. // // If the spinner is not running (stopped, paused, or in transition to another // state) this returns an error. func (s *Spinner) Pause() error { if !atomic.CompareAndSwapUint32(s.status, statusRunning, statusPausing) { return errors.New("spinner not running") } // set up the channels the painter will use s.unpauseCh, s.unpausedCh = make(chan struct{}), make(chan struct{}) // inform the painter to pause as a blocking send s.pauseCh <- struct{}{} if !atomic.CompareAndSwapUint32(s.status, statusPausing, statusPaused) { panic("atomic invariant encountered") } return nil } // Unpause returns the spinner back to a running state after pausing. See // Pause() documentation for more detail. This function blocks until the // spinner's internal painting goroutine acknowledges the request to unpause. // // If the spinner is not paused this returns an error. func (s *Spinner) Unpause() error { if !atomic.CompareAndSwapUint32(s.status, statusPaused, statusUnpausing) { return errors.New("spinner not paused") } s.unpause() if !atomic.CompareAndSwapUint32(s.status, statusUnpausing, statusRunning) { panic("atomic invariant encountered") } return nil } func (s *Spinner) unpause() { // tell the painter to unpause close(s.unpauseCh) // wait for the painter to signal it will continue <-s.unpausedCh // clear the no longer needed channels s.unpauseCh = nil s.unpausedCh = nil } // Stop disables the spinner, and prints the StopCharacter with the StopMessage // using the StopColors. This blocks until the stopped message is printed. Only // possible error is if the spinner is not running. func (s *Spinner) Stop() error { return s.stop(false) } // StopFail disables the spinner, and prints the StopFailCharacter with the // StopFailMessage using the StopFailColors. This blocks until the stopped // message is printed. Only possible error is if the spinner is not running. func (s *Spinner) StopFail() error { return s.stop(true) } func (s *Spinner) stop(fail bool) error { // move us to a stopping state to protect against concurrent Stop() calls wasRunning := atomic.CompareAndSwapUint32(s.status, statusRunning, statusStopping) wasPaused := atomic.CompareAndSwapUint32(s.status, statusPaused, statusStopping) if !wasRunning && !wasPaused { return errors.New("spinner not running or paused") } // we now have an atomic guarantees of no other threads invoking state changes if !fail { // this tells the painter to print the StopMessage and not the // StopFailMessage s.cancelCh <- struct{}{} } close(s.cancelCh) if wasPaused { s.unpause() } // wait for the painter to stop <-s.doneCh s.mu.Lock() s.dataUpdateCh = make(chan struct{}) // prevent panic() in various setter methods s.frequencyUpdateCh = make(chan time.Duration) // prevent panic() in .Frequency() s.mu.Unlock() // because of atomic swaps and channel receive above we know it's // safe to mutate these fields outside of the mutex s.index = 0 s.cancelCh = nil s.doneCh = nil s.pauseCh = nil // move us to the stopped state if !atomic.CompareAndSwapUint32(s.status, statusStopping, statusStopped) { panic("atomic invariant encountered") } return nil } // handleFrequencyUpdate is for when the frequency was changed. This tries to // see if we should fire the timer now, or change its current duration to match // the new duration. func handleFrequencyUpdate(newFrequency time.Duration, timer *time.Timer, lastTick time.Time) { // if timer fired, drain the channel if !timer.Stop() { timerLoop: for { select { case <-timer.C: default: break timerLoop } } } timeSince := time.Since(lastTick) // if we've exceeded the new delay trigger timer immediately if timeSince >= newFrequency { timer.Reset(0) return } timer.Reset(newFrequency - timeSince) } func (s *Spinner) painter(cancel, dataUpdate, pause <-chan struct{}, done chan<- struct{}, frequencyUpdate <-chan time.Duration) { timer := time.NewTimer(0) var lastTick time.Time for { select { case <-timer.C: lastTick = time.Now() s.paintUpdate(timer, true) case <-pause: <-s.unpauseCh close(s.unpausedCh) case <-dataUpdate: // if this is not a TTY: animate the spinner on the data update s.paintUpdate(timer, termModeForceNoTTY(s.termMode)) case frequency := <-frequencyUpdate: handleFrequencyUpdate(frequency, timer, lastTick) case _, ok := <-cancel: defer close(done) timer.Stop() s.paintStop(ok) return } } } func (s *Spinner) paintUpdate(timer *time.Timer, animate bool) { s.mu.Lock() p := s.prefix m := s.message suf := s.suffix mw := s.maxWidth cFn := s.colorFn d := s.frequency index := s.index if animate { s.index++ if s.index == len(s.chars) { s.index = 0 } } else { // for data updates use the last spinner char index-- if index < 0 { index = len(s.chars) - 1 } } c := s.chars[index] s.mu.Unlock() defer s.buffer.Reset() if termModeForceSmart(s.termMode) { if err := erase(s.buffer); err != nil { panic(fmt.Sprintf("failed to erase line: %v", err)) } if s.cursorHidden { if err := hideCursor(s.buffer); err != nil { panic(fmt.Sprintf("failed to hide cursor: %v", err)) } } if _, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, s.colorAll, s.spinnerAtEnd, false, termModeForceNoTTY(s.termMode), cFn); err != nil { panic(fmt.Sprintf("failed to paint line: %v", err)) } } else { if err := s.eraseDumbTerm(s.buffer); err != nil { panic(fmt.Sprintf("failed to erase line: %v", err)) } n, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, false, s.spinnerAtEnd, false, termModeForceNoTTY(s.termMode), fmt.Sprintf) if err != nil { panic(fmt.Sprintf("failed to paint line: %v", err)) } s.lastPrintLen = n } if s.buffer.Len() > 0 { if _, err := s.writer.Write(s.buffer.Bytes()); err != nil { panic(fmt.Sprintf("failed to output buffer to writer: %v", err)) } } if animate { timer.Reset(d) } } func (s *Spinner) paintStop(chanOk bool) { var m string var c character var cFn func(format string, a ...interface{}) string s.mu.Lock() if chanOk { c = s.stopChar cFn = s.stopColorFn m = s.stopMsg } else { c = s.stopFailChar cFn = s.stopFailColorFn m = s.stopFailMsg } p := s.prefix suf := s.suffix mw := s.maxWidth s.mu.Unlock() defer s.buffer.Reset() if termModeForceSmart(s.termMode) { if err := erase(s.buffer); err != nil { panic(fmt.Sprintf("failed to erase line: %v", err)) } if s.cursorHidden { if err := unhideCursor(s.buffer); err != nil { panic(fmt.Sprintf("failed to hide cursor: %v", err)) } } if c.Size > 0 || len(m) > 0 { // paint the line with a newline as it's the final line if _, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, s.colorAll, s.spinnerAtEnd, true, termModeForceNoTTY(s.termMode), cFn); err != nil { panic(fmt.Sprintf("failed to paint line: %v", err)) } } } else { if err := s.eraseDumbTerm(s.buffer); err != nil { panic(fmt.Sprintf("failed to erase line: %v", err)) } if c.Size > 0 || len(m) > 0 { if _, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, false, s.spinnerAtEnd, true, termModeForceNoTTY(s.termMode), fmt.Sprintf); err != nil { panic(fmt.Sprintf("failed to paint line: %v", err)) } } s.lastPrintLen = 0 } if s.buffer.Len() > 0 { if _, err := s.writer.Write(s.buffer.Bytes()); err != nil { panic(fmt.Sprintf("failed to output buffer to writer: %v", err)) } } } // erase clears the line func erase(w io.Writer) error { _, err := fmt.Fprint(w, "\r\033[K\r") return err } // eraseDumbTerm clears the line on dumb terminals func (s *Spinner) eraseDumbTerm(w io.Writer) error { if termModeForceNoTTY(s.termMode) { // non-TTY outputs use \n instead of line erasure, // so return early return nil } clear := "\r" + strings.Repeat(" ", s.lastPrintLen) + "\r" _, err := fmt.Fprint(w, clear) return err } func hideCursor(w io.Writer) error { _, err := fmt.Fprint(w, "\r\033[?25l\r") return err } func unhideCursor(w io.Writer) error { _, err := fmt.Fprint(w, "\r\033[?25h\r") return err } // padChar pads the spinner character so suffix / message offset from left is // consistent func padChar(char character, maxWidth int) string { padSize := maxWidth - char.Size return char.Value + strings.Repeat(" ", padSize) } // paint writes a single line to the w, using the provided character, message, // and color function func paint(w io.Writer, maxWidth int, char character, prefix, message, suffix string, suffixAutoColon, colorAll, spinnerAtEnd, finalPaint, notTTY bool, colorFn func(format string, a ...interface{}) string) (int, error) { var output string switch char.Size { case 0: if colorAll { output = colorFn(message) break } output = message default: c := padChar(char, maxWidth) if spinnerAtEnd { if colorAll { output = colorFn("%s%s%s%s", message, prefix, c, suffix) break } output = fmt.Sprintf("%s%s%s%s", message, prefix, colorFn(c), suffix) break } if suffixAutoColon { // also implicitly !spinnerAtEnd if len(strings.TrimSpace(suffix)) > 0 && len(message) > 0 && message != "\n" { suffix += ": " } } if colorAll { output = colorFn("%s%s%s%s", prefix, c, suffix, message) break } output = fmt.Sprintf("%s%s%s%s", prefix, colorFn(c), suffix, message) } if finalPaint || notTTY { output += "\n" } return fmt.Fprint(w, output) } // Frequency updates the frequency of the spinner being animated. func (s *Spinner) Frequency(d time.Duration) error { if d < 1 { return errors.New("duration must be greater than 0") } if termModeForceNoTTY(s.termMode) { // when output target is not a TTY, we don't animate spinner // so there is no need to update the frequency return nil } s.mu.Lock() defer s.mu.Unlock() s.frequency = d // non-blocking notification select { case s.frequencyUpdateCh <- d: default: } return nil } // Prefix updates the Prefix before the spinner character. func (s *Spinner) Prefix(prefix string) { s.mu.Lock() defer s.mu.Unlock() s.prefix = prefix s.notifyDataChange() } // Suffix updates the Suffix printed after the spinner character and before the // message. It's recommended that this start with an empty space. func (s *Spinner) Suffix(suffix string) { s.mu.Lock() defer s.mu.Unlock() s.suffix = suffix s.notifyDataChange() } // Message updates the Message displayed after the suffix. func (s *Spinner) Message(message string) { s.mu.Lock() defer s.mu.Unlock() s.message = message s.notifyDataChange() } // Colors updates the github.com/fatih/colors for printing the spinner line. // ColorAll config parameter controls whether only the spinner character is // printed with these colors, or the whole line. // // StopColors() is the method to control the colors in the stop message. func (s *Spinner) Colors(colors ...string) error { colorFn, err := colorFunc(colors...) if err != nil { return fmt.Errorf("failed to build color function: %w", err) } s.mu.Lock() defer s.mu.Unlock() s.colorFn = colorFn s.notifyDataChange() return nil } // StopMessage updates the Message used when Stop() is called. func (s *Spinner) StopMessage(message string) { s.mu.Lock() defer s.mu.Unlock() s.stopMsg = message s.notifyDataChange() } // StopColors updates the colors used for the stop message. See Colors() method // documentation for more context. // // StopFailColors() is the method to control the colors in the failed stop // message. func (s *Spinner) StopColors(colors ...string) error { colorFn, err := colorFunc(colors...) if err != nil { return fmt.Errorf("failed to build stop color function: %w", err) } s.mu.Lock() defer s.mu.Unlock() s.stopColorFn = colorFn s.notifyDataChange() return nil } // StopCharacter sets the single "character" to use for the spinner when // stopping. Recommended character is ✓. func (s *Spinner) StopCharacter(char string) { n := runewidth.StringWidth(char) s.mu.Lock() defer s.mu.Unlock() s.stopChar = character{Value: char, Size: n} if n > s.maxWidth { s.maxWidth = n } s.notifyDataChange() } // StopFailMessage updates the Message used when StopFail() is called. func (s *Spinner) StopFailMessage(message string) { s.mu.Lock() defer s.mu.Unlock() s.stopFailMsg = message s.notifyDataChange() } // StopFailColors updates the colors used for the StopFail message. See Colors() method // documentation for more context. func (s *Spinner) StopFailColors(colors ...string) error { colorFn, err := colorFunc(colors...) if err != nil { return fmt.Errorf("failed to build stop fail color function: %w", err) } s.mu.Lock() defer s.mu.Unlock() s.stopFailColorFn = colorFn s.notifyDataChange() return nil } // StopFailCharacter sets the single "character" to use for the spinner when // stopping for a failure. Recommended character is ✗. func (s *Spinner) StopFailCharacter(char string) { n := runewidth.StringWidth(char) s.mu.Lock() defer s.mu.Unlock() s.stopFailChar = character{Value: char, Size: n} if n > s.maxWidth { s.maxWidth = n } s.notifyDataChange() } // CharSet updates the set of characters (strings) to use for the spinner. You // can provide your own, or use one from the yacspin.CharSets variable. // // The character sets available in the CharSets variable are from the // https://github.com/briandowns/spinner project. func (s *Spinner) CharSet(cs []string) error { if len(cs) == 0 { return errors.New("failed to set character set: must provide at least one string") } chars, mw := setToCharSlice(cs) s.mu.Lock() defer s.mu.Unlock() if n := s.stopChar.Size; n > mw { mw = s.stopChar.Size } if n := s.stopFailChar.Size; n > mw { mw = n } s.chars = chars s.maxWidth = mw s.index = 0 return nil } // Reverse flips the character set order of the spinner characters. func (s *Spinner) Reverse() { s.mu.Lock() defer s.mu.Unlock() for i, j := 0, len(s.chars)-1; i < j; { s.chars[i], s.chars[j] = s.chars[j], s.chars[i] i++ j-- } s.index = 0 } func uint32Ptr(u uint32) *uint32 { return &u } golang-github-theckman-yacspin-0.13.12/spinner_test.go000066400000000000000000001303031454414564700227230ustar00rootroot00000000000000package yacspin import ( "bytes" "fmt" "io" "math" "os" "strings" "sync" "sync/atomic" "testing" "time" "github.com/fatih/color" "github.com/google/go-cmp/cmp" "github.com/mattn/go-runewidth" ) const termModeTTY = ForceTTYMode | ForceSmartTerminalMode // testErrCheck looks to see if errContains is a substring of err.Error(). If // not, this calls t.Fatal(). It also calls t.Fatal() if there was an error, but // errContains is empty. Returns true if you should continue running the test, // or false if you should stop the test. func testErrCheck(t *testing.T, name string, errContains string, err error) bool { t.Helper() if len(errContains) > 0 { if err == nil { t.Fatalf("%s error = , should contain %q", name, errContains) return false } if errStr := err.Error(); !strings.Contains(errStr, errContains) { t.Fatalf("%s error = %q, should contain %q", name, errStr, errContains) return false } return false } if err != nil && len(errContains) == 0 { t.Fatalf("%s unexpected error: %v", name, err) return false } return true } func TestNew(t *testing.T) { tests := []struct { name string writer io.Writer maxWidth int overrideFreq time.Duration cfg Config charSet []character err string }{ { name: "config_with_frequency_and_default_writer", maxWidth: 1, writer: os.Stdout, cfg: Config{ Frequency: 100 * time.Millisecond, TerminalMode: termModeTTY, }, }, { name: "config_with_frequency_and_invalid_colors", writer: os.Stdout, cfg: Config{ Frequency: 100 * time.Millisecond, Colors: []string{"invalid"}, }, err: "failed to build color function: invalid is not a valid color", }, { name: "config_with_frequency_and_invalid_stopColors", writer: os.Stdout, cfg: Config{ Frequency: 100 * time.Millisecond, StopColors: []string{"invalid"}, }, err: "failed to build stop color function: invalid is not a valid color", }, { name: "config_with_frequency_and_invalid_stopFailColors", writer: os.Stdout, cfg: Config{ Frequency: 100 * time.Millisecond, StopFailColors: []string{"invalid"}, }, err: "failed to build stop fail color function: invalid is not a valid color", }, { name: "config_with_conflicting_cursor_settings", writer: os.Stdout, cfg: Config{ Frequency: 100 * time.Millisecond, ShowCursor: true, HideCursor: true, }, err: "cfg.ShowCursor and cfg.HideCursor cannot be true", }, { name: "config_with_conflicting_TerminalMode_Auto", cfg: Config{ Frequency: 100 * time.Millisecond, TerminalMode: AutomaticMode | ForceTTYMode, }, err: "cfg.TerminalMode cannot have AutomaticMode flag set if others are set", }, { name: "config_with_conflicting_TerminalMode_TTY", cfg: Config{ Frequency: 100 * time.Millisecond, TerminalMode: ForceTTYMode | ForceNoTTYMode, }, err: "cfg.TerminalMode cannot have both ForceTTYMode and ForceNoTTYMode flags set", }, { name: "config_with_conflicting_TerminalMode_Term", cfg: Config{ Frequency: 100 * time.Millisecond, TerminalMode: ForceDumbTerminalMode | ForceSmartTerminalMode, }, err: "cfg.TerminalMode cannot have both ForceDumbTerminalMode and ForceSmartTerminalMode flags set", }, { name: "full_config_with_deprecated_hidden_cursor", writer: os.Stderr, maxWidth: 3, cfg: Config{ Frequency: 100 * time.Millisecond, Writer: os.Stderr, HideCursor: true, ColorAll: true, Colors: []string{"fgYellow"}, CharSet: CharSets[59], Prefix: "test prefix: ", Suffix: " test suffix", Message: "test message", StopMessage: "test stop message", StopCharacter: "✓", StopColors: []string{"fgGreen"}, StopFailMessage: "test stop fail message", StopFailCharacter: "✗", StopFailColors: []string{"fgHiRed"}, SpinnerAtEnd: true, TerminalMode: termModeTTY, }, }, { name: "full_config", writer: os.Stderr, maxWidth: 3, cfg: Config{ Frequency: 100 * time.Millisecond, Writer: os.Stderr, ShowCursor: true, ColorAll: true, Colors: []string{"fgYellow"}, CharSet: CharSets[59], Prefix: "test prefix: ", Suffix: " test suffix", Message: "test message", StopMessage: "test stop message", StopCharacter: "✓", StopColors: []string{"fgGreen"}, StopFailMessage: "test stop fail message", StopFailCharacter: "✗", StopFailColors: []string{"fgHiRed"}, SpinnerAtEnd: true, TerminalMode: termModeTTY, }, }, { name: "not_tty", writer: os.Stderr, maxWidth: 3, overrideFreq: 9223372036854775807, cfg: Config{ Frequency: 100 * time.Millisecond, Writer: os.Stderr, CharSet: CharSets[59], NotTTY: true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { spinner, err := New(tt.cfg) if cont := testErrCheck(t, "New()", tt.err, err); !cont { return } if spinner == nil { t.Fatal("spinner is nil") } if n := spinner.buffer.Len(); n != 0 { t.Fatalf("spinner.buffer.Len() = %d, want 0", n) } if spinner.colorAll != tt.cfg.ColorAll { t.Fatalf("spinner.colorAll = %t, want %t", spinner.colorAll, tt.cfg.ColorAll) } if spinner.cursorHidden != !tt.cfg.ShowCursor { t.Fatalf("spinner.cursorHiddenn = %t, want %t", spinner.cursorHidden, tt.cfg.HideCursor) } if spinner.spinnerAtEnd != tt.cfg.SpinnerAtEnd { t.Fatalf("spinner.spinnerAtEnd = %t, want %t", spinner.spinnerAtEnd, tt.cfg.SpinnerAtEnd) } if spinner.mu == nil { t.Fatal("spinner.mu is nil") } if spinner.status == nil { t.Fatal("spinner.status is nil") } if spinner.frequencyUpdateCh == nil { t.Fatal("spinner.frequencyUpdateCh is nil") } if tt.overrideFreq > 0 { if spinner.frequency != tt.overrideFreq { t.Errorf("spinner.frequency = %d (%s), want %d (%s)", spinner.frequency, spinner.frequency, tt.overrideFreq, tt.overrideFreq) } } else { if spinner.frequency != tt.cfg.Frequency { t.Errorf("spinner.frequency = %d (%s), want %d (%s)", spinner.frequency, spinner.frequency, tt.cfg.Frequency, tt.cfg.Frequency) } } if spinner.writer == nil { t.Fatal("spinner.writer is nil") } if spinner.writer != tt.writer { t.Errorf("spinner.writer = %#v, want %#v", spinner.writer, tt.writer) } if spinner.prefix != tt.cfg.Prefix { t.Errorf("spinner.prefix = %q, want %q", spinner.prefix, tt.cfg.Prefix) } if spinner.suffix != tt.cfg.Suffix { t.Errorf("spinner.suffix = %q, want %q", spinner.suffix, tt.cfg.Suffix) } if spinner.message != tt.cfg.Message { t.Errorf("spinner.message = %q, want %q", spinner.message, tt.cfg.Message) } if spinner.stopMsg != tt.cfg.StopMessage { t.Errorf("spinner.stopMsg = %q, want %q", spinner.stopMsg, tt.cfg.StopMessage) } if tt.cfg.NotTTY { if spinner.termMode != ForceDumbTerminalMode|ForceNoTTYMode { t.Error("spinner.termMode != ForceDumbTerminalMode | ForceNoTTYMode") } if d := time.Duration(math.MaxInt64); spinner.frequency != d { t.Errorf("spinner.frequency = %d (%s), want %d (%s)", spinner.frequency, spinner.frequency, d, d) } } sc := character{Value: tt.cfg.StopCharacter, Size: runewidth.StringWidth(tt.cfg.StopCharacter)} if spinner.stopChar != sc { t.Errorf("spinner.stopChar = %#v, want %#v", spinner.stopChar, sc) } if spinner.stopFailMsg != tt.cfg.StopFailMessage { t.Errorf("spinner.stopFailMsg = %q, want %q", spinner.stopFailMsg, tt.cfg.StopFailMessage) } sfc := character{Value: tt.cfg.StopFailCharacter, Size: runewidth.StringWidth(tt.cfg.StopFailCharacter)} if spinner.stopFailChar != sfc { t.Errorf("spinner.stopFailChar = %#v, want %#v", spinner.stopFailChar, sfc) } if spinner.colorFn == nil { t.Fatal("spinner.colorFn is nil") } a := make([]color.Attribute, len(tt.cfg.Colors)) for i, c := range tt.cfg.Colors { ca, ok := colorAttributeMap[c] if !ok { continue } a[i] = ca } tfn := color.New(a...).SprintfFunc() gotStr, wantStr := spinner.colorFn("%s: %d", "test string", 42), tfn("%s: %d", "test string", 42) if gotStr != wantStr { t.Errorf(`spinner.colorFn("%%s: %%d", "test string", 42) = %q, want %q`, gotStr, wantStr) } if spinner.stopColorFn == nil { t.Fatal("spinner.stopColorFn is nil") } a = make([]color.Attribute, len(tt.cfg.StopColors)) for i, c := range tt.cfg.StopColors { ca, ok := colorAttributeMap[c] if !ok { continue } a[i] = ca } tfn = color.New(a...).SprintfFunc() gotStr, wantStr = spinner.stopColorFn("%s: %d", "test string", 42), tfn("%s: %d", "test string", 42) if gotStr != wantStr { t.Errorf(`spinner.stopColorFn("%%s: %%d", "test string", 42) = %q, want %q`, gotStr, wantStr) } if spinner.stopFailColorFn == nil { t.Fatal("spinner.stopFailColorFn is nil") } a = make([]color.Attribute, len(tt.cfg.StopFailColors)) for i, c := range tt.cfg.StopFailColors { ca, ok := colorAttributeMap[c] if !ok { continue } a[i] = ca } tfn = color.New(a...).SprintfFunc() gotStr, wantStr = spinner.stopFailColorFn("%s: %d", "test string", 42), tfn("%s: %d", "test string", 42) if gotStr != wantStr { t.Errorf(`spinner.stopFailColorFn("%%s: %%d", "test string", 42) = %q, want %q`, gotStr, wantStr) } // handle the default value in New() if len(tt.cfg.CharSet) == 0 { tt.cfg.CharSet = CharSets[9] } tt.charSet = make([]character, len(tt.cfg.CharSet)) for i, char := range tt.cfg.CharSet { tt.charSet[i] = character{ Value: char, Size: runewidth.StringWidth(char), } } if diff := cmp.Diff(tt.charSet, spinner.chars); diff != "" { t.Fatalf("spinner.chars differs: (-want +got)\n%s", diff) } if spinner.maxWidth != tt.maxWidth { t.Errorf("spinner.maxWidth = %d, want %d", spinner.maxWidth, tt.maxWidth) } }) } } func TestNew_dumbTerm(t *testing.T) { t.Setenv("TERM", "dumb") cfg := Config{ Frequency: 500 * time.Millisecond, CharSet: CharSets[59], Suffix: " backing up database to S3: ", Message: "exporting data to file", StopCharacter: "✓", StopColors: []string{"fgGreen"}, HideCursor: true, ColorAll: true, } spinner, err := New(cfg) testErrCheck(t, "New()", "", err) if !termModeForceDumb(spinner.termMode) { t.Fatal("spinner.termMode does not contain ForceDumbTerminalMode flag") } } func TestSpinner_Status(t *testing.T) { tests := []struct { name string spinner *Spinner shouldPanic bool want SpinnerStatus }{ { name: "should_panic", spinner: &Spinner{mu: &sync.Mutex{}}, shouldPanic: true, }, { name: "active_status", spinner: &Spinner{ mu: &sync.Mutex{}, status: uint32Ptr(statusUnpausing), }, want: SpinnerUnpausing, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { var got SpinnerStatus panicked := func() (p bool) { defer func() { if r := recover(); r != nil { p = true } }() got = tt.spinner.Status() return false }() if panicked != tt.shouldPanic { t.Fatalf("panicked = %t, want %t", panicked, tt.shouldPanic) } if tt.shouldPanic { return } if got != tt.want { t.Fatalf("got = %d, want = %d", got, tt.want) } }) } } func TestSpinner_notifyDataChange(t *testing.T) { tests := []struct { name string spinner *Spinner want bool shouldReceive bool }{ { name: "buffered_channel", spinner: &Spinner{dataUpdateCh: make(chan struct{}, 1)}, want: true, shouldReceive: true, }, { name: "unbuffered_channel", spinner: &Spinner{dataUpdateCh: make(chan struct{})}, shouldReceive: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.spinner.notifyDataChange() select { case _, got := <-tt.spinner.dataUpdateCh: if !tt.shouldReceive { t.Fatal("unexpected channel receive") } if got != tt.want { t.Errorf("got = %t, want %t", got, tt.want) } default: if tt.shouldReceive { t.Fatal("nothing received over channel") } } }) } } func TestSpinner_Frequency(t *testing.T) { tests := []struct { name string input time.Duration isNotTTY bool ch chan time.Duration err string }{ { name: "invalid", ch: make(chan time.Duration, 1), err: "duration must be greater than 0", }, { name: "assert_non-blocking", input: 42, ch: make(chan time.Duration, 1), }, { name: "assert_notification", input: 42, ch: make(chan time.Duration, 1), }, { name: "is_not_tty", input: 42, isNotTTY: true, ch: make(chan time.Duration, 1), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { defer close(tt.ch) spinner := &Spinner{ mu: &sync.Mutex{}, frequency: 0, frequencyUpdateCh: tt.ch, } if tt.isNotTTY { spinner.termMode = ForceDumbTerminalMode | ForceNoTTYMode } tmr := time.NewTimer(2 * time.Second) fnch := make(chan struct{}) var err error go func() { defer close(fnch) err = spinner.Frequency(tt.input) }() select { case <-tmr.C: t.Fatal("function blocked") case <-fnch: tmr.Stop() } if cont := testErrCheck(t, "spinner.Frequency()", tt.err, err); !cont { return } if cap(tt.ch) == 1 { select { case got, ok := <-tt.ch: if !ok { t.Fatal("channel closed") } if got != tt.input { t.Errorf("channel receive got = %s, want %s", got, tt.input) } default: if !tt.isNotTTY { t.Fatal("notification channel had no messages") } } } if !tt.isNotTTY { got := spinner.frequency if got != tt.input { t.Errorf("got = %s, want %s", got, tt.input) } } }) } } func TestSpinner_CharSet(t *testing.T) { tests := []struct { name string stopChar *character stopFailChar *character charSet []string maxWidth int err string }{ { name: "no_charset", err: "failed to set character set: must provide at least one string", }, { name: "charset", charSet: CharSets[59], maxWidth: 3, }, { name: "charset_with_big_stopChar", stopChar: &character{ Value: "xxxx", Size: 4, }, charSet: CharSets[59], maxWidth: 4, }, { name: "charset_with_big_stopFailChar", stopFailChar: &character{ Value: "xxxxx", Size: 5, }, charSet: CharSets[59], maxWidth: 5, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { spinner := &Spinner{ mu: &sync.Mutex{}, } if tt.stopChar != nil { spinner.stopChar = *tt.stopChar } if tt.stopFailChar != nil { spinner.stopFailChar = *tt.stopFailChar } err := spinner.CharSet(tt.charSet) if cont := testErrCheck(t, "spinner.CharSet()", tt.err, err); !cont { return } charSet := make([]character, len(tt.charSet)) for i, char := range tt.charSet { charSet[i] = character{ Value: char, Size: runewidth.StringWidth(char), } } if diff := cmp.Diff(charSet, spinner.chars); diff != "" { t.Fatalf("spinner.chars differs: (-want +got)\n%s", diff) } if spinner.maxWidth != tt.maxWidth { t.Errorf("spinner.maxWidth = %d, want %d", spinner.maxWidth, tt.maxWidth) } }) } } func TestSpinner_StopCharacter(t *testing.T) { tests := []struct { name string char string charSize int mw int }{ { name: "smaller_size", char: "x", charSize: 1, mw: 2, }, { name: "larger_size", char: "xxx", charSize: 3, mw: 3, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { spinner := &Spinner{ mu: &sync.Mutex{}, maxWidth: 2, } spinner.StopCharacter(tt.char) c := spinner.stopChar if c.Value != tt.char { t.Fatalf("c.Value = %q, want %q", c.Value, tt.char) } if c.Size != tt.charSize { t.Fatalf("c.Size = %d, want %d", c.Size, tt.charSize) } if spinner.maxWidth != tt.mw { t.Fatalf("spinner.maxWidth = %d, want %d", spinner.maxWidth, tt.mw) } }) } } func TestSpinner_StopFailCharacter(t *testing.T) { tests := []struct { name string char string charSize int mw int }{ { name: "smaller_size", char: "x", charSize: 1, mw: 2, }, { name: "larger_size", char: "xxx", charSize: 3, mw: 3, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { spinner := &Spinner{ mu: &sync.Mutex{}, maxWidth: 2, } spinner.StopFailCharacter(tt.char) c := spinner.stopFailChar if c.Value != tt.char { t.Fatalf("c.Value = %q, want %q", c.Value, tt.char) } if c.Size != tt.charSize { t.Fatalf("c.Size = %d, want %d", c.Size, tt.charSize) } if spinner.maxWidth != tt.mw { t.Fatalf("spinner.maxWidth = %d, want %d", spinner.maxWidth, tt.mw) } }) } } func TestSpinner_Reverse(t *testing.T) { cfg := Config{ Frequency: 100 * time.Millisecond, CharSet: CharSets[26], } spinner, err := New(cfg) testErrCheck(t, "New()", "", err) spinner.index = 1 csRev := make([]character, len(spinner.chars)) copy(csRev, spinner.chars) for i := len(csRev)/2 - 1; i >= 0; i-- { opp := len(csRev) - 1 - i csRev[i], csRev[opp] = csRev[opp], csRev[i] } spinner.Reverse() if diff := cmp.Diff(csRev, spinner.chars); diff != "" { t.Errorf("spinner.chars differs: (-want +got)\n%s", diff) } if spinner.index != 0 { t.Error("index was not reset") } } func TestSpinner_erase(t *testing.T) { const want = "\r\033[K\r" buf := &bytes.Buffer{} testErrCheck(t, "spinner.erase()", "", erase(buf)) got := buf.String() if got != want { t.Errorf("got = %q, want %q", got, want) } } func TestSpinner_hideCursor(t *testing.T) { const want = "\r\033[?25l\r" buf := &bytes.Buffer{} testErrCheck(t, "spinner.hideCursor()", "", hideCursor(buf)) got := buf.String() if got != want { t.Errorf("got = %q, want %q", got, want) } } func TestSpinner_unhideCursor(t *testing.T) { const want = "\r\033[?25h\r" buf := &bytes.Buffer{} testErrCheck(t, "spinner.unhideCursor()", "", unhideCursor(buf)) got := buf.String() if got != want { t.Errorf("got = %q, want %q", got, want) } } func TestSpinner_Start(t *testing.T) { tests := []struct { name string spinner *Spinner err string }{ { name: "invalid_frequency_when_tty", spinner: &Spinner{ status: uint32Ptr(statusStopped), mu: &sync.Mutex{}, frequency: 0, colorFn: fmt.Sprintf, stopColorFn: fmt.Sprintf, stopFailColorFn: fmt.Sprintf, termMode: ForceTTYMode, }, err: "spinner Frequency duration must be greater than 0 when used within a TTY", }, { name: "running_spinner", spinner: &Spinner{ status: uint32Ptr(statusRunning), mu: &sync.Mutex{}, frequency: time.Millisecond, colorFn: fmt.Sprintf, stopColorFn: fmt.Sprintf, stopFailColorFn: fmt.Sprintf, }, err: "spinner already running or shutting down", }, { name: "empty_CharSet", spinner: &Spinner{ buffer: &bytes.Buffer{}, status: uint32Ptr(statusStopped), mu: &sync.Mutex{}, frequency: time.Millisecond, colorFn: fmt.Sprintf, stopColorFn: fmt.Sprintf, stopFailColorFn: fmt.Sprintf, stopMsg: "stop msg", stopFailMsg: "stop fail msg", }, err: "before starting the spinner a CharSet must be set", }, { name: "spinner", spinner: &Spinner{ buffer: &bytes.Buffer{}, status: uint32Ptr(statusStopped), mu: &sync.Mutex{}, frequency: time.Millisecond, colorFn: fmt.Sprintf, stopColorFn: fmt.Sprintf, stopFailColorFn: fmt.Sprintf, stopMsg: "stop msg", stopFailMsg: "stop fail msg", maxWidth: 3, chars: []character{ character{ Value: ".", Size: 1, }, character{ Value: "..", Size: 21, }, character{ Value: "...", Size: 3, }, }, }, }, { name: "spinner_not_tty", spinner: &Spinner{ buffer: &bytes.Buffer{}, status: uint32Ptr(statusStopped), mu: &sync.Mutex{}, frequency: 9223372036854775807, colorFn: fmt.Sprintf, stopColorFn: fmt.Sprintf, stopFailColorFn: fmt.Sprintf, stopMsg: "stop msg", stopFailMsg: "stop fail msg", termMode: ForceNoTTYMode, maxWidth: 3, chars: []character{ character{ Value: ".", Size: 1, }, character{ Value: "..", Size: 21, }, character{ Value: "...", Size: 3, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { buf := &bytes.Buffer{} tt.spinner.writer = buf err := tt.spinner.Start() if cont := testErrCheck(t, "Start()", tt.err, err); !cont { return } if tt.spinner.cancelCh == nil { t.Fatal("tt.spinner.cancelCh == nil") } if tt.spinner.doneCh == nil { t.Fatal("tt.spinner.doneCh == nil") } close(tt.spinner.cancelCh) <-tt.spinner.doneCh if buf.Len() == 0 { t.Fatal("painter did not write data") } if max := time.Duration(math.MaxInt64); termModeForceNoTTY(tt.spinner.termMode) && tt.spinner.frequency != max { t.Fatalf("tt.spinner.duration = %s, want %s", tt.spinner.frequency, max) } }) } } func TestSpinner_Pause(t *testing.T) { tests := []struct { name string spinner *Spinner err string }{ { name: "not_running", spinner: &Spinner{ status: uint32Ptr(statusStopped), pauseCh: make(chan struct{}, 1), }, err: "spinner not running", }, { name: "running", spinner: &Spinner{ status: uint32Ptr(statusRunning), pauseCh: make(chan struct{}, 1), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if cont := testErrCheck(t, "Pause()", tt.err, tt.spinner.Pause()); !cont { return } if tt.spinner.unpauseCh == nil { t.Fatal("unpauseCh = nil") } if tt.spinner.unpausedCh == nil { t.Fatal("unpausedCh = nil") } select { case _, ok := <-tt.spinner.pauseCh: if !ok { t.Fatal("unexpected closed channel") } default: t.Fatal("expected message from pauseCh") } if s := atomic.LoadUint32(tt.spinner.status); s != statusPaused { t.Fatalf("status = %d, want %d", s, statusPaused) } }) } } func TestSpinner_Unpause(t *testing.T) { tests := []struct { name string spinner *Spinner err string }{ { name: "not_paused", spinner: &Spinner{ status: uint32Ptr(statusStopped), unpauseCh: make(chan struct{}), unpausedCh: make(chan struct{}), }, err: "spinner not paused", }, { name: "running", spinner: &Spinner{ status: uint32Ptr(statusPaused), unpauseCh: make(chan struct{}), unpausedCh: make(chan struct{}), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ch := tt.spinner.unpauseCh close(tt.spinner.unpausedCh) if cont := testErrCheck(t, "Unpause()", tt.err, tt.spinner.Unpause()); !cont { return } if tt.spinner.unpauseCh != nil { t.Fatal("unpauseCh != nil") } if tt.spinner.unpausedCh != nil { t.Fatal("unpausedCh != nil") } select { case _, ok := <-ch: if ok { t.Fatal("unexpected open channel") } default: t.Fatal("expected unpauseCh closed") } if s := atomic.LoadUint32(tt.spinner.status); s != statusRunning { t.Fatalf("status = %d, want %d", s, statusRunning) } }) } } func TestSpinner_Stop(t *testing.T) { tests := []struct { name string spinner *Spinner err string }{ { name: "not_running", spinner: &Spinner{ mu: &sync.Mutex{}, status: uint32Ptr(statusStopped), cancelCh: make(chan struct{}), doneCh: make(chan struct{}), }, err: "spinner not running or paused", }, { name: "running", spinner: &Spinner{ mu: &sync.Mutex{}, status: uint32Ptr(statusRunning), cancelCh: make(chan struct{}), doneCh: make(chan struct{}), }, }, { name: "paused", spinner: &Spinner{ mu: &sync.Mutex{}, status: uint32Ptr(statusPaused), cancelCh: make(chan struct{}), doneCh: make(chan struct{}), unpauseCh: make(chan struct{}), unpausedCh: make(chan struct{}), }, }, } for _, tt := range tests { tt := tt // create local copy t.Run(tt.name, func(t *testing.T) { if tt.spinner.unpausedCh != nil { close(tt.spinner.unpausedCh) } var ok bool wait := make(chan struct{}) go func(doneCh, cancelCh chan struct{}) { close(doneCh) _, ok = <-cancelCh close(wait) }(tt.spinner.doneCh, tt.spinner.cancelCh) if cont := testErrCheck(t, "spinner.Stop()", tt.err, tt.spinner.Stop()); !cont { return } <-wait if !ok { t.Error("expected stop() to send message and not close channel") } if tt.spinner.index != 0 { t.Errorf("tt.spinner.index = %d, want 0", tt.spinner.index) } if tt.spinner.cancelCh != nil { t.Error("tt.spinner.cancelCh is not nil") } if tt.spinner.doneCh != nil { t.Error("tt.spinner.doneCh is not nil") } status := atomic.LoadUint32(tt.spinner.status) if status != 0 { t.Errorf("tt.spinner.status = %d, want 0", status) } }) } } func TestSpinner_StopFail(t *testing.T) { tests := []struct { name string spinner *Spinner err string }{ { name: "not_running", spinner: &Spinner{ mu: &sync.Mutex{}, status: uint32Ptr(statusStopped), cancelCh: make(chan struct{}), doneCh: make(chan struct{}), }, err: "spinner not running or paused", }, { name: "running", spinner: &Spinner{ mu: &sync.Mutex{}, status: uint32Ptr(statusRunning), cancelCh: make(chan struct{}), doneCh: make(chan struct{}), }, }, } for _, tt := range tests { tt := tt // create local copy t.Run(tt.name, func(t *testing.T) { var ok bool wait := make(chan struct{}) go func(doneCh, cancelCh chan struct{}) { close(doneCh) _, ok = <-cancelCh close(wait) }(tt.spinner.doneCh, tt.spinner.cancelCh) if cont := testErrCheck(t, "spinner.Stop()", tt.err, tt.spinner.StopFail()); !cont { return } <-wait if ok { t.Error("expected stop() to not send message and instead close the channel") } if tt.spinner.index != 0 { t.Errorf("tt.spinner.index = %d, want 0", tt.spinner.index) } if tt.spinner.cancelCh != nil { t.Error("tt.spinner.cancelCh is not nil") } if tt.spinner.doneCh != nil { t.Error("tt.spinner.doneCh is not nil") } status := atomic.LoadUint32(tt.spinner.status) if status != 0 { t.Errorf("tt.spinner.status = %d, want 0", status) } }) } } func TestSpinner_paintUpdate(t *testing.T) { tests := []struct { name string spinner *Spinner want string }{ { name: "spinner_no_hide_cursor", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", message: "msg", suffix: " ", maxWidth: 1, colorFn: fmt.Sprintf, chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, frequency: 10, termMode: termModeTTY, }, want: "\r\033[K\ray msg\r\033[K\raz msg\r\033[K\raz msg\r\033[K\ray msg", }, { name: "spinner_no_hide_cursor_spinnerAtEnd", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: " a", message: "msg", suffix: " ", maxWidth: 1, colorFn: fmt.Sprintf, chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, frequency: 10, spinnerAtEnd: true, termMode: termModeTTY, }, want: "\r\033[K\rmsg ay \r\033[K\rmsg az \r\033[K\rmsg az \r\033[K\rmsg ay ", }, { name: "spinner_no_hide_cursor_auto_cursor_empty_suffix", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", message: "msg", suffix: " ", maxWidth: 1, colorFn: fmt.Sprintf, chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, frequency: 10, suffixAutoColon: true, termMode: termModeTTY, }, want: "\r\033[K\ray msg\r\033[K\raz msg\r\033[K\raz msg\r\033[K\ray msg", }, { name: "spinner_no_hide_cursor_auto_cursor_suffix", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", message: "msg", suffix: " foo", maxWidth: 1, colorFn: fmt.Sprintf, chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, frequency: 10, suffixAutoColon: true, termMode: termModeTTY, }, want: "\r\033[K\ray foo: msg\r\033[K\raz foo: msg\r\033[K\raz foo: msg\r\033[K\ray foo: msg", }, { name: "spinner_hide_cursor", spinner: &Spinner{ buffer: &bytes.Buffer{}, cursorHidden: true, mu: &sync.Mutex{}, prefix: "a", message: "msg", suffix: " ", maxWidth: 1, colorFn: fmt.Sprintf, chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, frequency: 10, termMode: termModeTTY, }, want: "\r\033[K\r\r\033[?25l\ray msg\r\033[K\r\r\033[?25l\raz msg\r\033[K\r\r\033[?25l\raz msg\r\033[K\r\r\033[?25l\ray msg", }, { name: "spinner_hide_cursor_dumbterm", spinner: &Spinner{ buffer: &bytes.Buffer{}, cursorHidden: true, mu: &sync.Mutex{}, prefix: "a", message: "msg", suffix: " ", maxWidth: 1, colorFn: fmt.Sprintf, chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, frequency: 10, // TODO(theckman): verify termMode: ForceDumbTerminalMode, }, want: "\r\ray msg\r \raz msg\r \raz msg\r \ray msg", }, { name: "spinner_empty_print", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, maxWidth: 0, colorFn: fmt.Sprintf, chars: []character{{Value: "", Size: 0}}, frequency: 10, termMode: termModeTTY, }, want: "\r\033[K\r\r\033[K\r\r\033[K\r\r\033[K\r", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { buf := &bytes.Buffer{} tt.spinner.writer = buf tm := time.NewTimer(10 * time.Millisecond) tt.spinner.paintUpdate(tm, true) tt.spinner.paintUpdate(tm, true) tt.spinner.paintUpdate(tm, false) tt.spinner.paintUpdate(tm, true) tm.Stop() got := buf.String() if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("output differs: (-want / +got)\n%s", diff) } }) } } func TestSpinner_paintStop(t *testing.T) { tests := []struct { name string ok bool spinner *Spinner want string }{ { name: "ok", ok: true, spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", suffix: " ", maxWidth: 1, stopColorFn: fmt.Sprintf, stopChar: character{Value: "x", Size: 1}, stopMsg: "stop", termMode: termModeTTY, }, want: "\r\033[K\rax stop\n", }, { name: "ok_spinnerAtEnd", ok: true, spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: " a", suffix: " ", maxWidth: 1, stopColorFn: fmt.Sprintf, spinnerAtEnd: true, stopChar: character{Value: "x", Size: 1}, stopMsg: "stop", termMode: termModeTTY, }, want: "\r\033[K\rstop ax \n", }, { name: "ok_spinnerAtEnd_suffixAutoColon", ok: true, spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: " a", suffix: " ", maxWidth: 1, stopColorFn: fmt.Sprintf, spinnerAtEnd: true, suffixAutoColon: true, stopChar: character{Value: "x", Size: 1}, stopMsg: "stop", termMode: termModeTTY, }, want: "\r\033[K\rstop ax \n", }, { name: "ok_auto_colon_empty_suffix", ok: true, spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", suffix: " ", maxWidth: 1, stopColorFn: fmt.Sprintf, stopChar: character{Value: "x", Size: 1}, stopMsg: "stop", suffixAutoColon: true, termMode: termModeTTY, }, want: "\r\033[K\rax stop\n", }, { name: "ok_auto_colon_suffix", ok: true, spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", suffix: " foo", maxWidth: 1, stopColorFn: fmt.Sprintf, stopChar: character{Value: "x", Size: 1}, stopMsg: "stop", suffixAutoColon: true, termMode: termModeTTY, }, want: "\r\033[K\rax foo: stop\n", }, { name: "ok_auto_colon_no_msg", ok: true, spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", suffix: " ", maxWidth: 1, stopColorFn: fmt.Sprintf, stopChar: character{Value: "x", Size: 1}, stopMsg: "", suffixAutoColon: true, termMode: termModeTTY, }, want: "\r\033[K\rax \n", }, { name: "ok_unhide", ok: true, spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, cursorHidden: true, prefix: "a", suffix: " ", maxWidth: 1, stopColorFn: fmt.Sprintf, stopChar: character{Value: "x", Size: 1}, stopMsg: "stop", termMode: termModeTTY, }, want: "\r\033[K\r\r\033[?25h\rax stop\n", }, { name: "ok_unhide_dumbterm", ok: true, spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, cursorHidden: true, prefix: "a", suffix: " ", maxWidth: 1, stopColorFn: fmt.Sprintf, stopChar: character{Value: "x", Size: 1}, stopMsg: "stop", // TODO(theckman): verify termMode: ForceDumbTerminalMode, lastPrintLen: 10, }, want: "\r \rax stop\n", }, { name: "fail", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", suffix: " ", maxWidth: 1, stopFailColorFn: fmt.Sprintf, stopFailChar: character{Value: "y", Size: 1}, stopFailMsg: "stop", termMode: termModeTTY, }, want: "\r\033[K\ray stop\n", }, { name: "fail_no_char_no_msg", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", suffix: " ", maxWidth: 1, stopFailColorFn: fmt.Sprintf, termMode: termModeTTY, }, want: "\r\033[K\r", }, { name: "fail_no_char_no_msg_dumb_term", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", suffix: " ", maxWidth: 1, // TODO(theckman): verify termMode: ForceDumbTerminalMode, stopFailColorFn: fmt.Sprintf, }, want: "\r\r", }, { name: "fail_colorall", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", suffix: " ", maxWidth: 1, stopFailColorFn: func(format string, a ...interface{}) string { return fmt.Sprintf("fullColor: %s", fmt.Sprintf(format, a...)) }, stopFailChar: character{Value: "y", Size: 1}, stopFailMsg: "stop", colorAll: true, termMode: termModeTTY, }, want: "\r\033[K\rfullColor: ay stop\n", }, { name: "fail_colorall_spinnerAtEnd", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: " a", suffix: " ", maxWidth: 1, stopFailColorFn: func(format string, a ...interface{}) string { return fmt.Sprintf("fullColor: %s", fmt.Sprintf(format, a...)) }, stopFailChar: character{Value: "y", Size: 1}, stopFailMsg: "stop", colorAll: true, spinnerAtEnd: true, termMode: termModeTTY, }, want: "\r\033[K\rfullColor: stop ay \n", }, { name: "fail_colorall_no_char", spinner: &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, prefix: "a", suffix: " ", maxWidth: 0, stopFailColorFn: func(format string, a ...interface{}) string { return fmt.Sprintf("fullColor: %s", fmt.Sprintf(format, a...)) }, stopFailChar: character{Value: "", Size: 0}, stopFailMsg: "stop", colorAll: true, termMode: termModeTTY, }, want: "\r\033[K\rfullColor: stop\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { buf := &bytes.Buffer{} tt.spinner.writer = buf tt.spinner.paintStop(tt.ok) got := buf.String() if diff := cmp.Diff(tt.want, got); diff != "" { t.Fatalf("output differs: (-want / +got)\n%s", diff) } }) } } func Test_handleFrequencyUpdate(t *testing.T) { tests := []struct { name string newFrequency time.Duration lastTickAgo time.Duration shouldTick time.Duration }{ { name: "moreTime", newFrequency: 200 * time.Millisecond, lastTickAgo: 100 * time.Millisecond, shouldTick: (100 * time.Millisecond) + (500 * time.Microsecond), }, { name: "lessTime", newFrequency: 100 * time.Millisecond, lastTickAgo: 200 * time.Millisecond, shouldTick: 100 * time.Microsecond, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { timer := time.NewTimer(0) lastTick := time.Now().Add(-tt.lastTickAgo) time.Sleep(10 * time.Microsecond) handleFrequencyUpdate(tt.newFrequency, timer, lastTick) testTimer := time.NewTimer(tt.shouldTick) select { case <-timer.C: testTimer.Stop() case <-testTimer.C: timer.Stop() t.Fatal("timer didn't fire when expected") } }) } } func Test_setToCharSlice(t *testing.T) { tests := []struct { name string input []string wantNil bool wantChars []character wantSize int }{ { name: "nil", wantNil: true, }, { name: "full", input: []string{"x", "zzz"}, wantChars: []character{{Value: "x", Size: 1}, {Value: "zzz", Size: 3}}, wantSize: 3, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { chars, size := setToCharSlice(tt.input) if size != tt.wantSize { t.Errorf("size = %d, want %d", size, tt.wantSize) } if tt.wantNil && chars != nil { t.Fatal("chars not nil") } for i := range chars { if x, y := chars[i], tt.wantChars[i]; x != y { t.Errorf("chars[%d] = %#v, want %#v", i, x, y) } } }) } } func TestSpinner_painter(t *testing.T) { t.Run("animated", func(t *testing.T) { if testing.Short() { t.Skip("skipping test in short mode.") } const want = "\r\033[K\ray msg\r\033[K\ray othermsg\r\033[K\raz msg\r\033[K\ray msg\r\x1b[K\rav stop\n" buf := &bytes.Buffer{} cancel, done, dataUpdate, pause := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) frequencyUpdate := make(chan time.Duration, 1) spinner := &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, writer: buf, prefix: "a", message: "msg", suffix: " ", maxWidth: 1, colorFn: fmt.Sprintf, chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, stopColorFn: fmt.Sprintf, stopMsg: "stop", stopChar: character{Value: "v", Size: 1}, frequency: 2000 * time.Millisecond, cancelCh: cancel, doneCh: done, dataUpdateCh: dataUpdate, frequencyUpdateCh: frequencyUpdate, termMode: termModeTTY, } go spinner.painter(cancel, dataUpdate, pause, done, frequencyUpdate) time.Sleep(500 * time.Millisecond) spinner.mu.Lock() spinner.message = "othermsg" spinner.dataUpdateCh <- struct{}{} spinner.mu.Unlock() time.Sleep(500 * time.Millisecond) spinner.unpauseCh, spinner.unpausedCh = make(chan struct{}), make(chan struct{}) pause <- struct{}{} close(spinner.unpauseCh) _, ok := <-spinner.unpausedCh if ok { t.Fatal("unexpected successful channel receive") } spinner.unpauseCh = nil spinner.unpausedCh = nil spinner.mu.Lock() spinner.message = "msg" spinner.frequency = 1000 * time.Millisecond frequencyUpdate <- 1000 * time.Millisecond spinner.mu.Unlock() time.Sleep(1200 * time.Millisecond) cancel <- struct{}{} <-done got := buf.String() if diff := cmp.Diff(want, got); diff != "" { t.Fatalf("output differs: (-want / +got)\n%s", diff) } }) t.Run("no_tty", func(t *testing.T) { const want = "ay msg\naz othermsg\nay msg\naz msg\nav stop\n" buf := &bytes.Buffer{} cancel, done, dataUpdate, pause := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) frequencyUpdate := make(chan time.Duration, 1) spinner := &Spinner{ buffer: &bytes.Buffer{}, mu: &sync.Mutex{}, writer: buf, prefix: "a", message: "msg", suffix: " ", maxWidth: 1, colorFn: fmt.Sprintf, chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, stopColorFn: fmt.Sprintf, stopMsg: "stop", stopChar: character{Value: "v", Size: 1}, frequency: time.Duration(math.MaxInt64), cancelCh: cancel, doneCh: done, dataUpdateCh: dataUpdate, frequencyUpdateCh: frequencyUpdate, termMode: ForceDumbTerminalMode | ForceNoTTYMode, } go spinner.painter(cancel, dataUpdate, pause, done, frequencyUpdate) time.Sleep(100 * time.Millisecond) spinner.mu.Lock() spinner.message = "othermsg" spinner.dataUpdateCh <- struct{}{} spinner.mu.Unlock() time.Sleep(100 * time.Millisecond) spinner.unpauseCh, spinner.unpausedCh = make(chan struct{}), make(chan struct{}) pause <- struct{}{} close(spinner.unpauseCh) _, ok := <-spinner.unpausedCh if ok { t.Fatal("unexpected successful channel receive") } spinner.unpauseCh = nil spinner.unpausedCh = nil spinner.mu.Lock() spinner.message = "msg" spinner.dataUpdateCh <- struct{}{} spinner.mu.Unlock() time.Sleep(100 * time.Millisecond) spinner.mu.Lock() spinner.message = "msg" spinner.dataUpdateCh <- struct{}{} spinner.mu.Unlock() time.Sleep(100 * time.Millisecond) cancel <- struct{}{} <-done got := buf.String() if diff := cmp.Diff(want, got); diff != "" { t.Fatalf("output differs: (-want / +got)\n%s", diff) } }) } func TestSpinnerStatus_String(t *testing.T) { tests := []struct { name string ss SpinnerStatus want string }{ { name: "stopped", ss: SpinnerStopped, want: "stopped", }, { name: "starting", ss: SpinnerStarting, want: "starting", }, { name: "running", ss: SpinnerRunning, want: "running", }, { name: "stopping", ss: SpinnerStopping, want: "stopping", }, { name: "pausing", ss: SpinnerPausing, want: "pausing", }, { name: "paused", ss: SpinnerPaused, want: "paused", }, { name: "unpausing", ss: SpinnerUnpausing, want: "unpausing", }, { name: "unknown", ss: 42, want: "unknown (42)", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := tt.ss.String(); got != tt.want { t.Errorf("got = %#v, got %#v", got, tt.want) } }) } }