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}