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