main 5041353

progress_bar.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close