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 // 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// hide performs the actual hide operation (must be called with lock held).
280func (t *Toaster) hide() {
281 if !t.visible {
282 return
283 }
284
285 // Cancel timer if running
286 if t.dismissTimer != 0 {
287 glib.SourceRemove(t.dismissTimer)
288 t.dismissTimer = 0
289 }
290
291 // Clear custom styles
292 t.clearCustomStyle()
293
294 t.visible = false
295 t.container.SetVisible(false)
296}
297
298// applyPosition sets the widget alignment based on the position.
299func (t *Toaster) applyPosition(pos ToastPosition) {
300 var halign, valign gtk.Align
301
302 switch pos {
303 case ToastPositionTopLeft:
304 halign = gtk.AlignStartValue
305 valign = gtk.AlignStartValue
306 case ToastPositionTopCenter:
307 halign = gtk.AlignCenterValue
308 valign = gtk.AlignStartValue
309 case ToastPositionTopRight:
310 halign = gtk.AlignEndValue
311 valign = gtk.AlignStartValue
312 case ToastPositionBottomLeft:
313 halign = gtk.AlignStartValue
314 valign = gtk.AlignEndValue
315 case ToastPositionBottomCenter:
316 halign = gtk.AlignCenterValue
317 valign = gtk.AlignEndValue
318 case ToastPositionBottomRight:
319 halign = gtk.AlignEndValue
320 valign = gtk.AlignEndValue
321 default:
322 halign = gtk.AlignStartValue
323 valign = gtk.AlignStartValue
324 }
325
326 t.container.SetHalign(halign)
327 t.container.SetValign(valign)
328}
329
330// applyCustomStyle applies custom background and text colors via inline CSS.
331func (t *Toaster) applyCustomStyle(opts ToastOptions) {
332 if opts.BackgroundColor == "" && opts.TextColor == "" {
333 return
334 }
335
336 // Add custom class to indicate custom styling is applied
337 t.container.AddCssClass("toast-custom")
338 t.hasCustomStyle = true
339
340 // Store the colors
341 t.customBgColor = opts.BackgroundColor
342 t.customTextColor = opts.TextColor
343
344 // Remove level-based styling when using custom colors
345 if opts.BackgroundColor != "" {
346 t.container.RemoveCssClass(t.currentLevel.cssClass())
347 }
348
349 // Generate inline CSS for custom colors
350 css := ".toast-custom {"
351 if opts.BackgroundColor != "" {
352 css += fmt.Sprintf(" background-color: %s;", opts.BackgroundColor)
353 }
354 if opts.TextColor != "" {
355 css += fmt.Sprintf(" color: %s;", opts.TextColor)
356 }
357 css += " }"
358
359 // Create or reuse CSS provider for this widget
360 if t.customCSSProvider == nil {
361 t.customCSSProvider = gtk.NewCssProvider()
362 }
363 t.customCSSProvider.LoadFromString(css)
364
365 // Apply to widget's style context
366 styleCtx := t.container.GtkWidget().GetStyleContext()
367 if styleCtx != nil {
368 styleCtx.AddProvider(t.customCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+1))
369 }
370}
371
372// clearCustomStyle removes any custom styling applied.
373func (t *Toaster) clearCustomStyle() {
374 if t.hasCustomStyle {
375 t.container.RemoveCssClass("toast-custom")
376 // Remove mode-specific class if one was applied
377 if t.customModeClass != "" {
378 t.container.RemoveCssClass(t.customModeClass)
379 t.customModeClass = ""
380 }
381 // Remove custom CSS provider if one was added
382 if t.customCSSProvider != nil {
383 styleCtx := t.container.GtkWidget().GetStyleContext()
384 if styleCtx != nil {
385 styleCtx.RemoveProvider(t.customCSSProvider)
386 }
387 }
388 // Re-add level class if it was removed due to custom background
389 if t.customBgColor != "" {
390 t.container.AddCssClass(t.currentLevel.cssClass())
391 }
392 t.hasCustomStyle = false
393 t.customBgColor = ""
394 t.customTextColor = ""
395 }
396}
397
398// applyModeClassInternal applies mode class without locking (must be called with lock held).
399func (t *Toaster) applyModeClassInternal(modeClass string) {
400 // Remove level class and add mode class
401 t.container.RemoveCssClass(t.currentLevel.cssClass())
402 t.container.AddCssClass(modeClass)
403 t.hasCustomStyle = true
404 t.customModeClass = modeClass // Store class name for cleanup tracking
405}
406
407// startDismissTimerWithDuration starts the auto-dismiss timer with the given duration.
408// Must be called with lock held.
409func (t *Toaster) startDismissTimerWithDuration(ctx context.Context, durationMs int) {
410 log := logging.FromContext(ctx)
411
412 cb := glib.SourceFunc(func(_ uintptr) bool {
413 t.mu.Lock()
414 defer t.mu.Unlock()
415
416 // Only hide if timer hasn't been replaced
417 t.hide()
418 t.dismissTimer = 0
419
420 log.Debug().Msg("toast auto-dismissed")
421 return false // Don't repeat
422 })
423
424 t.dismissTimer = glib.TimeoutAdd(uint(durationMs), &cb, 0)
425}
426
427// Widget returns the underlying widget for embedding in overlays.
428func (t *Toaster) Widget() layout.Widget {
429 return t.container
430}
431
432// IsVisible returns whether the toast is currently visible.
433func (t *Toaster) IsVisible() bool {
434 t.mu.Lock()
435 defer t.mu.Unlock()
436 return t.visible
437}