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