v0.21.0 73bf644

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// Toaster displays toast notifications in an overlay.
52// It supports different notification levels and auto-dismissal.
53// When a new toast is shown while one is already visible, the text
54// is updated in-place and the dismiss timer is reset (spam protection).
55type Toaster struct {
56	factory      layout.WidgetFactory
57	container    layout.BoxWidget   // Outer container for positioning
58	label        layout.LabelWidget // Message text
59	currentLevel ToastLevel
60	visible      bool
61	dismissTimer uint // GLib timer source ID
62
63	mu sync.Mutex
64}
65
66// NewToaster creates a new toaster component for overlay display.
67// The toaster is positioned in the top-left corner with margin.
68func NewToaster(factory layout.WidgetFactory) *Toaster {
69	// Create container box for styling and positioning
70	container := factory.NewBox(layout.OrientationHorizontal, 0)
71	container.AddCssClass("toast")
72	container.AddCssClass("toast-info") // Default level
73
74	// Position in top-left with margin
75	container.SetHalign(gtk.AlignStartValue)
76	container.SetValign(gtk.AlignStartValue)
77
78	// Don't expand to fill space
79	container.SetHexpand(false)
80	container.SetVexpand(false)
81
82	// Don't intercept pointer events - let clicks pass through
83	container.SetCanTarget(false)
84	container.SetCanFocus(false)
85
86	// Hidden by default
87	container.SetVisible(false)
88
89	// Create label for message text
90	label := factory.NewLabel("")
91	label.SetCanTarget(false)
92	label.SetCanFocus(false)
93	container.Append(label)
94
95	return &Toaster{
96		factory:      factory,
97		container:    container,
98		label:        label,
99		currentLevel: ToastInfo,
100		visible:      false,
101	}
102}
103
104// Show displays a toast notification with the given message and level.
105// If a toast is already visible, updates the text and resets the dismiss timer.
106func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel) {
107	log := logging.FromContext(ctx)
108
109	t.mu.Lock()
110	defer t.mu.Unlock()
111
112	// Update level CSS class if changed
113	if t.currentLevel != level {
114		t.container.RemoveCssClass(t.currentLevel.cssClass())
115		t.container.AddCssClass(level.cssClass())
116		t.currentLevel = level
117	}
118
119	// Update message text
120	t.label.SetText(message)
121
122	// Cancel existing timer if any
123	if t.dismissTimer != 0 {
124		glib.SourceRemove(t.dismissTimer)
125		t.dismissTimer = 0
126	}
127
128	// Show the toast if not already visible
129	if !t.visible {
130		t.visible = true
131		t.container.SetVisible(true)
132	}
133
134	// Start new dismiss timer
135	t.startDismissTimer(ctx)
136
137	log.Debug().
138		Str("toast_message", message).
139		Int("toast_level", int(level)).
140		Msg("toast shown")
141}
142
143// ShowZoom displays a zoom level toast (convenience method).
144// Formats the zoom percentage with a % suffix.
145func (t *Toaster) ShowZoom(ctx context.Context, zoomPercent int) {
146	message := fmt.Sprintf("%d%%", zoomPercent)
147	t.Show(ctx, message, ToastInfo)
148}
149
150// Hide manually dismisses the toast.
151func (t *Toaster) Hide() {
152	t.mu.Lock()
153	defer t.mu.Unlock()
154
155	t.hide()
156}
157
158// hide performs the actual hide operation (must be called with lock held).
159func (t *Toaster) hide() {
160	if !t.visible {
161		return
162	}
163
164	// Cancel timer if running
165	if t.dismissTimer != 0 {
166		glib.SourceRemove(t.dismissTimer)
167		t.dismissTimer = 0
168	}
169
170	t.visible = false
171	t.container.SetVisible(false)
172}
173
174// startDismissTimer starts the auto-dismiss timer.
175// Must be called with lock held.
176func (t *Toaster) startDismissTimer(ctx context.Context) {
177	log := logging.FromContext(ctx)
178
179	cb := glib.SourceFunc(func(_ uintptr) bool {
180		t.mu.Lock()
181		defer t.mu.Unlock()
182
183		// Only hide if timer hasn't been replaced
184		t.hide()
185		t.dismissTimer = 0
186
187		log.Debug().Msg("toast auto-dismissed")
188		return false // Don't repeat
189	})
190
191	t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0)
192}
193
194// Widget returns the underlying widget for embedding in overlays.
195func (t *Toaster) Widget() layout.Widget {
196	return t.container
197}
198
199// IsVisible returns whether the toast is currently visible.
200func (t *Toaster) IsVisible() bool {
201	t.mu.Lock()
202	defer t.mu.Unlock()
203	return t.visible
204}

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