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
19// PaneView is a container for a single WebView with active state indication.
20// It uses an overlay to display a border around the active pane.
21type PaneView struct {
22 ctx context.Context
23 factory layout.WidgetFactory
24 overlay layout.OverlayWidget
25 webViewWidget layout.Widget // The actual WebView widget
26 borderBox layout.BoxWidget // Border overlay for active indication
27 progressBar *ProgressBar // Loading progress indicator
28 toaster *Toaster // Toast notification overlay
29 linkStatus *LinkStatusOverlay // Link hover URL overlay
30 loading *LoadingSkeleton // Placeholder shown until WebView paints
31 paneID entity.PaneID
32 isActive bool
33
34 onFocusIn func(paneID entity.PaneID)
35 onFocusOut func(paneID entity.PaneID)
36 onHover func(paneID entity.PaneID)
37 onMouseMotion func()
38
39 hoverHandler *input.HoverHandler
40
41 mu sync.RWMutex
42}
43
44// NewPaneView creates a new pane view container for a WebView widget.
45func NewPaneView(ctx context.Context, factory layout.WidgetFactory, paneID entity.PaneID, webViewWidget layout.Widget) *PaneView {
46 overlay := factory.NewOverlay()
47 overlay.SetHexpand(true)
48 overlay.SetVexpand(true)
49 overlay.SetVisible(true)
50 overlay.AddCssClass("pane-overlay") // Theme background prevents white flash
51
52 // Set the WebView as the main child
53 // Note: WebView may be hidden initially (see pool.go) - shown on LoadCommitted
54 if webViewWidget != nil {
55 overlay.SetChild(webViewWidget)
56 }
57
58 // Loading skeleton overlay (shown until WebView paints)
59 loading := NewLoadingSkeleton(factory)
60 loading.Widget().SetCanFocus(false)
61 loading.Widget().SetCanTarget(false)
62 overlay.AddOverlay(loading.Widget())
63 // Don't let loading affect layout
64 overlay.SetClipOverlay(loading.Widget(), false)
65 overlay.SetMeasureOverlay(loading.Widget(), false)
66
67 // Create border overlay box (used for active pane indication)
68 // This is an empty box that gets styled via CSS to show a border
69 borderBox := factory.NewBox(layout.OrientationVertical, 0)
70 borderBox.SetCanFocus(false)
71 borderBox.SetCanTarget(false) // Don't intercept pointer events - let them pass through to WebView
72 borderBox.AddCssClass("pane-border")
73 // Position border to cover entire pane
74 borderBox.SetHexpand(true)
75 borderBox.SetVexpand(true)
76 overlay.AddOverlay(borderBox)
77
78 // Don't let border affect layout
79 overlay.SetClipOverlay(borderBox, false)
80 overlay.SetMeasureOverlay(borderBox, false)
81
82 // Progress bar is created lazily on first use to avoid GTK measurement
83 // issues with the internal progress gizmo before the overlay is realized
84
85 return &PaneView{
86 ctx: ctx,
87 factory: factory,
88 overlay: overlay,
89 webViewWidget: webViewWidget,
90 borderBox: borderBox,
91 progressBar: nil, // Created lazily in ensureProgressBar()
92 loading: loading,
93 paneID: paneID,
94 isActive: false,
95 }
96}
97
98// SetActive updates the active state of the pane.
99// Active panes display a visual border indicator.
100func (pv *PaneView) SetActive(active bool) {
101 pv.mu.Lock()
102 defer pv.mu.Unlock()
103
104 if pv.isActive == active {
105 return
106 }
107
108 pv.isActive = active
109
110 if active {
111 pv.borderBox.AddCssClass(activePaneClass)
112 } else {
113 pv.borderBox.RemoveCssClass(activePaneClass)
114 }
115}
116
117// IsActive returns whether this pane is currently active.
118func (pv *PaneView) IsActive() bool {
119 pv.mu.RLock()
120 defer pv.mu.RUnlock()
121
122 return pv.isActive
123}
124
125// PaneID returns the ID of the pane this view represents.
126func (pv *PaneView) PaneID() entity.PaneID {
127 return pv.paneID
128}
129
130// WebViewWidget returns the underlying WebView widget.
131func (pv *PaneView) WebViewWidget() layout.Widget {
132 pv.mu.RLock()
133 defer pv.mu.RUnlock()
134
135 return pv.webViewWidget
136}
137
138// SetWebViewWidget replaces the WebView widget.
139func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
140 pv.mu.Lock()
141 defer pv.mu.Unlock()
142
143 // Remove old widget from this overlay
144 if pv.webViewWidget != nil {
145 pv.overlay.SetChild(nil)
146 }
147
148 pv.webViewWidget = widget
149
150 if widget != nil {
151 overlayAllocWidthBefore := pv.overlay.GetAllocatedWidth()
152 overlayAllocHeightBefore := pv.overlay.GetAllocatedHeight()
153 widgetAllocWidthBefore := widget.GetAllocatedWidth()
154 widgetAllocHeightBefore := widget.GetAllocatedHeight()
155 hadParent := widget.GetParent() != nil
156 wasVisible := widget.IsVisible()
157
158 // If this widget already had a parent, we're reparenting an existing WebView
159 // (e.g. after rebuilding/moving panes). In that case the WebView has likely
160 // already painted, so the loading skeleton should be hidden immediately.
161
162 // Unparent widget from any previous parent (critical for rebuild scenarios)
163 // In GTK4, a widget can only have one parent at a time
164 if hadParent {
165 widget.Unparent()
166 }
167 pv.overlay.SetChild(widget)
168
169 if hadParent && wasVisible {
170 widget.SetVisible(true)
171 }
172 if hadParent && pv.loading != nil {
173 pv.loading.SetVisible(false)
174 }
175
176 logging.FromContext(pv.ctx).Debug().
177 Str("pane_id", string(pv.paneID)).
178 Bool("had_parent", hadParent).
179 Bool("was_visible", wasVisible).
180 Int("overlay_alloc_width_before", overlayAllocWidthBefore).
181 Int("overlay_alloc_height_before", overlayAllocHeightBefore).
182 Int("widget_alloc_width_before", widgetAllocWidthBefore).
183 Int("widget_alloc_height_before", widgetAllocHeightBefore).
184 Int("overlay_alloc_width_after", pv.overlay.GetAllocatedWidth()).
185 Int("overlay_alloc_height_after", pv.overlay.GetAllocatedHeight()).
186 Int("widget_alloc_width_after", widget.GetAllocatedWidth()).
187 Int("widget_alloc_height_after", widget.GetAllocatedHeight()).
188 Msg("pane view webview widget attached")
189 }
190}
191
192// GrabFocus attempts to focus the WebView.
193// Returns true if focus was successfully grabbed.
194func (pv *PaneView) GrabFocus() bool {
195 pv.mu.RLock()
196 wv := pv.webViewWidget
197 pv.mu.RUnlock()
198
199 if wv == nil {
200 return false
201 }
202 return wv.GrabFocus()
203}
204
205// HasFocus returns whether the WebView currently has focus.
206func (pv *PaneView) HasFocus() bool {
207 pv.mu.RLock()
208 wv := pv.webViewWidget
209 pv.mu.RUnlock()
210
211 if wv == nil {
212 return false
213 }
214 return wv.HasFocus()
215}
216
217// SetOnFocusIn sets the callback for when the pane gains focus.
218func (pv *PaneView) SetOnFocusIn(fn func(paneID entity.PaneID)) {
219 pv.mu.Lock()
220 defer pv.mu.Unlock()
221
222 pv.onFocusIn = fn
223}
224
225// SetOnFocusOut sets the callback for when the pane loses focus.
226func (pv *PaneView) SetOnFocusOut(fn func(paneID entity.PaneID)) {
227 pv.mu.Lock()
228 defer pv.mu.Unlock()
229
230 pv.onFocusOut = fn
231}
232
233// SetOnHover sets the callback for when the pane is hovered.
234func (pv *PaneView) SetOnHover(fn func(paneID entity.PaneID)) {
235 pv.mu.Lock()
236 defer pv.mu.Unlock()
237
238 pv.onHover = fn
239}
240
241// SetOnMouseMotion sets a callback fired on intentional mouse movement within the pane.
242// Used to detect real user interaction vs synthetic GTK enter events from widget rearrangement.
243func (pv *PaneView) SetOnMouseMotion(fn func()) {
244 pv.mu.Lock()
245 defer pv.mu.Unlock()
246
247 pv.onMouseMotion = fn
248}
249
250// AttachHoverHandler creates and attaches a hover handler for focus-follows-mouse behavior.
251func (pv *PaneView) AttachHoverHandler(ctx context.Context) {
252 pv.mu.Lock()
253 defer pv.mu.Unlock()
254
255 // Create hover handler
256 pv.hoverHandler = input.NewHoverHandler(ctx, pv.paneID)
257
258 // Wire up hover enter callback
259 pv.hoverHandler.SetOnEnter(func(paneID entity.PaneID) {
260 pv.mu.RLock()
261 callback := pv.onHover
262 pv.mu.RUnlock()
263
264 if callback != nil {
265 callback(paneID)
266 }
267 })
268
269 // Wire up mouse motion callback (intentional movement detection)
270 pv.hoverHandler.SetOnMotion(func() {
271 pv.mu.RLock()
272 callback := pv.onMouseMotion
273 pv.mu.RUnlock()
274
275 if callback != nil {
276 callback()
277 }
278 })
279
280 // Attach to overlay widget
281 gtkWidget := pv.overlay.GtkWidget()
282 if gtkWidget != nil {
283 pv.hoverHandler.AttachTo(gtkWidget)
284 }
285}
286
287// CancelPendingHover cancels any pending hover focus timer.
288// Called when keyboard navigation occurs to prevent hover from overriding keyboard focus.
289func (pv *PaneView) CancelPendingHover() {
290 pv.mu.RLock()
291 handler := pv.hoverHandler
292 pv.mu.RUnlock()
293
294 if handler != nil {
295 handler.Cancel()
296 }
297}
298
299// Widget returns the underlying overlay widget for embedding in containers.
300func (pv *PaneView) Widget() layout.Widget {
301 return pv.overlay
302}
303
304// Overlay returns the underlying overlay widget for direct access.
305func (pv *PaneView) Overlay() layout.OverlayWidget {
306 return pv.overlay
307}
308
309// Show makes the pane visible.
310func (pv *PaneView) Show() {
311 pv.overlay.Show()
312}
313
314// Hide makes the pane invisible.
315func (pv *PaneView) Hide() {
316 pv.overlay.Hide()
317}
318
319// SetVisible sets the visibility of the pane.
320func (pv *PaneView) SetVisible(visible bool) {
321 pv.overlay.SetVisible(visible)
322}
323
324// IsVisible returns whether the pane is visible.
325func (pv *PaneView) IsVisible() bool {
326 return pv.overlay.IsVisible()
327}
328
329// AddCssClass adds a CSS class to the overlay.
330func (pv *PaneView) AddCssClass(class string) {
331 pv.overlay.AddCssClass(class)
332}
333
334// RemoveCssClass removes a CSS class from the overlay.
335func (pv *PaneView) RemoveCssClass(class string) {
336 pv.overlay.RemoveCssClass(class)
337}
338
339// AddOverlayWidget adds a widget as an overlay on this pane.
340// The widget will appear above the WebView and border.
341func (pv *PaneView) AddOverlayWidget(widget layout.Widget) {
342 pv.overlay.AddOverlay(widget)
343 pv.overlay.SetClipOverlay(widget, false)
344 pv.overlay.SetMeasureOverlay(widget, false)
345}
346
347// RemoveOverlayWidget removes an overlay widget from this pane.
348func (pv *PaneView) RemoveOverlayWidget(widget layout.Widget) {
349 pv.overlay.RemoveOverlay(widget)
350}
351
352// GetContentDimensions returns the pane's allocated width and height.
353func (pv *PaneView) GetContentDimensions() (width, height int) {
354 return pv.overlay.GetAllocatedWidth(), pv.overlay.GetAllocatedHeight()
355}
356
357// ensureProgressBar creates the progress bar lazily on first use.
358// This avoids GTK measurement issues with the internal progress gizmo
359// that can occur when the widget is added before the overlay is realized.
360// Must be called with write lock held.
361func (pv *PaneView) ensureProgressBar() *ProgressBar {
362 if pv.progressBar != nil {
363 return pv.progressBar
364 }
365
366 ctx := pv.ctx
367 if ctx == nil {
368 ctx = context.Background()
369 }
370 logging.FromContext(ctx).
371 Debug().
372 Str("pane_id", string(pv.paneID)).
373 Msg("creating progress bar")
374
375 pb := NewProgressBar(pv.ctx, pv.factory)
376 pv.overlay.AddOverlay(pb.Widget())
377 pv.overlay.SetClipOverlay(pb.Widget(), false)
378 pv.overlay.SetMeasureOverlay(pb.Widget(), false)
379 pv.progressBar = pb
380 return pb
381}
382
383// SetLoadProgress updates the progress bar with the current load progress.
384// progress should be between 0.0 and 1.0.
385func (pv *PaneView) SetLoadProgress(progress float64) {
386 pv.mu.Lock()
387 pb := pv.ensureProgressBar()
388 ctx := pv.ctx
389 if ctx == nil {
390 ctx = context.Background()
391 }
392 logging.FromContext(ctx).
393 Trace().
394 Str("pane_id", string(pv.paneID)).
395 Float64("progress", progress).
396 Msg("setting load progress")
397 pv.mu.Unlock()
398
399 pb.SetProgress(progress)
400}
401
402// SetLoading shows or hides the progress bar.
403func (pv *PaneView) SetLoading(loading bool) {
404 pv.mu.Lock()
405 hadProgressBar := pv.progressBar != nil
406 pb := pv.ensureProgressBar()
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 Bool("loading", loading).
415 Bool("progress_bar_existed", hadProgressBar).
416 Msg("setting loading state")
417 pv.mu.Unlock()
418
419 if loading {
420 pb.Show()
421 } else {
422 pb.HideWithReason("loading_false")
423 }
424}
425
426// HideLoadingSkeleton hides the loading skeleton overlay (if present).
427func (pv *PaneView) HideLoadingSkeleton() {
428 pv.mu.Lock()
429 loading := pv.loading
430 pv.mu.Unlock()
431
432 if loading != nil {
433 loading.SetVisible(false)
434 }
435}
436
437// ensureToaster creates the toaster lazily on first use.
438// Must be called with write lock held.
439func (pv *PaneView) ensureToaster() *Toaster {
440 if pv.toaster != nil {
441 return pv.toaster
442 }
443
444 t := NewToaster(pv.factory)
445 pv.overlay.AddOverlay(t.Widget())
446 pv.overlay.SetClipOverlay(t.Widget(), false)
447 pv.overlay.SetMeasureOverlay(t.Widget(), false)
448 pv.toaster = t
449 return t
450}
451
452// ShowToast displays a toast notification with the given message and level.
453// If a toast is already visible, updates the text and resets the dismiss timer.
454func (pv *PaneView) ShowToast(ctx context.Context, message string, level ToastLevel) {
455 pv.mu.Lock()
456 t := pv.ensureToaster()
457 pv.mu.Unlock()
458
459 t.Show(ctx, message, level)
460}
461
462// ShowZoomToast displays a zoom level toast notification.
463// Formats the zoom percentage with a % suffix.
464func (pv *PaneView) ShowZoomToast(ctx context.Context, zoomPercent int) {
465 pv.mu.Lock()
466 t := pv.ensureToaster()
467 pv.mu.Unlock()
468
469 t.ShowZoom(ctx, zoomPercent)
470}
471
472// ensureLinkStatus creates the link status overlay lazily on first use.
473// Must be called with write lock held.
474func (pv *PaneView) ensureLinkStatus() *LinkStatusOverlay {
475 if pv.linkStatus != nil {
476 return pv.linkStatus
477 }
478
479 ls := NewLinkStatusOverlay(pv.factory)
480 pv.overlay.AddOverlay(ls.Widget())
481 pv.overlay.SetClipOverlay(ls.Widget(), false)
482 pv.overlay.SetMeasureOverlay(ls.Widget(), false)
483 pv.linkStatus = ls
484 return ls
485}
486
487// ShowLinkStatus displays the link status overlay with the given URI.
488// If uri is empty, hides the overlay instead.
489func (pv *PaneView) ShowLinkStatus(uri string) {
490 pv.mu.Lock()
491 ls := pv.ensureLinkStatus()
492 pv.mu.Unlock()
493
494 ls.Show(uri)
495}
496
497// HideLinkStatus hides the link status overlay.
498func (pv *PaneView) HideLinkStatus() {
499 pv.mu.Lock()
500 ls := pv.linkStatus
501 pv.mu.Unlock()
502
503 if ls != nil {
504 ls.Hide()
505 }
506}
507
508// Cleanup removes the WebView widget from the overlay and clears references.
509// This must be called before destroying the WebView to ensure proper GTK cleanup.
510// After calling Cleanup, the PaneView should not be reused.
511func (pv *PaneView) Cleanup() {
512 pv.mu.Lock()
513 defer pv.mu.Unlock()
514
515 // Clear callbacks to prevent use-after-free
516 pv.onFocusIn = nil
517 pv.onFocusOut = nil
518 pv.onHover = nil
519
520 // Detach hover handler if present
521 if pv.hoverHandler != nil {
522 pv.hoverHandler.Detach()
523 pv.hoverHandler = nil
524 }
525
526 // Remove WebView from overlay (unparents it from GTK hierarchy)
527 if pv.webViewWidget != nil {
528 pv.overlay.SetChild(nil)
529 pv.webViewWidget = nil
530 }
531
532 // Clear other overlay children
533 if pv.progressBar != nil {
534 pv.overlay.RemoveOverlay(pv.progressBar.Widget())
535 pv.progressBar = nil
536 }
537 if pv.toaster != nil {
538 pv.overlay.RemoveOverlay(pv.toaster.Widget())
539 pv.toaster = nil
540 }
541 if pv.linkStatus != nil {
542 pv.linkStatus.Cleanup() // Cancel pending timers before removal
543 pv.overlay.RemoveOverlay(pv.linkStatus.Widget())
544 pv.linkStatus = nil
545 }
546}