1package content
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/dumber/internal/ui/component"
11 "github.com/bnema/dumber/internal/ui/input"
12 "github.com/bnema/dumber/internal/ui/layout"
13)
14
15// EnsureWebView acquires or reuses a WebView for the given pane.
16func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (port.WebView, error) {
17 log := logging.FromContext(ctx)
18
19 if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() {
20 log.Debug().
21 Str("pane_id", string(paneID)).
22 Uint64("webview_id", uint64(wv.ID())).
23 Msg("webview reused for pane")
24 return wv, nil
25 }
26
27 if c.pool == nil {
28 return nil, fmt.Errorf("webview pool not configured")
29 }
30
31 // Mark tab_created on first webview (first tab)
32 if c.webViewCount() == 0 {
33 logging.Trace().Mark("tab_created")
34 }
35
36 wv, err := c.pool.Acquire(ctx)
37 if err != nil {
38 return nil, err
39 }
40 logging.Trace().Mark("webview_acquired")
41
42 c.setWebViewLocked(paneID, wv)
43 c.setupWebViewCallbacks(ctx, paneID, wv)
44
45 log.Debug().
46 Str("pane_id", string(paneID)).
47 Uint64("webview_id", uint64(wv.ID())).
48 Msg("webview acquired for pane")
49 return wv, nil
50}
51
52// ReleaseWebView returns the WebView for a pane to the pool.
53func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
54 log := logging.FromContext(ctx)
55
56 wv := c.deleteWebViewLocked(paneID)
57 if wv == nil {
58 return
59 }
60 c.ensurePopupManager().clearReusableNamedPopupByPaneID(paneID)
61 c.ensurePopupManager().clearReusableNamedPopupByWebViewID(wv.ID())
62 c.clearPendingAppearance(paneID)
63
64 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
65 // we must release the inhibition before destroying the webview.
66 // Otherwise the D-Bus inhibit request stays active forever.
67 if c.idleInhibitor != nil {
68 if wv.IsFullscreen() {
69 log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
70 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
71 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
72 }
73 }
74 if wv.IsPlayingAudio() {
75 log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
76 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
77 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
78 }
79 }
80 }
81
82 // Clean up title tracking
83 c.titleMu.Lock()
84 delete(c.paneTitles, paneID)
85 c.titleMu.Unlock()
86
87 // Clean up navigation origin tracking
88 c.navOriginMu.Lock()
89 delete(c.navOrigins, paneID)
90 c.navOriginMu.Unlock()
91
92 if c.pool != nil {
93 c.pool.Release(wv)
94 } else {
95 wv.Destroy()
96 }
97
98 log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
99}
100
101// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
102func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
103 log := logging.FromContext(ctx)
104
105 if ws == nil || wsView == nil || c.widgetFactory == nil {
106 return
107 }
108
109 for _, pane := range ws.AllPanes() {
110 if pane == nil {
111 continue
112 }
113
114 wv, err := c.EnsureWebView(ctx, pane.ID)
115 if err != nil {
116 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
117 continue
118 }
119
120 // Load the pane's URI if set and different from current
121 if pane.URI != "" && pane.URI != wv.URI() {
122 if err := wv.LoadURI(ctx, pane.URI); err != nil {
123 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
124 }
125 }
126
127 widget := c.WrapWidget(ctx, wv)
128 if widget == nil {
129 continue
130 }
131
132 log.Debug().
133 Str("pane_id", string(pane.ID)).
134 Uint64("webview_id", uint64(wv.ID())).
135 Int("wrapped_widget_alloc_width", widget.GetAllocatedWidth()).
136 Int("wrapped_widget_alloc_height", widget.GetAllocatedHeight()).
137 Msg("wrapped webview widget prepared for pane")
138
139 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
140 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
141 continue
142 }
143 logging.Trace().Mark("webview_attached")
144 }
145}
146
147// WrapWidget converts a WebView to a layout.Widget for embedding.
148// It also attaches gesture handlers for mouse button navigation.
149func (c *Coordinator) WrapWidget(ctx context.Context, wv port.WebView) layout.Widget {
150 log := logging.FromContext(ctx)
151
152 if wv == nil || c.widgetFactory == nil {
153 log.Debug().Msg("cannot wrap nil webview or factory")
154 return nil
155 }
156
157 // Use NativeWidgetProvider interface for GTK embedding (engine-agnostic)
158 nwp, ok := wv.(port.NativeWidgetProvider)
159 if !ok {
160 log.Debug().Msg("webview does not support widget embedding")
161 return nil
162 }
163
164 ptr := nwp.NativeWidget()
165 if ptr == 0 {
166 return nil
167 }
168
169 widget := c.widgetFactory.WrapNativeWidget(ptr)
170
171 // Attach gesture handler for mouse button 8/9 navigation
172 if widget != nil {
173 gestureHandler := input.NewGestureHandler(ctx)
174 // Pass WebView directly to preserve user gesture context (like Epiphany)
175 if nav, ok := wv.(input.DirectNavigator); ok {
176 gestureHandler.SetNavigator(nav)
177 }
178 // Keep callback as fallback
179 if c.gestureActionHandler != nil {
180 gestureHandler.SetOnAction(c.gestureActionHandler)
181 }
182 gestureHandler.AttachTo(widget.GtkWidget())
183 log.Debug().Msg("gesture handler attached to webview with direct navigator")
184 }
185
186 return widget
187}
188
189// ActiveWebView returns the WebView for the active pane.
190func (c *Coordinator) ActiveWebView(ctx context.Context) port.WebView {
191 log := logging.FromContext(ctx)
192
193 if paneID, ok := c.activePaneOverrideID(); ok {
194 wv := c.getWebViewLocked(paneID)
195 if wv != nil {
196 return wv
197 }
198 log.Debug().Str("pane_id", string(paneID)).Msg("active pane override has no webview, falling back")
199 }
200 if c.getActiveWS == nil {
201 log.Debug().Msg("no active workspace resolver")
202 return nil
203 }
204
205 ws, _ := c.getActiveWS()
206 if ws == nil {
207 log.Debug().Msg("no active workspace")
208 return nil
209 }
210
211 pane := ws.ActivePane()
212 if pane == nil || pane.Pane == nil {
213 log.Debug().Msg("no active pane")
214 return nil
215 }
216
217 return c.getWebViewLocked(pane.Pane.ID)
218}
219
220// GetWebView returns the WebView for a specific pane.
221func (c *Coordinator) GetWebView(paneID entity.PaneID) port.WebView {
222 return c.getWebViewLocked(paneID)
223}
224
225// RegisterPopupWebView registers a popup WebView that was created externally.
226// This is used when popup tabs are created and the WebView needs to be tracked.
227func (c *Coordinator) RegisterPopupWebView(paneID entity.PaneID, wv port.WebView) {
228 if wv != nil && paneID != "" {
229 c.setWebViewLocked(paneID, wv)
230 }
231}
232
233// SyncWebViewViewport requests an explicit viewport resync when the engine
234// supports it. This is useful after widget attachment, reparenting, visibility
235// changes, or layout promotion paths that may not emit a size change.
236func (c *Coordinator) SyncWebViewViewport(ctx context.Context, paneID entity.PaneID, reason string) {
237 if c == nil || paneID == "" {
238 return
239 }
240 wv := c.getWebViewLocked(paneID)
241 if wv == nil {
242 return
243 }
244 syncer, ok := wv.(port.ViewportSyncCapable)
245 if !ok {
246 return
247 }
248 syncer.SyncViewport(ctx, reason)
249}