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