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