v0.27.2 5960dd8

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/jwijenbergh/puregotk/v4/glib"
10	"github.com/jwijenbergh/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	// ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode").
84	// When set, this class is applied atomically with Show() to avoid visual flicker.
85	ModeClass string
86}
87
88// ToastOption is a functional option for configuring toast display.
89type ToastOption func(*ToastOptions)
90
91// WithDuration sets the auto-dismiss duration in milliseconds.
92// Use 0 for persistent toasts that require manual dismissal.
93func WithDuration(ms int) ToastOption {
94	return func(o *ToastOptions) {
95		o.Duration = ms
96	}
97}
98
99// WithBackgroundColor sets a custom background color (CSS color value).
100func WithBackgroundColor(color string) ToastOption {
101	return func(o *ToastOptions) {
102		o.BackgroundColor = color
103	}
104}
105
106// WithTextColor sets a custom text color (CSS color value).
107func WithTextColor(color string) ToastOption {
108	return func(o *ToastOptions) {
109		o.TextColor = color
110	}
111}
112
113// WithPosition sets the toast position on screen.
114func WithPosition(pos ToastPosition) ToastOption {
115	return func(o *ToastOptions) {
116		o.Position = pos
117	}
118}
119
120// WithModeClass sets a mode-specific CSS class for styling.
121// This is applied atomically within Show() to avoid visual flicker.
122func WithModeClass(class string) ToastOption {
123	return func(o *ToastOptions) {
124		o.ModeClass = class
125	}
126}
127
128// defaultToastOptions returns the default toast options.
129func defaultToastOptions() ToastOptions {
130	return ToastOptions{
131		Duration: toastDismissTimeoutMs,
132		Position: ToastPositionTopLeft,
133	}
134}
135
136// Toaster displays toast notifications in an overlay.
137// It supports different notification levels and auto-dismissal.
138// When a new toast is shown while one is already visible, the text
139// is updated in-place and the dismiss timer is reset (spam protection).
140type Toaster struct {
141	factory      layout.WidgetFactory
142	container    layout.BoxWidget   // Outer container for positioning
143	label        layout.LabelWidget // Message text
144	currentLevel ToastLevel
145	visible      bool
146	dismissTimer uint // GLib timer source ID
147
148	// Track current options for cleanup
149	currentOpts       ToastOptions
150	hasCustomStyle    bool
151	customBgColor     string           // Current custom background color
152	customTextColor   string           // Current custom text color
153	customModeClass   string           // Current mode-specific CSS class (e.g., "toast-pane-mode")
154	customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors
155
156	mu sync.Mutex
157}
158
159// NewToaster creates a new toaster component for overlay display.
160// The toaster is positioned in the top-left corner with margin by default.
161func NewToaster(factory layout.WidgetFactory) *Toaster {
162	// Create container box for styling and positioning
163	container := factory.NewBox(layout.OrientationHorizontal, 0)
164	container.AddCssClass("toast")
165	container.AddCssClass("toast-info") // Default level
166
167	// Position in top-left with margin (default)
168	container.SetHalign(gtk.AlignStartValue)
169	container.SetValign(gtk.AlignStartValue)
170
171	// Don't expand to fill space
172	container.SetHexpand(false)
173	container.SetVexpand(false)
174
175	// Don't intercept pointer events - let clicks pass through
176	container.SetCanTarget(false)
177	container.SetCanFocus(false)
178
179	// Hidden by default
180	container.SetVisible(false)
181
182	// Create label for message text
183	label := factory.NewLabel("")
184	label.SetCanTarget(false)
185	label.SetCanFocus(false)
186	container.Append(label)
187
188	return &Toaster{
189		factory:      factory,
190		container:    container,
191		label:        label,
192		currentLevel: ToastInfo,
193		visible:      false,
194		currentOpts:  defaultToastOptions(),
195	}
196}
197
198// Show displays a toast notification with the given message and level.
199// If a toast is already visible, updates the text and resets the dismiss timer.
200// Optional ToastOption arguments can customize duration, colors, and position.
201func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, opts ...ToastOption) {
202	log := logging.FromContext(ctx)
203
204	// Build options from defaults and overrides
205	options := defaultToastOptions()
206	for _, opt := range opts {
207		opt(&options)
208	}
209
210	t.mu.Lock()
211	defer t.mu.Unlock()
212
213	// Clear previous custom styles if any
214	t.clearCustomStyle()
215
216	// Update level CSS class if changed
217	if t.currentLevel != level {
218		t.container.RemoveCssClass(t.currentLevel.cssClass())
219		t.container.AddCssClass(level.cssClass())
220		t.currentLevel = level
221	}
222
223	// Apply custom colors if specified
224	t.applyCustomStyle(options)
225
226	// Apply mode class if specified (atomically to avoid flicker)
227	if options.ModeClass != "" {
228		t.applyModeClassInternal(options.ModeClass)
229	}
230
231	// Apply position
232	t.applyPosition(options.Position)
233
234	// Update message text
235	t.label.SetText(message)
236
237	// Cancel existing timer if any
238	if t.dismissTimer != 0 {
239		glib.SourceRemove(t.dismissTimer)
240		t.dismissTimer = 0
241	}
242
243	// Show the toast if not already visible
244	if !t.visible {
245		t.visible = true
246		t.container.SetVisible(true)
247	}
248
249	// Start new dismiss timer (if duration > 0)
250	if options.Duration > 0 {
251		t.startDismissTimerWithDuration(ctx, options.Duration)
252	}
253	// Duration == 0 means persistent, no timer
254
255	t.currentOpts = options
256
257	log.Debug().
258		Str("toast_message", message).
259		Int("toast_level", int(level)).
260		Int("duration", options.Duration).
261		Msg("toast shown")
262}
263
264// ShowZoom displays a zoom level toast (convenience method).
265// Formats the zoom percentage with a % suffix.
266func (t *Toaster) ShowZoom(ctx context.Context, zoomPercent int) {
267	message := fmt.Sprintf("%d%%", zoomPercent)
268	t.Show(ctx, message, ToastInfo)
269}
270
271// Hide manually dismisses the toast.
272func (t *Toaster) Hide() {
273	t.mu.Lock()
274	defer t.mu.Unlock()
275
276	t.hide()
277}
278
279// hide performs the actual hide operation (must be called with lock held).
280func (t *Toaster) hide() {
281	if !t.visible {
282		return
283	}
284
285	// Cancel timer if running
286	if t.dismissTimer != 0 {
287		glib.SourceRemove(t.dismissTimer)
288		t.dismissTimer = 0
289	}
290
291	// Clear custom styles
292	t.clearCustomStyle()
293
294	t.visible = false
295	t.container.SetVisible(false)
296}
297
298// applyPosition sets the widget alignment based on the position.
299func (t *Toaster) applyPosition(pos ToastPosition) {
300	var halign, valign gtk.Align
301
302	switch pos {
303	case ToastPositionTopLeft:
304		halign = gtk.AlignStartValue
305		valign = gtk.AlignStartValue
306	case ToastPositionTopCenter:
307		halign = gtk.AlignCenterValue
308		valign = gtk.AlignStartValue
309	case ToastPositionTopRight:
310		halign = gtk.AlignEndValue
311		valign = gtk.AlignStartValue
312	case ToastPositionBottomLeft:
313		halign = gtk.AlignStartValue
314		valign = gtk.AlignEndValue
315	case ToastPositionBottomCenter:
316		halign = gtk.AlignCenterValue
317		valign = gtk.AlignEndValue
318	case ToastPositionBottomRight:
319		halign = gtk.AlignEndValue
320		valign = gtk.AlignEndValue
321	default:
322		halign = gtk.AlignStartValue
323		valign = gtk.AlignStartValue
324	}
325
326	t.container.SetHalign(halign)
327	t.container.SetValign(valign)
328}
329
330// applyCustomStyle applies custom background and text colors via inline CSS.
331func (t *Toaster) applyCustomStyle(opts ToastOptions) {
332	if opts.BackgroundColor == "" && opts.TextColor == "" {
333		return
334	}
335
336	// Add custom class to indicate custom styling is applied
337	t.container.AddCssClass("toast-custom")
338	t.hasCustomStyle = true
339
340	// Store the colors
341	t.customBgColor = opts.BackgroundColor
342	t.customTextColor = opts.TextColor
343
344	// Remove level-based styling when using custom colors
345	if opts.BackgroundColor != "" {
346		t.container.RemoveCssClass(t.currentLevel.cssClass())
347	}
348
349	// Generate inline CSS for custom colors
350	css := ".toast-custom {"
351	if opts.BackgroundColor != "" {
352		css += fmt.Sprintf(" background-color: %s;", opts.BackgroundColor)
353	}
354	if opts.TextColor != "" {
355		css += fmt.Sprintf(" color: %s;", opts.TextColor)
356	}
357	css += " }"
358
359	// Create or reuse CSS provider for this widget
360	if t.customCSSProvider == nil {
361		t.customCSSProvider = gtk.NewCssProvider()
362	}
363	t.customCSSProvider.LoadFromString(css)
364
365	// Apply to widget's style context
366	styleCtx := t.container.GtkWidget().GetStyleContext()
367	if styleCtx != nil {
368		styleCtx.AddProvider(t.customCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+1))
369	}
370}
371
372// clearCustomStyle removes any custom styling applied.
373func (t *Toaster) clearCustomStyle() {
374	if t.hasCustomStyle {
375		t.container.RemoveCssClass("toast-custom")
376		// Remove mode-specific class if one was applied
377		if t.customModeClass != "" {
378			t.container.RemoveCssClass(t.customModeClass)
379			t.customModeClass = ""
380		}
381		// Remove custom CSS provider if one was added
382		if t.customCSSProvider != nil {
383			styleCtx := t.container.GtkWidget().GetStyleContext()
384			if styleCtx != nil {
385				styleCtx.RemoveProvider(t.customCSSProvider)
386			}
387		}
388		// Re-add level class if it was removed due to custom background
389		if t.customBgColor != "" {
390			t.container.AddCssClass(t.currentLevel.cssClass())
391		}
392		t.hasCustomStyle = false
393		t.customBgColor = ""
394		t.customTextColor = ""
395	}
396}
397
398// applyModeClassInternal applies mode class without locking (must be called with lock held).
399func (t *Toaster) applyModeClassInternal(modeClass string) {
400	// Remove level class and add mode class
401	t.container.RemoveCssClass(t.currentLevel.cssClass())
402	t.container.AddCssClass(modeClass)
403	t.hasCustomStyle = true
404	t.customModeClass = modeClass // Store class name for cleanup tracking
405}
406
407// startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
408// Must be called with lock held.
409func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
410	log := logging.FromContext(ctx)
411
412	cb := glib.SourceFunc(func(_ uintptr) bool {
413		t.mu.Lock()
414		defer t.mu.Unlock()
415
416		// Only hide if timer hasn't been replaced
417		t.hide()
418		t.dismissTimer = 0
419
420		log.Debug().Msg("toast auto-dismissed")
421		return false // Don't repeat
422	})
423
424	t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0)
425}
426
427// Widget returns the underlying widget for embedding in overlays.
428func (t *Toaster) Widget() layout.Widget {
429	return t.container
430}
431
432// IsVisible returns whether the toast is currently visible.
433func (t *Toaster) IsVisible() bool {
434	t.mu.Lock()
435	defer t.mu.Unlock()
436	return t.visible
437}

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