Skip to content

Commit 575b375

Browse files
committed
Stabilize header loading animation
1 parent 2751c1a commit 575b375

1 file changed

Lines changed: 77 additions & 28 deletions

File tree

internal/ui/components/header.go

Lines changed: 77 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package components
22

33
import (
4+
"context"
45
"fmt"
6+
"sync"
57
"time"
68

79
"github.com/rivo/tview"
@@ -15,9 +17,10 @@ const appName = "pvetui"
1517
type Header struct {
1618
*tview.TextView
1719

20+
mu sync.Mutex
1821
isLoading bool
1922
loadingText string
20-
stopLoading chan bool
23+
loadingCancel context.CancelFunc
2124
app *tview.Application
2225
currentProfile string // Track the current active profile
2326
}
@@ -34,8 +37,7 @@ func NewHeader() *Header {
3437
header.SetTextColor(theme.Colors.HeaderText)
3538

3639
return &Header{
37-
TextView: header,
38-
stopLoading: make(chan bool, 1),
40+
TextView: header,
3941
}
4042
}
4143

@@ -56,43 +58,57 @@ func (h *Header) SetText(text string) {
5658

5759
// ShowLoading displays an animated loading indicator.
5860
func (h *Header) ShowLoading(message string) {
59-
// Stop any existing loading first to avoid overlapping animations
60-
if h.isLoading {
61-
h.isLoading = false
62-
select {
63-
case h.stopLoading <- true:
64-
default:
65-
}
61+
h.mu.Lock()
62+
if h.isLoading && h.loadingText == message {
63+
h.mu.Unlock()
64+
return
65+
}
66+
67+
if h.isLoading && h.loadingCancel != nil {
68+
h.loadingCancel()
6669
}
6770

6871
h.isLoading = true
6972
h.loadingText = message
70-
h.stopLoading = make(chan bool, 1)
73+
ctx, cancel := context.WithCancel(context.Background())
74+
h.loadingCancel = cancel
75+
h.mu.Unlock()
7176

72-
// Start the loading animation
73-
go h.animateLoading()
77+
go h.animateLoading(ctx)
7478
}
7579

7680
// StopLoading stops the loading animation.
7781
func (h *Header) StopLoading() {
78-
if h.isLoading {
79-
h.isLoading = false
80-
select {
81-
case h.stopLoading <- true:
82-
default:
83-
}
82+
h.mu.Lock()
83+
if !h.isLoading {
84+
h.mu.Unlock()
85+
return
86+
}
87+
h.isLoading = false
88+
if h.loadingCancel != nil {
89+
h.loadingCancel()
90+
h.loadingCancel = nil
8491
}
92+
h.mu.Unlock()
8593
}
8694

8795
// IsLoading reports whether the header is currently showing a loading state.
8896
func (h *Header) IsLoading() bool {
97+
h.mu.Lock()
98+
defer h.mu.Unlock()
8999
return h.isLoading
90100
}
91101

92102
// ShowSuccess displays a success message temporarily.
93103
func (h *Header) ShowSuccess(message string) {
94104
// Mark not loading before changing text to prevent race with animateLoading
105+
h.mu.Lock()
95106
h.isLoading = false
107+
if h.loadingCancel != nil {
108+
h.loadingCancel()
109+
h.loadingCancel = nil
110+
}
111+
h.mu.Unlock()
96112
h.StopLoading()
97113
h.SetText(theme.ReplaceSemanticTags("[success]✓ " + message + "[-]"))
98114

@@ -102,7 +118,13 @@ func (h *Header) ShowSuccess(message string) {
102118

103119
// ShowError displays an error message temporarily.
104120
func (h *Header) ShowError(message string) {
121+
h.mu.Lock()
105122
h.isLoading = false
123+
if h.loadingCancel != nil {
124+
h.loadingCancel()
125+
h.loadingCancel = nil
126+
}
127+
h.mu.Unlock()
106128
h.StopLoading()
107129
h.SetText(theme.ReplaceSemanticTags("[error]✗ " + message + "[-]"))
108130

@@ -112,7 +134,13 @@ func (h *Header) ShowError(message string) {
112134

113135
// ShowWarning displays a warning message temporarily.
114136
func (h *Header) ShowWarning(message string) {
137+
h.mu.Lock()
115138
h.isLoading = false
139+
if h.loadingCancel != nil {
140+
h.loadingCancel()
141+
h.loadingCancel = nil
142+
}
143+
h.mu.Unlock()
116144
h.StopLoading()
117145
h.SetText(theme.ReplaceSemanticTags("[warning]⚠ " + message + "[-]"))
118146

@@ -131,7 +159,13 @@ func (h *Header) formatProfileText(profileName string) string {
131159

132160
// ShowActiveProfile displays the active profile in the header.
133161
func (h *Header) ShowActiveProfile(profileName string) {
162+
h.mu.Lock()
134163
h.isLoading = false
164+
if h.loadingCancel != nil {
165+
h.loadingCancel()
166+
h.loadingCancel = nil
167+
}
168+
h.mu.Unlock()
135169
h.StopLoading()
136170
h.currentProfile = profileName // Store the profile name
137171
h.SetText(h.formatProfileText(profileName))
@@ -155,7 +189,10 @@ func (h *Header) clearMessageAfterDelay(delay time.Duration) {
155189
if h.app != nil {
156190
h.app.QueueUpdateDraw(func() {
157191
// Avoid overriding an active loading indicator that may have started after ShowSuccess/ShowError
158-
if h.isLoading {
192+
h.mu.Lock()
193+
loading := h.isLoading
194+
h.mu.Unlock()
195+
if loading {
159196
return
160197
}
161198
// Restore the current profile if it exists, otherwise reset to default
@@ -170,28 +207,40 @@ func (h *Header) clearMessageAfterDelay(delay time.Duration) {
170207
}
171208

172209
// animateLoading displays an animated loading indicator.
173-
func (h *Header) animateLoading() {
210+
func (h *Header) animateLoading(ctx context.Context) {
174211
spinner := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
175212
index := 0
213+
ticker := time.NewTicker(100 * time.Millisecond)
214+
defer ticker.Stop()
176215

177-
for h.isLoading {
216+
for {
178217
select {
179-
case <-h.stopLoading:
218+
case <-ctx.Done():
180219
return
181-
default:
220+
case <-ticker.C:
221+
h.mu.Lock()
222+
loading := h.isLoading
223+
h.mu.Unlock()
224+
225+
if !loading {
226+
return
227+
}
228+
182229
if h.app != nil {
230+
spinnerChar := spinner[index]
183231
h.app.QueueUpdateDraw(func() {
232+
h.mu.Lock()
184233
if !h.isLoading {
234+
h.mu.Unlock()
185235
return
186236
}
187-
spinnerChar := spinner[index]
188-
h.SetText(theme.ReplaceSemanticTags(fmt.Sprintf("[info]%s %s[-]", spinnerChar, h.loadingText)))
237+
currentMessage := h.loadingText
238+
h.mu.Unlock()
239+
h.SetText(theme.ReplaceSemanticTags(fmt.Sprintf("[info]%s %s[-]", spinnerChar, currentMessage)))
189240
})
190241
}
191242

192243
index = (index + 1) % len(spinner)
193-
194-
time.Sleep(100 * time.Millisecond)
195244
}
196245
}
197246
}

0 commit comments

Comments
 (0)