detached 83a6fc8

feat(ui): add toast notification system for user feedback

commit
83a6fc83a2bd82f6bafe9022d9c8c405b9612998
author
brice · · 2025-12-17 17:13 UTC
committer
brice · · 2025-12-17 17:19 UTC
parent
89a38fc
signature
unsigned
5 files · +274 -2
M internal/ui/app.go +1 -0
@@ -215,6 +215,7 @@ func (a *App) onActivate(ctx context.Context) {
215215 HistoryUC: a.deps.HistoryUC,
216216 FavoritesUC: a.deps.FavoritesUC,
217217 FaviconCache: a.faviconCache,
218+ Clipboard: a.deps.Clipboard,
218219 Shortcuts: a.deps.Config.SearchShortcuts,
219220 DefaultSearch: a.deps.Config.DefaultSearchEngine,
220221 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
@@ -215,6 +215,7 @@ func (a *App) onActivate(ctx context.Context) {
215 HistoryUC: a.deps.HistoryUC,215 HistoryUC: a.deps.HistoryUC,
216 FavoritesUC: a.deps.FavoritesUC,216 FavoritesUC: a.deps.FavoritesUC,
217 FaviconCache: a.faviconCache,217 FaviconCache: a.faviconCache,
218+ Clipboard: a.deps.Clipboard,
218 Shortcuts: a.deps.Config.SearchShortcuts,219 Shortcuts: a.deps.Config.SearchShortcuts,
219 DefaultSearch: a.deps.Config.DefaultSearchEngine,220 DefaultSearch: a.deps.Config.DefaultSearchEngine,
220 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,221 InitialBehavior: a.deps.Config.Omnibox.InitialBehavior,
A internal/ui/component/toaster.go +205 -0
@@ -0,0 +1,205 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
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+
16+const (
17+ // Default auto-dismiss timeout in milliseconds.
18+ toastDismissTimeoutMs = 1500
19+)
20+
21+// ToastLevel indicates the visual style of a toast notification.
22+type ToastLevel int
23+
24+const (
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.
36+func (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).
55+type 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.
68+func 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.
106+func (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("message", message).
139+ Int("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.
145+func (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.
151+func (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).
159+func (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.
176+func (t *Toaster) startDismissTimer(ctx context.Context) {
177+ log := logging.FromContext(ctx)
178+
179+ var cb glib.SourceFunc
180+ cb = func(_ uintptr) bool {
181+ t.mu.Lock()
182+ defer t.mu.Unlock()
183+
184+ // Only hide if timer hasn't been replaced
185+ t.hide()
186+ t.dismissTimer = 0
187+
188+ log.Debug().Msg("toast auto-dismissed")
189+ return false // Don't repeat
190+ }
191+
192+ t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0)
193+}
194+
195+// Widget returns the underlying widget for embedding in overlays.
196+func (t *Toaster) Widget() layout.Widget {
197+ return t.container
198+}
199+
200+// IsVisible returns whether the toast is currently visible.
201+func (t *Toaster) IsVisible() bool {
202+ t.mu.Lock()
203+ defer t.mu.Unlock()
204+ return t.visible
205+}
@@ -0,0 +1,205 @@
1+// Package component provides UI components for the browser.
2+package component
3+
4+import (
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+
16+const (
17+ // Default auto-dismiss timeout in milliseconds.
18+ toastDismissTimeoutMs = 1500
19+)
20+
21+// ToastLevel indicates the visual style of a toast notification.
22+type ToastLevel int
23+
24+const (
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.
36+func (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).
55+type 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.
68+func 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.
106+func (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("message", message).
139+ Int("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.
145+func (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.
151+func (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).
159+func (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.
176+func (t *Toaster) startDismissTimer(ctx context.Context) {
177+ log := logging.FromContext(ctx)
178+
179+ var cb glib.SourceFunc
180+ cb = func(_ uintptr) bool {
181+ t.mu.Lock()
182+ defer t.mu.Unlock()
183+
184+ // Only hide if timer hasn't been replaced
185+ t.hide()
186+ t.dismissTimer = 0
187+
188+ log.Debug().Msg("toast auto-dismissed")
189+ return false // Don't repeat
190+ }
191+
192+ t.dismissTimer = glib.TimeoutAdd(toastDismissTimeoutMs, &cb, 0)
193+}
194+
195+// Widget returns the underlying widget for embedding in overlays.
196+func (t *Toaster) Widget() layout.Widget {
197+ return t.container
198+}
199+
200+// IsVisible returns whether the toast is currently visible.
201+func (t *Toaster) IsVisible() bool {
202+ t.mu.Lock()
203+ defer t.mu.Unlock()
204+ return t.visible
205+}
M internal/ui/component/workspace_view.go +6 -2
@@ -455,8 +455,12 @@ func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
455455 return
456456 }
457457
458- // Create omnibox
459- omnibox := NewOmnibox(ctx, wv.omniboxCfg)
458+ // Create omnibox with pane-specific toast callback
459+ cfg := wv.omniboxCfg
460+ cfg.OnToast = func(message string) {
461+ pv.ShowToast(ctx, message, ToastSuccess)
462+ }
463+ omnibox := NewOmnibox(ctx, cfg)
460464 if omnibox == nil {
461465 wv.logger.Error().Msg("failed to create omnibox")
462466 return
@@ -455,8 +455,12 @@ func (wv *WorkspaceView) ShowOmnibox(ctx context.Context, query string) {
455 return455 return
456 }456 }
457 457
458- // Create omnibox458+ // Create omnibox with pane-specific toast callback
459- omnibox := NewOmnibox(ctx, wv.omniboxCfg)459+ cfg := wv.omniboxCfg
460+ cfg.OnToast = func(message string) {
461+ pv.ShowToast(ctx, message, ToastSuccess)
462+ }
463+ omnibox := NewOmnibox(ctx, cfg)
460 if omnibox == nil {464 if omnibox == nil {
461 wv.logger.Error().Msg("failed to create omnibox")465 wv.logger.Error().Msg("failed to create omnibox")
462 return466 return
M internal/ui/coordinator/workspace.go +13 -0
@@ -1567,3 +1567,16 @@ func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input Inse
15671567
15681568 return nil
15691569 }
1570+
1571+// ShowZoomToast displays a zoom level toast on the active pane.
1572+func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) {
1573+ _, wsView := c.getActiveWS()
1574+ if wsView == nil {
1575+ return
1576+ }
1577+
1578+ paneView := wsView.GetActivePaneView()
1579+ if paneView != nil {
1580+ paneView.ShowZoomToast(ctx, zoomPercent)
1581+ }
1582+}
@@ -1567,3 +1567,16 @@ func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input Inse
1567 1567
1568 return nil1568 return nil
1569 }1569 }
1570+
1571+// ShowZoomToast displays a zoom level toast on the active pane.
1572+func (c *WorkspaceCoordinator) ShowZoomToast(ctx context.Context, zoomPercent int) {
1573+ _, wsView := c.getActiveWS()
1574+ if wsView == nil {
1575+ return
1576+ }
1577+
1578+ paneView := wsView.GetActivePaneView()
1579+ if paneView != nil {
1580+ paneView.ShowZoomToast(ctx, zoomPercent)
1581+ }
1582+}
M internal/ui/theme/css.go +49 -0
@@ -50,6 +50,10 @@ func GenerateCSSWithScale(p Palette, scale float64) string {
5050
5151 // Progress bar styling
5252 sb.WriteString(generateProgressBarCSS(p))
53+ sb.WriteString("\n")
54+
55+ // Toaster styling
56+ sb.WriteString(generateToasterCSS(p))
5357
5458 return sb.String()
5559 }
@@ -394,12 +398,57 @@ progressbar.osd {
394398
395399 progressbar.osd trough {
396400 min-height: 4px;
401+ min-width: 0px;
397402 background-color: alpha(var(--bg), 0.3);
398403 }
399404
400405 progressbar.osd progress {
401406 min-height: 4px;
407+ min-width: 0px;
402408 background-color: var(--accent);
403409 }
404410 `
405411 }
412+
413+// generateToasterCSS creates toast notification styles.
414+// Uses em units for scalable UI.
415+func generateToasterCSS(p Palette) string {
416+ return `/* ===== Toaster Styling ===== */
417+
418+/* Toast notification container */
419+.toast {
420+ background-color: var(--surface-variant);
421+ border-radius: 0.25em;
422+ padding: 0.375em 0.625em;
423+ margin: 0.5em;
424+ font-size: 0.875em;
425+ font-weight: 600;
426+ box-shadow: 0 2px 8px alpha(black, 0.3);
427+ min-width: 2.5em;
428+}
429+
430+/* Toast level: info (default, accent color) */
431+.toast-info {
432+ background-color: alpha(var(--accent), 0.9);
433+ color: var(--bg);
434+}
435+
436+/* Toast level: success (green) */
437+.toast-success {
438+ background-color: alpha(#4ade80, 0.9);
439+ color: var(--bg);
440+}
441+
442+/* Toast level: warning (yellow/amber) */
443+.toast-warning {
444+ background-color: alpha(#fbbf24, 0.9);
445+ color: var(--bg);
446+}
447+
448+/* Toast level: error (red) */
449+.toast-error {
450+ background-color: alpha(#ef4444, 0.9);
451+ color: white;
452+}
453+`
454+}
@@ -50,6 +50,10 @@ func GenerateCSSWithScale(p Palette, scale float64) string {
50 50
51 // Progress bar styling51 // Progress bar styling
52 sb.WriteString(generateProgressBarCSS(p))52 sb.WriteString(generateProgressBarCSS(p))
53+ sb.WriteString("\n")
54+
55+ // Toaster styling
56+ sb.WriteString(generateToasterCSS(p))
53 57
54 return sb.String()58 return sb.String()
55 }59 }
@@ -394,12 +398,57 @@ progressbar.osd {
394 398
395 progressbar.osd trough {399 progressbar.osd trough {
396 min-height: 4px;400 min-height: 4px;
401+ min-width: 0px;
397 background-color: alpha(var(--bg), 0.3);402 background-color: alpha(var(--bg), 0.3);
398 }403 }
399 404
400 progressbar.osd progress {405 progressbar.osd progress {
401 min-height: 4px;406 min-height: 4px;
407+ min-width: 0px;
402 background-color: var(--accent);408 background-color: var(--accent);
403 }409 }
404 `410 `
405 }411 }
412+
413+// generateToasterCSS creates toast notification styles.
414+// Uses em units for scalable UI.
415+func generateToasterCSS(p Palette) string {
416+ return `/* ===== Toaster Styling ===== */
417+
418+/* Toast notification container */
419+.toast {
420+ background-color: var(--surface-variant);
421+ border-radius: 0.25em;
422+ padding: 0.375em 0.625em;
423+ margin: 0.5em;
424+ font-size: 0.875em;
425+ font-weight: 600;
426+ box-shadow: 0 2px 8px alpha(black, 0.3);
427+ min-width: 2.5em;
428+}
429+
430+/* Toast level: info (default, accent color) */
431+.toast-info {
432+ background-color: alpha(var(--accent), 0.9);
433+ color: var(--bg);
434+}
435+
436+/* Toast level: success (green) */
437+.toast-success {
438+ background-color: alpha(#4ade80, 0.9);
439+ color: var(--bg);
440+}
441+
442+/* Toast level: warning (yellow/amber) */
443+.toast-warning {
444+ background-color: alpha(#fbbf24, 0.9);
445+ color: var(--bg);
446+}
447+
448+/* Toast level: error (red) */
449+.toast-error {
450+ background-color: alpha(#ef4444, 0.9);
451+ color: white;
452+}
453+`
454+}

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