1// Package component provides UI components for the browser.
2package component
3
4import (
5 "context"
6 "sync"
7
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/dumber/internal/ui/input"
11 "github.com/bnema/dumber/internal/ui/layout"
12)
13
14const (
15 // CSS class applied to active pane's border overlay
16 activePaneClass = "pane-active"
17
18 // vimModeActiveClass is added to the pane overlay when Vim mode is active.
19 // It produces a subtle local border accent using the pane mode color.
20 vimModeActiveClass = "vim-mode-active"
21
22 // vimModePulseClass triggers a normal scroll pulse on the pane overlay.
23 // Repeated calls must remove-then-add to restart the CSS animation.
24 vimModePulseClass = "vim-mode-pulse"
25
26 // vimModeFastPulseClass triggers a fast/stronger scroll pulse.
27 vimModeFastPulseClass = "vim-mode-pulse-fast"
28
29 // Alternate equivalent animations so repeated scrolls reliably restart
30 // the GTK CSS animation.
31 vimModePulseCycleClassA = "vim-mode-pulse-cycle-a"
32 vimModePulseCycleClassB = "vim-mode-pulse-cycle-b"
33
34 // Exported aliases for tests and external inspection.
35 VimModeActiveClass = vimModeActiveClass
36 VimModePulseClass = vimModePulseClass
37 VimModeFastPulseClass = vimModeFastPulseClass
38)
39
40// PaneView is a container for a single WebView with active state indication.
41// It uses an overlay to display a border around the active pane.
42type PaneView struct {
43 ctx context.Context
44 factory layout.WidgetFactory
45 overlay layout.OverlayWidget
46 webViewWidget layout.Widget // The actual WebView widget
47 borderBox layout.BoxWidget // Border overlay for active indication
48 progressBar *ProgressBar // Loading progress indicator
49 toaster *Toaster // Toast notification overlay
50 linkStatus *LinkStatusOverlay // Link hover URL overlay
51 loading *LoadingSkeleton // Placeholder shown until WebView paints
52 paneID entity.PaneID
53 isActive bool
54
55 // Vim Mode pane-local visual state.
56 vimMode bool
57 pulseCycle bool
58
59 onFocusIn func(paneID entity.PaneID)
60 onFocusOut func(paneID entity.PaneID)
61 onHover func(paneID entity.PaneID)
62 onMouseMotion func()
63
64 hoverHandler *input.HoverHandler
65
66 mu sync.RWMutex
67}
68
69// NewPaneView creates a new pane view container for a WebView widget.
70func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
71 overlay := factory.NewOverlay()
72 overlay.SetHexpand(true)
73 overlay.SetVexpand(true)
74 overlay.SetVisible(true)
75 overlay.AddCssClass("pane-overlay") // Theme background prevents white flash
76
77 // Set the WebView as the main child
78 // Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted
79 if webViewWidget != nil {
80 overlay.SetChild(webViewWidget)
81 }
82
83 // Loading skeleton overlay (shown until WebView paints)
84 loading := NewLoadingSkeleton(factory)
85 loading.Widget().SetCanFocus(false)
86 loading.Widget().SetCanTarget(false)
87 overlay.AddOverlay(loading.Widget())
88 // Don't let loading affect layout
89 overlay.SetClipOverlay(loading.Widget(), false)
90 overlay.SetMeasureOverlay(loading.Widget(), false)
91
92 // Create border overlay box (used for active pane indication)
93 // This is an empty box that gets styled via CSS to show a border
94 borderBox := factory.NewBox(layout.OrientationVertical, 0)
95 borderBox.SetCanFocus(false)
96 borderBox.SetCanTarget(false) // Don't intercept pointer events - let them pass through to WebView
97 borderBox.AddCssClass("pane-border")
98 // Position border to cover entire pane
99 borderBox.SetHexpand(true)
100 borderBox.SetVexpand(true)
101 overlay.AddOverlay(borderBox)
102
103 // Don't let border affect layout
104 overlay.SetClipOverlay(borderBox, false)
105 overlay.SetMeasureOverlay(borderBox, false)
106
107 // Progress bar is created lazily on first use to avoid GTK measurement
108 // issues with the internal progress gizmo before the overlay is realized
109
110 return &PaneView{
111 ctx: ctx,
112 factory: factory,
113 overlay: overlay,
114 webViewWidget: webViewWidget,
115 borderBox: borderBox,
116 progressBar: nil, // Created lazily in ensureProgressBar()
117 loading: loading,
118 paneID: paneID,
119 isActive: false,
120 vimMode: false,
121 }
122}
123
124// SetActive updates the active state of the pane.
125// Active panes display a visual border indicator.
126func (pv *PaneView) SetActive(active bool) {
127 pv.mu.Lock()
128 defer pv.mu.Unlock()
129
130 if pv.isActive == active {
131 return
132 }
133
134 pv.isActive = active
135
136 if active {
137 pv.borderBox.AddCssClass(activePaneClass)
138 } else {
139 pv.borderBox.RemoveCssClass(activePaneClass)
140 }
141}
142
143// IsActive returns whether this pane is currently active.
144func (pv *PaneView) IsActive() bool {
145 pv.mu.RLock()
146 defer pv.mu.RUnlock()
147
148 return pv.isActive
149}
150
151// PaneID returns the ID of the pane this view represents.
152func (pv *PaneView) PaneID() entity.PaneID {
153 return pv.paneID
154}
155
156// WebViewWidget returns the underlying WebView widget.
157func (pv *PaneView) WebViewWidget() layout.Widget {
158 pv.mu.RLock()
159 defer pv.mu.RUnlock()
160
161 return pv.webViewWidget
162}
163
164// SetWebViewWidget replaces the WebView widget as not-yet-revealed content.
165// Call AttachWebViewWidget when the content coordinator knows the WebView has
166// already painted.
167func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
168 pv.AttachWebViewWidget(widget, false)
169}
170
171// AttachWebViewWidget replaces the WebView widget and applies its explicit
172// content reveal state. GTK parentage is used only to satisfy GTK's one-parent
173// ownership rule; it must not be used to infer whether CEF has painted.
174func (pv *PaneView) AttachWebViewWidget(widget layout.Widget, revealed bool) {
175 pv.mu.Lock()
176 defer pv.mu.Unlock()
177
178 // Remove old widget from this overlay.
179 if pv.webViewWidget != nil {
180 pv.overlay.SetChild(nil)
181 }
182
183 pv.webViewWidget = widget
184 if widget == nil {
185 return
186 }
187
188 overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth()
189 overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight()
190 widgetAllocWidthBefore := widget.GetAllocatedWidth()
191 widgetAllocHeightBefore := widget.GetAllocatedHeight()
192 hadParent := widget.GetParent() != nil
193 wasVisible := widget.IsVisible()
194
195 // GTK4 widgets may have only one parent. Cleanup during a WorkspaceView
196 // rebuild leaves a reusable native widget unparented, which says nothing
197 // about whether its content was previously revealed.
198 if hadParent {
199 widget.Unparent()
200 }
201 pv.overlay.SetChild(widget)
202
203 if hadParent && wasVisible {
204 widget.SetVisible(true)
205 }
206 // A replacement can follow an already-revealed WebView in the same pane.
207 // Apply both states explicitly: an unrevealed current WebView must restart
208 // the skeleton rather than inheriting the previous view's hidden overlay.
209 if pv.loading != nil {
210 pv.loading.SetVisible(!revealed)
211 }
212
213 ctx := pv.ctx
214 if ctx == nil {
215 ctx = context.Background()
216 }
217 logging.FromContext(ctx).Debug().
218 Str("pane_id", string(pv.paneID)).
219 Bool("had_parent", hadParent).
220 Bool("was_visible", wasVisible).
221 Bool("revealed", revealed).
222 Int("overlay_alloc_width_before", overlayAllocWidthBefore).
223 Int("overlay_alloc_height_before", overlayAllocHeightBefore).
224 Int("widget_alloc_width_before", widgetAllocWidthBefore).
225 Int("widget_alloc_height_before", widgetAllocHeightBefore).
226 Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()).
227 Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()).
228 Int("widget_alloc_width_after", widget.GetAllocatedWidth()).
229 Int("widget_alloc_height_after", widget.GetAllocatedHeight()).
230 Msg("pane view webview widget attached")
231}
232
233// GrabFocus attempts to focus the WebView.
234// Returns true if focus was successfully grabbed.
235func (pv *PaneView) GrabFocus() bool {
236 pv.mu.RLock()
237 wv := pv.webViewWidget
238 pv.mu.RUnlock()
239
240 if wv == nil {
241 return false
242 }
243 return wv.GrabFocus()
244}
245
246// HasFocus returns whether the WebView currently has focus.
247func (pv *PaneView) HasFocus() bool {
248 pv.mu.RLock()
249 wv := pv.webViewWidget
250 pv.mu.RUnlock()
251
252 if wv == nil {
253 return false
254 }
255 return wv.HasFocus()
256}
257
258// SetOnFocusIn sets the callback for when the pane gains focus.
259func (pv *PaneView) SetOnFocusIn(fn func(paneID entity.PaneID)) {
260 pv.mu.Lock()
261 defer pv.mu.Unlock()
262
263 pv.onFocusIn = fn
264}
265
266// SetOnFocusOut sets the callback for when the pane loses focus.
267func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) {
268 pv.mu.Lock()
269 defer pv.mu.Unlock()
270
271 pv.onFocusOut = fn
272}
273
274// SetOnHover sets the callback for when the pane is hovered.
275func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
276 pv.mu.Lock()
277 defer pv.mu.Unlock()
278
279 pv.onHover = fn
280}
281
282// SetOnMouseMotion sets a callback fired on intentional mouse movement within the pane.
283// Used to detect real user interaction vs synthetic GTK enter events from widget rearrangement.
284func (pv *PaneView) SetOnMouseMotion(fn func()) {
285 pv.mu.Lock()
286 defer pv.mu.Unlock()
287
288 pv.onMouseMotion = fn
289}
290
291// AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
292func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
293 pv.mu.Lock()
294 defer pv.mu.Unlock()
295
296 // Create hover handler
297 pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
298
299 // Wire up hover enter callback
300 pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
301 pv.mu.RLock()
302 callback := pv.onHover
303 pv.mu.RUnlock()
304
305 if callback != nil {
306 callback(paneID)
307 }
308 })
309
310 // Wire up mouse motion callback (intentional movement detection)
311 pv.hoverHandler.SetOnMotion(func() {
312 pv.mu.RLock()
313 callback := pv.onMouseMotion
314 pv.mu.RUnlock()
315
316 if callback != nil {
317 callback()
318 }
319 })
320
321 // Attach to overlay widget
322 gtkWidget := pv.overlay.GtkWidget()
323 if gtkWidget != nil {
324 pv.hoverHandler.AttachTo(gtkWidget)
325 }
326}
327
328// CancelPendingHover cancels any pending hover focus timer.
329// Called when keyboard navigation occurs to prevent hover from overriding keyboard focus.
330func (pv *PaneView) CancelPendingHover() {
331 pv.mu.RLock()
332 handler := pv.hoverHandler
333 pv.mu.RUnlock()
334
335 if handler != nil {
336 handler.Cancel()
337 }
338}
339
340// Widget returns the underlying overlay widget for embedding in containers.
341func (pv *PaneView) Widget() layout.Widget {
342 return pv.overlay
343}
344
345// Overlay returns the underlying overlay widget for direct access.
346func (pv *PaneView) Overlay() layout.OverlayWidget {
347 return pv.overlay
348}
349
350// Show makes the pane visible.
351func (pv *PaneView) Show() {
352 pv.overlay.Show()
353}
354
355// Hide makes the pane invisible.
356func (pv *PaneView) Hide() {
357 pv.overlay.Hide()
358}
359
360// SetVisible sets the visibility of the pane.
361func (pv *PaneView) SetVisible(visible bool) {
362 pv.overlay.SetVisible(visible)
363}
364
365// IsVisible returns whether the pane is visible.
366func (pv *PaneView) IsVisible() bool {
367 return pv.overlay.IsVisible()
368}
369
370// AddCssClass adds a CSS class to the overlay.
371func (pv *PaneView) AddCssClass(class string) {
372 pv.overlay.AddCssClass(class)
373}
374
375// RemoveCssClass removes a CSS class from the overlay.
376func (pv *PaneView) RemoveCssClass(class string) {
377 pv.overlay.RemoveCssClass(class)
378}
379
380// AddOverlayWidget adds a widget as an overlay on this pane.
381// The widget will appear above the WebView and border.
382func (pv *PaneView) AddOverlayWidget(widget layout.Widget) {
383 pv.overlay.AddOverlay(widget)
384 pv.overlay.SetClipOverlay(widget, false)
385 pv.overlay.SetMeasureOverlay(widget, false)
386}
387
388// RemoveOverlayWidget removes an overlay widget from this pane.
389func (pv *PaneView) RemoveOverlayWidget(widget layout.Widget) {
390 pv.overlay.RemoveOverlay(widget)
391}
392
393// GetContentDimensions returns the pane's allocated width and height.
394func (pv *PaneView) GetContentDimensions() (width, height int) {
395 return pv.overlay.GetAllocatedWidth(), pv.overlay.GetAllocatedHeight()
396}
397
398// ensureProgressBar creates the progress bar lazily on first use.
399// This avoids GTK measurement issues with the internal progress gizmo
400// that can occur when the widget is added before the overlay is realized.
401// Must be called with write lock held.
402func (pv *PaneView) ensureProgressBar() *ProgressBar {
403 if pv.progressBar != nil {
404 return pv.progressBar
405 }
406
407 ctx := pv.ctx
408 if ctx == nil {
409 ctx = context.Background()
410 }
411 logging.FromContext(ctx).
412 Debug().
413 Str("pane_id", string(pv.paneID)).
414 Msg("creating progress bar")
415
416 pb := NewProgressBar(pv.ctx, pv.factory)
417 pv.overlay.AddOverlay(pb.Widget())
418 pv.overlay.SetClipOverlay(pb.Widget(), false)
419 pv.overlay.SetMeasureOverlay(pb.Widget(), false)
420 pv.progressBar = pb
421 return pb
422}
423
424// SetLoadProgress updates the progress bar with the current load progress.
425// progress should be between 0.0 and 1.0.
426func (pv *PaneView) SetLoadProgress(progress float64) {
427 pv.mu.Lock()
428 pb := pv.ensureProgressBar()
429 ctx := pv.ctx
430 if ctx == nil {
431 ctx = context.Background()
432 }
433 logging.FromContext(ctx).
434 Trace().
435 Str("pane_id", string(pv.paneID)).
436 Float64("progress", progress).
437 Msg("setting load progress")
438 pv.mu.Unlock()
439
440 pb.SetProgress(progress)
441}
442
443// SetLoading shows or hides the progress bar.
444func (pv *PaneView) SetLoading(loading bool) {
445 pv.mu.Lock()
446 hadProgressBar := pv.progressBar != nil
447 pb := pv.ensureProgressBar()
448 ctx := pv.ctx
449 if ctx == nil {
450 ctx = context.Background()
451 }
452 logging.FromContext(ctx).
453 Debug().
454 Str("pane_id", string(pv.paneID)).
455 Bool("loading", loading).
456 Bool("progress_bar_existed", hadProgressBar).
457 Msg("setting loading state")
458 pv.mu.Unlock()
459
460 if loading {
461 pb.Show()
462 } else {
463 pb.HideWithReason("loading_false")
464 }
465}
466
467// HideLoadingSkeleton hides the loading skeleton overlay (if present).
468func (pv *PaneView) HideLoadingSkeleton() {
469 pv.mu.Lock()
470 loading := pv.loading
471 pv.mu.Unlock()
472
473 if loading != nil {
474 loading.SetVisible(false)
475 }
476}
477
478// ensureToaster creates the toaster lazily on first use.
479// Must be called with write lock held.
480func (pv *PaneView) ensureToaster() *Toaster {
481 if pv.toaster != nil {
482 return pv.toaster
483 }
484
485 t := NewToaster(pv.factory)
486 pv.overlay.AddOverlay(t.Widget())
487 pv.overlay.SetClipOverlay(t.Widget(), false)
488 pv.overlay.SetMeasureOverlay(t.Widget(), false)
489 pv.toaster = t
490 return t
491}
492
493// ShowToast displays a toast notification with the given message and level.
494// If a toast is already visible, updates the text and resets the dismiss timer.
495func (pv *PaneView) ShowToast(ctx context.Context, message string, level ToastLevel) {
496 pv.mu.Lock()
497 t := pv.ensureToaster()
498 pv.mu.Unlock()
499
500 t.Show(ctx, message, level)
501}
502
503// ShowZoomToast displays a zoom level toast notification.
504// Formats the zoom percentage with a % suffix.
505func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) {
506 pv.mu.Lock()
507 t := pv.ensureToaster()
508 pv.mu.Unlock()
509
510 t.ShowZoom(ctx, zoomPercent)
511}
512
513// ensureLinkStatus creates the link status overlay lazily on first use.
514// Must be called with write lock held.
515func (pv *PaneView) ensureLinkStatus() *LinkStatusOverlay {
516 if pv.linkStatus != nil {
517 return pv.linkStatus
518 }
519
520 ls := NewLinkStatusOverlay(pv.factory)
521 pv.overlay.AddOverlay(ls.Widget())
522 pv.overlay.SetClipOverlay(ls.Widget(), false)
523 pv.overlay.SetMeasureOverlay(ls.Widget(), false)
524 pv.linkStatus = ls
525 return ls
526}
527
528// ShowLinkStatus displays the link status overlay with the given URI.
529// If uri is empty, hides the overlay instead.
530func (pv *PaneView) ShowLinkStatus(uri string) {
531 pv.mu.Lock()
532 ls := pv.ensureLinkStatus()
533 pv.mu.Unlock()
534
535 ls.Show(uri)
536}
537
538// HideLinkStatus hides the link status overlay.
539func (pv *PaneView) HideLinkStatus() {
540 pv.mu.Lock()
541 ls := pv.linkStatus
542 pv.mu.Unlock()
543
544 if ls != nil {
545 ls.Hide()
546 }
547}
548
549// SetVimMode activates or deactivates the pane-local Vim Mode accent.
550func (pv *PaneView) SetVimMode(active bool) {
551 pv.mu.Lock()
552 defer pv.mu.Unlock()
553
554 if pv.vimMode == active {
555 return
556 }
557
558 pv.vimMode = active
559
560 if active {
561 pv.overlay.AddCssClass(vimModeActiveClass)
562 } else {
563 pv.overlay.RemoveCssClass(vimModeActiveClass)
564 }
565}
566
567// IsVimMode returns whether Vim mode is currently active on this pane.
568func (pv *PaneView) IsVimMode() bool {
569 pv.mu.RLock()
570 defer pv.mu.RUnlock()
571
572 return pv.vimMode
573}
574
575// TriggerVimModePulse triggers a normal scroll pulse on the pane overlay.
576func (pv *PaneView) TriggerVimModePulse() {
577 pv.triggerOverlayPulse(false)
578}
579
580// TriggerVimModePulseFast triggers a stronger, longer pane-overlay pulse.
581func (pv *PaneView) TriggerVimModePulseFast() {
582 pv.triggerOverlayPulse(true)
583}
584
585// triggerOverlayPulse adds a transient pulse CSS class to the pane overlay.
586// fast=true uses the stronger/longer pulse animation. Both pulse classes are
587// removed first to reliably re-trigger the animation.
588func (pv *PaneView) triggerOverlayPulse(fast bool) {
589 pv.mu.Lock()
590 defer pv.mu.Unlock()
591
592 pv.overlay.RemoveCssClass(vimModePulseClass)
593 pv.overlay.RemoveCssClass(vimModeFastPulseClass)
594 pv.overlay.RemoveCssClass(vimModePulseCycleClassA)
595 pv.overlay.RemoveCssClass(vimModePulseCycleClassB)
596 if fast {
597 pv.overlay.AddCssClass(vimModeFastPulseClass)
598 } else {
599 pv.overlay.AddCssClass(vimModePulseClass)
600 }
601 cycle := vimModePulseCycleClassA
602 if pv.pulseCycle {
603 cycle = vimModePulseCycleClassB
604 }
605 pv.pulseCycle = !pv.pulseCycle
606 pv.overlay.AddCssClass(cycle)
607}
608
609// Cleanup removes the WebView widget from the overlay and clears references.
610// This must be called before destroying the WebView to ensure proper GTK cleanup.
611// After calling Cleanup, the PaneView should not be reused.
612func (pv *PaneView) Cleanup() {
613 pv.mu.Lock()
614 defer pv.mu.Unlock()
615
616 // Clear callbacks to prevent use-after-free
617 pv.onFocusIn = nil
618 pv.onFocusOut = nil
619 pv.onHover = nil
620 pv.onMouseMotion = nil
621
622 // Detach hover handler if present
623 if pv.hoverHandler != nil {
624 pv.hoverHandler.Detach()
625 pv.hoverHandler = nil
626 }
627
628 // Remove WebView from overlay (unparents it from GTK hierarchy)
629 if pv.webViewWidget != nil {
630 pv.overlay.SetChild(nil)
631 pv.webViewWidget = nil
632 }
633
634 // Clear other overlay children
635 if pv.loading != nil {
636 pv.overlay.RemoveOverlay(pv.loading.Widget())
637 pv.loading = nil
638 }
639 if pv.progressBar != nil {
640 pv.overlay.RemoveOverlay(pv.progressBar.Widget())
641 pv.progressBar = nil
642 }
643 if pv.toaster != nil {
644 pv.overlay.RemoveOverlay(pv.toaster.Widget())
645 pv.toaster = nil
646 }
647 if pv.linkStatus != nil {
648 pv.linkStatus.Cleanup() // Cancel pending timers before removal
649 pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
650 pv.linkStatus = nil
651 }
652}