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