refactor(browser): simplify border API and improve deduplication logic
Changes: - Remove determineBorderContext() in favor of direct applyActivePaneBorder(node) - Update IsDuplicate() to only dedupe when target is already active - Change CSS from outline to border for better rendering - Update all callsites to use simplified border API This simplifies the border management system and fixes focus request deduplication to allow re-focusing a pane that lost focus.
- commit
- 3f10f364355cc922bb646cfc3a7977a05db2daec
- author
- bamen · · 2025-10-09 20:29 UTC
- committer
- bamen · · 2025-10-09 19:09 UTC
- parent
- a6d29c6
- signature
- unsigned
7 files · +38 -91
M internal/app/browser/focus_state_machine.go +16 -11
| @@ -159,7 +159,7 @@ func NewRequestDeduplicator(ttl time.Duration) *RequestDeduplicator { | |||
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | 161 | // IsDuplicate checks if a request is a duplicate within the TTL window | |
| 162 | - | func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool { | |
| 162 | + | func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest, isActiveTarget bool) bool { | |
| 163 | 163 | rd.mu.Lock() | |
| 164 | 164 | defer rd.mu.Unlock() | |
| 165 | 165 | ||
| @@ -174,12 +174,16 @@ func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool { | |||
| 174 | 174 | // Generate signature for this request | |
| 175 | 175 | sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source) | |
| 176 | 176 | ||
| 177 | - | // Check if this signature exists | |
| 178 | - | if _, exists := rd.recentSigs[sig]; exists { | |
| 179 | - | return true | |
| 177 | + | // Always record latest timestamp so future checks use fresh window | |
| 178 | + | // Prevent duplicates only when the target is already active; otherwise we | |
| 179 | + | // want to process the request to move focus back to that pane. | |
| 180 | + | if timestamp, exists := rd.recentSigs[sig]; exists { | |
| 181 | + | if isActiveTarget && now.Sub(timestamp) <= rd.ttl { | |
| 182 | + | rd.recentSigs[sig] = now | |
| 183 | + | return true | |
| 184 | + | } | |
| 180 | 185 | } | |
| 181 | 186 | ||
| 182 | - | // Record this signature | |
| 183 | 187 | rd.recentSigs[sig] = now | |
| 184 | 188 | return false | |
| 185 | 189 | } | |
| @@ -367,8 +371,11 @@ func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) e | |||
| 367 | 371 | fsm.mu.Unlock() | |
| 368 | 372 | } | |
| 369 | 373 | ||
| 370 | - | // Check for duplicates | |
| 371 | - | if fsm.deduplicator.IsDuplicate(request) { | |
| 374 | + | activePane := fsm.GetActivePane() | |
| 375 | + | ||
| 376 | + | // Check for duplicates (only dedupe when the request targets the currently | |
| 377 | + | // active pane to avoid suppressing necessary focus transitions). | |
| 378 | + | if fsm.deduplicator.IsDuplicate(request, activePane == node) { | |
| 372 | 379 | if fsm.metricsEnabled { | |
| 373 | 380 | fsm.mu.Lock() | |
| 374 | 381 | fsm.metrics.DuplicateRequests++ | |
| @@ -589,8 +596,7 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error { | |||
| 589 | 596 | ||
| 590 | 597 | // Apply initial visual border | |
| 591 | 598 | if fsm.wm != nil { | |
| 592 | - | ctx := fsm.wm.determineBorderContext(node) | |
| 593 | - | fsm.wm.applyActivePaneBorder(ctx) | |
| 599 | + | fsm.wm.applyActivePaneBorder(node) | |
| 594 | 600 | } | |
| 595 | 601 | ||
| 596 | 602 | // Update state | |
| @@ -756,8 +762,7 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error { | |||
| 756 | 762 | fsm.wm.removeActivePaneBorder(oldPane) | |
| 757 | 763 | } | |
| 758 | 764 | // Add border to new pane | |
| 759 | - | ctx := fsm.wm.determineBorderContext(newPane) | |
| 760 | - | fsm.wm.applyActivePaneBorder(ctx) | |
| 765 | + | fsm.wm.applyActivePaneBorder(newPane) | |
| 761 | 766 | } | |
| 762 | 767 | ||
| 763 | 768 | // Update state | |
| @@ -159,7 +159,7 @@ func NewRequestDeduplicator(ttl time.Duration) *RequestDeduplicator { | |||||
| 159 | } | 159 | } | ||
| 160 | 160 | ||||
| 161 | // IsDuplicate checks if a request is a duplicate within the TTL window | 161 | // IsDuplicate checks if a request is a duplicate within the TTL window | ||
| 162 | - | func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool { | 162 | + | func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest, isActiveTarget bool) bool { |
| 163 | rd.mu.Lock() | 163 | rd.mu.Lock() | ||
| 164 | defer rd.mu.Unlock() | 164 | defer rd.mu.Unlock() | ||
| 165 | 165 | ||||
| @@ -174,12 +174,16 @@ func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool { | |||||
| 174 | // Generate signature for this request | 174 | // Generate signature for this request | ||
| 175 | sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source) | 175 | sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source) | ||
| 176 | 176 | ||||
| 177 | - | // Check if this signature exists | 177 | + | // Always record latest timestamp so future checks use fresh window |
| 178 | - | if _, exists := rd.recentSigs[sig]; exists { | 178 | + | // Prevent duplicates only when the target is already active; otherwise we |
| 179 | - | return true | 179 | + | // want to process the request to move focus back to that pane. |
| 180 | + | if timestamp, exists := rd.recentSigs[sig]; exists { | |||
| 181 | + | if isActiveTarget && now.Sub(timestamp) <= rd.ttl { | |||
| 182 | + | rd.recentSigs[sig] = now | |||
| 183 | + | return true | |||
| 184 | + | } | |||
| 180 | } | 185 | } | ||
| 181 | 186 | ||||
| 182 | - | // Record this signature | |||
| 183 | rd.recentSigs[sig] = now | 187 | rd.recentSigs[sig] = now | ||
| 184 | return false | 188 | return false | ||
| 185 | } | 189 | } | ||
| @@ -367,8 +371,11 @@ func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) e | |||||
| 367 | fsm.mu.Unlock() | 371 | fsm.mu.Unlock() | ||
| 368 | } | 372 | } | ||
| 369 | 373 | ||||
| 370 | - | // Check for duplicates | 374 | + | activePane := fsm.GetActivePane() |
| 371 | - | if fsm.deduplicator.IsDuplicate(request) { | 375 | + | |
| 376 | + | // Check for duplicates (only dedupe when the request targets the currently | |||
| 377 | + | // active pane to avoid suppressing necessary focus transitions). | |||
| 378 | + | if fsm.deduplicator.IsDuplicate(request, activePane == node) { | |||
| 372 | if fsm.metricsEnabled { | 379 | if fsm.metricsEnabled { | ||
| 373 | fsm.mu.Lock() | 380 | fsm.mu.Lock() | ||
| 374 | fsm.metrics.DuplicateRequests++ | 381 | fsm.metrics.DuplicateRequests++ | ||
| @@ -589,8 +596,7 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error { | |||||
| 589 | 596 | ||||
| 590 | // Apply initial visual border | 597 | // Apply initial visual border | ||
| 591 | if fsm.wm != nil { | 598 | if fsm.wm != nil { | ||
| 592 | - | ctx := fsm.wm.determineBorderContext(node) | 599 | + | fsm.wm.applyActivePaneBorder(node) |
| 593 | - | fsm.wm.applyActivePaneBorder(ctx) | |||
| 594 | } | 600 | } | ||
| 595 | 601 | ||||
| 596 | // Update state | 602 | // Update state | ||
| @@ -756,8 +762,7 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error { | |||||
| 756 | fsm.wm.removeActivePaneBorder(oldPane) | 762 | fsm.wm.removeActivePaneBorder(oldPane) | ||
| 757 | } | 763 | } | ||
| 758 | // Add border to new pane | 764 | // Add border to new pane | ||
| 759 | - | ctx := fsm.wm.determineBorderContext(newPane) | 765 | + | fsm.wm.applyActivePaneBorder(newPane) |
| 760 | - | fsm.wm.applyActivePaneBorder(ctx) | |||
| 761 | } | 766 | } | ||
| 762 | 767 | ||||
| 763 | // Update state | 768 | // Update state | ||
M internal/app/browser/stacked_panes.go +3 -4
| @@ -20,9 +20,9 @@ const ( | |||
| 20 | 20 | ||
| 21 | 21 | // StackedPaneManager handles all stacked pane operations | |
| 22 | 22 | type StackedPaneManager struct { | |
| 23 | - | wm *WorkspaceManager | |
| 24 | - | titleBarToPane map[uint64]*paneNode | |
| 25 | - | nextTitleBarID uint64 | |
| 23 | + | wm *WorkspaceManager | |
| 24 | + | titleBarToPane map[uint64]*paneNode | |
| 25 | + | nextTitleBarID uint64 | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | // NewStackedPaneManager creates a new stacked pane manager | |
| @@ -90,7 +90,6 @@ func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) { | |||
| 90 | 90 | } | |
| 91 | 91 | webkit.WidgetSetHExpand(newContainer, true) | |
| 92 | 92 | webkit.WidgetSetVExpand(newContainer, true) | |
| 93 | - | webkit.WidgetRealize(newContainer) | |
| 94 | 93 | ||
| 95 | 94 | // Create the new leaf node | |
| 96 | 95 | newLeaf := &paneNode{ | |
| @@ -20,9 +20,9 @@ const ( | |||||
| 20 | 20 | ||||
| 21 | // StackedPaneManager handles all stacked pane operations | 21 | // StackedPaneManager handles all stacked pane operations | ||
| 22 | type StackedPaneManager struct { | 22 | type StackedPaneManager struct { | ||
| 23 | - | wm *WorkspaceManager | 23 | + | wm *WorkspaceManager |
| 24 | - | titleBarToPane map[uint64]*paneNode | 24 | + | titleBarToPane map[uint64]*paneNode |
| 25 | - | nextTitleBarID uint64 | 25 | + | nextTitleBarID uint64 |
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | // NewStackedPaneManager creates a new stacked pane manager | 28 | // NewStackedPaneManager creates a new stacked pane manager | ||
| @@ -90,7 +90,6 @@ func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) { | |||||
| 90 | } | 90 | } | ||
| 91 | webkit.WidgetSetHExpand(newContainer, true) | 91 | webkit.WidgetSetHExpand(newContainer, true) | ||
| 92 | webkit.WidgetSetVExpand(newContainer, true) | 92 | webkit.WidgetSetVExpand(newContainer, true) | ||
| 93 | - | webkit.WidgetRealize(newContainer) | |||
| 94 | 93 | ||||
| 95 | // Create the new leaf node | 94 | // Create the new leaf node | ||
| 96 | newLeaf := &paneNode{ | 95 | newLeaf := &paneNode{ | ||
M internal/app/browser/workspace_css.go +11 -12
| @@ -79,25 +79,24 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||
| 79 | 79 | ||
| 80 | 80 | /* Base pane styling - subtle border for inactive panes */ | |
| 81 | 81 | .workspace-pane, .stacked-pane-container { | |
| 82 | - | outline: 2px solid %s; | |
| 83 | - | outline-offset: -2px; | |
| 84 | - | transition: outline-color %dms ease-in-out; | |
| 82 | + | border: 2px solid %s; | |
| 83 | + | border-radius: %dpx; | |
| 84 | + | transition: border-color %dms ease-in-out; | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | - | /* Active pane border styling using outline (no layout impact) */ | |
| 87 | + | /* Active pane border styling */ | |
| 88 | 88 | .workspace-pane-active { | |
| 89 | - | outline-color: %s; | |
| 89 | + | border-color: %s; | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 92 | 92 | /* Stacked panes styling */ | |
| 93 | 93 | .stacked-pane-container { | |
| 94 | 94 | background-color: %s; | |
| 95 | - | border-radius: %dpx; | |
| 96 | 95 | } | |
| 97 | 96 | ||
| 98 | 97 | /* Active stacked pane container gets the outline color */ | |
| 99 | 98 | .stacked-pane-container.workspace-pane-active { | |
| 100 | - | outline-color: %s; | |
| 99 | + | border-color: %s; | |
| 101 | 100 | } | |
| 102 | 101 | ||
| 103 | 102 | .stacked-pane-title { | |
| @@ -130,12 +129,12 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||
| 130 | 129 | /* Collapsed panes are hidden - handled in code via widget visibility */ | |
| 131 | 130 | }`, | |
| 132 | 131 | windowBackgroundColor, // window background | |
| 133 | - | inactiveBorderColor, // base pane outline color (inactive) | |
| 134 | - | styling.TransitionDuration, // base pane outline transition | |
| 135 | - | activeBorderColor, // workspace-pane-active outline-color | |
| 132 | + | inactiveBorderColor, // base pane border color (inactive) | |
| 133 | + | styling.BorderRadius, // base pane border radius | |
| 134 | + | styling.TransitionDuration, // base pane border transition | |
| 135 | + | activeBorderColor, // workspace-pane-active border color | |
| 136 | 136 | windowBackgroundColor, // stacked-pane-container background | |
| 137 | - | styling.BorderRadius, // stacked-pane-container border-radius | |
| 138 | - | activeBorderColor, // stacked-pane-container.workspace-pane-active outline-color | |
| 137 | + | activeBorderColor, // stacked-pane-container.workspace-pane-active border color | |
| 139 | 138 | getStackTitleBg(isDark), // stacked-pane-title background | |
| 140 | 139 | inactiveBorderColor, // stacked-pane-title border-bottom | |
| 141 | 140 | styling.TransitionDuration, // stacked-pane-title transition | |
| @@ -79,25 +79,24 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||||
| 79 | 79 | ||||
| 80 | /* Base pane styling - subtle border for inactive panes */ | 80 | /* Base pane styling - subtle border for inactive panes */ | ||
| 81 | .workspace-pane, .stacked-pane-container { | 81 | .workspace-pane, .stacked-pane-container { | ||
| 82 | - | outline: 2px solid %s; | 82 | + | border: 2px solid %s; |
| 83 | - | outline-offset: -2px; | 83 | + | border-radius: %dpx; |
| 84 | - | transition: outline-color %dms ease-in-out; | 84 | + | transition: border-color %dms ease-in-out; |
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| 87 | - | /* Active pane border styling using outline (no layout impact) */ | 87 | + | /* Active pane border styling */ |
| 88 | .workspace-pane-active { | 88 | .workspace-pane-active { | ||
| 89 | - | outline-color: %s; | 89 | + | border-color: %s; |
| 90 | } | 90 | } | ||
| 91 | 91 | ||||
| 92 | /* Stacked panes styling */ | 92 | /* Stacked panes styling */ | ||
| 93 | .stacked-pane-container { | 93 | .stacked-pane-container { | ||
| 94 | background-color: %s; | 94 | background-color: %s; | ||
| 95 | - | border-radius: %dpx; | |||
| 96 | } | 95 | } | ||
| 97 | 96 | ||||
| 98 | /* Active stacked pane container gets the outline color */ | 97 | /* Active stacked pane container gets the outline color */ | ||
| 99 | .stacked-pane-container.workspace-pane-active { | 98 | .stacked-pane-container.workspace-pane-active { | ||
| 100 | - | outline-color: %s; | 99 | + | border-color: %s; |
| 101 | } | 100 | } | ||
| 102 | 101 | ||||
| 103 | .stacked-pane-title { | 102 | .stacked-pane-title { | ||
| @@ -130,12 +129,12 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||||
| 130 | /* Collapsed panes are hidden - handled in code via widget visibility */ | 129 | /* Collapsed panes are hidden - handled in code via widget visibility */ | ||
| 131 | }`, | 130 | }`, | ||
| 132 | windowBackgroundColor, // window background | 131 | windowBackgroundColor, // window background | ||
| 133 | - | inactiveBorderColor, // base pane outline color (inactive) | 132 | + | inactiveBorderColor, // base pane border color (inactive) |
| 134 | - | styling.TransitionDuration, // base pane outline transition | 133 | + | styling.BorderRadius, // base pane border radius |
| 135 | - | activeBorderColor, // workspace-pane-active outline-color | 134 | + | styling.TransitionDuration, // base pane border transition |
| 135 | + | activeBorderColor, // workspace-pane-active border color | |||
| 136 | windowBackgroundColor, // stacked-pane-container background | 136 | windowBackgroundColor, // stacked-pane-container background | ||
| 137 | - | styling.BorderRadius, // stacked-pane-container border-radius | 137 | + | activeBorderColor, // stacked-pane-container.workspace-pane-active border color |
| 138 | - | activeBorderColor, // stacked-pane-container.workspace-pane-active outline-color | |||
| 139 | getStackTitleBg(isDark), // stacked-pane-title background | 138 | getStackTitleBg(isDark), // stacked-pane-title background | ||
| 140 | inactiveBorderColor, // stacked-pane-title border-bottom | 139 | inactiveBorderColor, // stacked-pane-title border-bottom | ||
| 141 | styling.TransitionDuration, // stacked-pane-title transition | 140 | styling.TransitionDuration, // stacked-pane-title transition | ||
M internal/app/browser/workspace_manager.go +3 -0
| @@ -166,6 +166,9 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 166 | 166 | log.Printf("[workspace] Failed to initialize focus state machine: %v", err) | |
| 167 | 167 | } | |
| 168 | 168 | ||
| 169 | + | // Ensure hover controllers are attached to all panes for mouse-driven focus changes | |
| 170 | + | manager.attachHoverHandlersToAllPanes() | |
| 171 | + | ||
| 169 | 172 | if manager.debugPaneClose { | |
| 170 | 173 | manager.dumpTreeState("workspace_init") | |
| 171 | 174 | } | |
| @@ -166,6 +166,9 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||||
| 166 | log.Printf("[workspace] Failed to initialize focus state machine: %v", err) | 166 | log.Printf("[workspace] Failed to initialize focus state machine: %v", err) | ||
| 167 | } | 167 | } | ||
| 168 | 168 | ||||
| 169 | + | // Ensure hover controllers are attached to all panes for mouse-driven focus changes | |||
| 170 | + | manager.attachHoverHandlersToAllPanes() | |||
| 171 | + | ||||
| 169 | if manager.debugPaneClose { | 172 | if manager.debugPaneClose { | ||
| 170 | manager.dumpTreeState("workspace_init") | 173 | manager.dumpTreeState("workspace_init") | ||
| 171 | } | 174 | } | ||
M internal/app/browser/workspace_navigation.go +1 -41
| @@ -89,47 +89,7 @@ func (wm *WorkspaceManager) focusByView(view *webkit.WebView) { | |||
| 89 | 89 | } | |
| 90 | 90 | } | |
| 91 | 91 | ||
| 92 | - | // ensureHover sets up hover handlers for a pane node | |
| 93 | - | func (wm *WorkspaceManager) ensureHover(node *paneNode) { | |
| 94 | - | if wm == nil || node == nil || !node.isLeaf { | |
| 95 | - | return | |
| 96 | - | } | |
| 97 | - | if node.container == nil || node.hoverToken != 0 { | |
| 98 | - | return | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | // Create motion controller for hover detection | |
| 102 | - | controller := gtk.NewEventControllerMotion() | |
| 103 | - | controller.ConnectEnter(func(x, y float64) { | |
| 104 | - | if wm == nil { | |
| 105 | - | return | |
| 106 | - | } | |
| 107 | - | wm.SetActivePane(node, SourceMouse) | |
| 108 | - | }) | |
| 109 | - | ||
| 110 | - | webkit.WidgetAddController(node.container, controller) | |
| 111 | - | // Store controller pointer as token for later removal | |
| 112 | - | node.hoverToken = uintptr(controller.Native()) | |
| 113 | - | if node.hoverToken == 0 { | |
| 114 | - | log.Printf("[workspace] failed to attach hover handler") | |
| 115 | - | } | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // detachHover removes hover handlers from a pane node | |
| 119 | - | func (wm *WorkspaceManager) detachHover(node *paneNode) { | |
| 120 | - | if wm == nil || node == nil || node.hoverToken == 0 { | |
| 121 | - | return | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | if node.container == nil { | |
| 125 | - | node.hoverToken = 0 | |
| 126 | - | return | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | // Note: In GTK4, controllers are automatically removed when widget is destroyed | |
| 130 | - | // We just need to clear our token reference | |
| 131 | - | node.hoverToken = 0 | |
| 132 | - | } | |
| 92 | + | // Note: ensureHover and detachHover are now implemented in workspace_hover.go | |
| 133 | 93 | ||
| 134 | 94 | // detachFocus removes the GTK focus controller attached to a pane, if any. | |
| 135 | 95 | func (wm *WorkspaceManager) detachFocus(node *paneNode) { | |
| @@ -89,47 +89,7 @@ func (wm *WorkspaceManager) focusByView(view *webkit.WebView) { | |||||
| 89 | } | 89 | } | ||
| 90 | } | 90 | } | ||
| 91 | 91 | ||||
| 92 | - | // ensureHover sets up hover handlers for a pane node | 92 | + | // Note: ensureHover and detachHover are now implemented in workspace_hover.go |
| 93 | - | func (wm *WorkspaceManager) ensureHover(node *paneNode) { | |||
| 94 | - | if wm == nil || node == nil || !node.isLeaf { | |||
| 95 | - | return | |||
| 96 | - | } | |||
| 97 | - | if node.container == nil || node.hoverToken != 0 { | |||
| 98 | - | return | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | // Create motion controller for hover detection | |||
| 102 | - | controller := gtk.NewEventControllerMotion() | |||
| 103 | - | controller.ConnectEnter(func(x, y float64) { | |||
| 104 | - | if wm == nil { | |||
| 105 | - | return | |||
| 106 | - | } | |||
| 107 | - | wm.SetActivePane(node, SourceMouse) | |||
| 108 | - | }) | |||
| 109 | - | ||||
| 110 | - | webkit.WidgetAddController(node.container, controller) | |||
| 111 | - | // Store controller pointer as token for later removal | |||
| 112 | - | node.hoverToken = uintptr(controller.Native()) | |||
| 113 | - | if node.hoverToken == 0 { | |||
| 114 | - | log.Printf("[workspace] failed to attach hover handler") | |||
| 115 | - | } | |||
| 116 | - | } | |||
| 117 | - | ||||
| 118 | - | // detachHover removes hover handlers from a pane node | |||
| 119 | - | func (wm *WorkspaceManager) detachHover(node *paneNode) { | |||
| 120 | - | if wm == nil || node == nil || node.hoverToken == 0 { | |||
| 121 | - | return | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | if node.container == nil { | |||
| 125 | - | node.hoverToken = 0 | |||
| 126 | - | return | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | // Note: In GTK4, controllers are automatically removed when widget is destroyed | |||
| 130 | - | // We just need to clear our token reference | |||
| 131 | - | node.hoverToken = 0 | |||
| 132 | - | } | |||
| 133 | 93 | ||||
| 134 | // detachFocus removes the GTK focus controller attached to a pane, if any. | 94 | // detachFocus removes the GTK focus controller attached to a pane, if any. | ||
| 135 | func (wm *WorkspaceManager) detachFocus(node *paneNode) { | 95 | func (wm *WorkspaceManager) detachFocus(node *paneNode) { | ||
M internal/app/browser/workspace_pane_ops.go +3 -13
| @@ -140,7 +140,7 @@ func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode | |||
| 140 | 140 | return | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | - | if target.hoverToken != 0 { | |
| 143 | + | if target.hoverToken != nil { | |
| 144 | 144 | target.pendingHoverReattach = true | |
| 145 | 145 | wm.detachHover(target) | |
| 146 | 146 | } | |
| @@ -319,13 +319,6 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 319 | 319 | ||
| 320 | 320 | webkit.WidgetSetHExpand(newContainer, true) | |
| 321 | 321 | webkit.WidgetSetVExpand(newContainer, true) | |
| 322 | - | webkit.WidgetRealize(newContainer) | |
| 323 | - | ||
| 324 | - | // Also realize the WebView widget itself for proper popup rendering | |
| 325 | - | webViewWidget := newPane.webView.Widget() | |
| 326 | - | if webViewWidget != nil { | |
| 327 | - | webkit.WidgetRealize(webViewWidget) | |
| 328 | - | } | |
| 329 | 322 | ||
| 330 | 323 | // CRITICAL: Handle stacked panes correctly - same logic as splitNode | |
| 331 | 324 | // When the target is inside a stacked pane, we need to split around the entire stack, | |
| @@ -571,7 +564,7 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||
| 571 | 564 | } | |
| 572 | 565 | if candidate.pendingHoverReattach { | |
| 573 | 566 | wm.ensureHover(candidate) | |
| 574 | - | if candidate.hoverToken != 0 { | |
| 567 | + | if candidate.hoverToken != nil { | |
| 575 | 568 | candidate.pendingHoverReattach = false | |
| 576 | 569 | } | |
| 577 | 570 | } | |
| @@ -642,7 +635,6 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 642 | 635 | } | |
| 643 | 636 | webkit.WidgetSetHExpand(newContainer, true) | |
| 644 | 637 | webkit.WidgetSetVExpand(newContainer, true) | |
| 645 | - | webkit.WidgetRealize(newContainer) | |
| 646 | 638 | ||
| 647 | 639 | // Use the determined split target container | |
| 648 | 640 | if splitTargetContainer == nil { | |
| @@ -655,14 +647,12 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 655 | 647 | if splitTargetContainer != nil { | |
| 656 | 648 | webkit.WidgetSetHExpand(splitTargetContainer, true) | |
| 657 | 649 | webkit.WidgetSetVExpand(splitTargetContainer, true) | |
| 658 | - | webkit.WidgetRealize(splitTargetContainer) | |
| 659 | 650 | } | |
| 660 | 651 | } else if splitTarget.isStacked { | |
| 661 | 652 | // Stack containers need the same setup as regular panes for proper splitting | |
| 662 | 653 | if splitTargetContainer != nil { | |
| 663 | 654 | webkit.WidgetSetHExpand(splitTargetContainer, true) | |
| 664 | 655 | webkit.WidgetSetVExpand(splitTargetContainer, true) | |
| 665 | - | webkit.WidgetRealize(splitTargetContainer) | |
| 666 | 656 | log.Printf("[workspace] configured stack wrapper for split: %p", splitTargetContainer) | |
| 667 | 657 | } | |
| 668 | 658 | } | |
| @@ -871,7 +861,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 871 | 861 | } | |
| 872 | 862 | if candidate.pendingHoverReattach { | |
| 873 | 863 | wm.ensureHover(candidate) | |
| 874 | - | if candidate.hoverToken != 0 { | |
| 864 | + | if candidate.hoverToken != nil { | |
| 875 | 865 | candidate.pendingHoverReattach = false | |
| 876 | 866 | } | |
| 877 | 867 | } | |
| @@ -140,7 +140,7 @@ func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode | |||||
| 140 | return | 140 | return | ||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | - | if target.hoverToken != 0 { | 143 | + | if target.hoverToken != nil { |
| 144 | target.pendingHoverReattach = true | 144 | target.pendingHoverReattach = true | ||
| 145 | wm.detachHover(target) | 145 | wm.detachHover(target) | ||
| 146 | } | 146 | } | ||
| @@ -319,13 +319,6 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||||
| 319 | 319 | ||||
| 320 | webkit.WidgetSetHExpand(newContainer, true) | 320 | webkit.WidgetSetHExpand(newContainer, true) | ||
| 321 | webkit.WidgetSetVExpand(newContainer, true) | 321 | webkit.WidgetSetVExpand(newContainer, true) | ||
| 322 | - | webkit.WidgetRealize(newContainer) | |||
| 323 | - | ||||
| 324 | - | // Also realize the WebView widget itself for proper popup rendering | |||
| 325 | - | webViewWidget := newPane.webView.Widget() | |||
| 326 | - | if webViewWidget != nil { | |||
| 327 | - | webkit.WidgetRealize(webViewWidget) | |||
| 328 | - | } | |||
| 329 | 322 | ||||
| 330 | // CRITICAL: Handle stacked panes correctly - same logic as splitNode | 323 | // CRITICAL: Handle stacked panes correctly - same logic as splitNode | ||
| 331 | // When the target is inside a stacked pane, we need to split around the entire stack, | 324 | // When the target is inside a stacked pane, we need to split around the entire stack, | ||
| @@ -571,7 +564,7 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa | |||||
| 571 | } | 564 | } | ||
| 572 | if candidate.pendingHoverReattach { | 565 | if candidate.pendingHoverReattach { | ||
| 573 | wm.ensureHover(candidate) | 566 | wm.ensureHover(candidate) | ||
| 574 | - | if candidate.hoverToken != 0 { | 567 | + | if candidate.hoverToken != nil { |
| 575 | candidate.pendingHoverReattach = false | 568 | candidate.pendingHoverReattach = false | ||
| 576 | } | 569 | } | ||
| 577 | } | 570 | } | ||
| @@ -642,7 +635,6 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 642 | } | 635 | } | ||
| 643 | webkit.WidgetSetHExpand(newContainer, true) | 636 | webkit.WidgetSetHExpand(newContainer, true) | ||
| 644 | webkit.WidgetSetVExpand(newContainer, true) | 637 | webkit.WidgetSetVExpand(newContainer, true) | ||
| 645 | - | webkit.WidgetRealize(newContainer) | |||
| 646 | 638 | ||||
| 647 | // Use the determined split target container | 639 | // Use the determined split target container | ||
| 648 | if splitTargetContainer == nil { | 640 | if splitTargetContainer == nil { | ||
| @@ -655,14 +647,12 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 655 | if splitTargetContainer != nil { | 647 | if splitTargetContainer != nil { | ||
| 656 | webkit.WidgetSetHExpand(splitTargetContainer, true) | 648 | webkit.WidgetSetHExpand(splitTargetContainer, true) | ||
| 657 | webkit.WidgetSetVExpand(splitTargetContainer, true) | 649 | webkit.WidgetSetVExpand(splitTargetContainer, true) | ||
| 658 | - | webkit.WidgetRealize(splitTargetContainer) | |||
| 659 | } | 650 | } | ||
| 660 | } else if splitTarget.isStacked { | 651 | } else if splitTarget.isStacked { | ||
| 661 | // Stack containers need the same setup as regular panes for proper splitting | 652 | // Stack containers need the same setup as regular panes for proper splitting | ||
| 662 | if splitTargetContainer != nil { | 653 | if splitTargetContainer != nil { | ||
| 663 | webkit.WidgetSetHExpand(splitTargetContainer, true) | 654 | webkit.WidgetSetHExpand(splitTargetContainer, true) | ||
| 664 | webkit.WidgetSetVExpand(splitTargetContainer, true) | 655 | webkit.WidgetSetVExpand(splitTargetContainer, true) | ||
| 665 | - | webkit.WidgetRealize(splitTargetContainer) | |||
| 666 | log.Printf("[workspace] configured stack wrapper for split: %p", splitTargetContainer) | 656 | log.Printf("[workspace] configured stack wrapper for split: %p", splitTargetContainer) | ||
| 667 | } | 657 | } | ||
| 668 | } | 658 | } | ||
| @@ -871,7 +861,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||||
| 871 | } | 861 | } | ||
| 872 | if candidate.pendingHoverReattach { | 862 | if candidate.pendingHoverReattach { | ||
| 873 | wm.ensureHover(candidate) | 863 | wm.ensureHover(candidate) | ||
| 874 | - | if candidate.hoverToken != 0 { | 864 | + | if candidate.hoverToken != nil { |
| 875 | candidate.pendingHoverReattach = false | 865 | candidate.pendingHoverReattach = false | ||
| 876 | } | 866 | } | ||
| 877 | } | 867 | } | ||
M pkg/webkit/widgets.go +1 -10
| @@ -244,16 +244,7 @@ func getWidget(w gtk.Widgetter) *gtk.Widget { | |||
| 244 | 244 | if w == nil { | |
| 245 | 245 | return nil | |
| 246 | 246 | } | |
| 247 | - | // The Widgetter interface provides a way to get the base widget | |
| 248 | - | // In gotk4, we can cast to get access to the underlying widget | |
| 249 | - | if widget, ok := w.(interface{ Widget() *gtk.Widget }); ok { | |
| 250 | - | return widget.Widget() | |
| 251 | - | } | |
| 252 | - | // Fallback: try to type assert directly | |
| 253 | - | if widget, ok := w.(*gtk.Widget); ok { | |
| 254 | - | return widget | |
| 255 | - | } | |
| 256 | - | return nil | |
| 247 | + | return gtk.BaseWidget(w) | |
| 257 | 248 | } | |
| 258 | 249 | ||
| 259 | 250 | // WidgetShow makes a widget visible | |
| @@ -244,16 +244,7 @@ func getWidget(w gtk.Widgetter) *gtk.Widget { | |||||
| 244 | if w == nil { | 244 | if w == nil { | ||
| 245 | return nil | 245 | return nil | ||
| 246 | } | 246 | } | ||
| 247 | - | // The Widgetter interface provides a way to get the base widget | 247 | + | return gtk.BaseWidget(w) |
| 248 | - | // In gotk4, we can cast to get access to the underlying widget | |||
| 249 | - | if widget, ok := w.(interface{ Widget() *gtk.Widget }); ok { | |||
| 250 | - | return widget.Widget() | |||
| 251 | - | } | |||
| 252 | - | // Fallback: try to type assert directly | |||
| 253 | - | if widget, ok := w.(*gtk.Widget); ok { | |||
| 254 | - | return widget | |||
| 255 | - | } | |||
| 256 | - | return nil | |||
| 257 | } | 248 | } | ||
| 258 | 249 | ||||
| 259 | // WidgetShow makes a widget visible | 250 | // WidgetShow makes a widget visible | ||