v0.23.0 d05d132

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

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