1// Package component provides UI components for the browser.
2package component
3
4import (
5 "context"
6 "sync"
7 "time"
8
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk/v4/glib"
11 "github.com/bnema/puregotk/v4/gtk"
12
13 "github.com/bnema/dumber/internal/ui/layout"
14)
15
16const (
17 // Animation step size - how much to increment per frame
18 progressStep = 0.02
19 // Animation interval in milliseconds
20 progressIntervalMs = 16 // ~60fps
21 // Timeout to auto-hide progress bar if stuck (30 seconds)
22 progressTimeoutMs = 30000
23)
24
25// ProgressBar displays a slim loading progress indicator at the bottom of a pane.
26// Uses native GtkProgressBar with "osd" styling for overlay appearance.
27// Implements smooth animation by incrementing towards the target value.
28// Includes a 30-second timeout to auto-hide if the page load stalls.
29type ProgressBar struct {
30 ctx context.Context
31 progressBar layout.ProgressBarWidget
32
33 visible bool
34 currentValue float64 // Current displayed value
35 targetValue float64 // Target value to animate towards
36 animationTimer uint // Timer source ID for animation
37 timeoutTimer uint // Timer source ID for auto-hide timeout
38 lastShowAt time.Time
39 lastProgressAt time.Time
40 progressEventSeq uint64
41
42 mu sync.Mutex
43}
44
45// NewProgressBar creates a new progress bar component using the widget factory.
46func NewProgressBar(ctx context.Context, factory layout.WidgetFactory) *ProgressBar {
47 progressBar := factory.NewProgressBar()
48
49 // Add "osd" class for on-screen-display overlay styling (like Epiphany)
50 progressBar.AddCssClass("osd")
51
52 // Position at bottom, full width
53 progressBar.SetValign(gtk.AlignEndValue)
54 progressBar.SetHalign(gtk.AlignFillValue)
55 progressBar.SetHexpand(true)
56
57 // Set minimum size to prevent GTK warning about negative minimum width (-2)
58 // The internal "progress" gizmo needs valid dimensions before realization
59 // Using 0 for width (not -1) ensures GTK doesn't calculate negative sizes
60 progressBar.SetSizeRequest(0, 4)
61
62 // Don't intercept pointer events - let clicks pass through to WebView
63 progressBar.SetCanTarget(false)
64 progressBar.SetCanFocus(false)
65
66 // Initialize fraction to 0
67 progressBar.SetFraction(0)
68
69 // Hidden by default
70 progressBar.SetVisible(false)
71
72 return &ProgressBar{
73 ctx: ctx,
74 progressBar: progressBar,
75 visible: false,
76 currentValue: 0,
77 targetValue: 0,
78 }
79}
80
81// SetProgress sets the target progress value and starts smooth animation.
82// progress should be between 0.0 and 1.0.
83func (pb *ProgressBar) SetProgress(progress float64) {
84 pb.mu.Lock()
85 defer pb.mu.Unlock()
86
87 incomingProgress := progress
88 ctx := pb.ctx
89 if ctx == nil {
90 ctx = context.Background()
91 }
92 now := time.Now()
93 logging.FromContext(ctx).
94 Trace().
95 Float64("incoming_progress", incomingProgress).
96 Float64("current_value", pb.currentValue).
97 Float64("target_value", pb.targetValue).
98 Bool("visible", pb.visible).
99 Bool("animation_running", pb.animationTimer != 0).
100 Bool("timeout_running", pb.timeoutTimer != 0).
101 Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
102 Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
103 Msg("setting progress")
104
105 // Clamp progress to valid range
106 if progress < 0 {
107 progress = 0
108 } else if progress > 1 {
109 progress = 1
110 }
111
112 pb.targetValue = progress
113 pb.lastProgressAt = now
114 pb.progressEventSeq++
115 if pb.visible {
116 pb.resetTimeout()
117 }
118
119 // For large jumps (>0.3) or completion, update immediately
120 diff := progress - pb.currentValue
121 if diff > 0.3 || progress >= 1.0 {
122 pb.currentValue = progress
123 pb.progressBar.SetFraction(progress)
124 return
125 }
126
127 // Start animation if not already running
128 if pb.animationTimer == 0 {
129 pb.startAnimation()
130 }
131}
132
133// startAnimation begins the smooth progress animation.
134// Must be called with lock held.
135func (pb *ProgressBar) startAnimation() {
136 cb := glib.SourceFunc(func(_ uintptr) bool {
137 pb.mu.Lock()
138 defer pb.mu.Unlock()
139
140 // Check if we've reached the target
141 if pb.currentValue >= pb.targetValue {
142 pb.animationTimer = 0
143 return false // Stop the timer
144 }
145
146 // Increment towards target
147 pb.currentValue += progressStep
148 if pb.currentValue > pb.targetValue {
149 pb.currentValue = pb.targetValue
150 }
151
152 pb.progressBar.SetFraction(pb.currentValue)
153
154 // Continue animation if not at target
155 if pb.currentValue >= pb.targetValue {
156 pb.animationTimer = 0 // Clear before GLib auto-removes
157 return false
158 }
159 return true
160 })
161
162 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
163 ctx := pb.ctx
164 if ctx == nil {
165 ctx = context.Background()
166 }
167 logging.FromContext(ctx).
168 Trace().
169 Uint64("progress_event_seq", pb.progressEventSeq).
170 Float64("current_value", pb.currentValue).
171 Float64("target_value", pb.targetValue).
172 Msg("progress bar animation started")
173}
174
175// initialProgressFraction is the fraction set when the progress bar first
176// appears so the user gets immediate visual feedback that loading has begun.
177// CEF may not fire OnLoadingProgressChange for hundreds of milliseconds
178// during cross-site process swaps, leaving the bar at 0% (invisible).
179const initialProgressFraction = 0.1
180
181// Show makes the progress bar visible and starts the auto-hide timeout.
182func (pb *ProgressBar) Show() {
183 pb.mu.Lock()
184 defer pb.mu.Unlock()
185
186 ctx := pb.ctx
187 if ctx == nil {
188 ctx = context.Background()
189 }
190 logging.FromContext(ctx).
191 Debug().
192 Bool("visible", pb.visible).
193 Float64("current_value", pb.currentValue).
194 Float64("target_value", pb.targetValue).
195 Msg("progress bar show before")
196
197 if !pb.visible {
198 pb.visible = true
199 pb.lastShowAt = time.Now()
200 // Set an initial non-zero fraction so the bar is visually noticeable
201 // immediately. Without this, the bar is technically visible but
202 // renders as empty (0% fill) until the first progress callback,
203 // which in CEF can be delayed during cross-site process swaps.
204 pb.currentValue = initialProgressFraction
205 pb.targetValue = initialProgressFraction
206 pb.progressBar.SetFraction(initialProgressFraction)
207 pb.progressBar.SetVisible(true)
208 }
209
210 // Reset timeout timer on every Show call.
211 pb.resetTimeout()
212
213 logging.FromContext(ctx).
214 Debug().
215 Bool("visible", pb.visible).
216 Float64("current_value", pb.currentValue).
217 Float64("target_value", pb.targetValue).
218 Msg("progress bar show after")
219}
220
221// resetTimeout cancels any existing timeout and starts a new one.
222// Must be called with lock held.
223func (pb *ProgressBar) resetTimeout() {
224 pb.resetTimeoutAfter(time.Duration(progressTimeoutMs) * time.Millisecond)
225}
226
227// resetTimeoutAfter cancels any existing timeout and starts a new one after
228// the requested delay. Must be called with lock held.
229func (pb *ProgressBar) resetTimeoutAfter(delay time.Duration) {
230 if delay <= 0 {
231 delay = time.Millisecond
232 }
233
234 // Cancel existing timeout.
235 if pb.timeoutTimer != 0 {
236 glib.SourceRemove(pb.timeoutTimer)
237 pb.timeoutTimer = 0
238 }
239
240 timeoutMs := int(delay / time.Millisecond)
241 if delay%time.Millisecond != 0 {
242 timeoutMs++
243 }
244 if timeoutMs < 1 {
245 timeoutMs = 1
246 }
247
248 cb := glib.SourceFunc(func(_ uintptr) bool {
249 pb.mu.Lock()
250 defer pb.mu.Unlock()
251
252 now := time.Now()
253 remaining := pb.timeoutRemainingLocked(now)
254 ctx := pb.ctx
255 if ctx == nil {
256 ctx = context.Background()
257 }
258 logging.FromContext(ctx).
259 Debug().
260 Float64("current_value", pb.currentValue).
261 Float64("target_value", pb.targetValue).
262 Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
263 Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
264 Int64("timeout_remaining_ms", remaining.Milliseconds()).
265 Msg("progress bar timeout fired")
266
267 // Clear timer ID (timer is being removed).
268 pb.timeoutTimer = 0
269
270 if remaining > 0 {
271 pb.resetTimeoutAfter(remaining)
272 return false
273 }
274
275 // Hide the progress bar inline to avoid race condition.
276 pb.hideInternal("timeout")
277 return false // Don't repeat
278 })
279
280 pb.timeoutTimer = glib.TimeoutAdd(uint(timeoutMs), &cb, 0)
281}
282
283// Hide makes the progress bar invisible and resets state.
284func (pb *ProgressBar) Hide() {
285 pb.HideWithReason("manual")
286}
287
288// HideWithReason makes the progress bar invisible and records why it was hidden.
289func (pb *ProgressBar) HideWithReason(reason string) {
290 pb.mu.Lock()
291 defer pb.mu.Unlock()
292
293 pb.hideInternal(reason)
294}
295
296// hideInternal performs the actual hide operation.
297// Must be called with lock held.
298func (pb *ProgressBar) hideInternal(reason string) {
299 ctx := pb.ctx
300 if ctx == nil {
301 ctx = context.Background()
302 }
303 now := time.Now()
304 logging.FromContext(ctx).
305 Debug().
306 Bool("visible", pb.visible).
307 Str("reason", reason).
308 Float64("current_value", pb.currentValue).
309 Float64("target_value", pb.targetValue).
310 Bool("animation_running", pb.animationTimer != 0).
311 Bool("timeout_running", pb.timeoutTimer != 0).
312 Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
313 Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
314 Msg("progress bar hide before")
315
316 if pb.visible {
317 pb.visible = false
318 pb.progressBar.SetVisible(false)
319
320 // Stop any running animation
321 if pb.animationTimer != 0 {
322 glib.SourceRemove(pb.animationTimer)
323 pb.animationTimer = 0
324 }
325
326 // Stop timeout timer
327 if pb.timeoutTimer != 0 {
328 glib.SourceRemove(pb.timeoutTimer)
329 pb.timeoutTimer = 0
330 }
331
332 // Reset values
333 pb.currentValue = 0
334 pb.targetValue = 0
335 pb.lastShowAt = time.Time{}
336 pb.lastProgressAt = time.Time{}
337 pb.progressBar.SetFraction(0)
338 }
339}
340
341func (pb *ProgressBar) timeoutRemainingLocked(now time.Time) time.Duration {
342 lastActivityAt := pb.lastShowAt
343 if pb.lastProgressAt.After(lastActivityAt) {
344 lastActivityAt = pb.lastProgressAt
345 }
346 if lastActivityAt.IsZero() {
347 return 0
348 }
349
350 remaining := time.Duration(progressTimeoutMs)*time.Millisecond - now.Sub(lastActivityAt)
351 if remaining < 0 {
352 return 0
353 }
354 return remaining
355}
356
357func sinceTimeMs(start, now time.Time) int64 {
358 if start.IsZero() {
359 return -1
360 }
361 return now.Sub(start).Milliseconds()
362}
363
364// IsVisible returns whether the progress bar is currently visible.
365func (pb *ProgressBar) IsVisible() bool {
366 pb.mu.Lock()
367 defer pb.mu.Unlock()
368 return pb.visible
369}
370
371// Widget returns the underlying widget for embedding in overlays.
372func (pb *ProgressBar) Widget() layout.Widget {
373 return pb.progressBar
374}