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