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