fix(tabs): tab container hierarchy and workspace isolation
- Fix ROOT SPLIT and pane operations to use TabManager.ContentArea instead of window.SetChild - Fix tab switching to use current workspace root instead of stale cached container - Fix pane close promotion to remove old paned from ContentArea before adding sibling - Add helper methods setRootContainer/clearRootContainer for tab-aware container management - Fix workspace pane mode to work with active tab's workspace instead of closure workspace - Remove unused tab.container field - always get current root from workspace.GetRootWidget() - Add tab mode visual borders using margins on ContentArea - Move tab mode action keys from global shortcuts to EventControllerKey with active check
- commit
- 6ab096f1ea49f25991a5e3f7836c44e07af68a75
- author
- bnema · · 2025-11-18 03:18 UTC
- parent
- 6b46846
- signature
- unsigned
7 files · +386 -125
M internal/app/browser/stacked_panes.go +10 -13
| @@ -307,10 +307,8 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo | |||
| 307 | 307 | // detachFromParent removes a pane from its current parent | |
| 308 | 308 | func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) { | |
| 309 | 309 | if parent == nil { | |
| 310 | - | // Target is the root - remove it from window | |
| 311 | - | if spm.wm.window != nil { | |
| 312 | - | spm.wm.window.SetChild(nil) | |
| 313 | - | } | |
| 310 | + | // Target is the root - clear it (handles both tab and non-tab environments) | |
| 311 | + | spm.wm.clearRootContainer() | |
| 314 | 312 | // Unparent if it has a GTK parent | |
| 315 | 313 | if target.container != nil { | |
| 316 | 314 | if webkit.WidgetGetParent(target.container) != nil { | |
| @@ -330,9 +328,8 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo | |||
| 330 | 328 | // reattachToParent attaches a container to the parent | |
| 331 | 329 | func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) { | |
| 332 | 330 | if parent == nil { | |
| 333 | - | if spm.wm.window != nil { | |
| 334 | - | spm.wm.window.SetChild(container) | |
| 335 | - | } | |
| 331 | + | // Attach as root (handles both tab and non-tab environments) | |
| 332 | + | spm.wm.setRootContainer(container) | |
| 336 | 333 | } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil { | |
| 337 | 334 | if parent.left == target { | |
| 338 | 335 | paned.SetStartChild(container) | |
| @@ -784,9 +781,8 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 784 | 781 | webkit.WidgetUnparent(stackNode.container) | |
| 785 | 782 | } | |
| 786 | 783 | } | |
| 787 | - | if spm.wm.window != nil { | |
| 788 | - | spm.wm.window.SetChild(nil) | |
| 789 | - | } | |
| 784 | + | // Clear root container (handles both tab and non-tab environments) | |
| 785 | + | spm.wm.clearRootContainer() | |
| 790 | 786 | _, err := spm.wm.cleanupAndExit(node) | |
| 791 | 787 | return err | |
| 792 | 788 | } | |
| @@ -810,7 +806,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 810 | 806 | grandparent := parent.parent | |
| 811 | 807 | ||
| 812 | 808 | spm.wm.promoteSibling(grandparent, parent, sibling) | |
| 813 | - | spm.wm.swapContainers(grandparent, sibling) | |
| 809 | + | spm.wm.swapContainers(grandparent, parent, sibling) | |
| 814 | 810 | ||
| 815 | 811 | generation := spm.wm.nextCleanupGeneration() | |
| 816 | 812 | spm.wm.cleanupPane(node, generation) | |
| @@ -848,10 +844,11 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 848 | 844 | } | |
| 849 | 845 | ||
| 850 | 846 | if parent == nil { | |
| 847 | + | // Stack was root - make last pane the new root | |
| 851 | 848 | spm.wm.root = lastPane | |
| 852 | 849 | lastPane.parent = nil | |
| 853 | - | if spm.wm.window != nil && lastPaneContainer != nil { | |
| 854 | - | spm.wm.window.SetChild(lastPaneContainer) | |
| 850 | + | if lastPaneContainer != nil { | |
| 851 | + | spm.wm.setRootContainer(lastPaneContainer) | |
| 855 | 852 | webkit.WidgetQueueAllocate(lastPaneContainer) | |
| 856 | 853 | webkit.WidgetShow(lastPaneContainer) | |
| 857 | 854 | } | |
| @@ -307,10 +307,8 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo | |||||
| 307 | // detachFromParent removes a pane from its current parent | 307 | // detachFromParent removes a pane from its current parent | ||
| 308 | func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) { | 308 | func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) { | ||
| 309 | if parent == nil { | 309 | if parent == nil { | ||
| 310 | - | // Target is the root - remove it from window | 310 | + | // Target is the root - clear it (handles both tab and non-tab environments) |
| 311 | - | if spm.wm.window != nil { | 311 | + | spm.wm.clearRootContainer() |
| 312 | - | spm.wm.window.SetChild(nil) | |||
| 313 | - | } | |||
| 314 | // Unparent if it has a GTK parent | 312 | // Unparent if it has a GTK parent | ||
| 315 | if target.container != nil { | 313 | if target.container != nil { | ||
| 316 | if webkit.WidgetGetParent(target.container) != nil { | 314 | if webkit.WidgetGetParent(target.container) != nil { | ||
| @@ -330,9 +328,8 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo | |||||
| 330 | // reattachToParent attaches a container to the parent | 328 | // reattachToParent attaches a container to the parent | ||
| 331 | func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) { | 329 | func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) { | ||
| 332 | if parent == nil { | 330 | if parent == nil { | ||
| 333 | - | if spm.wm.window != nil { | 331 | + | // Attach as root (handles both tab and non-tab environments) |
| 334 | - | spm.wm.window.SetChild(container) | 332 | + | spm.wm.setRootContainer(container) |
| 335 | - | } | |||
| 336 | } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil { | 333 | } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil { | ||
| 337 | if parent.left == target { | 334 | if parent.left == target { | ||
| 338 | paned.SetStartChild(container) | 335 | paned.SetStartChild(container) | ||
| @@ -784,9 +781,8 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||||
| 784 | webkit.WidgetUnparent(stackNode.container) | 781 | webkit.WidgetUnparent(stackNode.container) | ||
| 785 | } | 782 | } | ||
| 786 | } | 783 | } | ||
| 787 | - | if spm.wm.window != nil { | 784 | + | // Clear root container (handles both tab and non-tab environments) |
| 788 | - | spm.wm.window.SetChild(nil) | 785 | + | spm.wm.clearRootContainer() |
| 789 | - | } | |||
| 790 | _, err := spm.wm.cleanupAndExit(node) | 786 | _, err := spm.wm.cleanupAndExit(node) | ||
| 791 | return err | 787 | return err | ||
| 792 | } | 788 | } | ||
| @@ -810,7 +806,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||||
| 810 | grandparent := parent.parent | 806 | grandparent := parent.parent | ||
| 811 | 807 | ||||
| 812 | spm.wm.promoteSibling(grandparent, parent, sibling) | 808 | spm.wm.promoteSibling(grandparent, parent, sibling) | ||
| 813 | - | spm.wm.swapContainers(grandparent, sibling) | 809 | + | spm.wm.swapContainers(grandparent, parent, sibling) |
| 814 | 810 | ||||
| 815 | generation := spm.wm.nextCleanupGeneration() | 811 | generation := spm.wm.nextCleanupGeneration() | ||
| 816 | spm.wm.cleanupPane(node, generation) | 812 | spm.wm.cleanupPane(node, generation) | ||
| @@ -848,10 +844,11 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||||
| 848 | } | 844 | } | ||
| 849 | 845 | ||||
| 850 | if parent == nil { | 846 | if parent == nil { | ||
| 847 | + | // Stack was root - make last pane the new root | |||
| 851 | spm.wm.root = lastPane | 848 | spm.wm.root = lastPane | ||
| 852 | lastPane.parent = nil | 849 | lastPane.parent = nil | ||
| 853 | - | if spm.wm.window != nil && lastPaneContainer != nil { | 850 | + | if lastPaneContainer != nil { |
| 854 | - | spm.wm.window.SetChild(lastPaneContainer) | 851 | + | spm.wm.setRootContainer(lastPaneContainer) |
| 855 | webkit.WidgetQueueAllocate(lastPaneContainer) | 852 | webkit.WidgetQueueAllocate(lastPaneContainer) | ||
| 856 | webkit.WidgetShow(lastPaneContainer) | 853 | webkit.WidgetShow(lastPaneContainer) | ||
| 857 | } | 854 | } | ||
M internal/app/browser/tab_manager.go +44 -24
| @@ -22,7 +22,7 @@ type TabManager struct { | |||
| 22 | 22 | // GTK widgets | |
| 23 | 23 | rootContainer gtk.Widgetter // Main vertical box containing tab bar + content | |
| 24 | 24 | tabBar gtk.Widgetter // Horizontal tab bar container | |
| 25 | - | contentArea gtk.Widgetter // Container for active workspace | |
| 25 | + | ContentArea gtk.Widgetter // Container for active workspace (exported for border styling) | |
| 26 | 26 | ||
| 27 | 27 | // Modal state | |
| 28 | 28 | tabModeActive bool | |
| @@ -39,7 +39,6 @@ type Tab struct { | |||
| 39 | 39 | customTitle string // User-provided custom name (persists across page loads) | |
| 40 | 40 | workspace *WorkspaceManager | |
| 41 | 41 | titleButton gtk.Widgetter | |
| 42 | - | container gtk.Widgetter | |
| 43 | 42 | isActive bool | |
| 44 | 43 | } | |
| 45 | 44 | ||
| @@ -126,7 +125,7 @@ func (tm *TabManager) createRootContainer() error { | |||
| 126 | 125 | // Store references | |
| 127 | 126 | tm.rootContainer = rootBox | |
| 128 | 127 | tm.tabBar = tabBar | |
| 129 | - | tm.contentArea = contentArea | |
| 128 | + | tm.ContentArea = contentArea | |
| 130 | 129 | ||
| 131 | 130 | logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position)) | |
| 132 | 131 | return nil | |
| @@ -184,12 +183,12 @@ func (tm *TabManager) createTabInternal(url string) error { | |||
| 184 | 183 | workspace := NewWorkspaceManager(tm.app, pane) | |
| 185 | 184 | tab.workspace = workspace | |
| 186 | 185 | ||
| 187 | - | // Get workspace root container | |
| 186 | + | // Verify workspace root exists | |
| 188 | 187 | rootWidget := workspace.GetRootWidget() | |
| 189 | 188 | if rootWidget == nil { | |
| 190 | 189 | return fmt.Errorf("workspace root widget is nil") | |
| 191 | 190 | } | |
| 192 | - | tab.container = rootWidget | |
| 191 | + | logging.Info(fmt.Sprintf("[tabs] New tab created with root container: %p (workspace %p)", rootWidget, workspace)) | |
| 193 | 192 | ||
| 194 | 193 | // Load the URL in the workspace's pane | |
| 195 | 194 | if url != "" { | |
| @@ -253,13 +252,24 @@ func (tm *TabManager) switchToTabInternal(index int) error { | |||
| 253 | 252 | oldTab := tm.tabs[tm.activeIndex] | |
| 254 | 253 | oldTab.isActive = false | |
| 255 | 254 | ||
| 255 | + | // Clear GTK focus from old tab's workspace to prevent it from catching keyboard events | |
| 256 | + | if oldTab.workspace != nil { | |
| 257 | + | oldTab.workspace.ClearFocus() | |
| 258 | + | } | |
| 259 | + | ||
| 256 | 260 | // Hide old workspace container and remove from content area | |
| 257 | - | if oldTab.container != nil { | |
| 258 | - | webkit.RunOnMainThread(func() { | |
| 259 | - | if contentBox, ok := tm.contentArea.(*gtk.Box); ok { | |
| 260 | - | contentBox.Remove(oldTab.container) | |
| 261 | - | } | |
| 262 | - | }) | |
| 261 | + | // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container, | |
| 262 | + | // because splits/stacks change the workspace root | |
| 263 | + | if oldTab.workspace != nil { | |
| 264 | + | currentRoot := oldTab.workspace.GetRootWidget() | |
| 265 | + | if currentRoot != nil { | |
| 266 | + | webkit.RunOnMainThread(func() { | |
| 267 | + | if contentBox, ok := tm.ContentArea.(*gtk.Box); ok { | |
| 268 | + | contentBox.Remove(currentRoot) | |
| 269 | + | logging.Info(fmt.Sprintf("[tabs] Removed tab %d container %p from ContentArea (current root)", tm.activeIndex, currentRoot)) | |
| 270 | + | } | |
| 271 | + | }) | |
| 272 | + | } | |
| 263 | 273 | } | |
| 264 | 274 | ||
| 265 | 275 | // Remove active CSS class from old button | |
| @@ -272,13 +282,18 @@ func (tm *TabManager) switchToTabInternal(index int) error { | |||
| 272 | 282 | tm.activeIndex = index | |
| 273 | 283 | ||
| 274 | 284 | // Add new workspace container to content area | |
| 275 | - | if newTab.container != nil { | |
| 276 | - | webkit.RunOnMainThread(func() { | |
| 277 | - | if contentBox, ok := tm.contentArea.(*gtk.Box); ok { | |
| 278 | - | contentBox.Append(newTab.container) | |
| 279 | - | webkit.WidgetSetVisible(newTab.container, true) | |
| 280 | - | } | |
| 281 | - | }) | |
| 285 | + | // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container | |
| 286 | + | if newTab.workspace != nil { | |
| 287 | + | currentRoot := newTab.workspace.GetRootWidget() | |
| 288 | + | if currentRoot != nil { | |
| 289 | + | webkit.RunOnMainThread(func() { | |
| 290 | + | if contentBox, ok := tm.ContentArea.(*gtk.Box); ok { | |
| 291 | + | contentBox.Append(currentRoot) | |
| 292 | + | webkit.WidgetSetVisible(currentRoot, true) | |
| 293 | + | logging.Info(fmt.Sprintf("[tabs] Added tab %d container %p to ContentArea (current root)", index, currentRoot)) | |
| 294 | + | } | |
| 295 | + | }) | |
| 296 | + | } | |
| 282 | 297 | } | |
| 283 | 298 | ||
| 284 | 299 | // Add active CSS class to new button | |
| @@ -329,12 +344,17 @@ func (tm *TabManager) closeTabInternal(index int) error { | |||
| 329 | 344 | tm.removeTabFromBar(tab) | |
| 330 | 345 | ||
| 331 | 346 | // Remove workspace container from content area if it's currently visible | |
| 332 | - | if tab.isActive && tab.container != nil { | |
| 333 | - | webkit.RunOnMainThread(func() { | |
| 334 | - | if contentBox, ok := tm.contentArea.(*gtk.Box); ok { | |
| 335 | - | contentBox.Remove(tab.container) | |
| 336 | - | } | |
| 337 | - | }) | |
| 347 | + | // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container | |
| 348 | + | if tab.isActive && tab.workspace != nil { | |
| 349 | + | currentRoot := tab.workspace.GetRootWidget() | |
| 350 | + | if currentRoot != nil { | |
| 351 | + | webkit.RunOnMainThread(func() { | |
| 352 | + | if contentBox, ok := tm.ContentArea.(*gtk.Box); ok { | |
| 353 | + | contentBox.Remove(currentRoot) | |
| 354 | + | logging.Info(fmt.Sprintf("[tabs] Removed closing tab %d container %p from ContentArea (current root)", index, currentRoot)) | |
| 355 | + | } | |
| 356 | + | }) | |
| 357 | + | } | |
| 338 | 358 | } | |
| 339 | 359 | ||
| 340 | 360 | // Remove from slice | |
| @@ -22,7 +22,7 @@ type TabManager struct { | |||||
| 22 | // GTK widgets | 22 | // GTK widgets | ||
| 23 | rootContainer gtk.Widgetter // Main vertical box containing tab bar + content | 23 | rootContainer gtk.Widgetter // Main vertical box containing tab bar + content | ||
| 24 | tabBar gtk.Widgetter // Horizontal tab bar container | 24 | tabBar gtk.Widgetter // Horizontal tab bar container | ||
| 25 | - | contentArea gtk.Widgetter // Container for active workspace | 25 | + | ContentArea gtk.Widgetter // Container for active workspace (exported for border styling) |
| 26 | 26 | ||||
| 27 | // Modal state | 27 | // Modal state | ||
| 28 | tabModeActive bool | 28 | tabModeActive bool | ||
| @@ -39,7 +39,6 @@ type Tab struct { | |||||
| 39 | customTitle string // User-provided custom name (persists across page loads) | 39 | customTitle string // User-provided custom name (persists across page loads) | ||
| 40 | workspace *WorkspaceManager | 40 | workspace *WorkspaceManager | ||
| 41 | titleButton gtk.Widgetter | 41 | titleButton gtk.Widgetter | ||
| 42 | - | container gtk.Widgetter | |||
| 43 | isActive bool | 42 | isActive bool | ||
| 44 | } | 43 | } | ||
| 45 | 44 | ||||
| @@ -126,7 +125,7 @@ func (tm *TabManager) createRootContainer() error { | |||||
| 126 | // Store references | 125 | // Store references | ||
| 127 | tm.rootContainer = rootBox | 126 | tm.rootContainer = rootBox | ||
| 128 | tm.tabBar = tabBar | 127 | tm.tabBar = tabBar | ||
| 129 | - | tm.contentArea = contentArea | 128 | + | tm.ContentArea = contentArea |
| 130 | 129 | ||||
| 131 | logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position)) | 130 | logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position)) | ||
| 132 | return nil | 131 | return nil | ||
| @@ -184,12 +183,12 @@ func (tm *TabManager) createTabInternal(url string) error { | |||||
| 184 | workspace := NewWorkspaceManager(tm.app, pane) | 183 | workspace := NewWorkspaceManager(tm.app, pane) | ||
| 185 | tab.workspace = workspace | 184 | tab.workspace = workspace | ||
| 186 | 185 | ||||
| 187 | - | // Get workspace root container | 186 | + | // Verify workspace root exists |
| 188 | rootWidget := workspace.GetRootWidget() | 187 | rootWidget := workspace.GetRootWidget() | ||
| 189 | if rootWidget == nil { | 188 | if rootWidget == nil { | ||
| 190 | return fmt.Errorf("workspace root widget is nil") | 189 | return fmt.Errorf("workspace root widget is nil") | ||
| 191 | } | 190 | } | ||
| 192 | - | tab.container = rootWidget | 191 | + | logging.Info(fmt.Sprintf("[tabs] New tab created with root container: %p (workspace %p)", rootWidget, workspace)) |
| 193 | 192 | ||||
| 194 | // Load the URL in the workspace's pane | 193 | // Load the URL in the workspace's pane | ||
| 195 | if url != "" { | 194 | if url != "" { | ||
| @@ -253,13 +252,24 @@ func (tm *TabManager) switchToTabInternal(index int) error { | |||||
| 253 | oldTab := tm.tabs[tm.activeIndex] | 252 | oldTab := tm.tabs[tm.activeIndex] | ||
| 254 | oldTab.isActive = false | 253 | oldTab.isActive = false | ||
| 255 | 254 | ||||
| 255 | + | // Clear GTK focus from old tab's workspace to prevent it from catching keyboard events | |||
| 256 | + | if oldTab.workspace != nil { | |||
| 257 | + | oldTab.workspace.ClearFocus() | |||
| 258 | + | } | |||
| 259 | + | ||||
| 256 | // Hide old workspace container and remove from content area | 260 | // Hide old workspace container and remove from content area | ||
| 257 | - | if oldTab.container != nil { | 261 | + | // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container, |
| 258 | - | webkit.RunOnMainThread(func() { | 262 | + | // because splits/stacks change the workspace root |
| 259 | - | if contentBox, ok := tm.contentArea.(*gtk.Box); ok { | 263 | + | if oldTab.workspace != nil { |
| 260 | - | contentBox.Remove(oldTab.container) | 264 | + | currentRoot := oldTab.workspace.GetRootWidget() |
| 261 | - | } | 265 | + | if currentRoot != nil { |
| 262 | - | }) | 266 | + | webkit.RunOnMainThread(func() { |
| 267 | + | if contentBox, ok := tm.ContentArea.(*gtk.Box); ok { | |||
| 268 | + | contentBox.Remove(currentRoot) | |||
| 269 | + | logging.Info(fmt.Sprintf("[tabs] Removed tab %d container %p from ContentArea (current root)", tm.activeIndex, currentRoot)) | |||
| 270 | + | } | |||
| 271 | + | }) | |||
| 272 | + | } | |||
| 263 | } | 273 | } | ||
| 264 | 274 | ||||
| 265 | // Remove active CSS class from old button | 275 | // Remove active CSS class from old button | ||
| @@ -272,13 +282,18 @@ func (tm *TabManager) switchToTabInternal(index int) error { | |||||
| 272 | tm.activeIndex = index | 282 | tm.activeIndex = index | ||
| 273 | 283 | ||||
| 274 | // Add new workspace container to content area | 284 | // Add new workspace container to content area | ||
| 275 | - | if newTab.container != nil { | 285 | + | // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container |
| 276 | - | webkit.RunOnMainThread(func() { | 286 | + | if newTab.workspace != nil { |
| 277 | - | if contentBox, ok := tm.contentArea.(*gtk.Box); ok { | 287 | + | currentRoot := newTab.workspace.GetRootWidget() |
| 278 | - | contentBox.Append(newTab.container) | 288 | + | if currentRoot != nil { |
| 279 | - | webkit.WidgetSetVisible(newTab.container, true) | 289 | + | webkit.RunOnMainThread(func() { |
| 280 | - | } | 290 | + | if contentBox, ok := tm.ContentArea.(*gtk.Box); ok { |
| 281 | - | }) | 291 | + | contentBox.Append(currentRoot) |
| 292 | + | webkit.WidgetSetVisible(currentRoot, true) | |||
| 293 | + | logging.Info(fmt.Sprintf("[tabs] Added tab %d container %p to ContentArea (current root)", index, currentRoot)) | |||
| 294 | + | } | |||
| 295 | + | }) | |||
| 296 | + | } | |||
| 282 | } | 297 | } | ||
| 283 | 298 | ||||
| 284 | // Add active CSS class to new button | 299 | // Add active CSS class to new button | ||
| @@ -329,12 +344,17 @@ func (tm *TabManager) closeTabInternal(index int) error { | |||||
| 329 | tm.removeTabFromBar(tab) | 344 | tm.removeTabFromBar(tab) | ||
| 330 | 345 | ||||
| 331 | // Remove workspace container from content area if it's currently visible | 346 | // Remove workspace container from content area if it's currently visible | ||
| 332 | - | if tab.isActive && tab.container != nil { | 347 | + | // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container |
| 333 | - | webkit.RunOnMainThread(func() { | 348 | + | if tab.isActive && tab.workspace != nil { |
| 334 | - | if contentBox, ok := tm.contentArea.(*gtk.Box); ok { | 349 | + | currentRoot := tab.workspace.GetRootWidget() |
| 335 | - | contentBox.Remove(tab.container) | 350 | + | if currentRoot != nil { |
| 336 | - | } | 351 | + | webkit.RunOnMainThread(func() { |
| 337 | - | }) | 352 | + | if contentBox, ok := tm.ContentArea.(*gtk.Box); ok { |
| 353 | + | contentBox.Remove(currentRoot) | |||
| 354 | + | logging.Info(fmt.Sprintf("[tabs] Removed closing tab %d container %p from ContentArea (current root)", index, currentRoot)) | |||
| 355 | + | } | |||
| 356 | + | }) | |||
| 357 | + | } | |||
| 338 | } | 358 | } | ||
| 339 | 359 | ||||
| 340 | // Remove from slice | 360 | // Remove from slice | ||
M internal/app/browser/tab_mode.go +34 -12
| @@ -205,34 +205,56 @@ func (tm *TabManager) handleRenameTab() { | |||
| 205 | 205 | } | |
| 206 | 206 | ||
| 207 | 207 | // applyTabModeBorder applies visual indicator when tab mode is active. | |
| 208 | - | // This adds an orange border/highlight to the tab bar. | |
| 208 | + | // This adds an orange border around the entire workspace area. | |
| 209 | 209 | func (tm *TabManager) applyTabModeBorder() { | |
| 210 | 210 | logging.Debug("[tab-mode] applyTabModeBorder: entry") | |
| 211 | 211 | ||
| 212 | - | if tm.window == nil { | |
| 213 | - | logging.Debug("[tab-mode] applyTabModeBorder: window is nil, returning") | |
| 212 | + | if tm.window == nil || tm.ContentArea == nil { | |
| 213 | + | logging.Debug("[tab-mode] applyTabModeBorder: window or ContentArea is nil, returning") | |
| 214 | 214 | return | |
| 215 | 215 | } | |
| 216 | 216 | ||
| 217 | - | logging.Debug("[tab-mode] applyTabModeBorder: window exists, converting to gtk.Window") | |
| 218 | - | gtkWindow := tm.window.AsWindow() | |
| 219 | - | logging.Debug("[tab-mode] applyTabModeBorder: got gtk.Window, about to add CSS class") | |
| 217 | + | logging.Debug("[tab-mode] applyTabModeBorder: applying margins to content area") | |
| 218 | + | ||
| 219 | + | // Apply 4px margins to content area to create space for the border | |
| 220 | + | webkit.WidgetSetMargin(tm.ContentArea, 4) | |
| 220 | 221 | ||
| 221 | - | // Add CSS class to window for tab mode visual indicator | |
| 222 | - | // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern) | |
| 222 | + | // Add CSS class to window for background color (the "border" color shows in the margin space) | |
| 223 | + | gtkWindow := tm.window.AsWindow() | |
| 223 | 224 | webkit.WidgetAddCSSClass(gtkWindow, "tab-mode-active") | |
| 224 | - | logging.Debug("[tab-mode] applyTabModeBorder: CSS class added successfully") | |
| 225 | + | ||
| 226 | + | // Force resize/allocation to apply margin changes immediately | |
| 227 | + | webkit.WidgetQueueResize(tm.ContentArea) | |
| 228 | + | webkit.WidgetQueueAllocate(tm.ContentArea) | |
| 229 | + | ||
| 230 | + | // Queue redraw to show changes | |
| 231 | + | webkit.WidgetQueueDraw(gtkWindow) | |
| 232 | + | webkit.WidgetQueueDraw(tm.ContentArea) | |
| 233 | + | ||
| 234 | + | logging.Debug("[tab-mode] applyTabModeBorder: border applied successfully") | |
| 225 | 235 | } | |
| 226 | 236 | ||
| 227 | 237 | // removeTabModeBorder removes the visual indicator when exiting tab mode. | |
| 228 | 238 | func (tm *TabManager) removeTabModeBorder() { | |
| 229 | - | if tm.window == nil { | |
| 239 | + | if tm.window == nil || tm.ContentArea == nil { | |
| 230 | 240 | return | |
| 231 | 241 | } | |
| 232 | 242 | ||
| 243 | + | // Remove margins from content area | |
| 244 | + | webkit.WidgetSetMargin(tm.ContentArea, 0) | |
| 245 | + | ||
| 233 | 246 | // Remove CSS class from window | |
| 234 | - | // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern) | |
| 235 | - | webkit.WidgetRemoveCSSClass(tm.window.AsWindow(), "tab-mode-active") | |
| 247 | + | gtkWindow := tm.window.AsWindow() | |
| 248 | + | webkit.WidgetRemoveCSSClass(gtkWindow, "tab-mode-active") | |
| 249 | + | ||
| 250 | + | // Force resize/allocation to apply margin changes immediately | |
| 251 | + | webkit.WidgetQueueResize(tm.ContentArea) | |
| 252 | + | webkit.WidgetQueueAllocate(tm.ContentArea) | |
| 253 | + | ||
| 254 | + | // Queue redraw to show changes | |
| 255 | + | webkit.WidgetQueueDraw(gtkWindow) | |
| 256 | + | webkit.WidgetQueueDraw(tm.ContentArea) | |
| 257 | + | ||
| 236 | 258 | logging.Debug("[tab-mode] Removed tab mode visual indicator") | |
| 237 | 259 | } | |
| 238 | 260 | ||
| @@ -205,34 +205,56 @@ func (tm *TabManager) handleRenameTab() { | |||||
| 205 | } | 205 | } | ||
| 206 | 206 | ||||
| 207 | // applyTabModeBorder applies visual indicator when tab mode is active. | 207 | // applyTabModeBorder applies visual indicator when tab mode is active. | ||
| 208 | - | // This adds an orange border/highlight to the tab bar. | 208 | + | // This adds an orange border around the entire workspace area. |
| 209 | func (tm *TabManager) applyTabModeBorder() { | 209 | func (tm *TabManager) applyTabModeBorder() { | ||
| 210 | logging.Debug("[tab-mode] applyTabModeBorder: entry") | 210 | logging.Debug("[tab-mode] applyTabModeBorder: entry") | ||
| 211 | 211 | ||||
| 212 | - | if tm.window == nil { | 212 | + | if tm.window == nil || tm.ContentArea == nil { |
| 213 | - | logging.Debug("[tab-mode] applyTabModeBorder: window is nil, returning") | 213 | + | logging.Debug("[tab-mode] applyTabModeBorder: window or ContentArea is nil, returning") |
| 214 | return | 214 | return | ||
| 215 | } | 215 | } | ||
| 216 | 216 | ||||
| 217 | - | logging.Debug("[tab-mode] applyTabModeBorder: window exists, converting to gtk.Window") | 217 | + | logging.Debug("[tab-mode] applyTabModeBorder: applying margins to content area") |
| 218 | - | gtkWindow := tm.window.AsWindow() | 218 | + | |
| 219 | - | logging.Debug("[tab-mode] applyTabModeBorder: got gtk.Window, about to add CSS class") | 219 | + | // Apply 4px margins to content area to create space for the border |
| 220 | + | webkit.WidgetSetMargin(tm.ContentArea, 4) | |||
| 220 | 221 | ||||
| 221 | - | // Add CSS class to window for tab mode visual indicator | 222 | + | // Add CSS class to window for background color (the "border" color shows in the margin space) |
| 222 | - | // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern) | 223 | + | gtkWindow := tm.window.AsWindow() |
| 223 | webkit.WidgetAddCSSClass(gtkWindow, "tab-mode-active") | 224 | webkit.WidgetAddCSSClass(gtkWindow, "tab-mode-active") | ||
| 224 | - | logging.Debug("[tab-mode] applyTabModeBorder: CSS class added successfully") | 225 | + | |
| 226 | + | // Force resize/allocation to apply margin changes immediately | |||
| 227 | + | webkit.WidgetQueueResize(tm.ContentArea) | |||
| 228 | + | webkit.WidgetQueueAllocate(tm.ContentArea) | |||
| 229 | + | ||||
| 230 | + | // Queue redraw to show changes | |||
| 231 | + | webkit.WidgetQueueDraw(gtkWindow) | |||
| 232 | + | webkit.WidgetQueueDraw(tm.ContentArea) | |||
| 233 | + | ||||
| 234 | + | logging.Debug("[tab-mode] applyTabModeBorder: border applied successfully") | |||
| 225 | } | 235 | } | ||
| 226 | 236 | ||||
| 227 | // removeTabModeBorder removes the visual indicator when exiting tab mode. | 237 | // removeTabModeBorder removes the visual indicator when exiting tab mode. | ||
| 228 | func (tm *TabManager) removeTabModeBorder() { | 238 | func (tm *TabManager) removeTabModeBorder() { | ||
| 229 | - | if tm.window == nil { | 239 | + | if tm.window == nil || tm.ContentArea == nil { |
| 230 | return | 240 | return | ||
| 231 | } | 241 | } | ||
| 232 | 242 | ||||
| 243 | + | // Remove margins from content area | |||
| 244 | + | webkit.WidgetSetMargin(tm.ContentArea, 0) | |||
| 245 | + | ||||
| 233 | // Remove CSS class from window | 246 | // Remove CSS class from window | ||
| 234 | - | // Note: Call directly without RunOnMainThread wrapper (matches pane mode pattern) | 247 | + | gtkWindow := tm.window.AsWindow() |
| 235 | - | webkit.WidgetRemoveCSSClass(tm.window.AsWindow(), "tab-mode-active") | 248 | + | webkit.WidgetRemoveCSSClass(gtkWindow, "tab-mode-active") |
| 249 | + | ||||
| 250 | + | // Force resize/allocation to apply margin changes immediately | |||
| 251 | + | webkit.WidgetQueueResize(tm.ContentArea) | |||
| 252 | + | webkit.WidgetQueueAllocate(tm.ContentArea) | |||
| 253 | + | ||||
| 254 | + | // Queue redraw to show changes | |||
| 255 | + | webkit.WidgetQueueDraw(gtkWindow) | |||
| 256 | + | webkit.WidgetQueueDraw(tm.ContentArea) | |||
| 257 | + | ||||
| 236 | logging.Debug("[tab-mode] Removed tab mode visual indicator") | 258 | logging.Debug("[tab-mode] Removed tab mode visual indicator") | ||
| 237 | } | 259 | } | ||
| 238 | 260 | ||||
M internal/app/browser/window_shortcuts.go +83 -22
| @@ -68,23 +68,15 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||
| 68 | 68 | {"ctrl+shift+c", h.handleCopyURL, "Copy URL"}, | |
| 69 | 69 | {"ctrl+shift+p", h.handlePrint, "Print page"}, | |
| 70 | 70 | {"F12", h.handleDevTools, "Developer tools"}, | |
| 71 | - | // Pane management | |
| 72 | - | {"ctrl+p", h.handlePaneMode, "Enter pane mode"}, | |
| 71 | + | // Pane management: Ctrl+P handled by WebView keyboard bridge (not window shortcuts) | |
| 73 | 72 | // Tab management | |
| 74 | 73 | {"ctrl+t", h.handleTabMode, "Enter tab mode"}, | |
| 75 | 74 | // Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts | |
| 76 | 75 | // This prevents GTK's default focus cycling behavior | |
| 77 | - | // Tab mode actions (only active when tab mode is enabled) | |
| 78 | - | {"n", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"}, | |
| 79 | - | {"c", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"}, | |
| 80 | - | {"x", func() { h.handleTabModeAction("close-tab") }, "Close tab (tab mode)"}, | |
| 81 | - | {"l", func() { h.handleTabModeAction("next-tab") }, "Next tab (tab mode)"}, | |
| 82 | - | {"h", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"}, | |
| 83 | - | {"r", func() { h.handleTabModeAction("rename-tab") }, "Rename tab (tab mode)"}, | |
| 84 | - | {"Tab", func() { h.handleTabModeAction("next-tab") }, "Next tab (tab mode)"}, | |
| 85 | - | {"shift+Tab", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"}, | |
| 86 | - | {"Return", func() { h.handleTabModeAction("confirm") }, "Confirm (tab mode)"}, | |
| 87 | - | {"Escape", func() { h.handleTabModeAction("cancel") }, "Cancel (tab mode)"}, | |
| 76 | + | // NOTE: Tab mode action keys (n, c, x, l, h, r, Tab, Escape, Enter) are NOT registered | |
| 77 | + | // as global shortcuts. They are handled by the WebView keyboard bridge which checks | |
| 78 | + | // if tab mode is active before processing them. This prevents them from consuming | |
| 79 | + | // keys when tab mode is not active (allowing normal typing in web pages). | |
| 88 | 80 | // Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts) | |
| 89 | 81 | // This supports all keyboard layouts (QWERTY, AZERTY, etc.) | |
| 90 | 82 | // Page reload shortcuts | |
| @@ -115,6 +107,9 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||
| 115 | 107 | shortcut.key, shortcut.desc) | |
| 116 | 108 | } | |
| 117 | 109 | ||
| 110 | + | // NOTE: Ctrl+P is NOT registered here - it's handled by WebView's keyboard bridge | |
| 111 | + | // (RegisterPaneModeHandler in workspace_manager.go) which calls app.workspace.EnterPaneMode() | |
| 112 | + | ||
| 118 | 113 | return nil | |
| 119 | 114 | } | |
| 120 | 115 | ||
| @@ -136,7 +131,15 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error { | |||
| 136 | 131 | ||
| 137 | 132 | // GDK key constants | |
| 138 | 133 | const ( | |
| 139 | - | gdkKeyTab = 0xff09 | |
| 134 | + | gdkKeyTab = 0xff09 | |
| 135 | + | gdkKeyEscape = 0xff1b | |
| 136 | + | gdkKeyReturn = 0xff0d | |
| 137 | + | gdkKeyN = 0x006e | |
| 138 | + | gdkKeyC = 0x0063 | |
| 139 | + | gdkKeyX = 0x0078 | |
| 140 | + | gdkKeyL = 0x006c | |
| 141 | + | gdkKeyH = 0x0068 | |
| 142 | + | gdkKeyR = 0x0072 | |
| 140 | 143 | ) | |
| 141 | 144 | ||
| 142 | 145 | // Map hardware keycodes to tab indices | |
| @@ -155,6 +158,10 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error { | |||
| 155 | 158 | } | |
| 156 | 159 | ||
| 157 | 160 | keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 161 | + | // IMPORTANT: Let WebView's keyboard bridge handle pane mode action keys first | |
| 162 | + | // During pane mode, single-letter keys (h,j,k,l,x,s,v, etc.) must reach the WebView | |
| 163 | + | // So we only intercept specific shortcuts here and let everything else through | |
| 164 | + | ||
| 158 | 165 | // Handle Ctrl+Tab for next tab | |
| 159 | 166 | if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) { | |
| 160 | 167 | log.Printf("[window-shortcuts] Ctrl+Tab -> next tab") | |
| @@ -179,11 +186,62 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error { | |||
| 179 | 186 | } | |
| 180 | 187 | } | |
| 181 | 188 | ||
| 189 | + | // Handle tab mode action keys (only when tab mode is active) | |
| 190 | + | // These keys need to be checked here so they can propagate when tab mode is not active | |
| 191 | + | if h.isTabModeActive() { | |
| 192 | + | handled := false | |
| 193 | + | action := "" | |
| 194 | + | ||
| 195 | + | // Check for no modifiers (plain key press) | |
| 196 | + | if !state.Has(gdk.ControlMask) && !state.Has(gdk.AltMask) { | |
| 197 | + | switch keyval { | |
| 198 | + | case gdkKeyN, gdkKeyC: | |
| 199 | + | action = "new-tab" | |
| 200 | + | handled = true | |
| 201 | + | case gdkKeyX: | |
| 202 | + | action = "close-tab" | |
| 203 | + | handled = true | |
| 204 | + | case gdkKeyL: | |
| 205 | + | action = "next-tab" | |
| 206 | + | handled = true | |
| 207 | + | case gdkKeyH: | |
| 208 | + | action = "previous-tab" | |
| 209 | + | handled = true | |
| 210 | + | case gdkKeyR: | |
| 211 | + | action = "rename-tab" | |
| 212 | + | handled = true | |
| 213 | + | case gdkKeyTab: | |
| 214 | + | action = "next-tab" | |
| 215 | + | handled = true | |
| 216 | + | case gdkKeyReturn: | |
| 217 | + | action = "confirm" | |
| 218 | + | handled = true | |
| 219 | + | case gdkKeyEscape: | |
| 220 | + | action = "cancel" | |
| 221 | + | handled = true | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // Handle Shift+Tab for previous tab in tab mode | |
| 226 | + | if keyval == gdkKeyTab && state.Has(gdk.ShiftMask) && !state.Has(gdk.ControlMask) { | |
| 227 | + | action = "previous-tab" | |
| 228 | + | handled = true | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | if handled { | |
| 232 | + | log.Printf("[window-shortcuts] Tab mode action: %s", action) | |
| 233 | + | h.handleTabModeAction(action) | |
| 234 | + | return true // Consume event | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // Let all other keys propagate to WebView's keyboard bridge | |
| 239 | + | // This includes Ctrl+P, Ctrl+T, and all pane mode action keys | |
| 182 | 240 | return false | |
| 183 | 241 | }) | |
| 184 | 242 | ||
| 185 | 243 | gtkWindow.AddController(keyController) | |
| 186 | - | log.Printf("[window-shortcuts] Registered hardware keycode shortcuts for Alt+number (AZERTY support)") | |
| 244 | + | log.Printf("[window-shortcuts] Registered capture phase shortcuts: Ctrl+Tab, Alt+number") | |
| 187 | 245 | ||
| 188 | 246 | return nil | |
| 189 | 247 | } | |
| @@ -564,16 +622,19 @@ func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) { | |||
| 564 | 622 | } | |
| 565 | 623 | } | |
| 566 | 624 | ||
| 567 | - | // handleTabModeAction handles tab mode action keys (n, x, l, h, etc.) | |
| 568 | - | // Only processes actions when tab mode is active, otherwise lets keys pass through to WebView | |
| 569 | - | func (h *WindowShortcutHandler) handleTabModeAction(action string) { | |
| 625 | + | // isTabModeActive checks if tab mode is currently active | |
| 626 | + | func (h *WindowShortcutHandler) isTabModeActive() bool { | |
| 570 | 627 | if h.app == nil || h.app.tabManager == nil { | |
| 571 | - | return // Tab manager not available, let key pass through | |
| 628 | + | return false | |
| 572 | 629 | } | |
| 630 | + | return h.app.tabManager.IsTabModeActive() | |
| 631 | + | } | |
| 573 | 632 | ||
| 574 | - | // Check if tab mode is active | |
| 575 | - | if !h.app.tabManager.IsTabModeActive() { | |
| 576 | - | return // Tab mode not active, let key pass through to WebView | |
| 633 | + | // handleTabModeAction handles tab mode action keys (n, x, l, h, etc.) | |
| 634 | + | // Only processes actions when tab mode is active | |
| 635 | + | func (h *WindowShortcutHandler) handleTabModeAction(action string) { | |
| 636 | + | if h.app == nil || h.app.tabManager == nil { | |
| 637 | + | return | |
| 577 | 638 | } | |
| 578 | 639 | ||
| 579 | 640 | // Tab mode is active, handle the action | |
| @@ -68,23 +68,15 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||||
| 68 | {"ctrl+shift+c", h.handleCopyURL, "Copy URL"}, | 68 | {"ctrl+shift+c", h.handleCopyURL, "Copy URL"}, | ||
| 69 | {"ctrl+shift+p", h.handlePrint, "Print page"}, | 69 | {"ctrl+shift+p", h.handlePrint, "Print page"}, | ||
| 70 | {"F12", h.handleDevTools, "Developer tools"}, | 70 | {"F12", h.handleDevTools, "Developer tools"}, | ||
| 71 | - | // Pane management | 71 | + | // Pane management: Ctrl+P handled by WebView keyboard bridge (not window shortcuts) |
| 72 | - | {"ctrl+p", h.handlePaneMode, "Enter pane mode"}, | |||
| 73 | // Tab management | 72 | // Tab management | ||
| 74 | {"ctrl+t", h.handleTabMode, "Enter tab mode"}, | 73 | {"ctrl+t", h.handleTabMode, "Enter tab mode"}, | ||
| 75 | // Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts | 74 | // Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts | ||
| 76 | // This prevents GTK's default focus cycling behavior | 75 | // This prevents GTK's default focus cycling behavior | ||
| 77 | - | // Tab mode actions (only active when tab mode is enabled) | 76 | + | // NOTE: Tab mode action keys (n, c, x, l, h, r, Tab, Escape, Enter) are NOT registered |
| 78 | - | {"n", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"}, | 77 | + | // as global shortcuts. They are handled by the WebView keyboard bridge which checks |
| 79 | - | {"c", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"}, | 78 | + | // if tab mode is active before processing them. This prevents them from consuming |
| 80 | - | {"x", func() { h.handleTabModeAction("close-tab") }, "Close tab (tab mode)"}, | 79 | + | // keys when tab mode is not active (allowing normal typing in web pages). |
| 81 | - | {"l", func() { h.handleTabModeAction("next-tab") }, "Next tab (tab mode)"}, | |||
| 82 | - | {"h", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"}, | |||
| 83 | - | {"r", func() { h.handleTabModeAction("rename-tab") }, "Rename tab (tab mode)"}, | |||
| 84 | - | {"Tab", func() { h.handleTabModeAction("next-tab") }, "Next tab (tab mode)"}, | |||
| 85 | - | {"shift+Tab", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"}, | |||
| 86 | - | {"Return", func() { h.handleTabModeAction("confirm") }, "Confirm (tab mode)"}, | |||
| 87 | - | {"Escape", func() { h.handleTabModeAction("cancel") }, "Cancel (tab mode)"}, | |||
| 88 | // Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts) | 80 | // Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts) | ||
| 89 | // This supports all keyboard layouts (QWERTY, AZERTY, etc.) | 81 | // This supports all keyboard layouts (QWERTY, AZERTY, etc.) | ||
| 90 | // Page reload shortcuts | 82 | // Page reload shortcuts | ||
| @@ -115,6 +107,9 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||||
| 115 | shortcut.key, shortcut.desc) | 107 | shortcut.key, shortcut.desc) | ||
| 116 | } | 108 | } | ||
| 117 | 109 | ||||
| 110 | + | // NOTE: Ctrl+P is NOT registered here - it's handled by WebView's keyboard bridge | |||
| 111 | + | // (RegisterPaneModeHandler in workspace_manager.go) which calls app.workspace.EnterPaneMode() | |||
| 112 | + | ||||
| 118 | return nil | 113 | return nil | ||
| 119 | } | 114 | } | ||
| 120 | 115 | ||||
| @@ -136,7 +131,15 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error { | |||||
| 136 | 131 | ||||
| 137 | // GDK key constants | 132 | // GDK key constants | ||
| 138 | const ( | 133 | const ( | ||
| 139 | - | gdkKeyTab = 0xff09 | 134 | + | gdkKeyTab = 0xff09 |
| 135 | + | gdkKeyEscape = 0xff1b | |||
| 136 | + | gdkKeyReturn = 0xff0d | |||
| 137 | + | gdkKeyN = 0x006e | |||
| 138 | + | gdkKeyC = 0x0063 | |||
| 139 | + | gdkKeyX = 0x0078 | |||
| 140 | + | gdkKeyL = 0x006c | |||
| 141 | + | gdkKeyH = 0x0068 | |||
| 142 | + | gdkKeyR = 0x0072 | |||
| 140 | ) | 143 | ) | ||
| 141 | 144 | ||||
| 142 | // Map hardware keycodes to tab indices | 145 | // Map hardware keycodes to tab indices | ||
| @@ -155,6 +158,10 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error { | |||||
| 155 | } | 158 | } | ||
| 156 | 159 | ||||
| 157 | keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool { | 160 | keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 161 | + | // IMPORTANT: Let WebView's keyboard bridge handle pane mode action keys first | |||
| 162 | + | // During pane mode, single-letter keys (h,j,k,l,x,s,v, etc.) must reach the WebView | |||
| 163 | + | // So we only intercept specific shortcuts here and let everything else through | |||
| 164 | + | ||||
| 158 | // Handle Ctrl+Tab for next tab | 165 | // Handle Ctrl+Tab for next tab | ||
| 159 | if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) { | 166 | if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) { | ||
| 160 | log.Printf("[window-shortcuts] Ctrl+Tab -> next tab") | 167 | log.Printf("[window-shortcuts] Ctrl+Tab -> next tab") | ||
| @@ -179,11 +186,62 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error { | |||||
| 179 | } | 186 | } | ||
| 180 | } | 187 | } | ||
| 181 | 188 | ||||
| 189 | + | // Handle tab mode action keys (only when tab mode is active) | |||
| 190 | + | // These keys need to be checked here so they can propagate when tab mode is not active | |||
| 191 | + | if h.isTabModeActive() { | |||
| 192 | + | handled := false | |||
| 193 | + | action := "" | |||
| 194 | + | ||||
| 195 | + | // Check for no modifiers (plain key press) | |||
| 196 | + | if !state.Has(gdk.ControlMask) && !state.Has(gdk.AltMask) { | |||
| 197 | + | switch keyval { | |||
| 198 | + | case gdkKeyN, gdkKeyC: | |||
| 199 | + | action = "new-tab" | |||
| 200 | + | handled = true | |||
| 201 | + | case gdkKeyX: | |||
| 202 | + | action = "close-tab" | |||
| 203 | + | handled = true | |||
| 204 | + | case gdkKeyL: | |||
| 205 | + | action = "next-tab" | |||
| 206 | + | handled = true | |||
| 207 | + | case gdkKeyH: | |||
| 208 | + | action = "previous-tab" | |||
| 209 | + | handled = true | |||
| 210 | + | case gdkKeyR: | |||
| 211 | + | action = "rename-tab" | |||
| 212 | + | handled = true | |||
| 213 | + | case gdkKeyTab: | |||
| 214 | + | action = "next-tab" | |||
| 215 | + | handled = true | |||
| 216 | + | case gdkKeyReturn: | |||
| 217 | + | action = "confirm" | |||
| 218 | + | handled = true | |||
| 219 | + | case gdkKeyEscape: | |||
| 220 | + | action = "cancel" | |||
| 221 | + | handled = true | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // Handle Shift+Tab for previous tab in tab mode | |||
| 226 | + | if keyval == gdkKeyTab && state.Has(gdk.ShiftMask) && !state.Has(gdk.ControlMask) { | |||
| 227 | + | action = "previous-tab" | |||
| 228 | + | handled = true | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | if handled { | |||
| 232 | + | log.Printf("[window-shortcuts] Tab mode action: %s", action) | |||
| 233 | + | h.handleTabModeAction(action) | |||
| 234 | + | return true // Consume event | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // Let all other keys propagate to WebView's keyboard bridge | |||
| 239 | + | // This includes Ctrl+P, Ctrl+T, and all pane mode action keys | |||
| 182 | return false | 240 | return false | ||
| 183 | }) | 241 | }) | ||
| 184 | 242 | ||||
| 185 | gtkWindow.AddController(keyController) | 243 | gtkWindow.AddController(keyController) | ||
| 186 | - | log.Printf("[window-shortcuts] Registered hardware keycode shortcuts for Alt+number (AZERTY support)") | 244 | + | log.Printf("[window-shortcuts] Registered capture phase shortcuts: Ctrl+Tab, Alt+number") |
| 187 | 245 | ||||
| 188 | return nil | 246 | return nil | ||
| 189 | } | 247 | } | ||
| @@ -564,16 +622,19 @@ func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) { | |||||
| 564 | } | 622 | } | ||
| 565 | } | 623 | } | ||
| 566 | 624 | ||||
| 567 | - | // handleTabModeAction handles tab mode action keys (n, x, l, h, etc.) | 625 | + | // isTabModeActive checks if tab mode is currently active |
| 568 | - | // Only processes actions when tab mode is active, otherwise lets keys pass through to WebView | 626 | + | func (h *WindowShortcutHandler) isTabModeActive() bool { |
| 569 | - | func (h *WindowShortcutHandler) handleTabModeAction(action string) { | |||
| 570 | if h.app == nil || h.app.tabManager == nil { | 627 | if h.app == nil || h.app.tabManager == nil { | ||
| 571 | - | return // Tab manager not available, let key pass through | 628 | + | return false |
| 572 | } | 629 | } | ||
| 630 | + | return h.app.tabManager.IsTabModeActive() | |||
| 631 | + | } | |||
| 573 | 632 | ||||
| 574 | - | // Check if tab mode is active | 633 | + | // handleTabModeAction handles tab mode action keys (n, x, l, h, etc.) |
| 575 | - | if !h.app.tabManager.IsTabModeActive() { | 634 | + | // Only processes actions when tab mode is active |
| 576 | - | return // Tab mode not active, let key pass through to WebView | 635 | + | func (h *WindowShortcutHandler) handleTabModeAction(action string) { |
| 636 | + | if h.app == nil || h.app.tabManager == nil { | |||
| 637 | + | return | |||
| 577 | } | 638 | } | ||
| 578 | 639 | ||||
| 579 | // Tab mode is active, handle the action | 640 | // Tab mode is active, handle the action | ||
M internal/app/browser/workspace_manager.go +106 -17
| @@ -149,7 +149,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 149 | 149 | handler.SetWorkspaceObserver(manager) | |
| 150 | 150 | } | |
| 151 | 151 | ||
| 152 | - | app.workspace = manager | |
| 152 | + | // CRITICAL: Register navigation handler (including pane mode) for the root pane | |
| 153 | + | // This must happen here because when creating the first tab, app.workspace is nil | |
| 154 | + | // in createPaneForView, so it doesn't register the handler | |
| 155 | + | manager.RegisterNavigationHandler(rootPane.webView) | |
| 156 | + | ||
| 157 | + | // NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager. | |
| 158 | + | // TabManager sets app.workspace during tab switching (including initial tab creation). | |
| 159 | + | // This prevents workspaces from interfering with each other when multiple tabs exist. | |
| 153 | 160 | ||
| 154 | 161 | // Configure debug settings from app config | |
| 155 | 162 | if app.config != nil { | |
| @@ -414,15 +421,20 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |||
| 414 | 421 | return wm.FocusNeighbor(direction) | |
| 415 | 422 | }) | |
| 416 | 423 | ||
| 417 | - | // Register pane mode callbacks for Ctrl+P and pane mode actions | |
| 424 | + | // Register pane mode callbacks for Ctrl+P and pane mode action keys | |
| 418 | 425 | webView.RegisterPaneModeHandler(func(action string) bool { | |
| 419 | 426 | if wm == nil { | |
| 420 | 427 | return false | |
| 421 | 428 | } | |
| 422 | 429 | ||
| 423 | 430 | if action == "enter" { | |
| 424 | - | wm.EnterPaneMode() | |
| 425 | - | return true | |
| 431 | + | // CRITICAL FIX: Use app.workspace instead of closure's wm | |
| 432 | + | // This ensures Ctrl+P always targets the ACTIVE tab's workspace | |
| 433 | + | if wm.app != nil && wm.app.workspace != nil { | |
| 434 | + | wm.app.workspace.EnterPaneMode() | |
| 435 | + | return true | |
| 436 | + | } | |
| 437 | + | return false | |
| 426 | 438 | } | |
| 427 | 439 | ||
| 428 | 440 | if action == "exit" { | |
| @@ -438,18 +450,24 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |||
| 438 | 450 | return false // No mode active, pass through to DOM | |
| 439 | 451 | } | |
| 440 | 452 | ||
| 441 | - | // For other actions, only handle if pane mode is active | |
| 442 | - | wm.paneMutex.Lock() | |
| 443 | - | isActive := wm.paneModeActive | |
| 444 | - | wm.paneMutex.Unlock() | |
| 453 | + | // For other actions, use active workspace (same pattern as "enter") | |
| 454 | + | // This ensures action keys work on the correct tab | |
| 455 | + | if wm.app != nil && wm.app.workspace != nil { | |
| 456 | + | // Check if pane mode is active on the active workspace | |
| 457 | + | activeWs := wm.app.workspace | |
| 458 | + | activeWs.paneMutex.Lock() | |
| 459 | + | isActive := activeWs.paneModeActive | |
| 460 | + | activeWs.paneMutex.Unlock() | |
| 461 | + | ||
| 462 | + | if !isActive { | |
| 463 | + | return false // Not in pane mode, allow normal behavior | |
| 464 | + | } | |
| 445 | 465 | ||
| 446 | - | if !isActive { | |
| 447 | - | return false // Not in pane mode, allow normal behavior | |
| 466 | + | // Pane mode is active, handle the action on active workspace | |
| 467 | + | activeWs.HandlePaneAction(action) | |
| 468 | + | return true | |
| 448 | 469 | } | |
| 449 | - | ||
| 450 | - | // Pane mode is active, handle the action | |
| 451 | - | wm.HandlePaneAction(action) | |
| 452 | - | return true | |
| 470 | + | return false | |
| 453 | 471 | }, func() bool { | |
| 454 | 472 | // Check if pane mode is currently active | |
| 455 | 473 | wm.paneMutex.Lock() | |
| @@ -568,17 +586,43 @@ func (wm *WorkspaceManager) GetActivePane() *BrowserPane { | |||
| 568 | 586 | // RestoreFocus restores focus to the active pane in this workspace. | |
| 569 | 587 | // Called when switching to a tab to ensure the correct pane has focus. | |
| 570 | 588 | func (wm *WorkspaceManager) RestoreFocus() { | |
| 571 | - | // Focus is managed automatically by the focus state machine | |
| 572 | - | // when the workspace becomes visible | |
| 589 | + | log.Printf("[workspace] RestoreFocus: workspace %p", wm) | |
| 590 | + | ||
| 591 | + | // Get the active pane and explicitly grab GTK focus | |
| 573 | 592 | activeNode := wm.GetActiveNode() | |
| 574 | 593 | if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil { | |
| 575 | - | // Request focus on the active pane | |
| 594 | + | // Explicitly grab GTK focus to this WebView | |
| 595 | + | // This ensures keyboard events go to the active tab's WebView | |
| 596 | + | // Note: RestoreFocus is called from switchToTabInternal which is already on the main thread | |
| 597 | + | webkit.WidgetGrabFocus(activeNode.pane.webView.AsWidget()) | |
| 598 | + | log.Printf("[workspace] Grabbed GTK focus for WebView in workspace %p", wm) | |
| 599 | + | ||
| 600 | + | // Also request focus via FSM | |
| 576 | 601 | if wm.focusStateMachine != nil { | |
| 577 | 602 | wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic) | |
| 578 | 603 | } | |
| 579 | 604 | } | |
| 580 | 605 | } | |
| 581 | 606 | ||
| 607 | + | // IsInActiveTab returns whether this workspace is the app's current active workspace. | |
| 608 | + | // This is checked dynamically by comparing with app.workspace (updated during tab switching). | |
| 609 | + | func (wm *WorkspaceManager) IsInActiveTab() bool { | |
| 610 | + | if wm.app == nil { | |
| 611 | + | return false | |
| 612 | + | } | |
| 613 | + | // The active workspace is the one currently assigned to app.workspace | |
| 614 | + | isActive := wm.app.workspace == wm | |
| 615 | + | log.Printf("[workspace] IsInActiveTab check: workspace %p, app.workspace %p, isActive=%v", wm, wm.app.workspace, isActive) | |
| 616 | + | return isActive | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | // ClearFocus removes GTK focus from all panes in this workspace. | |
| 620 | + | // This prevents inactive tab's WebViews from catching keyboard events. | |
| 621 | + | func (wm *WorkspaceManager) ClearFocus() { | |
| 622 | + | log.Printf("[workspace] ClearFocus: workspace %p", wm) | |
| 623 | + | // No state to clear - IsInActiveTab() checks dynamically | |
| 624 | + | } | |
| 625 | + | ||
| 582 | 626 | // GetRootWidget returns the root GTK widget for this workspace. | |
| 583 | 627 | // This is the container that should be added to the tab's content area. | |
| 584 | 628 | func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter { | |
| @@ -587,3 +631,48 @@ func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter { | |||
| 587 | 631 | } | |
| 588 | 632 | return nil | |
| 589 | 633 | } | |
| 634 | + | ||
| 635 | + | // setRootContainer attaches a widget as the workspace root to TabManager.ContentArea. | |
| 636 | + | // The tab system is always present (even with a single tab), so this always uses ContentArea. | |
| 637 | + | func (wm *WorkspaceManager) setRootContainer(container gtk.Widgetter) { | |
| 638 | + | if container == nil { | |
| 639 | + | return | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |
| 643 | + | log.Printf("[workspace] ERROR: Cannot set root - tab manager not initialized") | |
| 644 | + | return | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |
| 648 | + | if !ok || contentBox == nil { | |
| 649 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |
| 650 | + | return | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | contentBox.Append(container) | |
| 654 | + | webkit.WidgetSetVisible(container, true) | |
| 655 | + | log.Printf("[workspace] Attached root container %p to ContentArea", container) | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | // clearRootContainer removes the current workspace root from TabManager.ContentArea. | |
| 659 | + | // The tab system is always present (even with a single tab), so this always uses ContentArea. | |
| 660 | + | func (wm *WorkspaceManager) clearRootContainer() { | |
| 661 | + | if wm.root == nil || wm.root.container == nil { | |
| 662 | + | return | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |
| 666 | + | log.Printf("[workspace] ERROR: Cannot clear root - tab manager not initialized") | |
| 667 | + | return | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |
| 671 | + | if !ok || contentBox == nil { | |
| 672 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |
| 673 | + | return | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | contentBox.Remove(wm.root.container) | |
| 677 | + | log.Printf("[workspace] Removed root container %p from ContentArea", wm.root.container) | |
| 678 | + | } | |
| @@ -149,7 +149,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||||
| 149 | handler.SetWorkspaceObserver(manager) | 149 | handler.SetWorkspaceObserver(manager) | ||
| 150 | } | 150 | } | ||
| 151 | 151 | ||||
| 152 | - | app.workspace = manager | 152 | + | // CRITICAL: Register navigation handler (including pane mode) for the root pane |
| 153 | + | // This must happen here because when creating the first tab, app.workspace is nil | |||
| 154 | + | // in createPaneForView, so it doesn't register the handler | |||
| 155 | + | manager.RegisterNavigationHandler(rootPane.webView) | |||
| 156 | + | ||||
| 157 | + | // NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager. | |||
| 158 | + | // TabManager sets app.workspace during tab switching (including initial tab creation). | |||
| 159 | + | // This prevents workspaces from interfering with each other when multiple tabs exist. | |||
| 153 | 160 | ||||
| 154 | // Configure debug settings from app config | 161 | // Configure debug settings from app config | ||
| 155 | if app.config != nil { | 162 | if app.config != nil { | ||
| @@ -414,15 +421,20 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |||||
| 414 | return wm.FocusNeighbor(direction) | 421 | return wm.FocusNeighbor(direction) | ||
| 415 | }) | 422 | }) | ||
| 416 | 423 | ||||
| 417 | - | // Register pane mode callbacks for Ctrl+P and pane mode actions | 424 | + | // Register pane mode callbacks for Ctrl+P and pane mode action keys |
| 418 | webView.RegisterPaneModeHandler(func(action string) bool { | 425 | webView.RegisterPaneModeHandler(func(action string) bool { | ||
| 419 | if wm == nil { | 426 | if wm == nil { | ||
| 420 | return false | 427 | return false | ||
| 421 | } | 428 | } | ||
| 422 | 429 | ||||
| 423 | if action == "enter" { | 430 | if action == "enter" { | ||
| 424 | - | wm.EnterPaneMode() | 431 | + | // CRITICAL FIX: Use app.workspace instead of closure's wm |
| 425 | - | return true | 432 | + | // This ensures Ctrl+P always targets the ACTIVE tab's workspace |
| 433 | + | if wm.app != nil && wm.app.workspace != nil { | |||
| 434 | + | wm.app.workspace.EnterPaneMode() | |||
| 435 | + | return true | |||
| 436 | + | } | |||
| 437 | + | return false | |||
| 426 | } | 438 | } | ||
| 427 | 439 | ||||
| 428 | if action == "exit" { | 440 | if action == "exit" { | ||
| @@ -438,18 +450,24 @@ func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |||||
| 438 | return false // No mode active, pass through to DOM | 450 | return false // No mode active, pass through to DOM | ||
| 439 | } | 451 | } | ||
| 440 | 452 | ||||
| 441 | - | // For other actions, only handle if pane mode is active | 453 | + | // For other actions, use active workspace (same pattern as "enter") |
| 442 | - | wm.paneMutex.Lock() | 454 | + | // This ensures action keys work on the correct tab |
| 443 | - | isActive := wm.paneModeActive | 455 | + | if wm.app != nil && wm.app.workspace != nil { |
| 444 | - | wm.paneMutex.Unlock() | 456 | + | // Check if pane mode is active on the active workspace |
| 457 | + | activeWs := wm.app.workspace | |||
| 458 | + | activeWs.paneMutex.Lock() | |||
| 459 | + | isActive := activeWs.paneModeActive | |||
| 460 | + | activeWs.paneMutex.Unlock() | |||
| 461 | + | ||||
| 462 | + | if !isActive { | |||
| 463 | + | return false // Not in pane mode, allow normal behavior | |||
| 464 | + | } | |||
| 445 | 465 | ||||
| 446 | - | if !isActive { | 466 | + | // Pane mode is active, handle the action on active workspace |
| 447 | - | return false // Not in pane mode, allow normal behavior | 467 | + | activeWs.HandlePaneAction(action) |
| 468 | + | return true | |||
| 448 | } | 469 | } | ||
| 449 | - | 470 | + | return false | |
| 450 | - | // Pane mode is active, handle the action | |||
| 451 | - | wm.HandlePaneAction(action) | |||
| 452 | - | return true | |||
| 453 | }, func() bool { | 471 | }, func() bool { | ||
| 454 | // Check if pane mode is currently active | 472 | // Check if pane mode is currently active | ||
| 455 | wm.paneMutex.Lock() | 473 | wm.paneMutex.Lock() | ||
| @@ -568,17 +586,43 @@ func (wm *WorkspaceManager) GetActivePane() *BrowserPane { | |||||
| 568 | // RestoreFocus restores focus to the active pane in this workspace. | 586 | // RestoreFocus restores focus to the active pane in this workspace. | ||
| 569 | // Called when switching to a tab to ensure the correct pane has focus. | 587 | // Called when switching to a tab to ensure the correct pane has focus. | ||
| 570 | func (wm *WorkspaceManager) RestoreFocus() { | 588 | func (wm *WorkspaceManager) RestoreFocus() { | ||
| 571 | - | // Focus is managed automatically by the focus state machine | 589 | + | log.Printf("[workspace] RestoreFocus: workspace %p", wm) |
| 572 | - | // when the workspace becomes visible | 590 | + | |
| 591 | + | // Get the active pane and explicitly grab GTK focus | |||
| 573 | activeNode := wm.GetActiveNode() | 592 | activeNode := wm.GetActiveNode() | ||
| 574 | if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil { | 593 | if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil { | ||
| 575 | - | // Request focus on the active pane | 594 | + | // Explicitly grab GTK focus to this WebView |
| 595 | + | // This ensures keyboard events go to the active tab's WebView | |||
| 596 | + | // Note: RestoreFocus is called from switchToTabInternal which is already on the main thread | |||
| 597 | + | webkit.WidgetGrabFocus(activeNode.pane.webView.AsWidget()) | |||
| 598 | + | log.Printf("[workspace] Grabbed GTK focus for WebView in workspace %p", wm) | |||
| 599 | + | ||||
| 600 | + | // Also request focus via FSM | |||
| 576 | if wm.focusStateMachine != nil { | 601 | if wm.focusStateMachine != nil { | ||
| 577 | wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic) | 602 | wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic) | ||
| 578 | } | 603 | } | ||
| 579 | } | 604 | } | ||
| 580 | } | 605 | } | ||
| 581 | 606 | ||||
| 607 | + | // IsInActiveTab returns whether this workspace is the app's current active workspace. | |||
| 608 | + | // This is checked dynamically by comparing with app.workspace (updated during tab switching). | |||
| 609 | + | func (wm *WorkspaceManager) IsInActiveTab() bool { | |||
| 610 | + | if wm.app == nil { | |||
| 611 | + | return false | |||
| 612 | + | } | |||
| 613 | + | // The active workspace is the one currently assigned to app.workspace | |||
| 614 | + | isActive := wm.app.workspace == wm | |||
| 615 | + | log.Printf("[workspace] IsInActiveTab check: workspace %p, app.workspace %p, isActive=%v", wm, wm.app.workspace, isActive) | |||
| 616 | + | return isActive | |||
| 617 | + | } | |||
| 618 | + | ||||
| 619 | + | // ClearFocus removes GTK focus from all panes in this workspace. | |||
| 620 | + | // This prevents inactive tab's WebViews from catching keyboard events. | |||
| 621 | + | func (wm *WorkspaceManager) ClearFocus() { | |||
| 622 | + | log.Printf("[workspace] ClearFocus: workspace %p", wm) | |||
| 623 | + | // No state to clear - IsInActiveTab() checks dynamically | |||
| 624 | + | } | |||
| 625 | + | ||||
| 582 | // GetRootWidget returns the root GTK widget for this workspace. | 626 | // GetRootWidget returns the root GTK widget for this workspace. | ||
| 583 | // This is the container that should be added to the tab's content area. | 627 | // This is the container that should be added to the tab's content area. | ||
| 584 | func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter { | 628 | func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter { | ||
| @@ -587,3 +631,48 @@ func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter { | |||||
| 587 | } | 631 | } | ||
| 588 | return nil | 632 | return nil | ||
| 589 | } | 633 | } | ||
| 634 | + | ||||
| 635 | + | // setRootContainer attaches a widget as the workspace root to TabManager.ContentArea. | |||
| 636 | + | // The tab system is always present (even with a single tab), so this always uses ContentArea. | |||
| 637 | + | func (wm *WorkspaceManager) setRootContainer(container gtk.Widgetter) { | |||
| 638 | + | if container == nil { | |||
| 639 | + | return | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |||
| 643 | + | log.Printf("[workspace] ERROR: Cannot set root - tab manager not initialized") | |||
| 644 | + | return | |||
| 645 | + | } | |||
| 646 | + | ||||
| 647 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |||
| 648 | + | if !ok || contentBox == nil { | |||
| 649 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |||
| 650 | + | return | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | contentBox.Append(container) | |||
| 654 | + | webkit.WidgetSetVisible(container, true) | |||
| 655 | + | log.Printf("[workspace] Attached root container %p to ContentArea", container) | |||
| 656 | + | } | |||
| 657 | + | ||||
| 658 | + | // clearRootContainer removes the current workspace root from TabManager.ContentArea. | |||
| 659 | + | // The tab system is always present (even with a single tab), so this always uses ContentArea. | |||
| 660 | + | func (wm *WorkspaceManager) clearRootContainer() { | |||
| 661 | + | if wm.root == nil || wm.root.container == nil { | |||
| 662 | + | return | |||
| 663 | + | } | |||
| 664 | + | ||||
| 665 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |||
| 666 | + | log.Printf("[workspace] ERROR: Cannot clear root - tab manager not initialized") | |||
| 667 | + | return | |||
| 668 | + | } | |||
| 669 | + | ||||
| 670 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |||
| 671 | + | if !ok || contentBox == nil { | |||
| 672 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |||
| 673 | + | return | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | contentBox.Remove(wm.root.container) | |||
| 677 | + | log.Printf("[workspace] Removed root container %p from ContentArea", wm.root.container) | |||
| 678 | + | } | |||
M internal/app/browser/workspace_pane_mode.go +31 -10
| @@ -5,11 +5,14 @@ import ( | |||
| 5 | 5 | "log" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/pkg/webkit" | |
| 8 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | // EnterPaneMode activates pane mode on the currently focused pane | |
| 11 | 12 | // This is called directly from the keyboard bridge, not from JavaScript | |
| 12 | 13 | func (wm *WorkspaceManager) EnterPaneMode() { | |
| 14 | + | // Note: Caller (RegisterPaneModeHandler) ensures this is called on app.workspace (active tab) | |
| 15 | + | ||
| 13 | 16 | wm.paneMutex.Lock() | |
| 14 | 17 | defer wm.paneMutex.Unlock() | |
| 15 | 18 | ||
| @@ -26,7 +29,7 @@ func (wm *WorkspaceManager) EnterPaneMode() { | |||
| 26 | 29 | return | |
| 27 | 30 | } | |
| 28 | 31 | ||
| 29 | - | log.Printf("[pane-mode] Entering pane mode on pane %p", activeNode) | |
| 32 | + | log.Printf("[pane-mode] Entering pane mode on pane %p (workspace=%p)", activeNode, wm) | |
| 30 | 33 | ||
| 31 | 34 | wm.paneModeActive = true | |
| 32 | 35 | wm.paneModeActivePane = activeNode | |
| @@ -67,6 +70,9 @@ func (wm *WorkspaceManager) ExitPaneMode(reason string) { | |||
| 67 | 70 | // HandlePaneAction processes pane mode actions (close, split, etc.) | |
| 68 | 71 | // Called from keyboard bridge when user presses action keys in pane mode | |
| 69 | 72 | func (wm *WorkspaceManager) HandlePaneAction(action string) { | |
| 73 | + | // Note: No IsInActiveTab check here - caller (RegisterPaneModeHandler) ensures | |
| 74 | + | // this is called on app.workspace (active tab's workspace) | |
| 75 | + | ||
| 70 | 76 | wm.paneMutex.Lock() | |
| 71 | 77 | ||
| 72 | 78 | if !wm.paneModeActive { | |
| @@ -201,29 +207,44 @@ func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) { | |||
| 201 | 207 | ||
| 202 | 208 | // applyPaneModeBorder applies pane mode visual indicator using GTK margins | |
| 203 | 209 | func (wm *WorkspaceManager) applyPaneModeBorder() { | |
| 204 | - | if wm.root == nil || wm.root.container == nil || wm.window == nil { | |
| 210 | + | if wm.window == nil { | |
| 211 | + | return | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // Determine which container to apply margins to: | |
| 215 | + | // - If tab manager exists, apply to its ContentArea (so window background shows through) | |
| 216 | + | // - Otherwise, apply to workspace root (direct child of window) | |
| 217 | + | var targetContainer gtk.Widgetter | |
| 218 | + | if wm.app != nil && wm.app.tabManager != nil && wm.app.tabManager.ContentArea != nil { | |
| 219 | + | // Tab environment: apply margins to tab manager's content area | |
| 220 | + | targetContainer = wm.app.tabManager.ContentArea | |
| 221 | + | log.Printf("[pane-mode] Using tab manager's content area for border") | |
| 222 | + | } else if wm.root != nil && wm.root.container != nil { | |
| 223 | + | // Non-tab environment: apply margins to workspace root | |
| 224 | + | targetContainer = wm.root.container | |
| 225 | + | log.Printf("[pane-mode] Using workspace root for border") | |
| 226 | + | } else { | |
| 205 | 227 | return | |
| 206 | 228 | } | |
| 207 | 229 | ||
| 208 | 230 | // Save the container reference so we can remove margins from it later | |
| 209 | - | // (even if wm.root.container changes due to splits) | |
| 210 | - | wm.paneModeContainer = wm.root.container | |
| 231 | + | wm.paneModeContainer = targetContainer | |
| 211 | 232 | ||
| 212 | - | // Apply 4px margins to root container to create space for the border | |
| 213 | - | webkit.WidgetSetMargin(wm.root.container, 4) | |
| 233 | + | // Apply 4px margins to create space for the border | |
| 234 | + | webkit.WidgetSetMargin(targetContainer, 4) | |
| 214 | 235 | ||
| 215 | 236 | // Add CSS class to window for background color (the "border" color shows in the margin space) | |
| 216 | 237 | webkit.WidgetAddCSSClass(wm.window.AsWindow(), "pane-mode-active") | |
| 217 | 238 | ||
| 218 | 239 | // Force resize/allocation to apply margin changes immediately | |
| 219 | - | webkit.WidgetQueueResize(wm.root.container) | |
| 220 | - | webkit.WidgetQueueAllocate(wm.root.container) | |
| 240 | + | webkit.WidgetQueueResize(targetContainer) | |
| 241 | + | webkit.WidgetQueueAllocate(targetContainer) | |
| 221 | 242 | ||
| 222 | 243 | // Queue redraw to show changes | |
| 223 | 244 | webkit.WidgetQueueDraw(wm.window.AsWindow()) | |
| 224 | - | webkit.WidgetQueueDraw(wm.root.container) | |
| 245 | + | webkit.WidgetQueueDraw(targetContainer) | |
| 225 | 246 | ||
| 226 | - | log.Printf("[pane-mode] Applied border to workspace root using margins (container=%p)", wm.root.container) | |
| 247 | + | log.Printf("[pane-mode] Applied border using margins (container=%p)", targetContainer) | |
| 227 | 248 | } | |
| 228 | 249 | ||
| 229 | 250 | // removePaneModeBorder removes the pane mode visual indicator | |
| @@ -5,11 +5,14 @@ import ( | |||||
| 5 | "log" | 5 | "log" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/pkg/webkit" | 7 | "github.com/bnema/dumber/pkg/webkit" | ||
| 8 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | // EnterPaneMode activates pane mode on the currently focused pane | 11 | // EnterPaneMode activates pane mode on the currently focused pane | ||
| 11 | // This is called directly from the keyboard bridge, not from JavaScript | 12 | // This is called directly from the keyboard bridge, not from JavaScript | ||
| 12 | func (wm *WorkspaceManager) EnterPaneMode() { | 13 | func (wm *WorkspaceManager) EnterPaneMode() { | ||
| 14 | + | // Note: Caller (RegisterPaneModeHandler) ensures this is called on app.workspace (active tab) | |||
| 15 | + | ||||
| 13 | wm.paneMutex.Lock() | 16 | wm.paneMutex.Lock() | ||
| 14 | defer wm.paneMutex.Unlock() | 17 | defer wm.paneMutex.Unlock() | ||
| 15 | 18 | ||||
| @@ -26,7 +29,7 @@ func (wm *WorkspaceManager) EnterPaneMode() { | |||||
| 26 | return | 29 | return | ||
| 27 | } | 30 | } | ||
| 28 | 31 | ||||
| 29 | - | log.Printf("[pane-mode] Entering pane mode on pane %p", activeNode) | 32 | + | log.Printf("[pane-mode] Entering pane mode on pane %p (workspace=%p)", activeNode, wm) |
| 30 | 33 | ||||
| 31 | wm.paneModeActive = true | 34 | wm.paneModeActive = true | ||
| 32 | wm.paneModeActivePane = activeNode | 35 | wm.paneModeActivePane = activeNode | ||
| @@ -67,6 +70,9 @@ func (wm *WorkspaceManager) ExitPaneMode(reason string) { | |||||
| 67 | // HandlePaneAction processes pane mode actions (close, split, etc.) | 70 | // HandlePaneAction processes pane mode actions (close, split, etc.) | ||
| 68 | // Called from keyboard bridge when user presses action keys in pane mode | 71 | // Called from keyboard bridge when user presses action keys in pane mode | ||
| 69 | func (wm *WorkspaceManager) HandlePaneAction(action string) { | 72 | func (wm *WorkspaceManager) HandlePaneAction(action string) { | ||
| 73 | + | // Note: No IsInActiveTab check here - caller (RegisterPaneModeHandler) ensures | |||
| 74 | + | // this is called on app.workspace (active tab's workspace) | |||
| 75 | + | ||||
| 70 | wm.paneMutex.Lock() | 76 | wm.paneMutex.Lock() | ||
| 71 | 77 | ||||
| 72 | if !wm.paneModeActive { | 78 | if !wm.paneModeActive { | ||
| @@ -201,29 +207,44 @@ func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) { | |||||
| 201 | 207 | ||||
| 202 | // applyPaneModeBorder applies pane mode visual indicator using GTK margins | 208 | // applyPaneModeBorder applies pane mode visual indicator using GTK margins | ||
| 203 | func (wm *WorkspaceManager) applyPaneModeBorder() { | 209 | func (wm *WorkspaceManager) applyPaneModeBorder() { | ||
| 204 | - | if wm.root == nil || wm.root.container == nil || wm.window == nil { | 210 | + | if wm.window == nil { |
| 211 | + | return | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // Determine which container to apply margins to: | |||
| 215 | + | // - If tab manager exists, apply to its ContentArea (so window background shows through) | |||
| 216 | + | // - Otherwise, apply to workspace root (direct child of window) | |||
| 217 | + | var targetContainer gtk.Widgetter | |||
| 218 | + | if wm.app != nil && wm.app.tabManager != nil && wm.app.tabManager.ContentArea != nil { | |||
| 219 | + | // Tab environment: apply margins to tab manager's content area | |||
| 220 | + | targetContainer = wm.app.tabManager.ContentArea | |||
| 221 | + | log.Printf("[pane-mode] Using tab manager's content area for border") | |||
| 222 | + | } else if wm.root != nil && wm.root.container != nil { | |||
| 223 | + | // Non-tab environment: apply margins to workspace root | |||
| 224 | + | targetContainer = wm.root.container | |||
| 225 | + | log.Printf("[pane-mode] Using workspace root for border") | |||
| 226 | + | } else { | |||
| 205 | return | 227 | return | ||
| 206 | } | 228 | } | ||
| 207 | 229 | ||||
| 208 | // Save the container reference so we can remove margins from it later | 230 | // Save the container reference so we can remove margins from it later | ||
| 209 | - | // (even if wm.root.container changes due to splits) | 231 | + | wm.paneModeContainer = targetContainer |
| 210 | - | wm.paneModeContainer = wm.root.container | |||
| 211 | 232 | ||||
| 212 | - | // Apply 4px margins to root container to create space for the border | 233 | + | // Apply 4px margins to create space for the border |
| 213 | - | webkit.WidgetSetMargin(wm.root.container, 4) | 234 | + | webkit.WidgetSetMargin(targetContainer, 4) |
| 214 | 235 | ||||
| 215 | // Add CSS class to window for background color (the "border" color shows in the margin space) | 236 | // Add CSS class to window for background color (the "border" color shows in the margin space) | ||
| 216 | webkit.WidgetAddCSSClass(wm.window.AsWindow(), "pane-mode-active") | 237 | webkit.WidgetAddCSSClass(wm.window.AsWindow(), "pane-mode-active") | ||
| 217 | 238 | ||||
| 218 | // Force resize/allocation to apply margin changes immediately | 239 | // Force resize/allocation to apply margin changes immediately | ||
| 219 | - | webkit.WidgetQueueResize(wm.root.container) | 240 | + | webkit.WidgetQueueResize(targetContainer) |
| 220 | - | webkit.WidgetQueueAllocate(wm.root.container) | 241 | + | webkit.WidgetQueueAllocate(targetContainer) |
| 221 | 242 | ||||
| 222 | // Queue redraw to show changes | 243 | // Queue redraw to show changes | ||
| 223 | webkit.WidgetQueueDraw(wm.window.AsWindow()) | 244 | webkit.WidgetQueueDraw(wm.window.AsWindow()) | ||
| 224 | - | webkit.WidgetQueueDraw(wm.root.container) | 245 | + | webkit.WidgetQueueDraw(targetContainer) |
| 225 | 246 | ||||
| 226 | - | log.Printf("[pane-mode] Applied border to workspace root using margins (container=%p)", wm.root.container) | 247 | + | log.Printf("[pane-mode] Applied border using margins (container=%p)", targetContainer) |
| 227 | } | 248 | } | ||
| 228 | 249 | ||||
| 229 | // removePaneModeBorder removes the pane mode visual indicator | 250 | // removePaneModeBorder removes the pane mode visual indicator | ||
M internal/app/browser/workspace_pane_ops.go +78 -27
| @@ -3,11 +3,13 @@ package browser | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "errors" | |
| 6 | + | "fmt" | |
| 6 | 7 | "log" | |
| 7 | 8 | ||
| 8 | - | "github.com/bnema/dumber/pkg/webkit" | |
| 9 | 9 | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 10 | 10 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | // closeContext manages the context for a pane close operation | |
| @@ -529,19 +531,28 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existi | |||
| 529 | 531 | ||
| 530 | 532 | // GTK4 handles widget operations automatically - no need to force redraw | |
| 531 | 533 | ||
| 532 | - | // Handle root split: attach paned to window FIRST, then let GTK handle reparenting | |
| 534 | + | // Handle root split: attach paned to ContentArea (tab system is always present) | |
| 533 | 535 | if parent == nil { | |
| 534 | - | // ROOT SPLIT: Attach empty paned to window, then add children | |
| 535 | - | // GTK will automatically reparent the existing widget when we call SetStartChild/SetEndChild | |
| 536 | - | if wm.window != nil { | |
| 537 | - | // Clear any existing window child (the splitTargetContainer) | |
| 538 | - | wm.window.SetChild(nil) | |
| 539 | - | ||
| 540 | - | // Attach the new paned to window FIRST | |
| 541 | - | wm.window.SetChild(paned) | |
| 542 | - | log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned) | |
| 536 | + | // ROOT SPLIT: Manipulate TabManager.ContentArea | |
| 537 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |
| 538 | + | log.Printf("[workspace] ERROR: Cannot perform root split - tab manager not initialized") | |
| 539 | + | return nil, fmt.Errorf("tab manager not initialized") | |
| 543 | 540 | } | |
| 544 | 541 | ||
| 542 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |
| 543 | + | if !ok || contentBox == nil { | |
| 544 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |
| 545 | + | return nil, fmt.Errorf("ContentArea is not a Box") | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | // Remove the old workspace root from ContentArea | |
| 549 | + | contentBox.Remove(splitTargetContainer) | |
| 550 | + | log.Printf("[workspace] ROOT SPLIT: removed old root %p from ContentArea", splitTargetContainer) | |
| 551 | + | ||
| 552 | + | // Attach the new paned to ContentArea FIRST | |
| 553 | + | contentBox.Append(paned) | |
| 554 | + | log.Printf("[workspace] ROOT SPLIT: attached paned %p to ContentArea", paned) | |
| 555 | + | ||
| 545 | 556 | // Now add children to the paned - GTK will handle reparenting automatically | |
| 546 | 557 | if existingFirst { | |
| 547 | 558 | paned.SetStartChild(splitTargetContainer) | |
| @@ -710,12 +721,41 @@ func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, si | |||
| 710 | 721 | } | |
| 711 | 722 | ||
| 712 | 723 | // swapContainers updates GTK widget hierarchy for promoted siblings | |
| 713 | - | func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) { | |
| 724 | + | func (wm *WorkspaceManager) swapContainers(grand *paneNode, parent *paneNode, sibling *paneNode) { | |
| 714 | 725 | if grand == nil { | |
| 726 | + | // Sibling is being promoted to root | |
| 715 | 727 | // When TreeRebalancer is enabled, skip GTK attachment here as it will be | |
| 716 | 728 | // handled by the rebalancer's promotion transaction to avoid double-attachment | |
| 717 | 729 | if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled { | |
| 718 | - | wm.attachRoot(sibling) | |
| 730 | + | // NOTE: Can't use attachRoot() here because wm.root has already been updated | |
| 731 | + | // to point to the sibling by promoteSibling(). We need to manually swap in ContentArea: | |
| 732 | + | // the old paned (parent) is still in ContentArea and needs to be removed, | |
| 733 | + | // then the sibling needs to be added. | |
| 734 | + | ||
| 735 | + | // The sibling has already been unparented from the old paned earlier in the close logic, | |
| 736 | + | // so it has no GTK parent now. | |
| 737 | + | ||
| 738 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |
| 739 | + | log.Printf("[workspace] ERROR: Cannot promote sibling to root - tab manager not initialized") | |
| 740 | + | return | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |
| 744 | + | if !ok || contentBox == nil { | |
| 745 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |
| 746 | + | return | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | // CRITICAL: Remove the old paned from ContentArea first | |
| 750 | + | if parent != nil && parent.container != nil { | |
| 751 | + | contentBox.Remove(parent.container) | |
| 752 | + | log.Printf("[workspace] Removed old paned %p from ContentArea", parent.container) | |
| 753 | + | } | |
| 754 | + | ||
| 755 | + | // Now add sibling to ContentArea as the new root | |
| 756 | + | contentBox.Append(sibling.container) | |
| 757 | + | webkit.WidgetSetVisible(sibling.container, true) | |
| 758 | + | log.Printf("[workspace] Promoted sibling %p to root in ContentArea", sibling.container) | |
| 719 | 759 | } | |
| 720 | 760 | return | |
| 721 | 761 | } | |
| @@ -827,34 +867,45 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) { | |||
| 827 | 867 | } | |
| 828 | 868 | } | |
| 829 | 869 | ||
| 830 | - | // attachRoot attaches a node as the new window root | |
| 870 | + | // attachRoot attaches a node as the new root in TabManager.ContentArea. | |
| 871 | + | // NOTE: This is typically called AFTER wm.root has already been updated to point to this node. | |
| 872 | + | // The old root container (if any) will be orphaned in ContentArea and garbage collected. | |
| 831 | 873 | func (wm *WorkspaceManager) attachRoot(root *paneNode) { | |
| 832 | - | if root == nil || root.container == nil || wm.window == nil { | |
| 874 | + | if root == nil || root.container == nil { | |
| 833 | 875 | return | |
| 834 | 876 | } | |
| 835 | 877 | ||
| 836 | 878 | // Note: In gotk4, reference counting is handled automatically by Go's GC | |
| 837 | 879 | // No need for manual Ref()/Unref() calls | |
| 838 | 880 | ||
| 839 | - | // Detach from previous container (paned, stack, etc.) before replacing the window child. | |
| 840 | - | // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent | |
| 881 | + | // Detach from previous container (paned, stack, etc.) before attaching to ContentArea | |
| 882 | + | // While GTK4 auto-unparents for paned operations, Box.Append requires manual unparent | |
| 841 | 883 | if parent := webkit.WidgetGetParent(root.container); parent != nil { | |
| 842 | - | log.Printf("[workspace] unparenting widget %p from parent %p before window attach", root.container, parent) | |
| 884 | + | log.Printf("[workspace] unparenting widget %p from parent %p before root attach", root.container, parent) | |
| 843 | 885 | webkit.WidgetUnparent(root.container) | |
| 844 | 886 | // Note: In gotk4, the Go object reference remains valid after unparent | |
| 845 | 887 | // Only the GTK parent relationship is cleared | |
| 846 | 888 | } | |
| 847 | 889 | ||
| 848 | - | // Clear any existing window child to avoid GTK warnings when swapping roots. | |
| 849 | - | wm.window.SetChild(nil) | |
| 850 | - | ||
| 851 | 890 | if root.container != nil { | |
| 852 | 891 | webkit.WidgetSetHExpand(root.container, true) | |
| 853 | 892 | webkit.WidgetSetVExpand(root.container, true) | |
| 854 | 893 | } | |
| 855 | 894 | ||
| 856 | - | // GTK4 will automatically unparent root.container from its previous parent when we attach it here | |
| 857 | - | wm.window.SetChild(root.container) | |
| 895 | + | // Add to ContentArea (the old root will be orphaned and garbage collected) | |
| 896 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |
| 897 | + | log.Printf("[workspace] ERROR: Cannot attach root - tab manager not initialized") | |
| 898 | + | return | |
| 899 | + | } | |
| 900 | + | ||
| 901 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |
| 902 | + | if !ok || contentBox == nil { | |
| 903 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |
| 904 | + | return | |
| 905 | + | } | |
| 906 | + | ||
| 907 | + | contentBox.Append(root.container) | |
| 908 | + | webkit.WidgetSetVisible(root.container, true) | |
| 858 | 909 | if root.container != nil { | |
| 859 | 910 | webkit.WidgetQueueAllocate(root.container) | |
| 860 | 911 | webkit.WidgetShow(root.container) | |
| @@ -1051,8 +1102,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||
| 1051 | 1102 | ||
| 1052 | 1103 | // STEP 7: GTK widget reparenting | |
| 1053 | 1104 | // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent | |
| 1054 | - | // For window: we already manually unparented above, so now we can safely attach | |
| 1055 | - | wm.swapContainers(grandparent, sibling) | |
| 1105 | + | // For root promotion: we need to remove the old paned from ContentArea | |
| 1106 | + | wm.swapContainers(grandparent, parent, sibling) | |
| 1056 | 1107 | ||
| 1057 | 1108 | // Reconnect the promoted sibling's rendering pipeline after GTK reparenting. | |
| 1058 | 1109 | if sibling != nil && sibling.container != nil { | |
| @@ -1137,10 +1188,10 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e | |||
| 1137 | 1188 | } | |
| 1138 | 1189 | } | |
| 1139 | 1190 | } else { | |
| 1140 | - | // Stack was root | |
| 1191 | + | // Stack was root - attach remaining pane as new root | |
| 1141 | 1192 | wm.root = remaining | |
| 1142 | 1193 | if remaining.container != nil { | |
| 1143 | - | wm.window.SetChild(remaining.container) | |
| 1194 | + | wm.setRootContainer(remaining.container) | |
| 1144 | 1195 | } | |
| 1145 | 1196 | } | |
| 1146 | 1197 | ||
| @@ -3,11 +3,13 @@ package browser | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | + | "fmt" | |||
| 6 | "log" | 7 | "log" | ||
| 7 | 8 | ||||
| 8 | - | "github.com/bnema/dumber/pkg/webkit" | |||
| 9 | "github.com/diamondburned/gotk4/pkg/gdk/v4" | 9 | "github.com/diamondburned/gotk4/pkg/gdk/v4" | ||
| 10 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | 10 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | ||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | // closeContext manages the context for a pane close operation | 15 | // closeContext manages the context for a pane close operation | ||
| @@ -529,19 +531,28 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existi | |||||
| 529 | 531 | ||||
| 530 | // GTK4 handles widget operations automatically - no need to force redraw | 532 | // GTK4 handles widget operations automatically - no need to force redraw | ||
| 531 | 533 | ||||
| 532 | - | // Handle root split: attach paned to window FIRST, then let GTK handle reparenting | 534 | + | // Handle root split: attach paned to ContentArea (tab system is always present) |
| 533 | if parent == nil { | 535 | if parent == nil { | ||
| 534 | - | // ROOT SPLIT: Attach empty paned to window, then add children | 536 | + | // ROOT SPLIT: Manipulate TabManager.ContentArea |
| 535 | - | // GTK will automatically reparent the existing widget when we call SetStartChild/SetEndChild | 537 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { |
| 536 | - | if wm.window != nil { | 538 | + | log.Printf("[workspace] ERROR: Cannot perform root split - tab manager not initialized") |
| 537 | - | // Clear any existing window child (the splitTargetContainer) | 539 | + | return nil, fmt.Errorf("tab manager not initialized") |
| 538 | - | wm.window.SetChild(nil) | |||
| 539 | - | ||||
| 540 | - | // Attach the new paned to window FIRST | |||
| 541 | - | wm.window.SetChild(paned) | |||
| 542 | - | log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned) | |||
| 543 | } | 540 | } | ||
| 544 | 541 | ||||
| 542 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |||
| 543 | + | if !ok || contentBox == nil { | |||
| 544 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |||
| 545 | + | return nil, fmt.Errorf("ContentArea is not a Box") | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | // Remove the old workspace root from ContentArea | |||
| 549 | + | contentBox.Remove(splitTargetContainer) | |||
| 550 | + | log.Printf("[workspace] ROOT SPLIT: removed old root %p from ContentArea", splitTargetContainer) | |||
| 551 | + | ||||
| 552 | + | // Attach the new paned to ContentArea FIRST | |||
| 553 | + | contentBox.Append(paned) | |||
| 554 | + | log.Printf("[workspace] ROOT SPLIT: attached paned %p to ContentArea", paned) | |||
| 555 | + | ||||
| 545 | // Now add children to the paned - GTK will handle reparenting automatically | 556 | // Now add children to the paned - GTK will handle reparenting automatically | ||
| 546 | if existingFirst { | 557 | if existingFirst { | ||
| 547 | paned.SetStartChild(splitTargetContainer) | 558 | paned.SetStartChild(splitTargetContainer) | ||
| @@ -710,12 +721,41 @@ func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, si | |||||
| 710 | } | 721 | } | ||
| 711 | 722 | ||||
| 712 | // swapContainers updates GTK widget hierarchy for promoted siblings | 723 | // swapContainers updates GTK widget hierarchy for promoted siblings | ||
| 713 | - | func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) { | 724 | + | func (wm *WorkspaceManager) swapContainers(grand *paneNode, parent *paneNode, sibling *paneNode) { |
| 714 | if grand == nil { | 725 | if grand == nil { | ||
| 726 | + | // Sibling is being promoted to root | |||
| 715 | // When TreeRebalancer is enabled, skip GTK attachment here as it will be | 727 | // When TreeRebalancer is enabled, skip GTK attachment here as it will be | ||
| 716 | // handled by the rebalancer's promotion transaction to avoid double-attachment | 728 | // handled by the rebalancer's promotion transaction to avoid double-attachment | ||
| 717 | if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled { | 729 | if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled { | ||
| 718 | - | wm.attachRoot(sibling) | 730 | + | // NOTE: Can't use attachRoot() here because wm.root has already been updated |
| 731 | + | // to point to the sibling by promoteSibling(). We need to manually swap in ContentArea: | |||
| 732 | + | // the old paned (parent) is still in ContentArea and needs to be removed, | |||
| 733 | + | // then the sibling needs to be added. | |||
| 734 | + | ||||
| 735 | + | // The sibling has already been unparented from the old paned earlier in the close logic, | |||
| 736 | + | // so it has no GTK parent now. | |||
| 737 | + | ||||
| 738 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { | |||
| 739 | + | log.Printf("[workspace] ERROR: Cannot promote sibling to root - tab manager not initialized") | |||
| 740 | + | return | |||
| 741 | + | } | |||
| 742 | + | ||||
| 743 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |||
| 744 | + | if !ok || contentBox == nil { | |||
| 745 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |||
| 746 | + | return | |||
| 747 | + | } | |||
| 748 | + | ||||
| 749 | + | // CRITICAL: Remove the old paned from ContentArea first | |||
| 750 | + | if parent != nil && parent.container != nil { | |||
| 751 | + | contentBox.Remove(parent.container) | |||
| 752 | + | log.Printf("[workspace] Removed old paned %p from ContentArea", parent.container) | |||
| 753 | + | } | |||
| 754 | + | ||||
| 755 | + | // Now add sibling to ContentArea as the new root | |||
| 756 | + | contentBox.Append(sibling.container) | |||
| 757 | + | webkit.WidgetSetVisible(sibling.container, true) | |||
| 758 | + | log.Printf("[workspace] Promoted sibling %p to root in ContentArea", sibling.container) | |||
| 719 | } | 759 | } | ||
| 720 | return | 760 | return | ||
| 721 | } | 761 | } | ||
| @@ -827,34 +867,45 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) { | |||||
| 827 | } | 867 | } | ||
| 828 | } | 868 | } | ||
| 829 | 869 | ||||
| 830 | - | // attachRoot attaches a node as the new window root | 870 | + | // attachRoot attaches a node as the new root in TabManager.ContentArea. |
| 871 | + | // NOTE: This is typically called AFTER wm.root has already been updated to point to this node. | |||
| 872 | + | // The old root container (if any) will be orphaned in ContentArea and garbage collected. | |||
| 831 | func (wm *WorkspaceManager) attachRoot(root *paneNode) { | 873 | func (wm *WorkspaceManager) attachRoot(root *paneNode) { | ||
| 832 | - | if root == nil || root.container == nil || wm.window == nil { | 874 | + | if root == nil || root.container == nil { |
| 833 | return | 875 | return | ||
| 834 | } | 876 | } | ||
| 835 | 877 | ||||
| 836 | // Note: In gotk4, reference counting is handled automatically by Go's GC | 878 | // Note: In gotk4, reference counting is handled automatically by Go's GC | ||
| 837 | // No need for manual Ref()/Unref() calls | 879 | // No need for manual Ref()/Unref() calls | ||
| 838 | 880 | ||||
| 839 | - | // Detach from previous container (paned, stack, etc.) before replacing the window child. | 881 | + | // Detach from previous container (paned, stack, etc.) before attaching to ContentArea |
| 840 | - | // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent | 882 | + | // While GTK4 auto-unparents for paned operations, Box.Append requires manual unparent |
| 841 | if parent := webkit.WidgetGetParent(root.container); parent != nil { | 883 | if parent := webkit.WidgetGetParent(root.container); parent != nil { | ||
| 842 | - | log.Printf("[workspace] unparenting widget %p from parent %p before window attach", root.container, parent) | 884 | + | log.Printf("[workspace] unparenting widget %p from parent %p before root attach", root.container, parent) |
| 843 | webkit.WidgetUnparent(root.container) | 885 | webkit.WidgetUnparent(root.container) | ||
| 844 | // Note: In gotk4, the Go object reference remains valid after unparent | 886 | // Note: In gotk4, the Go object reference remains valid after unparent | ||
| 845 | // Only the GTK parent relationship is cleared | 887 | // Only the GTK parent relationship is cleared | ||
| 846 | } | 888 | } | ||
| 847 | 889 | ||||
| 848 | - | // Clear any existing window child to avoid GTK warnings when swapping roots. | |||
| 849 | - | wm.window.SetChild(nil) | |||
| 850 | - | ||||
| 851 | if root.container != nil { | 890 | if root.container != nil { | ||
| 852 | webkit.WidgetSetHExpand(root.container, true) | 891 | webkit.WidgetSetHExpand(root.container, true) | ||
| 853 | webkit.WidgetSetVExpand(root.container, true) | 892 | webkit.WidgetSetVExpand(root.container, true) | ||
| 854 | } | 893 | } | ||
| 855 | 894 | ||||
| 856 | - | // GTK4 will automatically unparent root.container from its previous parent when we attach it here | 895 | + | // Add to ContentArea (the old root will be orphaned and garbage collected) |
| 857 | - | wm.window.SetChild(root.container) | 896 | + | if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil { |
| 897 | + | log.Printf("[workspace] ERROR: Cannot attach root - tab manager not initialized") | |||
| 898 | + | return | |||
| 899 | + | } | |||
| 900 | + | ||||
| 901 | + | contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box) | |||
| 902 | + | if !ok || contentBox == nil { | |||
| 903 | + | log.Printf("[workspace] ERROR: ContentArea is not a Box") | |||
| 904 | + | return | |||
| 905 | + | } | |||
| 906 | + | ||||
| 907 | + | contentBox.Append(root.container) | |||
| 908 | + | webkit.WidgetSetVisible(root.container, true) | |||
| 858 | if root.container != nil { | 909 | if root.container != nil { | ||
| 859 | webkit.WidgetQueueAllocate(root.container) | 910 | webkit.WidgetQueueAllocate(root.container) | ||
| 860 | webkit.WidgetShow(root.container) | 911 | webkit.WidgetShow(root.container) | ||
| @@ -1051,8 +1102,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||||
| 1051 | 1102 | ||||
| 1052 | // STEP 7: GTK widget reparenting | 1103 | // STEP 7: GTK widget reparenting | ||
| 1053 | // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent | 1104 | // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent | ||
| 1054 | - | // For window: we already manually unparented above, so now we can safely attach | 1105 | + | // For root promotion: we need to remove the old paned from ContentArea |
| 1055 | - | wm.swapContainers(grandparent, sibling) | 1106 | + | wm.swapContainers(grandparent, parent, sibling) |
| 1056 | 1107 | ||||
| 1057 | // Reconnect the promoted sibling's rendering pipeline after GTK reparenting. | 1108 | // Reconnect the promoted sibling's rendering pipeline after GTK reparenting. | ||
| 1058 | if sibling != nil && sibling.container != nil { | 1109 | if sibling != nil && sibling.container != nil { | ||
| @@ -1137,10 +1188,10 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e | |||||
| 1137 | } | 1188 | } | ||
| 1138 | } | 1189 | } | ||
| 1139 | } else { | 1190 | } else { | ||
| 1140 | - | // Stack was root | 1191 | + | // Stack was root - attach remaining pane as new root |
| 1141 | wm.root = remaining | 1192 | wm.root = remaining | ||
| 1142 | if remaining.container != nil { | 1193 | if remaining.container != nil { | ||
| 1143 | - | wm.window.SetChild(remaining.container) | 1194 | + | wm.setRootContainer(remaining.container) |
| 1144 | } | 1195 | } | ||
| 1145 | } | 1196 | } | ||
| 1146 | 1197 | ||||