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