Repository: jesseduffield/lazygit (language: go)

Issue:
[Feature Request] Configurable progress animation
Would you consider adding to the configuration a field to configure frames for the inline animation?

Right now it seems to be: 
```
\|/-
```

I would like to use:
```
⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏
```


Discussion / hints:
Would love to have this kind of loader too

I'm willing to contribute and implement this feature if the maintainers decide if it's reasonable
I think I found the place:) https://github.com/jesseduffield/lazygit/blob/fb675b79f8a4a949294e8cab85ce72fed3883362/pkg/utils/utils.go#L31-L36

Maybe it would be better to replace it with the animation I posted? Looks more modern.
Was looking on this function yesterday, decided not to proceed before code owners approval

I was actually thinking about passing `characters` as a param that can be configured via user config while keeping the current option as a default for the config
@wojciech-kulik absolutely agree with you regarding more modern look of your proposed option

However, maybe there are some reasons to keep the default option ascii only
I think is safe to asume that users with
```yml
gui:
  nerdFontsVersion: "3" 
```
prefer cool Unicode glyphs.
I'm all for making things look more modern/slick (like we did with the scrollbars), but one problem with the braille animation is that it is even more subtle than the current ascii animation. That's a problem, because we think the animation might not be attention-grabbing enough (see #3142).

I made other attempts at changing the loader in the past (e.g. [here](https://github.com/jesseduffield/lazygit/pull/2973#discussion_r1314471777)), but @jesseduffield didn't like that, so I dropped it again.

Finally, for those of you who want to experiment with this in the code: just pasting strings into the `characters` string literal is not going to work because of the bytes-are-not-characters-in-utf-8 problem. You'll have to change it to something like this:
```diff
diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go
index 053b7ac2f..50ebce3eb 100644
--- a/pkg/utils/utils.go
+++ b/pkg/utils/utils.go
@@ -29,10 +29,10 @@ const LoaderAnimationInterval = 50
 
 // Loader dumps a string to be displayed as a loader
 func Loader(now time.Time) string {
-	characters := "|/-\\"
+	strings := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
 	milliseconds := now.UnixMilli()
-	index := milliseconds / LoaderAnimationInterval % int64(len(characters))
-	return characters[index : index+1]
+	index := milliseconds / LoaderAnimationInterval % int64(len(strings))
+	return strings[index]
 }
 
 // Min returns the minimum of two integers
```
I guess, someone familiar with `go` could create a PR :)? I think the approach to go with the new loader for `nerdFontsVersion: "3"` sounds reasonable + adding to config an option to set own frames :)
I will take a look during the weekend and create a PR

Fix patch:
diff --git a/docs/Config.md b/docs/Config.md
index 603f45b4f92..cf78a3113fe 100644
--- a/docs/Config.md
+++ b/docs/Config.md
@@ -87,6 +87,9 @@ gui:
   animateExplosion: true # shows an explosion animation when nuking the working tree
   portraitMode: 'auto' # one of 'auto' | 'never' | 'always'
   filterMode: 'substring' # one of 'substring' | 'fuzzy'; see 'Filtering' section below
+  spinner:
+    frames: ['|', '/', '-', '\\']
+    rate: 50 # spinner rate in milliseconds
 git:
   paging:
     colorArg: always
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index e93fbb06c80..20a404b16ab 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -145,6 +145,8 @@ type GuiConfig struct {
 	// How things are filtered when typing '/'.
 	// One of 'substring' (default) | 'fuzzy'
 	FilterMode string `yaml:"filterMode" jsonschema:"enum=substring,enum=fuzzy"`
+	// Config relating to the spinner.
+	Spinner SpinnerConfig `yaml:"spinner"`
 }
 
 func (c *GuiConfig) UseFuzzySearch() bool {
@@ -182,6 +184,13 @@ type CommitLengthConfig struct {
 	Show bool `yaml:"show"`
 }
 
+type SpinnerConfig struct {
+	// The frames of the spinner animation.
+	Frames []string `yaml:"frames"`
+	// The "speed" of the spinner in milliseconds.
+	Rate int `yaml:"rate" jsonschema:"minimum=1"`
+}
+
 type GitConfig struct {
 	// See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md
 	Paging PagingConfig `yaml:"paging"`
@@ -671,6 +680,10 @@ func GetDefaultConfig() *UserConfig {
 			AnimateExplosion:          true,
 			PortraitMode:              "auto",
 			FilterMode:                "substring",
+			Spinner: SpinnerConfig{
+				Frames: []string{"|", "/", "-", "\\"},
+				Rate:   50,
+			},
 		},
 		Git: GitConfig{
 			Paging: PagingConfig{
diff --git a/pkg/gui/context/remotes_context.go b/pkg/gui/context/remotes_context.go
index 73ea428aaa9..ae01bb56b7c 100644
--- a/pkg/gui/context/remotes_context.go
+++ b/pkg/gui/context/remotes_context.go
@@ -26,7 +26,7 @@ func NewRemotesContext(c *ContextCommon) *RemotesContext {
 
 	getDisplayStrings := func(_ int, _ int) [][]string {
 		return presentation.GetRemoteListDisplayStrings(
-			viewModel.GetItems(), c.Modes().Diffing.Ref, c.State().GetItemOperation, c.Tr)
+			viewModel.GetItems(), c.Modes().Diffing.Ref, c.State().GetItemOperation, c.Tr, c.UserConfig)
 	}
 
 	return &RemotesContext{
diff --git a/pkg/gui/context/tags_context.go b/pkg/gui/context/tags_context.go
index d827564dd56..b956c77f47a 100644
--- a/pkg/gui/context/tags_context.go
+++ b/pkg/gui/context/tags_context.go
@@ -30,7 +30,7 @@ func NewTagsContext(
 		return presentation.GetTagListDisplayStrings(
 			viewModel.GetItems(),
 			c.State().GetItemOperation,
-			c.Modes().Diffing.Ref, c.Tr)
+			c.Modes().Diffing.Ref, c.Tr, c.UserConfig)
 	}
 
 	return &TagsContext{
diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go
index fd6bc247fa3..c0afcbbce94 100644
--- a/pkg/gui/controllers/helpers/app_status_helper.go
+++ b/pkg/gui/controllers/helpers/app_status_helper.go
@@ -6,7 +6,6 @@ import (
 	"github.com/jesseduffield/gocui"
 	"github.com/jesseduffield/lazygit/pkg/gui/status"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
-	"github.com/jesseduffield/lazygit/pkg/utils"
 )
 
 type AppStatusHelper struct {
@@ -88,16 +87,16 @@ func (self *AppStatusHelper) HasStatus() bool {
 }
 
 func (self *AppStatusHelper) GetStatusString() string {
-	appStatus, _ := self.statusMgr().GetStatusString()
+	appStatus, _ := self.statusMgr().GetStatusString(self.c.UserConfig)
 	return appStatus
 }
 
 func (self *AppStatusHelper) renderAppStatus() {
 	self.c.OnWorker(func(_ gocui.Task) {
-		ticker := time.NewTicker(time.Millisecond * utils.LoaderAnimationInterval)
+		ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig.Gui.Spinner.Rate))
 		defer ticker.Stop()
 		for range ticker.C {
-			appStatus, color := self.statusMgr().GetStatusString()
+			appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig)
 			self.c.Views().AppStatus.FgColor = color
 			self.c.OnUIThread(func() error {
 				self.c.SetViewContent(self.c.Views().AppStatus, appStatus)
@@ -130,7 +129,7 @@ func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) {
 		for {
 			select {
 			case <-ticker.C:
-				appStatus, color := self.statusMgr().GetStatusString()
+				appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig)
 				self.c.Views().AppStatus.FgColor = color
 				self.c.SetViewContent(self.c.Views().AppStatus, appStatus)
 				// Redraw all views of the bottom line:
diff --git a/pkg/gui/controllers/helpers/inline_status_helper.go b/pkg/gui/controllers/helpers/inline_status_helper.go
index 7bc8cb45637..bcf186a3193 100644
--- a/pkg/gui/controllers/helpers/inline_status_helper.go
+++ b/pkg/gui/controllers/helpers/inline_status_helper.go
@@ -105,7 +105,7 @@ func (self *InlineStatusHelper) start(opts InlineStatusOpts) {
 		self.contextsWithInlineStatus[opts.ContextKey] = info
 
 		go utils.Safe(func() {
-			ticker := time.NewTicker(time.Millisecond * utils.LoaderAnimationInterval)
+			ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig.Gui.Spinner.Rate))
 			defer ticker.Stop()
 		outer:
 			for {
diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go
index fd0d11881d6..66645e698a1 100644
--- a/pkg/gui/controllers/helpers/refresh_helper.go
+++ b/pkg/gui/controllers/helpers/refresh_helper.go
@@ -706,7 +706,7 @@ func (self *RefreshHelper) refreshStatus() {
 
 	repoName := self.c.Git().RepoPaths.RepoName()
 
-	status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr)
+	status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig)
 
 	self.c.SetViewContent(self.c.Views().Status, status)
 }
diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go
index 49a182fba4a..54bd04ad5f7 100644
--- a/pkg/gui/controllers/status_controller.go
+++ b/pkg/gui/controllers/status_controller.go
@@ -136,7 +136,7 @@ func (self *StatusController) onClick() error {
 	}
 
 	cx, _ := self.c.Views().Status.Cursor()
-	upstreamStatus := presentation.BranchStatus(currentBranch, types.ItemOperationNone, self.c.Tr, time.Now())
+	upstreamStatus := presentation.BranchStatus(currentBranch, types.ItemOperationNone, self.c.Tr, time.Now(), self.c.UserConfig)
 	repoName := self.c.Git().RepoPaths.RepoName()
 	workingTreeState := self.c.Git().Status.WorkingTreeState()
 	switch workingTreeState {
diff --git a/pkg/gui/presentation/branches.go b/pkg/gui/presentation/branches.go
index bb1f0bdc3c9..4f9a4aba4ff 100644
--- a/pkg/gui/presentation/branches.go
+++ b/pkg/gui/presentation/branches.go
@@ -50,7 +50,7 @@ func getBranchDisplayStrings(
 ) []string {
 	checkedOutByWorkTree := git_commands.CheckedOutByOtherWorktree(b, worktrees)
 	showCommitHash := fullDescription || userConfig.Gui.ShowBranchCommitHash
-	branchStatus := BranchStatus(b, itemOperation, tr, now)
+	branchStatus := BranchStatus(b, itemOperation, tr, now, userConfig)
 	worktreeIcon := lo.Ternary(icons.IsIconEnabled(), icons.LINKED_WORKTREE_ICON, fmt.Sprintf("(%s)", tr.LcWorktree))
 
 	// Recency is always three characters, plus one for the space
@@ -159,14 +159,25 @@ func branchStatusColor(branch *models.Branch, itemOperation types.ItemOperation)
 	return colour
 }
 
-func ColoredBranchStatus(branch *models.Branch, itemOperation types.ItemOperation, tr *i18n.TranslationSet) string {
-	return branchStatusColor(branch, itemOperation).Sprint(BranchStatus(branch, itemOperation, tr, time.Now()))
+func ColoredBranchStatus(
+	branch *models.Branch,
+	itemOperation types.ItemOperation,
+	tr *i18n.TranslationSet,
+	userConfig *config.UserConfig,
+) string {
+	return branchStatusColor(branch, itemOperation).Sprint(BranchStatus(branch, itemOperation, tr, time.Now(), userConfig))
 }
 
-func BranchStatus(branch *models.Branch, itemOperation types.ItemOperation, tr *i18n.TranslationSet, now time.Time) string {
+func BranchStatus(
+	branch *models.Branch,
+	itemOperation types.ItemOperation,
+	tr *i18n.TranslationSet,
+	now time.Time,
+	userConfig *config.UserConfig,
+) string {
 	itemOperationStr := ItemOperationToString(itemOperation, tr)
 	if itemOperationStr != "" {
-		return itemOperationStr + " " + utils.Loader(now)
+		return itemOperationStr + " " + utils.Loader(now, userConfig.Gui.Spinner)
 	}
 
 	if !branch.IsTrackingRemote() {
diff --git a/pkg/gui/presentation/remotes.go b/pkg/gui/presentation/remotes.go
index dc0f39ec0fe..61fc82e023f 100644
--- a/pkg/gui/presentation/remotes.go
+++ b/pkg/gui/presentation/remotes.go
@@ -4,6 +4,7 @@ import (
 	"time"
 
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
+	"github.com/jesseduffield/lazygit/pkg/config"
 	"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
 	"github.com/jesseduffield/lazygit/pkg/gui/style"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
@@ -18,10 +19,11 @@ func GetRemoteListDisplayStrings(
 	diffName string,
 	getItemOperation func(item types.HasUrn) types.ItemOperation,
 	tr *i18n.TranslationSet,
+	userConfig *config.UserConfig,
 ) [][]string {
 	return lo.Map(remotes, func(remote *models.Remote, _ int) []string {
 		diffed := remote.Name == diffName
-		return getRemoteDisplayStrings(remote, diffed, getItemOperation(remote), tr)
+		return getRemoteDisplayStrings(remote, diffed, getItemOperation(remote), tr, userConfig)
 	})
 }
 
@@ -31,6 +33,7 @@ func getRemoteDisplayStrings(
 	diffed bool,
 	itemOperation types.ItemOperation,
 	tr *i18n.TranslationSet,
+	userConfig *config.UserConfig,
 ) []string {
 	branchCount := len(r.Branches)
 
@@ -46,7 +49,7 @@ func getRemoteDisplayStrings(
 	descriptionStr := style.FgBlue.Sprintf("%d branches", branchCount)
 	itemOperationStr := ItemOperationToString(itemOperation, tr)
 	if itemOperationStr != "" {
-		descriptionStr += " " + style.FgCyan.Sprint(itemOperationStr+" "+utils.Loader(time.Now()))
+		descriptionStr += " " + style.FgCyan.Sprint(itemOperationStr+" "+utils.Loader(time.Now(), userConfig.Gui.Spinner))
 	}
 	res = append(res, textStyle.Sprint(r.Name), descriptionStr)
 	return res
diff --git a/pkg/gui/presentation/status.go b/pkg/gui/presentation/status.go
index 7bf81948f43..d3686510eb4 100644
--- a/pkg/gui/presentation/status.go
+++ b/pkg/gui/presentation/status.go
@@ -5,17 +5,26 @@ import (
 
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
 	"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
+	"github.com/jesseduffield/lazygit/pkg/config"
 	"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
 	"github.com/jesseduffield/lazygit/pkg/gui/style"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 	"github.com/jesseduffield/lazygit/pkg/i18n"
 )
 
-func FormatStatus(repoName string, currentBranch *models.Branch, itemOperation types.ItemOperation, linkedWorktreeName string, workingTreeState enums.RebaseMode, tr *i18n.TranslationSet) string {
+func FormatStatus(
+	repoName string,
+	currentBranch *models.Branch,
+	itemOperation types.ItemOperation,
+	linkedWorktreeName string,
+	workingTreeState enums.RebaseMode,
+	tr *i18n.TranslationSet,
+	userConfig *config.UserConfig,
+) string {
 	status := ""
 
 	if currentBranch.IsRealBranch() {
-		status += ColoredBranchStatus(currentBranch, itemOperation, tr) + " "
+		status += ColoredBranchStatus(currentBranch, itemOperation, tr, userConfig) + " "
 	}
 
 	if workingTreeState != enums.REBASE_MODE_NONE {
diff --git a/pkg/gui/presentation/tags.go b/pkg/gui/presentation/tags.go
index 2d3a7375513..441cd229a50 100644
--- a/pkg/gui/presentation/tags.go
+++ b/pkg/gui/presentation/tags.go
@@ -4,6 +4,7 @@ import (
 	"time"
 
 	"github.com/jesseduffield/lazygit/pkg/commands/models"
+	"github.com/jesseduffield/lazygit/pkg/config"
 	"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
 	"github.com/jesseduffield/lazygit/pkg/gui/style"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
@@ -18,15 +19,22 @@ func GetTagListDisplayStrings(
 	getItemOperation func(item types.HasUrn) types.ItemOperation,
 	diffName string,
 	tr *i18n.TranslationSet,
+	userConfig *config.UserConfig,
 ) [][]string {
 	return lo.Map(tags, func(tag *models.Tag, _ int) []string {
 		diffed := tag.Name == diffName
-		return getTagDisplayStrings(tag, getItemOperation(tag), diffed, tr)
+		return getTagDisplayStrings(tag, getItemOperation(tag), diffed, tr, userConfig)
 	})
 }
 
 // getTagDisplayStrings returns the display string of branch
-func getTagDisplayStrings(t *models.Tag, itemOperation types.ItemOperation, diffed bool, tr *i18n.TranslationSet) []string {
+func getTagDisplayStrings(
+	t *models.Tag,
+	itemOperation types.ItemOperation,
+	diffed bool,
+	tr *i18n.TranslationSet,
+	userConfig *config.UserConfig,
+) []string {
 	textStyle := theme.DefaultTextColor
 	if diffed {
 		textStyle = theme.DiffTerminalColor
@@ -39,7 +47,7 @@ func getTagDisplayStrings(t *models.Tag, itemOperation types.ItemOperation, diff
 	descriptionStr := descriptionColor.Sprint(t.Description())
 	itemOperationStr := ItemOperationToString(itemOperation, tr)
 	if itemOperationStr != "" {
-		descriptionStr = style.FgCyan.Sprint(itemOperationStr+" "+utils.Loader(time.Now())) + " " + descriptionStr
+		descriptionStr = style.FgCyan.Sprint(itemOperationStr+" "+utils.Loader(time.Now(), userConfig.Gui.Spinner)) + " " + descriptionStr
 	}
 	res = append(res, textStyle.Sprint(t.Name), descriptionStr)
 	return res
diff --git a/pkg/gui/status/status_manager.go b/pkg/gui/status/status_manager.go
index eb5b01d4a39..b1433a6f98c 100644
--- a/pkg/gui/status/status_manager.go
+++ b/pkg/gui/status/status_manager.go
@@ -4,6 +4,7 @@ import (
 	"time"
 
 	"github.com/jesseduffield/gocui"
+	"github.com/jesseduffield/lazygit/pkg/config"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 	"github.com/jesseduffield/lazygit/pkg/utils"
 	"github.com/samber/lo"
@@ -69,13 +70,13 @@ func (self *StatusManager) AddToastStatus(message string, kind types.ToastKind)
 	return id
 }
 
-func (self *StatusManager) GetStatusString() (string, gocui.Attribute) {
+func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (string, gocui.Attribute) {
 	if len(self.statuses) == 0 {
 		return "", gocui.ColorDefault
 	}
 	topStatus := self.statuses[0]
 	if topStatus.statusType == "waiting" {
-		return topStatus.message + " " + utils.Loader(time.Now()), topStatus.color
+		return topStatus.message + " " + utils.Loader(time.Now(), userConfig.Gui.Spinner), topStatus.color
 	}
 	return topStatus.message, topStatus.color
 }
diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go
index 053b7ac2f9b..3c4bda95b6f 100644
--- a/pkg/utils/utils.go
+++ b/pkg/utils/utils.go
@@ -11,6 +11,7 @@ import (
 	"time"
 
 	"github.com/jesseduffield/gocui"
+	"github.com/jesseduffield/lazygit/pkg/config"
 )
 
 // GetProjectRoot returns the path to the root of the project. Only to be used
@@ -24,15 +25,11 @@ func GetProjectRoot() string {
 	return strings.Split(dir, "lazygit")[0] + "lazygit"
 }
 
-// The duration between two frames of the loader animation in milliseconds
-const LoaderAnimationInterval = 50
-
 // Loader dumps a string to be displayed as a loader
-func Loader(now time.Time) string {
-	characters := "|/-\\"
+func Loader(now time.Time, config config.SpinnerConfig) string {
 	milliseconds := now.UnixMilli()
-	index := milliseconds / LoaderAnimationInterval % int64(len(characters))
-	return characters[index : index+1]
+	index := milliseconds / int64(config.Rate) % int64(len(config.Frames))
+	return config.Frames[index]
 }
 
 // Min returns the minimum of two integers
diff --git a/schema/config.json b/schema/config.json
index 3a2cad06085..21860c39ceb 100644
--- a/schema/config.json
+++ b/schema/config.json
@@ -366,6 +366,32 @@
           ],
           "description": "How things are filtered when typing '/'.\nOne of 'substring' (default) | 'fuzzy'",
           "default": "substring"
+        },
+        "spinner": {
+          "properties": {
+            "frames": {
+              "items": {
+                "type": "string"
+              },
+              "type": "array",
+              "description": "The frames of the spinner animation.",
+              "default": [
+                "|",
+                "/",
+                "-",
+                "\\"
+              ]
+            },
+            "rate": {
+              "type": "integer",
+              "minimum": 1,
+              "description": "The \"speed\" of the spinner in milliseconds.",
+              "default": 50
+            }
+          },
+          "additionalProperties": false,
+          "type": "object",
+          "description": "Config relating to the spinner."
         }
       },
       "additionalProperties": false,


Test patch: