main 5041353

toaster.go RawHistoryFolder

1// Package component provides UI components for the browser.
2package component
3
4import (
5	"context"
6	"fmt"
7	"sync"
8
9	"github.com/bnema/puregotk/v4/glib"
10	"github.com/bnema/puregotk/v4/gtk"
11
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/dumber/internal/ui/layout"
14)
15
16const (
17	// Default auto-dismiss timeout in milliseconds.
18	toastDismissTimeoutMs = 1500
19	// ToastBriefDurationMs is the duration for brief notifications (e.g., clipboard copy).
20	ToastBriefDurationMs = 800
21)
22
23// ToastLevel indicates the visual style of a toast notification.
24type ToastLevel int
25
26const (
27	// ToastInfo is for informational messages (accent color).
28	ToastInfo ToastLevel = iota
29	// ToastSuccess is for success confirmations (green).
30	ToastSuccess
31	// ToastWarning is for warning messages (yellow).
32	ToastWarning
33	// ToastError is for error messages (red).
34	ToastError
35)
36
37// cssClass returns the CSS class for this toast level.
38func (l ToastLevel) cssClass() string {
39	switch l {
40	case ToastInfo:
41		return "toast-info"
42	case ToastSuccess:
43		return "toast-success"
44	case ToastWarning:
45		return "toast-warning"
46	case ToastError:
47		return "toast-error"
48	default:
49		return "toast-info"
50	}
51}
52
53// ToastPosition defines where the toast appears on screen.
54type ToastPosition int
55
56const (
57	// ToastPositionTopLeft positions toast in top-left corner.
58	ToastPositionTopLeft ToastPosition = iota
59	// ToastPositionTopCenter positions toast at top center.
60	ToastPositionTopCenter
61	// ToastPositionTopRight positions toast in top-right corner.
62	ToastPositionTopRight
63	// ToastPositionBottomLeft positions toast in bottom-left corner.
64	ToastPositionBottomLeft
65	// ToastPositionBottomCenter positions toast at bottom center.
66	ToastPositionBottomCenter
67	// ToastPositionBottomRight positions toast in bottom-right corner.
68	ToastPositionBottomRight
69)
70
71// ToastOptions configures toast appearance and behavior.
72type ToastOptions struct {
73	// Duration in milliseconds. 0 = persistent (no auto-dismiss), >0 = auto-dismiss after duration.
74	Duration int
75	// BackgroundColor overrides the default background color (CSS color value).
76	// Empty string uses the level's default color.
77	BackgroundColor string
78	// TextColor overrides the default text color (CSS color value).
79	// Empty string uses the default (auto-contrast with background).
80	TextColor string
81	// Position determines where the toast appears on screen.
82	Position ToastPosition
83}
84
85// ToastOption is a functional option for configuring toast display.
86type ToastOption func(*ToastOptions)
87
88// WithDuration sets the auto-dismiss duration in milliseconds.
89// Use 0 for persistent toasts that require manual dismissal.
90func WithDuration(ms int) ToastOption {
91	return func(o *ToastOptions) {
92		o.Duration = ms
93	}
94}
95
96// WithBackgroundColor sets a custom background color (CSS color value).
97func WithBackgroundColor(color string) ToastOption {
98	return func(o *ToastOptions) {
99		o.BackgroundColor = color
100	}
101}
102
103// WithTextColor sets a custom text color (CSS color value).
104func WithTextColor(color string) ToastOption {
105	return func(o *ToastOptions) {
106		o.TextColor = color
107	}
108}
109
110// WithPosition sets the toast position on screen.
111func WithPosition(pos ToastPosition) ToastOption {
112	return func(o *ToastOptions) {
113		o.Position = pos
114	}
115}
116
117// defaultToastOptions returns the default toast options.
118func defaultToastOptions() ToastOptions {
119	return ToastOptions{
120		Duration: toastDismissTimeoutMs,
121		Position: ToastPositionTopLeft,
122	}
123}
124
125// Toaster displays toast notifications in an overlay.
126// It supports different notification levels and auto-dismissal.
127// When a new toast is shown while one is already visible, the text
128// is updated in-place and the dismiss timer is reset (spam protection).
129type Toaster struct {
130	factory      layout.WidgetFactory
131	container    layout.BoxWidget   // Outer container for positioning
132	label        layout.LabelWidget // Message text
133	currentLevel ToastLevel
134	visible      bool
135	dismissTimer uint // GLib timer source ID
136
137	// Track current options for cleanup
138	currentOpts       ToastOptions
139	hasCustomStyle    bool
140	customBgColor     string           // Current custom background color
141	customTextColor   string           // Current custom text color
142	customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors
143
144	mu sync.Mutex
145}
146
147// NewToaster creates a new toaster component for overlay display.
148// The toaster is positioned in the top-left corner with margin by default.
149func NewToaster(factory layout.WidgetFactory) *Toaster {
150	// Create container box for styling and positioning
151	container := factory.NewBox(layout.OrientationHorizontal, 0)
152	container.AddCssClass("toast")
153	container.AddCssClass("toast-info") // Default level
154
155	// Position in top-left with margin (default)
156	container.SetHalign(gtk.AlignStartValue)
157	container.SetValign(gtk.AlignStartValue)
158
159	// Don't expand to fill space
160	container.SetHexpand(false)
161	container.SetVexpand(false)
162
163	// Don't intercept pointer events - let clicks pass through
164	container.SetCanTarget(false)
165	container.SetCanFocus(false)
166
167	// Hidden by default
168	container.SetVisible(false)
169
170	// Create label for message text
171	label := factory.NewLabel("")
172	label.SetCanTarget(false)
173	label.SetCanFocus(false)
174	container.Append(label)
175
176	return &Toaster{
177		factory:      factory,
178		container:    container,
179		label:        label,
180		currentLevel: ToastInfo,
181		visible:      false,
182		currentOpts:  defaultToastOptions(),
183	}
184}
185
186// Show displays a toast notification with the given message and level.
187// If a toast is already visible, updates the text and resets the dismiss timer.
188// Optional ToastOption arguments can customize duration, colors, and position.
189func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) {
190	log := logging.FromContext(ctx)
191
192	// Build options from defaults and overrides
193	options := defaultToastOptions()
194	for _, opt := range opts {
195		opt(&options)
196	}
197
198	t.mu.Lock()
199	defer t.mu.Unlock()
200
201	// Clear previous custom styles if any
202	t.clearCustomStyle()
203
204	// Update level CSS class if changed
205	if t.currentLevel != level {
206		t.container.RemoveCssClass(t.currentLevel.cssClass())
207		t.container.AddCssClass(level.cssClass())
208		t.currentLevel = level
209	}
210
211	// Apply custom colors if specified
212	t.applyCustomStyle(options)
213
214	// Apply position
215	t.applyPosition(options.Position)
216
217	// Update message text
218	t.label.SetText(message)
219
220	// Cancel existing timer if any
221	if t.dismissTimer != 0 {
222		glib.SourceRemove(t.dismissTimer)
223		t.dismissTimer = 0
224	}
225
226	// Show the toast if not already visible
227	if !t.visible {
228		t.visible = true
229		t.container.SetVisible(true)
230	}
231
232	// Start new dismiss timer (if duration > 0)
233	if options.Duration > 0 {
234		t.startDismissTimerWithDuration(ctx, options.Duration)
235	}
236	// Duration == 0 means persistent, no timer
237
238	t.currentOpts = options
239
240	log.Debug().
241		Str("toast_message", message).
242		Int("toast_level", int(level)).
243		Int("duration", options.Duration).
244		Msg("toast shown")
245}
246
247// ShowZoom displays a zoom level toast (convenience method).
248// Formats the zoom percentage with a % suffix.
249func (t *Toaster) ShowZoom(ctx context.Context, zoomPercent int) {
250	message := fmt.Sprintf("%d%%", zoomPercent)
251	t.Show(ctx, message, ToastInfo)
252}
253
254// Hide manually dismisses the toast.
255func (t *Toaster) Hide() {
256	t.mu.Lock()
257	defer t.mu.Unlock()
258
259	t.hide()
260}
261
262// CancelAutoDismiss removes a pending auto-dismiss source without changing the
263// toast's visibility. It is useful when the owner is being torn down.
264func (t *Toaster) CancelAutoDismiss() {
265	t.mu.Lock()
266	defer t.mu.Unlock()
267
268	if t.dismissTimer != 0 {
269		glib.SourceRemove(t.dismissTimer)
270		t.dismissTimer = 0
271	}
272}
273
274// hide performs the actual hide operation (must be called with lock held).
275func (t *Toaster) hide() {
276	if !t.visible {
277		return
278	}
279
280	// Cancel timer if running
281	if t.dismissTimer != 0 {
282		glib.SourceRemove(t.dismissTimer)
283		t.dismissTimer = 0
284	}
285
286	// Clear custom styles
287	t.clearCustomStyle()
288
289	t.visible = false
290	t.container.SetVisible(false)
291}
292
293// applyPosition sets the widget alignment based on the position.
294func (t *Toaster) applyPosition(pos ToastPosition) {
295	var halign, valign gtk.Align
296
297	switch pos {
298	case ToastPositionTopLeft:
299		halign = gtk.AlignStartValue
300		valign = gtk.AlignStartValue
301	case ToastPositionTopCenter:
302		halign = gtk.AlignCenterValue
303		valign = gtk.AlignStartValue
304	case ToastPositionTopRight:
305		halign = gtk.AlignEndValue
306		valign = gtk.AlignStartValue
307	case ToastPositionBottomLeft:
308		halign = gtk.AlignStartValue
309		valign = gtk.AlignEndValue
310	case ToastPositionBottomCenter:
311		halign = gtk.AlignCenterValue
312		valign = gtk.AlignEndValue
313	case ToastPositionBottomRight:
314		halign = gtk.AlignEndValue
315		valign = gtk.AlignEndValue
316	default:
317		halign = gtk.AlignStartValue
318		valign = gtk.AlignStartValue
319	}
320
321	t.container.SetHalign(halign)
322	t.container.SetValign(valign)
323}
324
325// applyCustomStyle applies custom background and text colors via inline CSS.
326func (t *Toaster) applyCustomStyle(opts ToastOptions) {
327	if opts.BackgroundColor == "" && opts.TextColor == "" {
328		return
329	}
330
331	// Add custom class to indicate custom styling is applied
332	t.container.AddCssClass("toast-custom")
333	t.hasCustomStyle = true
334
335	// Store the colors
336	t.customBgColor = opts.BackgroundColor
337	t.customTextColor = opts.TextColor
338
339	// Remove level-based styling when using custom colors
340	if opts.BackgroundColor != "" {
341		t.container.RemoveCssClass(t.currentLevel.cssClass())
342	}
343
344	// Generate inline CSS for custom colors
345	css := ".toast-custom {"
346	if opts.BackgroundColor != "" {
347		css += fmt.Sprintf(" background-color: %s;", opts.BackgroundColor)
348	}
349	if opts.TextColor != "" {
350		css += fmt.Sprintf(" color: %s;", opts.TextColor)
351	}
352	css += " }"
353
354	// Create or reuse CSS provider for this widget
355	if t.customCSSProvider == nil {
356		t.customCSSProvider = gtk.NewCssProvider()
357	}
358	t.customCSSProvider.LoadFromString(css)
359
360	// Apply to widget's style context
361	styleCtx := t.container.GtkWidget().GetStyleContext()
362	if styleCtx != nil {
363		styleCtx.AddProvider(t.customCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+1))
364	}
365}
366
367// clearCustomStyle removes any custom styling applied.
368func (t *Toaster) clearCustomStyle() {
369	if t.hasCustomStyle {
370		t.container.RemoveCssClass("toast-custom")
371		// Remove custom CSS provider if one was added
372		if t.customCSSProvider != nil {
373			styleCtx := t.container.GtkWidget().GetStyleContext()
374			if styleCtx != nil {
375				styleCtx.RemoveProvider(t.customCSSProvider)
376			}
377		}
378		// Re-add level class if it was removed due to custom background
379		if t.customBgColor != "" {
380			t.container.AddCssClass(t.currentLevel.cssClass())
381		}
382		t.hasCustomStyle = false
383		t.customBgColor = ""
384		t.customTextColor = ""
385	}
386}
387
388// startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
389// Must be called with lock held.
390func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
391	log := logging.FromContext(ctx)
392
393	cb := glib.SourceFunc(func(_ uintptr) bool {
394		t.mu.Lock()
395		defer t.mu.Unlock()
396
397		// Only hide if timer hasn't been replaced
398		t.hide()
399		t.dismissTimer = 0
400
401		log.Debug().Msg("toast auto-dismissed")
402		return false // Don't repeat
403	})
404
405	t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0)
406}
407
408// Widget returns the underlying widget for embedding in overlays.
409func (t *Toaster) Widget() layout.Widget {
410	return t.container
411}
412
413// IsVisible returns whether the toast is currently visible.
414func (t *Toaster) IsVisible() bool {
415	t.mu.Lock()
416	defer t.mu.Unlock()
417	return t.visible
418}

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