detached 6260495

refactor(browser): adapt webview and webkit architecture for multi-pane workspaces

commit
62604950e4863839f9a2f06cfa96f09adce21dc1
author
bamen · · 2025-09-19 03:42 UTC
parent
f7997f7
signature
unsigned
9 files · +599 -146
M internal/app/browser/browser.go +42 -4
@@ -17,8 +17,8 @@ import (
1717 "github.com/bnema/dumber/internal/config"
1818 "github.com/bnema/dumber/internal/db"
1919 "github.com/bnema/dumber/internal/filtering"
20- "github.com/bnema/dumber/internal/migrations"
2120 "github.com/bnema/dumber/internal/logging"
21+ "github.com/bnema/dumber/internal/migrations"
2222 "github.com/bnema/dumber/pkg/webkit"
2323 "github.com/bnema/dumber/services"
2424 )
@@ -45,13 +45,19 @@ type BrowserApp struct {
4545 navigationController *control.NavigationController
4646 clipboardController *control.ClipboardController
4747
48+ // Pane workspace management
49+ panes []*BrowserPane
50+ activePane *BrowserPane
51+ workspace *WorkspaceManager
52+
4853 // Content filtering
4954 filterManager *filtering.FilterManager
5055
5156 // Handlers
52- schemeHandler *api.SchemeHandler
53- messageHandler *messaging.Handler
54- shortcutHandler *ShortcutHandler
57+ schemeHandler *api.SchemeHandler
58+ messageHandler *messaging.Handler
59+ shortcutHandler *ShortcutHandler
60+ windowShortcutHandler *WindowShortcutHandler
5561 }
5662
5763 // Run starts the browser application
@@ -172,11 +178,43 @@ func (app *BrowserApp) Run() {
172178
173179 // cleanup handles cleanup on shutdown
174180 func (app *BrowserApp) cleanup() {
181+ log.Printf("Starting browser cleanup...")
182+
183+ // Cleanup window shortcuts first
184+ if app.windowShortcutHandler != nil {
185+ log.Printf("Cleaning up window-level shortcuts")
186+ app.windowShortcutHandler.Cleanup()
187+ app.windowShortcutHandler = nil
188+ }
189+
190+ // Cleanup all panes
191+ if app.panes != nil {
192+ log.Printf("Cleaning up %d panes", len(app.panes))
193+ for i, pane := range app.panes {
194+ if pane != nil {
195+ log.Printf("Cleaning up pane %d (%s)", i, pane.ID())
196+ pane.Cleanup()
197+ }
198+ }
199+ app.panes = nil
200+ }
201+
202+ // Cleanup workspace manager
203+ if app.workspace != nil {
204+ log.Printf("Cleaning up workspace manager")
205+ // WorkspaceManager doesn't have explicit cleanup yet, but we clear the reference
206+ app.workspace = nil
207+ }
208+
209+ // Close database last
175210 if app.database != nil {
211+ log.Printf("Closing database")
176212 if closeErr := app.database.Close(); closeErr != nil {
177213 log.Printf("Warning: failed to close database: %v", closeErr)
178214 }
179215 }
216+
217+ log.Printf("Browser cleanup completed")
180218 }
181219
182220 // setupOutputCapture initializes stdout/stderr capture if configured
@@ -17,8 +17,8 @@ import (
17 "github.com/bnema/dumber/internal/config"17 "github.com/bnema/dumber/internal/config"
18 "github.com/bnema/dumber/internal/db"18 "github.com/bnema/dumber/internal/db"
19 "github.com/bnema/dumber/internal/filtering"19 "github.com/bnema/dumber/internal/filtering"
20- "github.com/bnema/dumber/internal/migrations"
21 "github.com/bnema/dumber/internal/logging"20 "github.com/bnema/dumber/internal/logging"
21+ "github.com/bnema/dumber/internal/migrations"
22 "github.com/bnema/dumber/pkg/webkit"22 "github.com/bnema/dumber/pkg/webkit"
23 "github.com/bnema/dumber/services"23 "github.com/bnema/dumber/services"
24 )24 )
@@ -45,13 +45,19 @@ type BrowserApp struct {
45 navigationController *control.NavigationController45 navigationController *control.NavigationController
46 clipboardController *control.ClipboardController46 clipboardController *control.ClipboardController
47 47
48+ // Pane workspace management
49+ panes []*BrowserPane
50+ activePane *BrowserPane
51+ workspace *WorkspaceManager
52+
48 // Content filtering53 // Content filtering
49 filterManager *filtering.FilterManager54 filterManager *filtering.FilterManager
50 55
51 // Handlers56 // Handlers
52- schemeHandler *api.SchemeHandler57+ schemeHandler *api.SchemeHandler
53- messageHandler *messaging.Handler58+ messageHandler *messaging.Handler
54- shortcutHandler *ShortcutHandler59+ shortcutHandler *ShortcutHandler
60+ windowShortcutHandler *WindowShortcutHandler
55 }61 }
56 62
57 // Run starts the browser application63 // Run starts the browser application
@@ -172,11 +178,43 @@ func (app *BrowserApp) Run() {
172 178
173 // cleanup handles cleanup on shutdown179 // cleanup handles cleanup on shutdown
174 func (app *BrowserApp) cleanup() {180 func (app *BrowserApp) cleanup() {
181+ log.Printf("Starting browser cleanup...")
182+
183+ // Cleanup window shortcuts first
184+ if app.windowShortcutHandler != nil {
185+ log.Printf("Cleaning up window-level shortcuts")
186+ app.windowShortcutHandler.Cleanup()
187+ app.windowShortcutHandler = nil
188+ }
189+
190+ // Cleanup all panes
191+ if app.panes != nil {
192+ log.Printf("Cleaning up %d panes", len(app.panes))
193+ for i, pane := range app.panes {
194+ if pane != nil {
195+ log.Printf("Cleaning up pane %d (%s)", i, pane.ID())
196+ pane.Cleanup()
197+ }
198+ }
199+ app.panes = nil
200+ }
201+
202+ // Cleanup workspace manager
203+ if app.workspace != nil {
204+ log.Printf("Cleaning up workspace manager")
205+ // WorkspaceManager doesn't have explicit cleanup yet, but we clear the reference
206+ app.workspace = nil
207+ }
208+
209+ // Close database last
175 if app.database != nil {210 if app.database != nil {
211+ log.Printf("Closing database")
176 if closeErr := app.database.Close(); closeErr != nil {212 if closeErr := app.database.Close(); closeErr != nil {
177 log.Printf("Warning: failed to close database: %v", closeErr)213 log.Printf("Warning: failed to close database: %v", closeErr)
178 }214 }
179 }215 }
216+
217+ log.Printf("Browser cleanup completed")
180 }218 }
181 219
182 // setupOutputCapture initializes stdout/stderr capture if configured220 // setupOutputCapture initializes stdout/stderr capture if configured
M internal/app/browser/shortcuts.go +75 -24
@@ -1,9 +1,12 @@
11 package browser
22
33 import (
4+ "encoding/json"
5+ "fmt"
46 "log"
57
68 "github.com/bnema/dumber/internal/app/control"
9+ "github.com/bnema/dumber/internal/config"
710 "github.com/bnema/dumber/pkg/webkit"
811 )
912
@@ -11,57 +14,105 @@ import (
1114 type ShortcutHandler struct {
1215 webView *webkit.WebView
1316 clipboardController *control.ClipboardController
17+ config *config.Config
18+ app *BrowserApp
1419 }
1520
1621 // NewShortcutHandler creates a new shortcut handler
17-func NewShortcutHandler(webView *webkit.WebView, clipboardController *control.ClipboardController) *ShortcutHandler {
22+func NewShortcutHandler(webView *webkit.WebView, clipboardController *control.ClipboardController, cfg *config.Config, app *BrowserApp) *ShortcutHandler {
1823 return &ShortcutHandler{
1924 webView: webView,
2025 clipboardController: clipboardController,
26+ config: cfg,
27+ app: app,
2128 }
2229 }
2330
24-// RegisterShortcuts registers all keyboard shortcuts
25-func (s *ShortcutHandler) RegisterShortcuts() {
26- // DevTools
27- _ = s.webView.RegisterKeyboardShortcut("F12", func() {
28- log.Printf("Shortcut: F12 (devtools)")
29- _ = s.webView.ShowDevTools()
30- })
31-
32- // Omnibox (Ctrl+L): use new keyboard service bridge
33- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+l", func() {
34- log.Printf("Shortcut: Omnibox toggle")
35- // Handled in GUI KeyboardService via unified 'dumber:key' event
36- })
31+func (s *ShortcutHandler) isActivePane() bool {
32+ if s == nil || s.webView == nil {
33+ log.Printf("[shortcut] isActivePane: handler or webView is nil")
34+ return false
35+ }
36+ if s.app == nil || s.app.activePane == nil {
37+ log.Printf("[shortcut] isActivePane: app or activePane is nil, returning true")
38+ return true
39+ }
40+ isActive := s.app.activePane.webView == s.webView
41+ log.Printf("[shortcut] isActivePane: handler webView=%p activePane webView=%p isActive=%v", s.webView, s.app.activePane.webView, isActive)
42+ return isActive
43+}
3744
38- // Find in page (Ctrl+F): use keyboard service bridge
39- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+f", func() {
40- log.Printf("Shortcut: Find in page")
41- // Handled in GUI KeyboardService via unified 'dumber:key' event
42- })
45+// RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards
46+func (s *ShortcutHandler) RegisterShortcuts() {
47+ s.exposeWorkspaceConfig()
4348
44- // Copy URL (Ctrl+Shift+C): use keyboard service with native fallback
45- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {
46- log.Printf("Shortcut: Copy URL")
47- // Handled in GUI KeyboardService via unified 'dumber:key' event
48- })
49+ // NOTE: Global shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C, F12) are now handled
50+ // by WindowShortcutHandler at the window level to prevent duplicates
4951
5052 // Page refresh shortcuts
5153 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", func() {
54+ if !s.isActivePane() {
55+ return
56+ }
5257 log.Printf("Shortcut: Reload page")
5358 _ = s.webView.Reload()
5459 })
5560
5661 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", func() {
62+ if !s.isActivePane() {
63+ return
64+ }
5765 log.Printf("Shortcut: Hard reload page")
5866 _ = s.webView.ReloadBypassCache()
5967 })
6068
6169 _ = s.webView.RegisterKeyboardShortcut("F5", func() {
70+ if !s.isActivePane() {
71+ return
72+ }
6273 log.Printf("Shortcut: F5 reload")
6374 _ = s.webView.Reload()
6475 })
6576
77+ // Note: Workspace pane navigation shortcuts (Alt + Arrow keys) are now handled
78+ // by the WorkspaceManager to ensure they work properly across all panes
79+
6680 // Zoom handled natively in webkit package (built-in shortcuts)
6781 }
82+
83+func (s *ShortcutHandler) dispatchUIShortcut(action string, extra map[string]any) error {
84+ if s == nil || s.webView == nil {
85+ return fmt.Errorf("webview unavailable for action %s", action)
86+ }
87+
88+ detail := map[string]any{
89+ "action": action,
90+ }
91+ for k, v := range extra {
92+ detail[k] = v
93+ }
94+
95+ return s.webView.DispatchCustomEvent("dumber:ui:shortcut", detail)
96+}
97+
98+func (s *ShortcutHandler) exposeWorkspaceConfig() {
99+ if s.webView == nil || s.config == nil {
100+ return
101+ }
102+
103+ payload, err := json.Marshal(s.config.Workspace)
104+ if err != nil {
105+ log.Printf("Shortcut: failed to marshal workspace config: %v", err)
106+ return
107+ }
108+
109+ script := fmt.Sprintf(`(function(){
110+ const cfg = %s;
111+ window.__dumber_workspace_config = cfg;
112+ document.dispatchEvent(new CustomEvent('dumber:workspace-config',{detail:cfg}));
113+})();`, string(payload))
114+
115+ if err := s.webView.InjectScript(script); err != nil {
116+ log.Printf("Shortcut: failed to expose workspace config: %v", err)
117+ }
118+}
@@ -1,9 +1,12 @@
1 package browser1 package browser
2 2
3 import (3 import (
4+ "encoding/json"
5+ "fmt"
4 "log"6 "log"
5 7
6 "github.com/bnema/dumber/internal/app/control"8 "github.com/bnema/dumber/internal/app/control"
9+ "github.com/bnema/dumber/internal/config"
7 "github.com/bnema/dumber/pkg/webkit"10 "github.com/bnema/dumber/pkg/webkit"
8 )11 )
9 12
@@ -11,57 +14,105 @@ import (
11 type ShortcutHandler struct {14 type ShortcutHandler struct {
12 webView *webkit.WebView15 webView *webkit.WebView
13 clipboardController *control.ClipboardController16 clipboardController *control.ClipboardController
17+ config *config.Config
18+ app *BrowserApp
14 }19 }
15 20
16 // NewShortcutHandler creates a new shortcut handler21 // NewShortcutHandler creates a new shortcut handler
17-func NewShortcutHandler(webView *webkit.WebView, clipboardController *control.ClipboardController) *ShortcutHandler {22+func NewShortcutHandler(webView *webkit.WebView, clipboardController *control.ClipboardController, cfg *config.Config, app *BrowserApp) *ShortcutHandler {
18 return &ShortcutHandler{23 return &ShortcutHandler{
19 webView: webView,24 webView: webView,
20 clipboardController: clipboardController,25 clipboardController: clipboardController,
26+ config: cfg,
27+ app: app,
21 }28 }
22 }29 }
23 30
24-// RegisterShortcuts registers all keyboard shortcuts31+func (s *ShortcutHandler) isActivePane() bool {
25-func (s *ShortcutHandler) RegisterShortcuts() {32+ if s == nil || s.webView == nil {
26- // DevTools33+ log.Printf("[shortcut] isActivePane: handler or webView is nil")
27- _ = s.webView.RegisterKeyboardShortcut("F12", func() {34+ return false
28- log.Printf("Shortcut: F12 (devtools)")35+ }
29- _ = s.webView.ShowDevTools()36+ if s.app == nil || s.app.activePane == nil {
30- })37+ log.Printf("[shortcut] isActivePane: app or activePane is nil, returning true")
31-38+ return true
32- // Omnibox (Ctrl+L): use new keyboard service bridge39+ }
33- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+l", func() {40+ isActive := s.app.activePane.webView == s.webView
34- log.Printf("Shortcut: Omnibox toggle")41+ log.Printf("[shortcut] isActivePane: handler webView=%p activePane webView=%p isActive=%v", s.webView, s.app.activePane.webView, isActive)
35- // Handled in GUI KeyboardService via unified 'dumber:key' event42+ return isActive
36- })43+}
37 44
38- // Find in page (Ctrl+F): use keyboard service bridge45+// RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards
39- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+f", func() {46+func (s *ShortcutHandler) RegisterShortcuts() {
40- log.Printf("Shortcut: Find in page")47+ s.exposeWorkspaceConfig()
41- // Handled in GUI KeyboardService via unified 'dumber:key' event
42- })
43 48
44- // Copy URL (Ctrl+Shift+C): use keyboard service with native fallback49+ // NOTE: Global shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C, F12) are now handled
45- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {50+ // by WindowShortcutHandler at the window level to prevent duplicates
46- log.Printf("Shortcut: Copy URL")
47- // Handled in GUI KeyboardService via unified 'dumber:key' event
48- })
49 51
50 // Page refresh shortcuts52 // Page refresh shortcuts
51 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", func() {53 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", func() {
54+ if !s.isActivePane() {
55+ return
56+ }
52 log.Printf("Shortcut: Reload page")57 log.Printf("Shortcut: Reload page")
53 _ = s.webView.Reload()58 _ = s.webView.Reload()
54 })59 })
55 60
56 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", func() {61 _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", func() {
62+ if !s.isActivePane() {
63+ return
64+ }
57 log.Printf("Shortcut: Hard reload page")65 log.Printf("Shortcut: Hard reload page")
58 _ = s.webView.ReloadBypassCache()66 _ = s.webView.ReloadBypassCache()
59 })67 })
60 68
61 _ = s.webView.RegisterKeyboardShortcut("F5", func() {69 _ = s.webView.RegisterKeyboardShortcut("F5", func() {
70+ if !s.isActivePane() {
71+ return
72+ }
62 log.Printf("Shortcut: F5 reload")73 log.Printf("Shortcut: F5 reload")
63 _ = s.webView.Reload()74 _ = s.webView.Reload()
64 })75 })
65 76
77+ // Note: Workspace pane navigation shortcuts (Alt + Arrow keys) are now handled
78+ // by the WorkspaceManager to ensure they work properly across all panes
79+
66 // Zoom handled natively in webkit package (built-in shortcuts)80 // Zoom handled natively in webkit package (built-in shortcuts)
67 }81 }
82+
83+func (s *ShortcutHandler) dispatchUIShortcut(action string, extra map[string]any) error {
84+ if s == nil || s.webView == nil {
85+ return fmt.Errorf("webview unavailable for action %s", action)
86+ }
87+
88+ detail := map[string]any{
89+ "action": action,
90+ }
91+ for k, v := range extra {
92+ detail[k] = v
93+ }
94+
95+ return s.webView.DispatchCustomEvent("dumber:ui:shortcut", detail)
96+}
97+
98+func (s *ShortcutHandler) exposeWorkspaceConfig() {
99+ if s.webView == nil || s.config == nil {
100+ return
101+ }
102+
103+ payload, err := json.Marshal(s.config.Workspace)
104+ if err != nil {
105+ log.Printf("Shortcut: failed to marshal workspace config: %v", err)
106+ return
107+ }
108+
109+ script := fmt.Sprintf(`(function(){
110+ const cfg = %s;
111+ window.__dumber_workspace_config = cfg;
112+ document.dispatchEvent(new CustomEvent('dumber:workspace-config',{detail:cfg}));
113+})();`, string(payload))
114+
115+ if err := s.webView.InjectScript(script); err != nil {
116+ log.Printf("Shortcut: failed to expose workspace config: %v", err)
117+ }
118+}
M internal/app/browser/webview.go +181 -60
@@ -2,6 +2,7 @@ package browser
22
33 import (
44 "context"
5+ "fmt"
56 "log"
67 "os"
78 "path/filepath"
@@ -11,6 +12,7 @@ import (
1112 "github.com/bnema/dumber/internal/app/constants"
1213 "github.com/bnema/dumber/internal/app/control"
1314 "github.com/bnema/dumber/internal/app/environment"
15+ "github.com/bnema/dumber/internal/app/messaging"
1416 "github.com/bnema/dumber/internal/config"
1517 "github.com/bnema/dumber/internal/filtering"
1618 "github.com/bnema/dumber/pkg/webkit"
@@ -43,17 +45,26 @@ func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig {
4345 return mc
4446 }
4547
46-// createWebView creates and configures the WebView
47-func (app *BrowserApp) createWebView() error {
48- log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
49- dataDir, _ := config.GetDataDir()
50- stateDir, _ := config.GetStateDir()
48+func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
49+ dataDir, err := config.GetDataDir()
50+ if err != nil {
51+ return nil, err
52+ }
53+ stateDir, err := config.GetStateDir()
54+ if err != nil {
55+ return nil, err
56+ }
57+
5158 webkitData := filepath.Join(dataDir, "webkit")
5259 webkitCache := filepath.Join(stateDir, "webkit-cache")
53- _ = os.MkdirAll(webkitData, constants.DirPerm)
54- _ = os.MkdirAll(webkitCache, constants.DirPerm)
60+ if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
61+ return nil, err
62+ }
63+ if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
64+ return nil, err
65+ }
5566
56- view, err := webkit.NewWebView(&webkit.Config{
67+ cfg := &webkit.Config{
5768 Assets: app.assets,
5869 InitialURL: "dumb://homepage",
5970 ZoomDefault: 1.0,
@@ -82,52 +93,77 @@ func (app *BrowserApp) createWebView() error {
8293 CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,
8394 DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
8495 },
85- })
96+ }
8697
87- if err != nil {
88- return err
98+ return cfg, nil
99+}
100+
101+func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
102+ pane := &BrowserPane{
103+ webView: view,
89104 }
90105
91- app.webView = view
92- app.setupWebViewIntegration()
93- app.setupWebViewHandlers()
94- app.setupControllers()
95- app.setupKeyboardShortcuts()
106+ pane.clipboardController = control.NewClipboardController(view)
107+ pane.zoomController = control.NewZoomController(app.browserService, view)
108+ pane.navigationController = control.NewNavigationController(
109+ app.parserService,
110+ app.browserService,
111+ view,
112+ pane.zoomController,
113+ )
114+ pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
115+ pane.messageHandler.SetWebView(view)
116+ pane.messageHandler.SetNavigationController(pane.navigationController)
117+ pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
96118
97- // Apply initial zoom and show window
98- app.zoomController.ApplyInitialZoom()
119+ pane.zoomController.RegisterHandlers()
120+ pane.shortcutHandler.RegisterShortcuts()
99121
100- log.Printf("Showing WebView window…")
101- if err := view.Show(); err != nil {
102- log.Printf("Warning: failed to show WebView: %v", err)
103- } else if !webkit.IsNativeAvailable() {
104- log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
122+ return pane, nil
123+}
124+
125+func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
126+ pane, err := app.buildPane(view)
127+ if err != nil {
128+ return nil, err
105129 }
106130
107- return nil
108-}
131+ // Generate unique pane ID
132+ pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
133+ pane.initializeGUITracking()
109134
110-// setupWebViewIntegration connects the WebView to browser services
111-func (app *BrowserApp) setupWebViewIntegration() {
112- app.browserService.AttachWebView(app.webView)
135+ // Inject minimal bootstrap script for pane initialization
136+ bootstrapScript := fmt.Sprintf(`
137+ window.__dumber_pane = {
138+ id: '%s',
139+ created: %d,
140+ active: false
141+ };
113142
114- // GUI bundle is loaded via WebKit user scripts in enableUserContentManager
115- // No need to load separately in browser service
143+ // GUI manager will be loaded on-demand
144+ window.__dumber_gui_ready = true;
116145
117- // Use native window as title updater
118- if win := app.webView.Window(); win != nil {
119- app.browserService.SetWindowTitleUpdater(win)
146+ console.log('[pane] Initialized pane %s');
147+ `, pane.ID(), time.Now().UnixMilli(), pane.ID())
148+
149+ if err := view.InjectScript(bootstrapScript); err != nil {
150+ log.Printf("[pane-%s] Failed to inject bootstrap: %v", pane.ID(), err)
120151 }
152+
153+ app.attachPaneHandlers(pane)
154+ return pane, nil
121155 }
122156
123-// setupWebViewHandlers configures WebView event handlers
124-func (app *BrowserApp) setupWebViewHandlers() {
125- // Persist page titles to DB when they change
126- app.webView.RegisterTitleChangedHandler(func(title string) {
157+func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
158+ if pane == nil || pane.webView == nil {
159+ return
160+ }
161+
162+ pane.webView.RegisterTitleChangedHandler(func(title string) {
127163 if title == "" {
128164 return
129165 }
130- url := app.webView.GetCurrentURL()
166+ url := pane.webView.GetCurrentURL()
131167 if url == "" {
132168 return
133169 }
@@ -139,36 +175,121 @@ func (app *BrowserApp) setupWebViewHandlers() {
139175 }(url, title)
140176 })
141177
142- // Set WebView reference for message handler and register script messages
143- app.messageHandler.SetWebView(app.webView)
144- app.webView.RegisterScriptMessageHandler(app.messageHandler.Handle)
178+ pane.webView.RegisterScriptMessageHandler(func(payload string) {
179+ if app.workspace != nil {
180+ app.workspace.focusByView(pane.webView)
181+ }
182+ pane.messageHandler.Handle(payload)
183+ })
184+
185+ if pane.messageHandler != nil && pane.navigationController != nil {
186+ pane.messageHandler.SetNavigationController(pane.navigationController)
187+ }
188+
189+ pane.webView.RegisterPopupHandler(func(uri string) bool {
190+ handled := false
191+ if app.workspace != nil {
192+ handled = app.workspace.HandlePopup(pane.webView, uri)
193+ }
194+ if handled {
195+ return true
196+ }
197+
198+ if pane.navigationController != nil && uri != "" {
199+ if err := pane.navigationController.NavigateToURL(uri); err != nil {
200+ log.Printf("[workspace] popup fallback navigation failed: %v", err)
201+ if pane.webView != nil {
202+ if loadErr := pane.webView.LoadURL(uri); loadErr != nil {
203+ log.Printf("[workspace] popup fallback load failed: %v", loadErr)
204+ }
205+ }
206+ }
207+ return true
208+ }
209+
210+ if pane.webView != nil && uri != "" {
211+ if err := pane.webView.LoadURL(uri); err != nil {
212+ log.Printf("[workspace] popup direct load failed: %v", err)
213+ }
214+ return true
215+ }
145216
217+ return false
218+ })
146219 }
147220
148-// setupControllers initializes and configures controller objects
149-func (app *BrowserApp) setupControllers() {
150- // Initialize controllers
151- app.zoomController = control.NewZoomController(app.browserService, app.webView)
152- app.navigationController = control.NewNavigationController(
153- app.parserService,
154- app.browserService,
155- app.webView,
156- app.zoomController,
157- )
158- app.clipboardController = control.NewClipboardController(app.webView)
221+// createWebView creates and configures the WebView
222+func (app *BrowserApp) createWebView() error {
223+ log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
224+
225+ cfg, err := app.buildWebkitConfig()
226+ if err != nil {
227+ return err
228+ }
229+
230+ // Main window needs a top-level window
231+ cfg.CreateWindow = true
232+
233+ view, err := webkit.NewWebView(cfg)
234+
235+ if err != nil {
236+ return err
237+ }
238+
239+ pane, err := app.createPaneForView(view)
240+ if err != nil {
241+ return err
242+ }
243+
244+ app.webView = view
245+ app.zoomController = pane.zoomController
246+ app.navigationController = pane.navigationController
247+ app.clipboardController = pane.clipboardController
248+ app.messageHandler = pane.messageHandler
249+ app.shortcutHandler = pane.shortcutHandler
250+ app.panes = []*BrowserPane{pane}
251+ app.activePane = pane
252+
253+ if app.workspace == nil {
254+ app.workspace = NewWorkspaceManager(app, pane)
255+ }
159256
160- if app.messageHandler != nil && app.navigationController != nil {
161- app.messageHandler.SetNavigationController(app.navigationController)
257+ // Initialize window-level global shortcuts AFTER workspace is set up
258+ if window := view.Window(); window != nil {
259+ app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
260+ if app.windowShortcutHandler != nil {
261+ log.Printf("Window-level global shortcuts initialized")
262+ } else {
263+ log.Printf("Warning: Failed to initialize window-level shortcuts")
264+ }
162265 }
163266
164- // Register controller handlers
165- app.zoomController.RegisterHandlers()
267+ app.setupWebViewIntegration()
268+
269+ // Apply initial zoom and show window
270+ app.zoomController.ApplyInitialZoom()
271+
272+ log.Printf("Showing WebView window…")
273+ if err := view.Show(); err != nil {
274+ log.Printf("Warning: failed to show WebView: %v", err)
275+ } else if !webkit.IsNativeAvailable() {
276+ log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
277+ }
278+
279+ return nil
166280 }
167281
168-// setupKeyboardShortcuts registers keyboard shortcuts
169-func (app *BrowserApp) setupKeyboardShortcuts() {
170- app.shortcutHandler = NewShortcutHandler(app.webView, app.clipboardController)
171- app.shortcutHandler.RegisterShortcuts()
282+// setupWebViewIntegration connects the WebView to browser services
283+func (app *BrowserApp) setupWebViewIntegration() {
284+ app.browserService.AttachWebView(app.webView)
285+
286+ // GUI bundle is loaded via WebKit user scripts in enableUserContentManager
287+ // No need to load separately in browser service
288+
289+ // Use native window as title updater
290+ if win := app.webView.Window(); win != nil {
291+ app.browserService.SetWindowTitleUpdater(win)
292+ }
172293 }
173294
174295 // setupContentBlocking initializes the content blocking system with proper timing
@@ -2,6 +2,7 @@ package browser
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "log"6 "log"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
@@ -11,6 +12,7 @@ import (
11 "github.com/bnema/dumber/internal/app/constants"12 "github.com/bnema/dumber/internal/app/constants"
12 "github.com/bnema/dumber/internal/app/control"13 "github.com/bnema/dumber/internal/app/control"
13 "github.com/bnema/dumber/internal/app/environment"14 "github.com/bnema/dumber/internal/app/environment"
15+ "github.com/bnema/dumber/internal/app/messaging"
14 "github.com/bnema/dumber/internal/config"16 "github.com/bnema/dumber/internal/config"
15 "github.com/bnema/dumber/internal/filtering"17 "github.com/bnema/dumber/internal/filtering"
16 "github.com/bnema/dumber/pkg/webkit"18 "github.com/bnema/dumber/pkg/webkit"
@@ -43,17 +45,26 @@ func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig {
43 return mc45 return mc
44 }46 }
45 47
46-// createWebView creates and configures the WebView48+func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
47-func (app *BrowserApp) createWebView() error {49+ dataDir, err := config.GetDataDir()
48- log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())50+ if err != nil {
49- dataDir, _ := config.GetDataDir()51+ return nil, err
50- stateDir, _ := config.GetStateDir()52+ }
53+ stateDir, err := config.GetStateDir()
54+ if err != nil {
55+ return nil, err
56+ }
57+
51 webkitData := filepath.Join(dataDir, "webkit")58 webkitData := filepath.Join(dataDir, "webkit")
52 webkitCache := filepath.Join(stateDir, "webkit-cache")59 webkitCache := filepath.Join(stateDir, "webkit-cache")
53- _ = os.MkdirAll(webkitData, constants.DirPerm)60+ if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
54- _ = os.MkdirAll(webkitCache, constants.DirPerm)61+ return nil, err
62+ }
63+ if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
64+ return nil, err
65+ }
55 66
56- view, err := webkit.NewWebView(&webkit.Config{67+ cfg := &webkit.Config{
57 Assets: app.assets,68 Assets: app.assets,
58 InitialURL: "dumb://homepage",69 InitialURL: "dumb://homepage",
59 ZoomDefault: 1.0,70 ZoomDefault: 1.0,
@@ -82,52 +93,77 @@ func (app *BrowserApp) createWebView() error {
82 CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,93 CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,
83 DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,94 DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
84 },95 },
85- })96+ }
86 97
87- if err != nil {98+ return cfg, nil
88- return err99+}
100+
101+func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
102+ pane := &BrowserPane{
103+ webView: view,
89 }104 }
90 105
91- app.webView = view106+ pane.clipboardController = control.NewClipboardController(view)
92- app.setupWebViewIntegration()107+ pane.zoomController = control.NewZoomController(app.browserService, view)
93- app.setupWebViewHandlers()108+ pane.navigationController = control.NewNavigationController(
94- app.setupControllers()109+ app.parserService,
95- app.setupKeyboardShortcuts()110+ app.browserService,
111+ view,
112+ pane.zoomController,
113+ )
114+ pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
115+ pane.messageHandler.SetWebView(view)
116+ pane.messageHandler.SetNavigationController(pane.navigationController)
117+ pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
96 118
97- // Apply initial zoom and show window119+ pane.zoomController.RegisterHandlers()
98- app.zoomController.ApplyInitialZoom()120+ pane.shortcutHandler.RegisterShortcuts()
99 121
100- log.Printf("Showing WebView window…")122+ return pane, nil
101- if err := view.Show(); err != nil {123+}
102- log.Printf("Warning: failed to show WebView: %v", err)124+
103- } else if !webkit.IsNativeAvailable() {125+func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
104- log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")126+ pane, err := app.buildPane(view)
127+ if err != nil {
128+ return nil, err
105 }129 }
106 130
107- return nil131+ // Generate unique pane ID
108-}132+ pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
133+ pane.initializeGUITracking()
109 134
110-// setupWebViewIntegration connects the WebView to browser services135+ // Inject minimal bootstrap script for pane initialization
111-func (app *BrowserApp) setupWebViewIntegration() {136+ bootstrapScript := fmt.Sprintf(`
112- app.browserService.AttachWebView(app.webView)137+ window.__dumber_pane = {
138+ id: '%s',
139+ created: %d,
140+ active: false
141+ };
113 142
114- // GUI bundle is loaded via WebKit user scripts in enableUserContentManager143+ // GUI manager will be loaded on-demand
115- // No need to load separately in browser service144+ window.__dumber_gui_ready = true;
116 145
117- // Use native window as title updater146+ console.log('[pane] Initialized pane %s');
118- if win := app.webView.Window(); win != nil {147+ `, pane.ID(), time.Now().UnixMilli(), pane.ID())
119- app.browserService.SetWindowTitleUpdater(win)148+
149+ if err := view.InjectScript(bootstrapScript); err != nil {
150+ log.Printf("[pane-%s] Failed to inject bootstrap: %v", pane.ID(), err)
120 }151 }
152+
153+ app.attachPaneHandlers(pane)
154+ return pane, nil
121 }155 }
122 156
123-// setupWebViewHandlers configures WebView event handlers157+func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
124-func (app *BrowserApp) setupWebViewHandlers() {158+ if pane == nil || pane.webView == nil {
125- // Persist page titles to DB when they change159+ return
126- app.webView.RegisterTitleChangedHandler(func(title string) {160+ }
161+
162+ pane.webView.RegisterTitleChangedHandler(func(title string) {
127 if title == "" {163 if title == "" {
128 return164 return
129 }165 }
130- url := app.webView.GetCurrentURL()166+ url := pane.webView.GetCurrentURL()
131 if url == "" {167 if url == "" {
132 return168 return
133 }169 }
@@ -139,36 +175,121 @@ func (app *BrowserApp) setupWebViewHandlers() {
139 }(url, title)175 }(url, title)
140 })176 })
141 177
142- // Set WebView reference for message handler and register script messages178+ pane.webView.RegisterScriptMessageHandler(func(payload string) {
143- app.messageHandler.SetWebView(app.webView)179+ if app.workspace != nil {
144- app.webView.RegisterScriptMessageHandler(app.messageHandler.Handle)180+ app.workspace.focusByView(pane.webView)
181+ }
182+ pane.messageHandler.Handle(payload)
183+ })
184+
185+ if pane.messageHandler != nil && pane.navigationController != nil {
186+ pane.messageHandler.SetNavigationController(pane.navigationController)
187+ }
188+
189+ pane.webView.RegisterPopupHandler(func(uri string) bool {
190+ handled := false
191+ if app.workspace != nil {
192+ handled = app.workspace.HandlePopup(pane.webView, uri)
193+ }
194+ if handled {
195+ return true
196+ }
197+
198+ if pane.navigationController != nil && uri != "" {
199+ if err := pane.navigationController.NavigateToURL(uri); err != nil {
200+ log.Printf("[workspace] popup fallback navigation failed: %v", err)
201+ if pane.webView != nil {
202+ if loadErr := pane.webView.LoadURL(uri); loadErr != nil {
203+ log.Printf("[workspace] popup fallback load failed: %v", loadErr)
204+ }
205+ }
206+ }
207+ return true
208+ }
209+
210+ if pane.webView != nil && uri != "" {
211+ if err := pane.webView.LoadURL(uri); err != nil {
212+ log.Printf("[workspace] popup direct load failed: %v", err)
213+ }
214+ return true
215+ }
145 216
217+ return false
218+ })
146 }219 }
147 220
148-// setupControllers initializes and configures controller objects221+// createWebView creates and configures the WebView
149-func (app *BrowserApp) setupControllers() {222+func (app *BrowserApp) createWebView() error {
150- // Initialize controllers223+ log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
151- app.zoomController = control.NewZoomController(app.browserService, app.webView)224+
152- app.navigationController = control.NewNavigationController(225+ cfg, err := app.buildWebkitConfig()
153- app.parserService,226+ if err != nil {
154- app.browserService,227+ return err
155- app.webView,228+ }
156- app.zoomController,229+
157- )230+ // Main window needs a top-level window
158- app.clipboardController = control.NewClipboardController(app.webView)231+ cfg.CreateWindow = true
232+
233+ view, err := webkit.NewWebView(cfg)
234+
235+ if err != nil {
236+ return err
237+ }
238+
239+ pane, err := app.createPaneForView(view)
240+ if err != nil {
241+ return err
242+ }
243+
244+ app.webView = view
245+ app.zoomController = pane.zoomController
246+ app.navigationController = pane.navigationController
247+ app.clipboardController = pane.clipboardController
248+ app.messageHandler = pane.messageHandler
249+ app.shortcutHandler = pane.shortcutHandler
250+ app.panes = []*BrowserPane{pane}
251+ app.activePane = pane
252+
253+ if app.workspace == nil {
254+ app.workspace = NewWorkspaceManager(app, pane)
255+ }
159 256
160- if app.messageHandler != nil && app.navigationController != nil {257+ // Initialize window-level global shortcuts AFTER workspace is set up
161- app.messageHandler.SetNavigationController(app.navigationController)258+ if window := view.Window(); window != nil {
259+ app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
260+ if app.windowShortcutHandler != nil {
261+ log.Printf("Window-level global shortcuts initialized")
262+ } else {
263+ log.Printf("Warning: Failed to initialize window-level shortcuts")
264+ }
162 }265 }
163 266
164- // Register controller handlers267+ app.setupWebViewIntegration()
165- app.zoomController.RegisterHandlers()268+
269+ // Apply initial zoom and show window
270+ app.zoomController.ApplyInitialZoom()
271+
272+ log.Printf("Showing WebView window…")
273+ if err := view.Show(); err != nil {
274+ log.Printf("Warning: failed to show WebView: %v", err)
275+ } else if !webkit.IsNativeAvailable() {
276+ log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
277+ }
278+
279+ return nil
166 }280 }
167 281
168-// setupKeyboardShortcuts registers keyboard shortcuts282+// setupWebViewIntegration connects the WebView to browser services
169-func (app *BrowserApp) setupKeyboardShortcuts() {283+func (app *BrowserApp) setupWebViewIntegration() {
170- app.shortcutHandler = NewShortcutHandler(app.webView, app.clipboardController)284+ app.browserService.AttachWebView(app.webView)
171- app.shortcutHandler.RegisterShortcuts()285+
286+ // GUI bundle is loaded via WebKit user scripts in enableUserContentManager
287+ // No need to load separately in browser service
288+
289+ // Use native window as title updater
290+ if win := app.webView.Window(); win != nil {
291+ app.browserService.SetWindowTitleUpdater(win)
292+ }
172 }293 }
173 294
174 // setupContentBlocking initializes the content blocking system with proper timing295 // setupContentBlocking initializes the content blocking system with proper timing
M pkg/webkit/keyboard_cgo.go +49 -10
@@ -8,15 +8,14 @@ package webkit
88 #include <gdk/gdk.h>
99
1010 // Declarations for Go callbacks
11-extern void goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state);
11+extern gboolean goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state);
1212 extern void goOnButtonPress(unsigned long id, unsigned int button, GdkModifierType state);
1313 extern void goOnScroll(unsigned long id, double dx, double dy, GdkModifierType state);
1414
1515 // Key controller callback -> forward to Go
1616 static gboolean on_key_pressed(GtkEventControllerKey* controller, guint keyval, guint keycode, GdkModifierType state, gpointer user_data) {
1717 (void)controller; (void)keycode;
18- goOnKeyPress((unsigned long)user_data, keyval, state);
19- return FALSE; // do not stop other handlers
18+ return goOnKeyPress((unsigned long)user_data, keyval, state);
2019 }
2120
2221 // Mouse click gesture -> forward side buttons to Go
@@ -137,15 +136,18 @@ func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error
137136 }
138137
139138 //export goOnKeyPress
140-func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) {
139+func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) C.gboolean {
141140 // Normalize and dispatch
142141 uid := uintptr(id)
143142 kv := uint(keyval)
144143 st := uint(state)
145- dispatchAccelerator(uid, kv, st)
144+ if dispatchAccelerator(uid, kv, st) {
145+ return C.gboolean(1)
146+ }
147+ return C.gboolean(0)
146148 }
147149
148-func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
150+func dispatchAccelerator(uid uintptr, keyval uint, state uint) bool {
149151 ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0
150152 alt := (state & uint(C.GDK_ALT_MASK)) != 0
151153 shift := (state & uint(C.GDK_SHIFT_MASK)) != 0
@@ -165,6 +167,18 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
165167 keyName = "ArrowLeft"
166168 case uint(C.GDK_KEY_Right):
167169 keyName = "ArrowRight"
170+ case uint(C.GDK_KEY_Up):
171+ keyName = "ArrowUp"
172+ case uint(C.GDK_KEY_Down):
173+ keyName = "ArrowDown"
174+ case uint(C.GDK_KEY_KP_Left):
175+ keyName = "ArrowLeft"
176+ case uint(C.GDK_KEY_KP_Right):
177+ keyName = "ArrowRight"
178+ case uint(C.GDK_KEY_KP_Up):
179+ keyName = "ArrowUp"
180+ case uint(C.GDK_KEY_KP_Down):
181+ keyName = "ArrowDown"
168182 case uint(C.GDK_KEY_F12):
169183 keyName = "F12"
170184 case uint(C.GDK_KEY_F5):
@@ -177,18 +191,20 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
177191 keyName = "l"
178192 case uint(C.GDK_KEY_f), uint(C.GDK_KEY_F):
179193 keyName = "f"
194+ case uint(C.GDK_KEY_p), uint(C.GDK_KEY_P):
195+ keyName = "p"
180196 default:
181197 // If Control held, log unknown keyval for diagnostics
182198 if ctrl {
183199 log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval)
184200 }
185- return
201+ return false
186202 }
187203
188204 now := time.Now()
189205 rawKey := fmt.Sprintf("raw:%v:%v:%v:%s", ctrl, alt, shift, keyName)
190206 if shouldDebounceShortcut(uid, rawKey, now) {
191- return
207+ return false
192208 }
193209
194210 if logMinusEvent {
@@ -196,6 +212,7 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
196212 }
197213
198214 // Candidate accelerator strings in order
215+ handled := false
199216 candidates := []string{keyName}
200217 if ctrl && shift {
201218 // Ctrl+Shift combinations
@@ -238,18 +255,21 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
238255 reg := viewShortcuts[uid]
239256 regMu.RUnlock()
240257 if reg == nil {
241- return
258+ return handled
242259 }
243260 for _, name := range candidates {
244261 if cb, ok := reg[name]; ok {
245262 if shouldDebounceShortcut(uid, "shortcut:"+name, now) {
246- return
263+ return handled
247264 }
248265 log.Printf("[accelerator] %s", name)
249266 cb()
267+ handled = true
250268 break
251269 }
252270 }
271+
272+ return handled
253273 }
254274
255275 func nextViewID() uintptr {
@@ -416,6 +436,25 @@ func goOnURIChanged(id C.ulong, curi *C.char) {
416436 vw.dispatchURIChanged(uri)
417437 }
418438
439+//export goOnPopupRequest
440+func goOnPopupRequest(id C.ulong, curi *C.char) C.gboolean {
441+ uid := uintptr(id)
442+ regMu.RLock()
443+ vw := viewByID[uid]
444+ regMu.RUnlock()
445+ if vw == nil {
446+ return C.gboolean(0)
447+ }
448+ var uri string
449+ if curi != nil {
450+ uri = C.GoString(curi)
451+ }
452+ if vw.dispatchPopupRequest(uri) {
453+ return C.gboolean(1)
454+ }
455+ return C.gboolean(0)
456+}
457+
419458 //export goOnButtonPress
420459 func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) {
421460 uid := uintptr(id)
@@ -8,15 +8,14 @@ package webkit
8 #include <gdk/gdk.h>8 #include <gdk/gdk.h>
9 9
10 // Declarations for Go callbacks10 // Declarations for Go callbacks
11-extern void goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state);11+extern gboolean goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state);
12 extern void goOnButtonPress(unsigned long id, unsigned int button, GdkModifierType state);12 extern void goOnButtonPress(unsigned long id, unsigned int button, GdkModifierType state);
13 extern void goOnScroll(unsigned long id, double dx, double dy, GdkModifierType state);13 extern void goOnScroll(unsigned long id, double dx, double dy, GdkModifierType state);
14 14
15 // Key controller callback -> forward to Go15 // Key controller callback -> forward to Go
16 static gboolean on_key_pressed(GtkEventControllerKey* controller, guint keyval, guint keycode, GdkModifierType state, gpointer user_data) {16 static gboolean on_key_pressed(GtkEventControllerKey* controller, guint keyval, guint keycode, GdkModifierType state, gpointer user_data) {
17 (void)controller; (void)keycode;17 (void)controller; (void)keycode;
18- goOnKeyPress((unsigned long)user_data, keyval, state);18+ return goOnKeyPress((unsigned long)user_data, keyval, state);
19- return FALSE; // do not stop other handlers
20 }19 }
21 20
22 // Mouse click gesture -> forward side buttons to Go21 // Mouse click gesture -> forward side buttons to Go
@@ -137,15 +136,18 @@ func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error
137 }136 }
138 137
139 //export goOnKeyPress138 //export goOnKeyPress
140-func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) {139+func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) C.gboolean {
141 // Normalize and dispatch140 // Normalize and dispatch
142 uid := uintptr(id)141 uid := uintptr(id)
143 kv := uint(keyval)142 kv := uint(keyval)
144 st := uint(state)143 st := uint(state)
145- dispatchAccelerator(uid, kv, st)144+ if dispatchAccelerator(uid, kv, st) {
145+ return C.gboolean(1)
146+ }
147+ return C.gboolean(0)
146 }148 }
147 149
148-func dispatchAccelerator(uid uintptr, keyval uint, state uint) {150+func dispatchAccelerator(uid uintptr, keyval uint, state uint) bool {
149 ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0151 ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0
150 alt := (state & uint(C.GDK_ALT_MASK)) != 0152 alt := (state & uint(C.GDK_ALT_MASK)) != 0
151 shift := (state & uint(C.GDK_SHIFT_MASK)) != 0153 shift := (state & uint(C.GDK_SHIFT_MASK)) != 0
@@ -165,6 +167,18 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
165 keyName = "ArrowLeft"167 keyName = "ArrowLeft"
166 case uint(C.GDK_KEY_Right):168 case uint(C.GDK_KEY_Right):
167 keyName = "ArrowRight"169 keyName = "ArrowRight"
170+ case uint(C.GDK_KEY_Up):
171+ keyName = "ArrowUp"
172+ case uint(C.GDK_KEY_Down):
173+ keyName = "ArrowDown"
174+ case uint(C.GDK_KEY_KP_Left):
175+ keyName = "ArrowLeft"
176+ case uint(C.GDK_KEY_KP_Right):
177+ keyName = "ArrowRight"
178+ case uint(C.GDK_KEY_KP_Up):
179+ keyName = "ArrowUp"
180+ case uint(C.GDK_KEY_KP_Down):
181+ keyName = "ArrowDown"
168 case uint(C.GDK_KEY_F12):182 case uint(C.GDK_KEY_F12):
169 keyName = "F12"183 keyName = "F12"
170 case uint(C.GDK_KEY_F5):184 case uint(C.GDK_KEY_F5):
@@ -177,18 +191,20 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
177 keyName = "l"191 keyName = "l"
178 case uint(C.GDK_KEY_f), uint(C.GDK_KEY_F):192 case uint(C.GDK_KEY_f), uint(C.GDK_KEY_F):
179 keyName = "f"193 keyName = "f"
194+ case uint(C.GDK_KEY_p), uint(C.GDK_KEY_P):
195+ keyName = "p"
180 default:196 default:
181 // If Control held, log unknown keyval for diagnostics197 // If Control held, log unknown keyval for diagnostics
182 if ctrl {198 if ctrl {
183 log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval)199 log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval)
184 }200 }
185- return201+ return false
186 }202 }
187 203
188 now := time.Now()204 now := time.Now()
189 rawKey := fmt.Sprintf("raw:%v:%v:%v:%s", ctrl, alt, shift, keyName)205 rawKey := fmt.Sprintf("raw:%v:%v:%v:%s", ctrl, alt, shift, keyName)
190 if shouldDebounceShortcut(uid, rawKey, now) {206 if shouldDebounceShortcut(uid, rawKey, now) {
191- return207+ return false
192 }208 }
193 209
194 if logMinusEvent {210 if logMinusEvent {
@@ -196,6 +212,7 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
196 }212 }
197 213
198 // Candidate accelerator strings in order214 // Candidate accelerator strings in order
215+ handled := false
199 candidates := []string{keyName}216 candidates := []string{keyName}
200 if ctrl && shift {217 if ctrl && shift {
201 // Ctrl+Shift combinations218 // Ctrl+Shift combinations
@@ -238,18 +255,21 @@ func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
238 reg := viewShortcuts[uid]255 reg := viewShortcuts[uid]
239 regMu.RUnlock()256 regMu.RUnlock()
240 if reg == nil {257 if reg == nil {
241- return258+ return handled
242 }259 }
243 for _, name := range candidates {260 for _, name := range candidates {
244 if cb, ok := reg[name]; ok {261 if cb, ok := reg[name]; ok {
245 if shouldDebounceShortcut(uid, "shortcut:"+name, now) {262 if shouldDebounceShortcut(uid, "shortcut:"+name, now) {
246- return263+ return handled
247 }264 }
248 log.Printf("[accelerator] %s", name)265 log.Printf("[accelerator] %s", name)
249 cb()266 cb()
267+ handled = true
250 break268 break
251 }269 }
252 }270 }
271+
272+ return handled
253 }273 }
254 274
255 func nextViewID() uintptr {275 func nextViewID() uintptr {
@@ -416,6 +436,25 @@ func goOnURIChanged(id C.ulong, curi *C.char) {
416 vw.dispatchURIChanged(uri)436 vw.dispatchURIChanged(uri)
417 }437 }
418 438
439+//export goOnPopupRequest
440+func goOnPopupRequest(id C.ulong, curi *C.char) C.gboolean {
441+ uid := uintptr(id)
442+ regMu.RLock()
443+ vw := viewByID[uid]
444+ regMu.RUnlock()
445+ if vw == nil {
446+ return C.gboolean(0)
447+ }
448+ var uri string
449+ if curi != nil {
450+ uri = C.GoString(curi)
451+ }
452+ if vw.dispatchPopupRequest(uri) {
453+ return C.gboolean(1)
454+ }
455+ return C.gboolean(0)
456+}
457+
419 //export goOnButtonPress458 //export goOnButtonPress
420 func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) {459 func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) {
421 uid := uintptr(id)460 uid := uintptr(id)
M pkg/webkit/settings.go +4 -0
@@ -47,6 +47,10 @@ type Config struct {
4747
4848 // UseDomZoom toggles CSS/DOM-based zoom instead of WebKit's native zoom.
4949 UseDomZoom bool
50+
51+ // CreateWindow controls whether to create a window during WebView initialization.
52+ // Set to false when creating WebViews for workspace panes that will be reparented.
53+ CreateWindow bool
5054 }
5155
5256 // CodecPreferencesConfig holds codec preferences for WebKit media playback
@@ -47,6 +47,10 @@ type Config struct {
47 47
48 // UseDomZoom toggles CSS/DOM-based zoom instead of WebKit's native zoom.48 // UseDomZoom toggles CSS/DOM-based zoom instead of WebKit's native zoom.
49 UseDomZoom bool49 UseDomZoom bool
50+
51+ // CreateWindow controls whether to create a window during WebView initialization.
52+ // Set to false when creating WebViews for workspace panes that will be reparented.
53+ CreateWindow bool
50 }54 }
51 55
52 // CodecPreferencesConfig holds codec preferences for WebKit media playback56 // CodecPreferencesConfig holds codec preferences for WebKit media playback
M pkg/webkit/webview.go +27 -0
@@ -20,8 +20,10 @@ type WebView struct {
2020 titleHandler func(title string)
2121 uriHandler func(uri string)
2222 zoomHandler func(level float64)
23+ popupHandler func(string) bool
2324 useDomZoom bool
2425 domZoomSeed float64
26+ container uintptr
2527 }
2628
2729 // NewWebView constructs a new WebView instance.
@@ -39,6 +41,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
3941 wv.domZoomSeed = wv.zoom
4042 // Construction succeeds; create a logical window placeholder.
4143 wv.window = &Window{Title: "Dumber Browser"}
44+ wv.container = newWidgetHandle()
4245 return wv, nil
4346 }
4447
@@ -111,12 +114,21 @@ func (w *WebView) CloseDevTools() error { return nil }
111114 // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
112115 func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
113116
117+func (w *WebView) RegisterPopupHandler(cb func(string) bool) { w.popupHandler = cb }
118+
114119 func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks
115120 if w != nil && w.msgHandler != nil {
116121 w.msgHandler(payload)
117122 }
118123 }
119124
125+func (w *WebView) dispatchPopupRequest(uri string) bool { //nolint:unused // Called from CGO WebKit callbacks
126+ if w != nil && w.popupHandler != nil {
127+ return w.popupHandler(uri)
128+ }
129+ return false
130+}
131+
120132 // RegisterTitleChangedHandler registers a callback invoked when the page title changes.
121133 func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
122134
@@ -144,6 +156,15 @@ func (w *WebView) dispatchZoomChanged(level float64) {
144156 }
145157 }
146158
159+// Widget returns an empty handle in stub builds.
160+func (w *WebView) Widget() uintptr { return 0 }
161+
162+// RootWidget returns the container widget handle in CGO builds. Stub returns 0.
163+func (w *WebView) RootWidget() uintptr { return w.container }
164+
165+// DestroyWindow is a no-op in stub builds.
166+func (w *WebView) DestroyWindow() {}
167+
147168 // RunOnMainThread executes fn immediately in non-CGO builds.
148169 func (w *WebView) RunOnMainThread(fn func()) {
149170 if fn != nil {
@@ -151,6 +172,12 @@ func (w *WebView) RunOnMainThread(fn func()) {
151172 }
152173 }
153174
175+// PrepareForReparenting is a no-op in stub builds.
176+func (w *WebView) PrepareForReparenting() {}
177+
178+// RefreshAfterReparenting is a no-op in stub builds.
179+func (w *WebView) RefreshAfterReparenting() {}
180+
154181 // UsesDomZoom reports whether DOM-based zoom is enabled in this WebView.
155182 func (w *WebView) UsesDomZoom() bool {
156183 return w != nil && w.useDomZoom
@@ -20,8 +20,10 @@ type WebView struct {
20 titleHandler func(title string)20 titleHandler func(title string)
21 uriHandler func(uri string)21 uriHandler func(uri string)
22 zoomHandler func(level float64)22 zoomHandler func(level float64)
23+ popupHandler func(string) bool
23 useDomZoom bool24 useDomZoom bool
24 domZoomSeed float6425 domZoomSeed float64
26+ container uintptr
25 }27 }
26 28
27 // NewWebView constructs a new WebView instance.29 // NewWebView constructs a new WebView instance.
@@ -39,6 +41,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
39 wv.domZoomSeed = wv.zoom41 wv.domZoomSeed = wv.zoom
40 // Construction succeeds; create a logical window placeholder.42 // Construction succeeds; create a logical window placeholder.
41 wv.window = &Window{Title: "Dumber Browser"}43 wv.window = &Window{Title: "Dumber Browser"}
44+ wv.container = newWidgetHandle()
42 return wv, nil45 return wv, nil
43 }46 }
44 47
@@ -111,12 +114,21 @@ func (w *WebView) CloseDevTools() error { return nil }
111 // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.114 // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
112 func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }115 func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
113 116
117+func (w *WebView) RegisterPopupHandler(cb func(string) bool) { w.popupHandler = cb }
118+
114 func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks119 func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks
115 if w != nil && w.msgHandler != nil {120 if w != nil && w.msgHandler != nil {
116 w.msgHandler(payload)121 w.msgHandler(payload)
117 }122 }
118 }123 }
119 124
125+func (w *WebView) dispatchPopupRequest(uri string) bool { //nolint:unused // Called from CGO WebKit callbacks
126+ if w != nil && w.popupHandler != nil {
127+ return w.popupHandler(uri)
128+ }
129+ return false
130+}
131+
120 // RegisterTitleChangedHandler registers a callback invoked when the page title changes.132 // RegisterTitleChangedHandler registers a callback invoked when the page title changes.
121 func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }133 func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
122 134
@@ -144,6 +156,15 @@ func (w *WebView) dispatchZoomChanged(level float64) {
144 }156 }
145 }157 }
146 158
159+// Widget returns an empty handle in stub builds.
160+func (w *WebView) Widget() uintptr { return 0 }
161+
162+// RootWidget returns the container widget handle in CGO builds. Stub returns 0.
163+func (w *WebView) RootWidget() uintptr { return w.container }
164+
165+// DestroyWindow is a no-op in stub builds.
166+func (w *WebView) DestroyWindow() {}
167+
147 // RunOnMainThread executes fn immediately in non-CGO builds.168 // RunOnMainThread executes fn immediately in non-CGO builds.
148 func (w *WebView) RunOnMainThread(fn func()) {169 func (w *WebView) RunOnMainThread(fn func()) {
149 if fn != nil {170 if fn != nil {
@@ -151,6 +172,12 @@ func (w *WebView) RunOnMainThread(fn func()) {
151 }172 }
152 }173 }
153 174
175+// PrepareForReparenting is a no-op in stub builds.
176+func (w *WebView) PrepareForReparenting() {}
177+
178+// RefreshAfterReparenting is a no-op in stub builds.
179+func (w *WebView) RefreshAfterReparenting() {}
180+
154 // UsesDomZoom reports whether DOM-based zoom is enabled in this WebView.181 // UsesDomZoom reports whether DOM-based zoom is enabled in this WebView.
155 func (w *WebView) UsesDomZoom() bool {182 func (w *WebView) UsesDomZoom() bool {
156 return w != nil && w.useDomZoom183 return w != nil && w.useDomZoom
M pkg/webkit/webview_cgo.go +195 -46
@@ -22,6 +22,7 @@ static GtkWidget* new_window() { return GTK_WIDGET(gtk_window_new()); }
2222 // Forward declaration
2323 extern void goQuitMainLoop();
2424 extern void goInvokeHandle(uintptr_t handle);
25+extern gboolean goOnPopupRequest(unsigned long id, char* uri);
2526
2627 static gboolean invoke_handle_cb(gpointer data) {
2728 goInvokeHandle((uintptr_t)data);
@@ -51,20 +52,24 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
5152 GTlsCertificateFlags errors,
5253 gpointer user_data);
5354
54-// Handle create signal for _blank links - redirect to current window
55+// Handle create signal for _blank links - delegate to Go workspace manager
5556 static WebKitWebView* on_create_new_window(WebKitWebView* web_view,
5657 WebKitNavigationAction* navigation_action,
5758 gpointer user_data) {
58- (void)user_data;
59+ unsigned long view_id = (unsigned long)user_data;
60+ const gchar* uri = NULL;
5961
6062 // Get the request from navigation action
6163 WebKitURIRequest* request = webkit_navigation_action_get_request(navigation_action);
6264 if (request) {
63- const gchar* uri = webkit_uri_request_get_uri(request);
64- if (uri) {
65- // Load in current window instead of creating new one
66- webkit_web_view_load_uri(web_view, uri);
67- }
65+ uri = webkit_uri_request_get_uri(request);
66+ }
67+
68+ gboolean handled = goOnPopupRequest(view_id, (char*)uri);
69+
70+ if (!handled && uri) {
71+ // Load in current window instead of creating new one
72+ webkit_web_view_load_uri(web_view, uri);
6873 }
6974
7075 // Return NULL to prevent new window creation
@@ -150,9 +155,6 @@ static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const c
150155 GdkRGBA black_color = { 0.0, 0.0, 0.0, 1.0 };
151156 webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(w), &black_color);
152157
153- // Handle create signal to redirect _blank links to current window
154- g_signal_connect(G_OBJECT(w), "create", G_CALLBACK(on_create_new_window), NULL);
155-
156158 // Connect TLS error handler
157159 connect_tls_error_handler(WEBKIT_WEB_VIEW(w));
158160
@@ -160,6 +162,11 @@ static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const c
160162 return w;
161163 }
162164
165+static void connect_create_handler(WebKitWebView* web_view, unsigned long id) {
166+ if (!web_view) return;
167+ g_signal_connect_data(G_OBJECT(web_view), "create", G_CALLBACK(on_create_new_window), (gpointer)id, NULL, 0);
168+}
169+
163170 static gboolean gtk_prefers_dark() {
164171 // Method 1: Check GNOME desktop interface color-scheme (primary method)
165172 GSettings* desktop_settings = g_settings_new("org.gnome.desktop.interface");
@@ -638,6 +645,7 @@ static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* host
638645 // Return response code: 0=Go Back, 1=Proceed Once, 2=Always Accept
639646 return response_data.response;
640647 }
648+
641649 */
642650 import "C"
643651
@@ -655,6 +663,7 @@ import (
655663 "runtime/cgo"
656664 "strconv"
657665 "strings"
666+ "sync"
658667 "time"
659668 "unsafe"
660669
@@ -791,6 +800,21 @@ const domBridgeTemplate = `(() => { try {
791800 };
792801 } catch (e) { console.warn('[dumber] unified bridge init failed', e); } })();`
793802
803+var registerSchemeOnce sync.Once
804+
805+func registerDumbScheme(ctx *C.WebKitWebContext) {
806+ if ctx == nil {
807+ return
808+ }
809+
810+ registerSchemeOnce.Do(func() {
811+ sch := C.CString("dumb")
812+ defer C.free(unsafe.Pointer(sch))
813+ C.register_uri_scheme_handler(ctx, sch)
814+ C.register_uri_scheme_security(ctx, sch)
815+ })
816+}
817+
794818 // Helper function to convert bool to int for C interop
795819 func boolToInt(b bool) int {
796820 if b {
@@ -894,10 +918,11 @@ func storeCertificateDecision(hostname, certificateInfo, decision string, perman
894918 }
895919
896920 type nativeView struct {
897- win *C.GtkWidget
898- view *C.GtkWidget
899- wv *C.WebKitWebView
900- ucm *C.WebKitUserContentManager
921+ win *C.GtkWidget
922+ container *C.GtkWidget
923+ view *C.GtkWidget
924+ wv *C.WebKitWebView
925+ ucm *C.WebKitUserContentManager
901926 }
902927
903928 type memoryStats struct {
@@ -919,6 +944,7 @@ type WebView struct {
919944 titleHandler func(title string)
920945 uriHandler func(uri string)
921946 zoomHandler func(level float64)
947+ popupHandler func(string) bool
922948 memStats *memoryStats
923949 gcTicker *time.Ticker
924950 gcDone chan struct{}
@@ -926,6 +952,7 @@ type WebView struct {
926952 useDomZoom bool
927953 domZoomSeed float64
928954 domBridgeScript *C.WebKitUserScript
955+ wasReparented bool // Track if this WebView has been reparented
929956 }
930957
931958 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
@@ -1018,13 +1045,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
10181045 }
10191046 }
10201047
1021- // Register custom URI scheme handler for "dumb://"
1022- {
1023- sch := C.CString("dumb")
1024- C.register_uri_scheme_handler(ctx, sch)
1025- C.register_uri_scheme_security(ctx, sch)
1026- C.free(unsafe.Pointer(sch))
1027- }
1048+ registerDumbScheme(ctx)
10281049 // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session
10291050
10301051 // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style)
@@ -1034,17 +1055,35 @@ func NewWebView(cfg *Config) (*WebView, error) {
10341055 return nil, errors.New("failed to create WebKitWebView")
10351056 }
10361057
1037- // Create a top-level window to host the view
1038- win := C.new_window()
1039- if win == nil {
1040- return nil, errors.New("failed to create GtkWindow")
1041- }
1058+ var win *C.GtkWidget
1059+ var window *Window
1060+ container := C.gtk_box_new(C.GtkOrientation(C.GTK_ORIENTATION_VERTICAL), 0)
1061+ if container == nil {
1062+ return nil, errors.New("failed to create GtkBox container")
1063+ }
1064+ // Hold a strong reference so the WebView keeps the container alive across reparenting.
1065+ C.g_object_ref_sink(C.gpointer(container))
1066+ C.gtk_widget_set_hexpand(container, C.gboolean(1))
1067+ C.gtk_widget_set_vexpand(container, C.gboolean(1))
1068+ C.gtk_widget_set_hexpand(viewWidget, C.gboolean(1))
1069+ C.gtk_widget_set_vexpand(viewWidget, C.gboolean(1))
1070+ C.gtk_box_append((*C.GtkBox)(unsafe.Pointer(container)), viewWidget)
1071+
1072+ // Only create window if requested
1073+ if cfg.CreateWindow {
1074+ // Create a top-level window to host the view
1075+ win = C.new_window()
1076+ if win == nil {
1077+ return nil, errors.New("failed to create GtkWindow")
1078+ }
10421079
1043- // Pack view widget into the window
1044- // GTK4: containers removed; use gtk_window_set_child
1045- C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), viewWidget)
1046- C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
1047- C.connect_destroy_quit(win)
1080+ // Pack view widget into the window
1081+ // GTK4: containers removed; use gtk_window_set_child
1082+ C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), container)
1083+ C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
1084+ C.connect_destroy_quit(win)
1085+ window = &Window{win: win}
1086+ }
10481087
10491088 seed := 1.0
10501089 if cfg.ZoomDefault > 0 {
@@ -1056,8 +1095,8 @@ func NewWebView(cfg *Config) (*WebView, error) {
10561095 zoom: 1.0,
10571096 useDomZoom: cfg.UseDomZoom,
10581097 domZoomSeed: seed,
1059- native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
1060- window: &Window{win: win},
1098+ native: &nativeView{win: win, container: container, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
1099+ window: window,
10611100 memStats: &memoryStats{
10621101 pageLoadCount: 0,
10631102 lastGCTime: time.Now(),
@@ -1069,6 +1108,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
10691108 // Assign an ID for accelerator dispatch
10701109 v.id = nextViewID()
10711110 registerView(v.id, v)
1111+ C.connect_create_handler(v.native.wv, C.ulong(v.id))
10721112
10731113 // Register with global memory manager if monitoring is enabled
10741114 if cfg.Memory.EnableMemoryMonitoring {
@@ -1140,12 +1180,12 @@ func NewWebView(cfg *Config) (*WebView, error) {
11401180 _ = v.RegisterKeyboardShortcut("cmdorctrl+f", func() {
11411181 _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+f'}}))")
11421182 })
1143- // Navigation with Alt+Arrow keys: use new keyboard service bridge
1144- _ = v.RegisterKeyboardShortcut("alt+ArrowLeft", func() {
1145- _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'alt+arrowleft'}}))")
1183+ // Navigation with Ctrl/Cmd + Arrow keys: forward to keyboard service bridge
1184+ _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowLeft", func() {
1185+ _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowleft'}}))")
11461186 })
1147- _ = v.RegisterKeyboardShortcut("alt+ArrowRight", func() {
1148- _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'alt+arrowright'}}))")
1187+ _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowRight", func() {
1188+ _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowright'}}))")
11491189 })
11501190 // Update window title when page title changes
11511191 C.connect_title_notify(viewWidget, (*C.GtkWindow)(unsafe.Pointer(win)))
@@ -1305,10 +1345,18 @@ func (w *WebView) LoadURL(rawURL string) error {
13051345 }
13061346
13071347 func (w *WebView) Show() error {
1308- if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
1348+ if w == nil || w.destroyed || w.native == nil {
1349+ return ErrNotImplemented
1350+ }
1351+ if w.native.container != nil {
1352+ C.gtk_widget_set_visible(w.native.container, C.gboolean(1))
1353+ }
1354+ if w.native.view != nil {
1355+ C.gtk_widget_set_visible(w.native.view, C.gboolean(1))
1356+ }
1357+ if w.native.win == nil {
13091358 return ErrNotImplemented
13101359 }
1311- C.gtk_widget_set_visible(w.native.view, C.gboolean(1))
13121360 C.gtk_widget_set_visible(w.native.win, C.gboolean(1))
13131361 C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
13141362 log.Printf("[webkit] Show window")
@@ -1316,16 +1364,24 @@ func (w *WebView) Show() error {
13161364 }
13171365
13181366 func (w *WebView) Hide() error {
1319- if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
1367+ if w == nil || w.destroyed || w.native == nil {
1368+ return ErrNotImplemented
1369+ }
1370+ if w.native.container != nil {
1371+ C.gtk_widget_set_visible(w.native.container, C.gboolean(0))
1372+ }
1373+ if w.native.view != nil {
1374+ C.gtk_widget_set_visible(w.native.view, C.gboolean(0))
1375+ }
1376+ if w.native.win == nil {
13201377 return ErrNotImplemented
13211378 }
1322- C.gtk_widget_set_visible(w.native.view, C.gboolean(0))
13231379 C.gtk_widget_set_visible(w.native.win, C.gboolean(0))
13241380 return nil
13251381 }
13261382
13271383 func (w *WebView) Destroy() error {
1328- if w == nil || w.native == nil || w.native.win == nil {
1384+ if w == nil || w.native == nil {
13291385 return ErrNotImplemented
13301386 }
13311387
@@ -1349,14 +1405,32 @@ func (w *WebView) Destroy() error {
13491405 w.domBridgeScript = nil
13501406 }
13511407
1352- // GTK4: destroy windows via gtk_window_destroy
1353- C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1408+ // GTK4: destroy windows via gtk_window_destroy if we created one
1409+ if w.native.win != nil {
1410+ C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1411+ w.native.win = nil
1412+ if w.window != nil {
1413+ w.window.win = nil
1414+ }
1415+ log.Printf("[webkit] Destroy window")
1416+ }
1417+
1418+ w.releaseNativeWidgets()
13541419 w.destroyed = true
13551420 unregisterView(w.id)
1356- log.Printf("[webkit] Destroy window")
13571421 return nil
13581422 }
13591423
1424+func (w *WebView) releaseNativeWidgets() {
1425+ if w == nil || w.native == nil {
1426+ return
1427+ }
1428+ if w.native.container != nil {
1429+ C.g_object_unref(C.gpointer(w.native.container))
1430+ w.native.container = nil
1431+ }
1432+}
1433+
13601434 // Window returns the associated native window.
13611435 func (w *WebView) Window() *Window { return w.window }
13621436
@@ -1409,12 +1483,21 @@ func (w *WebView) ReloadBypassCache() error {
14091483 // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
14101484 func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
14111485
1486+func (w *WebView) RegisterPopupHandler(cb func(string) bool) { w.popupHandler = cb }
1487+
14121488 func (w *WebView) dispatchScriptMessage(payload string) {
14131489 if w != nil && w.msgHandler != nil {
14141490 w.msgHandler(payload)
14151491 }
14161492 }
14171493
1494+func (w *WebView) dispatchPopupRequest(uri string) bool {
1495+ if w != nil && w.popupHandler != nil {
1496+ return w.popupHandler(uri)
1497+ }
1498+ return false
1499+}
1500+
14181501 // RegisterTitleChangedHandler registers a callback invoked when the page title changes.
14191502 func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
14201503
@@ -1451,6 +1534,72 @@ func (w *WebView) dispatchZoomChanged(level float64) {
14511534 }
14521535 }
14531536
1537+// Widget returns the underlying GtkWidget pointer for the WebView.
1538+func (w *WebView) Widget() uintptr {
1539+ if w == nil || w.native == nil || w.native.view == nil {
1540+ return 0
1541+ }
1542+ return uintptr(unsafe.Pointer(w.native.view))
1543+}
1544+
1545+// RootWidget returns the container widget that should be reparented for pane management.
1546+func (w *WebView) RootWidget() uintptr {
1547+ if w == nil || w.native == nil || w.native.container == nil {
1548+ return 0
1549+ }
1550+ return uintptr(unsafe.Pointer(w.native.container))
1551+}
1552+
1553+// DestroyWindow destroys the toplevel window hosting this WebView, if any.
1554+func (w *WebView) DestroyWindow() {
1555+ if w == nil || w.native == nil || w.native.win == nil {
1556+ return
1557+ }
1558+ C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1559+ w.native.win = nil
1560+ if w.window != nil {
1561+ w.window.win = nil
1562+ }
1563+}
1564+
1565+// PrepareForReparenting prepares the WebView widget for being moved to a new parent
1566+func (w *WebView) PrepareForReparenting() {
1567+ if w == nil || w.native == nil || w.native.container == nil {
1568+ return
1569+ }
1570+
1571+ // Mark that this view is being reparented
1572+ w.wasReparented = true
1573+
1574+ // Ensure the widget is visible and properly sized for reparenting
1575+ C.gtk_widget_set_visible(w.native.container, C.gboolean(1))
1576+ C.gtk_widget_set_size_request(w.native.container, 100, 100)
1577+ C.gtk_widget_queue_draw(w.native.container)
1578+}
1579+
1580+// RefreshAfterReparenting refreshes the WebView after it has been reparented
1581+func (w *WebView) RefreshAfterReparenting() {
1582+ if w == nil || w.native == nil || w.native.wv == nil {
1583+ return
1584+ }
1585+
1586+ if w.wasReparented {
1587+ widgetHandle := w.RootWidget()
1588+ parentHandle := WidgetGetParent(widgetHandle)
1589+ if parentHandle == 0 {
1590+ log.Printf("[workspace] RefreshAfterReparenting deferred: widget=%#x parent=%#x url=%s", widgetHandle, parentHandle, w.url)
1591+ return
1592+ }
1593+ log.Printf("[workspace] RefreshAfterReparenting: widget=%#x parent=%#x url=%s", widgetHandle, parentHandle, w.url)
1594+ // Force WebKit to refresh its rendering context by triggering a reload
1595+ // This ensures the rendering context is properly reinitialized after reparenting
1596+ if w.url != "" {
1597+ w.LoadURL(w.url) // Reload current URL to refresh rendering context
1598+ }
1599+ w.wasReparented = false
1600+ }
1601+}
1602+
14541603 // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.
14551604 func (w *WebView) enableUserContentManager(cfg *Config) {
14561605 if w == nil || w.native == nil || w.native.ucm == nil {
@@ -22,6 +22,7 @@ static GtkWidget* new_window() { return GTK_WIDGET(gtk_window_new()); }
22 // Forward declaration22 // Forward declaration
23 extern void goQuitMainLoop();23 extern void goQuitMainLoop();
24 extern void goInvokeHandle(uintptr_t handle);24 extern void goInvokeHandle(uintptr_t handle);
25+extern gboolean goOnPopupRequest(unsigned long id, char* uri);
25 26
26 static gboolean invoke_handle_cb(gpointer data) {27 static gboolean invoke_handle_cb(gpointer data) {
27 goInvokeHandle((uintptr_t)data);28 goInvokeHandle((uintptr_t)data);
@@ -51,20 +52,24 @@ static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
51 GTlsCertificateFlags errors,52 GTlsCertificateFlags errors,
52 gpointer user_data);53 gpointer user_data);
53 54
54-// Handle create signal for _blank links - redirect to current window55+// Handle create signal for _blank links - delegate to Go workspace manager
55 static WebKitWebView* on_create_new_window(WebKitWebView* web_view,56 static WebKitWebView* on_create_new_window(WebKitWebView* web_view,
56 WebKitNavigationAction* navigation_action,57 WebKitNavigationAction* navigation_action,
57 gpointer user_data) {58 gpointer user_data) {
58- (void)user_data;59+ unsigned long view_id = (unsigned long)user_data;
60+ const gchar* uri = NULL;
59 61
60 // Get the request from navigation action62 // Get the request from navigation action
61 WebKitURIRequest* request = webkit_navigation_action_get_request(navigation_action);63 WebKitURIRequest* request = webkit_navigation_action_get_request(navigation_action);
62 if (request) {64 if (request) {
63- const gchar* uri = webkit_uri_request_get_uri(request);65+ uri = webkit_uri_request_get_uri(request);
64- if (uri) {66+ }
65- // Load in current window instead of creating new one67+
66- webkit_web_view_load_uri(web_view, uri);68+ gboolean handled = goOnPopupRequest(view_id, (char*)uri);
67- }69+
70+ if (!handled && uri) {
71+ // Load in current window instead of creating new one
72+ webkit_web_view_load_uri(web_view, uri);
68 }73 }
69 74
70 // Return NULL to prevent new window creation75 // Return NULL to prevent new window creation
@@ -150,9 +155,6 @@ static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const c
150 GdkRGBA black_color = { 0.0, 0.0, 0.0, 1.0 };155 GdkRGBA black_color = { 0.0, 0.0, 0.0, 1.0 };
151 webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(w), &black_color);156 webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(w), &black_color);
152 157
153- // Handle create signal to redirect _blank links to current window
154- g_signal_connect(G_OBJECT(w), "create", G_CALLBACK(on_create_new_window), NULL);
155-
156 // Connect TLS error handler158 // Connect TLS error handler
157 connect_tls_error_handler(WEBKIT_WEB_VIEW(w));159 connect_tls_error_handler(WEBKIT_WEB_VIEW(w));
158 160
@@ -160,6 +162,11 @@ static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const c
160 return w;162 return w;
161 }163 }
162 164
165+static void connect_create_handler(WebKitWebView* web_view, unsigned long id) {
166+ if (!web_view) return;
167+ g_signal_connect_data(G_OBJECT(web_view), "create", G_CALLBACK(on_create_new_window), (gpointer)id, NULL, 0);
168+}
169+
163 static gboolean gtk_prefers_dark() {170 static gboolean gtk_prefers_dark() {
164 // Method 1: Check GNOME desktop interface color-scheme (primary method)171 // Method 1: Check GNOME desktop interface color-scheme (primary method)
165 GSettings* desktop_settings = g_settings_new("org.gnome.desktop.interface");172 GSettings* desktop_settings = g_settings_new("org.gnome.desktop.interface");
@@ -638,6 +645,7 @@ static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* host
638 // Return response code: 0=Go Back, 1=Proceed Once, 2=Always Accept645 // Return response code: 0=Go Back, 1=Proceed Once, 2=Always Accept
639 return response_data.response;646 return response_data.response;
640 }647 }
648+
641 */649 */
642 import "C"650 import "C"
643 651
@@ -655,6 +663,7 @@ import (
655 "runtime/cgo"663 "runtime/cgo"
656 "strconv"664 "strconv"
657 "strings"665 "strings"
666+ "sync"
658 "time"667 "time"
659 "unsafe"668 "unsafe"
660 669
@@ -791,6 +800,21 @@ const domBridgeTemplate = `(() => { try {
791 };800 };
792 } catch (e) { console.warn('[dumber] unified bridge init failed', e); } })();`801 } catch (e) { console.warn('[dumber] unified bridge init failed', e); } })();`
793 802
803+var registerSchemeOnce sync.Once
804+
805+func registerDumbScheme(ctx *C.WebKitWebContext) {
806+ if ctx == nil {
807+ return
808+ }
809+
810+ registerSchemeOnce.Do(func() {
811+ sch := C.CString("dumb")
812+ defer C.free(unsafe.Pointer(sch))
813+ C.register_uri_scheme_handler(ctx, sch)
814+ C.register_uri_scheme_security(ctx, sch)
815+ })
816+}
817+
794 // Helper function to convert bool to int for C interop818 // Helper function to convert bool to int for C interop
795 func boolToInt(b bool) int {819 func boolToInt(b bool) int {
796 if b {820 if b {
@@ -894,10 +918,11 @@ func storeCertificateDecision(hostname, certificateInfo, decision string, perman
894 }918 }
895 919
896 type nativeView struct {920 type nativeView struct {
897- win *C.GtkWidget921+ win *C.GtkWidget
898- view *C.GtkWidget922+ container *C.GtkWidget
899- wv *C.WebKitWebView923+ view *C.GtkWidget
900- ucm *C.WebKitUserContentManager924+ wv *C.WebKitWebView
925+ ucm *C.WebKitUserContentManager
901 }926 }
902 927
903 type memoryStats struct {928 type memoryStats struct {
@@ -919,6 +944,7 @@ type WebView struct {
919 titleHandler func(title string)944 titleHandler func(title string)
920 uriHandler func(uri string)945 uriHandler func(uri string)
921 zoomHandler func(level float64)946 zoomHandler func(level float64)
947+ popupHandler func(string) bool
922 memStats *memoryStats948 memStats *memoryStats
923 gcTicker *time.Ticker949 gcTicker *time.Ticker
924 gcDone chan struct{}950 gcDone chan struct{}
@@ -926,6 +952,7 @@ type WebView struct {
926 useDomZoom bool952 useDomZoom bool
927 domZoomSeed float64953 domZoomSeed float64
928 domBridgeScript *C.WebKitUserScript954 domBridgeScript *C.WebKitUserScript
955+ wasReparented bool // Track if this WebView has been reparented
929 }956 }
930 957
931 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.958 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
@@ -1018,13 +1045,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
1018 }1045 }
1019 }1046 }
1020 1047
1021- // Register custom URI scheme handler for "dumb://"1048+ registerDumbScheme(ctx)
1022- {
1023- sch := C.CString("dumb")
1024- C.register_uri_scheme_handler(ctx, sch)
1025- C.register_uri_scheme_security(ctx, sch)
1026- C.free(unsafe.Pointer(sch))
1027- }
1028 // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session1049 // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session
1029 1050
1030 // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style)1051 // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style)
@@ -1034,17 +1055,35 @@ func NewWebView(cfg *Config) (*WebView, error) {
1034 return nil, errors.New("failed to create WebKitWebView")1055 return nil, errors.New("failed to create WebKitWebView")
1035 }1056 }
1036 1057
1037- // Create a top-level window to host the view1058+ var win *C.GtkWidget
1038- win := C.new_window()1059+ var window *Window
1039- if win == nil {1060+ container := C.gtk_box_new(C.GtkOrientation(C.GTK_ORIENTATION_VERTICAL), 0)
1040- return nil, errors.New("failed to create GtkWindow")1061+ if container == nil {
1041- }1062+ return nil, errors.New("failed to create GtkBox container")
1063+ }
1064+ // Hold a strong reference so the WebView keeps the container alive across reparenting.
1065+ C.g_object_ref_sink(C.gpointer(container))
1066+ C.gtk_widget_set_hexpand(container, C.gboolean(1))
1067+ C.gtk_widget_set_vexpand(container, C.gboolean(1))
1068+ C.gtk_widget_set_hexpand(viewWidget, C.gboolean(1))
1069+ C.gtk_widget_set_vexpand(viewWidget, C.gboolean(1))
1070+ C.gtk_box_append((*C.GtkBox)(unsafe.Pointer(container)), viewWidget)
1071+
1072+ // Only create window if requested
1073+ if cfg.CreateWindow {
1074+ // Create a top-level window to host the view
1075+ win = C.new_window()
1076+ if win == nil {
1077+ return nil, errors.New("failed to create GtkWindow")
1078+ }
1042 1079
1043- // Pack view widget into the window1080+ // Pack view widget into the window
1044- // GTK4: containers removed; use gtk_window_set_child1081+ // GTK4: containers removed; use gtk_window_set_child
1045- C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), viewWidget)1082+ C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), container)
1046- C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)1083+ C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
1047- C.connect_destroy_quit(win)1084+ C.connect_destroy_quit(win)
1085+ window = &Window{win: win}
1086+ }
1048 1087
1049 seed := 1.01088 seed := 1.0
1050 if cfg.ZoomDefault > 0 {1089 if cfg.ZoomDefault > 0 {
@@ -1056,8 +1095,8 @@ func NewWebView(cfg *Config) (*WebView, error) {
1056 zoom: 1.0,1095 zoom: 1.0,
1057 useDomZoom: cfg.UseDomZoom,1096 useDomZoom: cfg.UseDomZoom,
1058 domZoomSeed: seed,1097 domZoomSeed: seed,
1059- native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},1098+ native: &nativeView{win: win, container: container, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
1060- window: &Window{win: win},1099+ window: window,
1061 memStats: &memoryStats{1100 memStats: &memoryStats{
1062 pageLoadCount: 0,1101 pageLoadCount: 0,
1063 lastGCTime: time.Now(),1102 lastGCTime: time.Now(),
@@ -1069,6 +1108,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
1069 // Assign an ID for accelerator dispatch1108 // Assign an ID for accelerator dispatch
1070 v.id = nextViewID()1109 v.id = nextViewID()
1071 registerView(v.id, v)1110 registerView(v.id, v)
1111+ C.connect_create_handler(v.native.wv, C.ulong(v.id))
1072 1112
1073 // Register with global memory manager if monitoring is enabled1113 // Register with global memory manager if monitoring is enabled
1074 if cfg.Memory.EnableMemoryMonitoring {1114 if cfg.Memory.EnableMemoryMonitoring {
@@ -1140,12 +1180,12 @@ func NewWebView(cfg *Config) (*WebView, error) {
1140 _ = v.RegisterKeyboardShortcut("cmdorctrl+f", func() {1180 _ = v.RegisterKeyboardShortcut("cmdorctrl+f", func() {
1141 _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+f'}}))")1181 _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+f'}}))")
1142 })1182 })
1143- // Navigation with Alt+Arrow keys: use new keyboard service bridge1183+ // Navigation with Ctrl/Cmd + Arrow keys: forward to keyboard service bridge
1144- _ = v.RegisterKeyboardShortcut("alt+ArrowLeft", func() {1184+ _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowLeft", func() {
1145- _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'alt+arrowleft'}}))")1185+ _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowleft'}}))")
1146 })1186 })
1147- _ = v.RegisterKeyboardShortcut("alt+ArrowRight", func() {1187+ _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowRight", func() {
1148- _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'alt+arrowright'}}))")1188+ _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowright'}}))")
1149 })1189 })
1150 // Update window title when page title changes1190 // Update window title when page title changes
1151 C.connect_title_notify(viewWidget, (*C.GtkWindow)(unsafe.Pointer(win)))1191 C.connect_title_notify(viewWidget, (*C.GtkWindow)(unsafe.Pointer(win)))
@@ -1305,10 +1345,18 @@ func (w *WebView) LoadURL(rawURL string) error {
1305 }1345 }
1306 1346
1307 func (w *WebView) Show() error {1347 func (w *WebView) Show() error {
1308- if w == nil || w.destroyed || w.native == nil || w.native.win == nil {1348+ if w == nil || w.destroyed || w.native == nil {
1349+ return ErrNotImplemented
1350+ }
1351+ if w.native.container != nil {
1352+ C.gtk_widget_set_visible(w.native.container, C.gboolean(1))
1353+ }
1354+ if w.native.view != nil {
1355+ C.gtk_widget_set_visible(w.native.view, C.gboolean(1))
1356+ }
1357+ if w.native.win == nil {
1309 return ErrNotImplemented1358 return ErrNotImplemented
1310 }1359 }
1311- C.gtk_widget_set_visible(w.native.view, C.gboolean(1))
1312 C.gtk_widget_set_visible(w.native.win, C.gboolean(1))1360 C.gtk_widget_set_visible(w.native.win, C.gboolean(1))
1313 C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win)))1361 C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1314 log.Printf("[webkit] Show window")1362 log.Printf("[webkit] Show window")
@@ -1316,16 +1364,24 @@ func (w *WebView) Show() error {
1316 }1364 }
1317 1365
1318 func (w *WebView) Hide() error {1366 func (w *WebView) Hide() error {
1319- if w == nil || w.destroyed || w.native == nil || w.native.win == nil {1367+ if w == nil || w.destroyed || w.native == nil {
1368+ return ErrNotImplemented
1369+ }
1370+ if w.native.container != nil {
1371+ C.gtk_widget_set_visible(w.native.container, C.gboolean(0))
1372+ }
1373+ if w.native.view != nil {
1374+ C.gtk_widget_set_visible(w.native.view, C.gboolean(0))
1375+ }
1376+ if w.native.win == nil {
1320 return ErrNotImplemented1377 return ErrNotImplemented
1321 }1378 }
1322- C.gtk_widget_set_visible(w.native.view, C.gboolean(0))
1323 C.gtk_widget_set_visible(w.native.win, C.gboolean(0))1379 C.gtk_widget_set_visible(w.native.win, C.gboolean(0))
1324 return nil1380 return nil
1325 }1381 }
1326 1382
1327 func (w *WebView) Destroy() error {1383 func (w *WebView) Destroy() error {
1328- if w == nil || w.native == nil || w.native.win == nil {1384+ if w == nil || w.native == nil {
1329 return ErrNotImplemented1385 return ErrNotImplemented
1330 }1386 }
1331 1387
@@ -1349,14 +1405,32 @@ func (w *WebView) Destroy() error {
1349 w.domBridgeScript = nil1405 w.domBridgeScript = nil
1350 }1406 }
1351 1407
1352- // GTK4: destroy windows via gtk_window_destroy1408+ // GTK4: destroy windows via gtk_window_destroy if we created one
1353- C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))1409+ if w.native.win != nil {
1410+ C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1411+ w.native.win = nil
1412+ if w.window != nil {
1413+ w.window.win = nil
1414+ }
1415+ log.Printf("[webkit] Destroy window")
1416+ }
1417+
1418+ w.releaseNativeWidgets()
1354 w.destroyed = true1419 w.destroyed = true
1355 unregisterView(w.id)1420 unregisterView(w.id)
1356- log.Printf("[webkit] Destroy window")
1357 return nil1421 return nil
1358 }1422 }
1359 1423
1424+func (w *WebView) releaseNativeWidgets() {
1425+ if w == nil || w.native == nil {
1426+ return
1427+ }
1428+ if w.native.container != nil {
1429+ C.g_object_unref(C.gpointer(w.native.container))
1430+ w.native.container = nil
1431+ }
1432+}
1433+
1360 // Window returns the associated native window.1434 // Window returns the associated native window.
1361 func (w *WebView) Window() *Window { return w.window }1435 func (w *WebView) Window() *Window { return w.window }
1362 1436
@@ -1409,12 +1483,21 @@ func (w *WebView) ReloadBypassCache() error {
1409 // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.1483 // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
1410 func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }1484 func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
1411 1485
1486+func (w *WebView) RegisterPopupHandler(cb func(string) bool) { w.popupHandler = cb }
1487+
1412 func (w *WebView) dispatchScriptMessage(payload string) {1488 func (w *WebView) dispatchScriptMessage(payload string) {
1413 if w != nil && w.msgHandler != nil {1489 if w != nil && w.msgHandler != nil {
1414 w.msgHandler(payload)1490 w.msgHandler(payload)
1415 }1491 }
1416 }1492 }
1417 1493
1494+func (w *WebView) dispatchPopupRequest(uri string) bool {
1495+ if w != nil && w.popupHandler != nil {
1496+ return w.popupHandler(uri)
1497+ }
1498+ return false
1499+}
1500+
1418 // RegisterTitleChangedHandler registers a callback invoked when the page title changes.1501 // RegisterTitleChangedHandler registers a callback invoked when the page title changes.
1419 func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }1502 func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
1420 1503
@@ -1451,6 +1534,72 @@ func (w *WebView) dispatchZoomChanged(level float64) {
1451 }1534 }
1452 }1535 }
1453 1536
1537+// Widget returns the underlying GtkWidget pointer for the WebView.
1538+func (w *WebView) Widget() uintptr {
1539+ if w == nil || w.native == nil || w.native.view == nil {
1540+ return 0
1541+ }
1542+ return uintptr(unsafe.Pointer(w.native.view))
1543+}
1544+
1545+// RootWidget returns the container widget that should be reparented for pane management.
1546+func (w *WebView) RootWidget() uintptr {
1547+ if w == nil || w.native == nil || w.native.container == nil {
1548+ return 0
1549+ }
1550+ return uintptr(unsafe.Pointer(w.native.container))
1551+}
1552+
1553+// DestroyWindow destroys the toplevel window hosting this WebView, if any.
1554+func (w *WebView) DestroyWindow() {
1555+ if w == nil || w.native == nil || w.native.win == nil {
1556+ return
1557+ }
1558+ C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1559+ w.native.win = nil
1560+ if w.window != nil {
1561+ w.window.win = nil
1562+ }
1563+}
1564+
1565+// PrepareForReparenting prepares the WebView widget for being moved to a new parent
1566+func (w *WebView) PrepareForReparenting() {
1567+ if w == nil || w.native == nil || w.native.container == nil {
1568+ return
1569+ }
1570+
1571+ // Mark that this view is being reparented
1572+ w.wasReparented = true
1573+
1574+ // Ensure the widget is visible and properly sized for reparenting
1575+ C.gtk_widget_set_visible(w.native.container, C.gboolean(1))
1576+ C.gtk_widget_set_size_request(w.native.container, 100, 100)
1577+ C.gtk_widget_queue_draw(w.native.container)
1578+}
1579+
1580+// RefreshAfterReparenting refreshes the WebView after it has been reparented
1581+func (w *WebView) RefreshAfterReparenting() {
1582+ if w == nil || w.native == nil || w.native.wv == nil {
1583+ return
1584+ }
1585+
1586+ if w.wasReparented {
1587+ widgetHandle := w.RootWidget()
1588+ parentHandle := WidgetGetParent(widgetHandle)
1589+ if parentHandle == 0 {
1590+ log.Printf("[workspace] RefreshAfterReparenting deferred: widget=%#x parent=%#x url=%s", widgetHandle, parentHandle, w.url)
1591+ return
1592+ }
1593+ log.Printf("[workspace] RefreshAfterReparenting: widget=%#x parent=%#x url=%s", widgetHandle, parentHandle, w.url)
1594+ // Force WebKit to refresh its rendering context by triggering a reload
1595+ // This ensures the rendering context is properly reinitialized after reparenting
1596+ if w.url != "" {
1597+ w.LoadURL(w.url) // Reload current URL to refresh rendering context
1598+ }
1599+ w.wasReparented = false
1600+ }
1601+}
1602+
1454 // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.1603 // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.
1455 func (w *WebView) enableUserContentManager(cfg *Config) {1604 func (w *WebView) enableUserContentManager(cfg *Config) {
1456 if w == nil || w.native == nil || w.native.ucm == nil {1605 if w == nil || w.native == nil || w.native.ucm == nil {
M pkg/webkit/window.go +5 -0
@@ -18,3 +18,8 @@ func NewWindow(title string) (*Window, error) {
1818 func (w *Window) SetTitle(title string) {
1919 w.Title = title
2020 }
21+
22+// SetChild is a stubbed window child setter for non-CGO builds.
23+func (w *Window) SetChild(child uintptr) {
24+ _ = child
25+}
@@ -18,3 +18,8 @@ func NewWindow(title string) (*Window, error) {
18 func (w *Window) SetTitle(title string) {18 func (w *Window) SetTitle(title string) {
19 w.Title = title19 w.Title = title
20 }20 }
21+
22+// SetChild is a stubbed window child setter for non-CGO builds.
23+func (w *Window) SetChild(child uintptr) {
24+ _ = child
25+}
M pkg/webkit/window_cgo.go +21 -2
@@ -10,10 +10,17 @@ package webkit
1010 */
1111 import "C"
1212
13-import "unsafe"
13+import (
14+ "sync"
15+ "unsafe"
16+)
1417
1518 type Window struct {
16- win *C.GtkWidget
19+ win *C.GtkWidget
20+ shortcutController *C.GtkShortcutController
21+ shortcutCallbacks map[string]func()
22+ shortcutHandles map[string]uintptr
23+ shortcutMu sync.Mutex
1724 }
1825
1926 func NewWindow(title string) (*Window, error) {
@@ -39,3 +46,15 @@ func (w *Window) SetTitle(title string) {
3946 defer C.free(unsafe.Pointer(ctitle))
4047 C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w.win)), (*C.gchar)(ctitle))
4148 }
49+
50+// SetChild assigns the given widget as the GtkWindow child. Passing 0 removes the child.
51+func (w *Window) SetChild(child uintptr) {
52+ if w == nil || w.win == nil {
53+ return
54+ }
55+ var widget *C.GtkWidget
56+ if child != 0 {
57+ widget = (*C.GtkWidget)(unsafe.Pointer(child))
58+ }
59+ C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(w.win)), widget)
60+}
@@ -10,10 +10,17 @@ package webkit
10 */10 */
11 import "C"11 import "C"
12 12
13-import "unsafe"13+import (
14+ "sync"
15+ "unsafe"
16+)
14 17
15 type Window struct {18 type Window struct {
16- win *C.GtkWidget19+ win *C.GtkWidget
20+ shortcutController *C.GtkShortcutController
21+ shortcutCallbacks map[string]func()
22+ shortcutHandles map[string]uintptr
23+ shortcutMu sync.Mutex
17 }24 }
18 25
19 func NewWindow(title string) (*Window, error) {26 func NewWindow(title string) (*Window, error) {
@@ -39,3 +46,15 @@ func (w *Window) SetTitle(title string) {
39 defer C.free(unsafe.Pointer(ctitle))46 defer C.free(unsafe.Pointer(ctitle))
40 C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w.win)), (*C.gchar)(ctitle))47 C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w.win)), (*C.gchar)(ctitle))
41 }48 }
49+
50+// SetChild assigns the given widget as the GtkWindow child. Passing 0 removes the child.
51+func (w *Window) SetChild(child uintptr) {
52+ if w == nil || w.win == nil {
53+ return
54+ }
55+ var widget *C.GtkWidget
56+ if child != 0 {
57+ widget = (*C.GtkWidget)(unsafe.Pointer(child))
58+ }
59+ C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(w.win)), widget)
60+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close