fix(browser): prevent GTK bloom filter crash with CSS class guards and hover debouncing
Root cause: Rapid mouse movements over panes caused overlapping CSS class add/remove operations, leading to GTK's internal counting bloom filter assertion failure when trying to remove classes that don't exist or weren't properly added. Changes: - Add WidgetHasCSSClass() checks before add/remove in workspace_utils.go - Implement 150ms hover debouncing in new workspace_hover.go file - Add lastHoverTime field to paneNode for debounce tracking - Create WidgetAddHoverHandler/WidgetRemoveHoverHandler in helpers.go - Simplify borderTargets() to return widget list instead of context struct This ensures CSS classes are only added when not present and only removed when present, preventing the bloom filter corruption that caused crashes.
- commit
- a6d29c6c2fed8df13e0b79fd6c71ecad15123552
- author
- bamen · · 2025-10-09 17:16 UTC
- committer
- bamen · · 2025-10-09 17:28 UTC
- parent
- 57752f1
- signature
- unsigned
4 files · +165 -85
A internal/app/browser/workspace_hover.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | // workspace_hover.go - Hover handler management for workspace panes | |
| 2 | + | package browser | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "log" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 8 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // ensureHover attaches hover handlers to a pane node for mouse-based focus changes | |
| 12 | + | func (wm *WorkspaceManager) ensureHover(node *paneNode) { | |
| 13 | + | if wm == nil || node == nil || !node.isLeaf { | |
| 14 | + | return | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // Skip if already attached or no container | |
| 18 | + | if node.container == nil || node.hoverToken != nil { | |
| 19 | + | return | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // Attach hover handler to the pane container | |
| 23 | + | controller := webkit.WidgetAddHoverHandler(node.container, func() { | |
| 24 | + | if wm == nil || node == nil { | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Skip if this pane is already active to avoid redundant focus churn. | |
| 29 | + | if wm.GetActiveNode() == node { | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Debounce hover events to prevent rapid focus changes (minimum 150ms between hover-triggered focus changes) | |
| 34 | + | now := glib.GetMonotonicTime() | |
| 35 | + | if node.lastHoverTime > 0 && (now - node.lastHoverTime) < 150000 { // 150ms in microseconds | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | node.lastHoverTime = now | |
| 39 | + | ||
| 40 | + | // Request focus change when mouse enters the pane | |
| 41 | + | wm.SetActivePane(node, SourceMouse) | |
| 42 | + | }) | |
| 43 | + | ||
| 44 | + | if controller != nil { | |
| 45 | + | node.hoverToken = controller | |
| 46 | + | log.Printf("[workspace] Attached hover handler to pane %p (controller=%p)", node, controller) | |
| 47 | + | } else { | |
| 48 | + | log.Printf("[workspace] Failed to attach hover handler to pane %p", node) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // detachHover removes hover handlers from a pane node | |
| 53 | + | func (wm *WorkspaceManager) detachHover(node *paneNode) { | |
| 54 | + | if wm == nil || node == nil || node.hoverToken == nil { | |
| 55 | + | return | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | if node.container == nil { | |
| 59 | + | node.hoverToken = nil | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | webkit.WidgetRemoveHoverHandler(node.container, node.hoverToken) | |
| 64 | + | log.Printf("[workspace] Detached hover handler from pane %p (controller=%p)", node, node.hoverToken) | |
| 65 | + | node.hoverToken = nil | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // attachHoverHandlersToAllPanes attaches hover handlers to all leaf panes | |
| 69 | + | func (wm *WorkspaceManager) attachHoverHandlersToAllPanes() { | |
| 70 | + | if wm == nil { | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | leaves := wm.collectLeaves() | |
| 75 | + | for _, leaf := range leaves { | |
| 76 | + | wm.ensureHover(leaf) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | log.Printf("[workspace] Attached hover handlers to %d panes", len(leaves)) | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | // workspace_hover.go - Hover handler management for workspace panes | |||
| 2 | + | package browser | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "log" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 8 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // ensureHover attaches hover handlers to a pane node for mouse-based focus changes | |||
| 12 | + | func (wm *WorkspaceManager) ensureHover(node *paneNode) { | |||
| 13 | + | if wm == nil || node == nil || !node.isLeaf { | |||
| 14 | + | return | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // Skip if already attached or no container | |||
| 18 | + | if node.container == nil || node.hoverToken != nil { | |||
| 19 | + | return | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // Attach hover handler to the pane container | |||
| 23 | + | controller := webkit.WidgetAddHoverHandler(node.container, func() { | |||
| 24 | + | if wm == nil || node == nil { | |||
| 25 | + | return | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Skip if this pane is already active to avoid redundant focus churn. | |||
| 29 | + | if wm.GetActiveNode() == node { | |||
| 30 | + | return | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Debounce hover events to prevent rapid focus changes (minimum 150ms between hover-triggered focus changes) | |||
| 34 | + | now := glib.GetMonotonicTime() | |||
| 35 | + | if node.lastHoverTime > 0 && (now - node.lastHoverTime) < 150000 { // 150ms in microseconds | |||
| 36 | + | return | |||
| 37 | + | } | |||
| 38 | + | node.lastHoverTime = now | |||
| 39 | + | ||||
| 40 | + | // Request focus change when mouse enters the pane | |||
| 41 | + | wm.SetActivePane(node, SourceMouse) | |||
| 42 | + | }) | |||
| 43 | + | ||||
| 44 | + | if controller != nil { | |||
| 45 | + | node.hoverToken = controller | |||
| 46 | + | log.Printf("[workspace] Attached hover handler to pane %p (controller=%p)", node, controller) | |||
| 47 | + | } else { | |||
| 48 | + | log.Printf("[workspace] Failed to attach hover handler to pane %p", node) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // detachHover removes hover handlers from a pane node | |||
| 53 | + | func (wm *WorkspaceManager) detachHover(node *paneNode) { | |||
| 54 | + | if wm == nil || node == nil || node.hoverToken == nil { | |||
| 55 | + | return | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | if node.container == nil { | |||
| 59 | + | node.hoverToken = nil | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | webkit.WidgetRemoveHoverHandler(node.container, node.hoverToken) | |||
| 64 | + | log.Printf("[workspace] Detached hover handler from pane %p (controller=%p)", node, node.hoverToken) | |||
| 65 | + | node.hoverToken = nil | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // attachHoverHandlersToAllPanes attaches hover handlers to all leaf panes | |||
| 69 | + | func (wm *WorkspaceManager) attachHoverHandlersToAllPanes() { | |||
| 70 | + | if wm == nil { | |||
| 71 | + | return | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | leaves := wm.collectLeaves() | |||
| 75 | + | for _, leaf := range leaves { | |||
| 76 | + | wm.ensureHover(leaf) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | log.Printf("[workspace] Attached hover handlers to %d panes", len(leaves)) | |||
| 80 | + | } | |||
M internal/app/browser/workspace_types.go +6 -5
| @@ -63,11 +63,12 @@ type paneNode struct { | |||
| 63 | 63 | parentPane *paneNode // Parent for related views | |
| 64 | 64 | activePopupChildren []string // WebView IDs of active popup children (for tracking related popups) | |
| 65 | 65 | autoClose bool // Auto-close on OAuth success | |
| 66 | - | requestID string // Request ID for popup (used for localStorage cleanup) | |
| 67 | - | hoverToken uintptr | |
| 68 | - | focusControllerToken uintptr | |
| 69 | - | pendingHoverReattach bool | |
| 70 | - | pendingFocusReattach bool | |
| 66 | + | requestID string // Request ID for popup (used for localStorage cleanup) | |
| 67 | + | hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus | |
| 68 | + | focusControllerToken uintptr // Focus controller token (stored as native pointer) | |
| 69 | + | pendingHoverReattach bool // Needs hover reattachment after reparent | |
| 70 | + | pendingFocusReattach bool // Needs focus reattachment after reparent | |
| 71 | + | lastHoverTime int64 // Timestamp of last hover event (for debouncing) | |
| 71 | 72 | ||
| 72 | 73 | // Stacked panes support - terminal branch nodes | |
| 73 | 74 | isStacked bool // Whether this node contains stacked panes | |
| @@ -63,11 +63,12 @@ type paneNode struct { | |||||
| 63 | parentPane *paneNode // Parent for related views | 63 | parentPane *paneNode // Parent for related views | ||
| 64 | activePopupChildren []string // WebView IDs of active popup children (for tracking related popups) | 64 | activePopupChildren []string // WebView IDs of active popup children (for tracking related popups) | ||
| 65 | autoClose bool // Auto-close on OAuth success | 65 | autoClose bool // Auto-close on OAuth success | ||
| 66 | - | requestID string // Request ID for popup (used for localStorage cleanup) | 66 | + | requestID string // Request ID for popup (used for localStorage cleanup) |
| 67 | - | hoverToken uintptr | 67 | + | hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus |
| 68 | - | focusControllerToken uintptr | 68 | + | focusControllerToken uintptr // Focus controller token (stored as native pointer) |
| 69 | - | pendingHoverReattach bool | 69 | + | pendingHoverReattach bool // Needs hover reattachment after reparent |
| 70 | - | pendingFocusReattach bool | 70 | + | pendingFocusReattach bool // Needs focus reattachment after reparent |
| 71 | + | lastHoverTime int64 // Timestamp of last hover event (for debouncing) | |||
| 71 | 72 | ||||
| 72 | // Stacked panes support - terminal branch nodes | 73 | // Stacked panes support - terminal branch nodes | ||
| 73 | isStacked bool // Whether this node contains stacked panes | 74 | isStacked bool // Whether this node contains stacked panes | ||
M internal/app/browser/workspace_utils.go +54 -80
| @@ -10,11 +10,6 @@ import ( | |||
| 10 | 10 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | // hasMultiplePanes returns true if there are multiple panes in the workspace | |
| 14 | - | func (wm *WorkspaceManager) hasMultiplePanes() bool { | |
| 15 | - | return wm != nil && wm.app != nil && len(wm.app.panes) > 1 | |
| 16 | - | } | |
| 17 | - | ||
| 18 | 13 | // UpdateTitleBar updates the title bar label for a WebView in stacked panes | |
| 19 | 14 | func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) { | |
| 20 | 15 | // Delegate to StackedPaneManager which has the correct logic | |
| @@ -97,106 +92,85 @@ func (wm *WorkspaceManager) setStackWrapper(node *paneNode, widget gtk.Widgetter | |||
| 97 | 92 | // Centralized Active Pane Border Management System | |
| 98 | 93 | // Handles all pane types: regular, popup, stacked, split panes | |
| 99 | 94 | ||
| 100 | - | // PaneBorderContext holds the context for applying borders to any pane type | |
| 101 | - | type PaneBorderContext struct { | |
| 102 | - | webViewWidget gtk.Widgetter // The WebView's native widget (for margin) | |
| 103 | - | borderContainer gtk.Widgetter // The container that gets background color | |
| 104 | - | cssClasses []string // Additional CSS classes to apply | |
| 105 | - | paneType string // Description for debugging | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // determineBorderContext analyzes a pane node and determines the correct border context | |
| 109 | - | func (wm *WorkspaceManager) determineBorderContext(node *paneNode) *PaneBorderContext { | |
| 95 | + | // borderTargets returns the widgets that should reflect the pane border state. | |
| 96 | + | func (wm *WorkspaceManager) borderTargets(node *paneNode) []gtk.Widgetter { | |
| 110 | 97 | if node == nil { | |
| 111 | 98 | return nil | |
| 112 | 99 | } | |
| 113 | 100 | ||
| 114 | - | ctx := &PaneBorderContext{} | |
| 115 | - | ||
| 116 | - | // Step 1: Get WebView widget for margin (same for all pane types) | |
| 117 | - | // Use AsWidget() to get the actual WebView widget, not the container | |
| 118 | - | if node.pane != nil && node.pane.webView != nil { | |
| 119 | - | ctx.webViewWidget = node.pane.webView.AsWidget() | |
| 101 | + | targets := make([]gtk.Widgetter, 0, 4) | |
| 102 | + | seen := make(map[*gtk.Widget]struct{}) | |
| 103 | + | add := func(widget gtk.Widgetter) { | |
| 104 | + | if widget == nil { | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | if base := gtk.BaseWidget(widget); base != nil { | |
| 108 | + | if _, exists := seen[base]; exists { | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | seen[base] = struct{}{} | |
| 112 | + | } | |
| 113 | + | targets = append(targets, widget) | |
| 120 | 114 | } | |
| 121 | 115 | ||
| 122 | - | // Step 2: Determine the border container based on pane type | |
| 123 | - | switch { | |
| 124 | - | case node.windowType == webkit.WindowTypePopup: | |
| 125 | - | // Popup panes: Border on popup container | |
| 126 | - | ctx.borderContainer = node.container | |
| 127 | - | ctx.cssClasses = []string{activePaneClass} | |
| 128 | - | ctx.paneType = "popup" | |
| 129 | - | ||
| 130 | - | case node.parent != nil && node.parent.isStacked: | |
| 131 | - | // Pane in stack: Border on stack wrapper container | |
| 132 | - | ctx.borderContainer = node.parent.stackWrapper | |
| 133 | - | ctx.cssClasses = []string{activePaneClass} | |
| 134 | - | ctx.paneType = "stacked" | |
| 116 | + | // Primary container for this node. | |
| 117 | + | add(node.container) | |
| 118 | + | add(node.stackWrapper) | |
| 135 | 119 | ||
| 136 | - | default: | |
| 137 | - | // Regular pane: Border on pane container | |
| 138 | - | ctx.borderContainer = node.container | |
| 139 | - | ctx.cssClasses = []string{activePaneClass} | |
| 140 | - | ctx.paneType = "regular" | |
| 120 | + | // Highlight stacks via their outer container as well. | |
| 121 | + | if node.parent != nil && node.parent.isStacked { | |
| 122 | + | add(node.parent.container) | |
| 123 | + | add(node.parent.stackWrapper) | |
| 141 | 124 | } | |
| 142 | 125 | ||
| 143 | - | return ctx | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // applyActivePaneBorder applies the active pane border using the border context | |
| 147 | - | func (wm *WorkspaceManager) applyActivePaneBorder(ctx *PaneBorderContext) { | |
| 148 | - | if ctx == nil { | |
| 149 | - | log.Printf("[workspace] applyActivePaneBorder: nil context") | |
| 150 | - | return | |
| 126 | + | // Fallback to the WebView root widget to cover any edge cases where the | |
| 127 | + | // container reference is temporarily nil. | |
| 128 | + | if node.pane != nil && node.pane.webView != nil { | |
| 129 | + | add(node.pane.webView.RootWidget()) | |
| 151 | 130 | } | |
| 152 | 131 | ||
| 153 | - | // Skip applying border if there's only one pane | |
| 154 | - | if !wm.hasMultiplePanes() { | |
| 155 | - | log.Printf("[workspace] Skipping active border: only one pane exists") | |
| 156 | - | return | |
| 132 | + | if len(targets) == 0 { | |
| 133 | + | return nil | |
| 157 | 134 | } | |
| 135 | + | return targets | |
| 136 | + | } | |
| 158 | 137 | ||
| 159 | - | // Apply margin to WebView widget (creates space for border) | |
| 160 | - | if ctx.webViewWidget != nil { | |
| 161 | - | webkit.WidgetSetMargin(ctx.webViewWidget, 2) | |
| 138 | + | // applyActivePaneBorder marks the target widgets as active for CSS styling. | |
| 139 | + | func (wm *WorkspaceManager) applyActivePaneBorder(node *paneNode) { | |
| 140 | + | if wm == nil || node == nil { | |
| 141 | + | return | |
| 162 | 142 | } | |
| 163 | 143 | ||
| 164 | - | // Apply CSS classes to border container | |
| 165 | - | if ctx.borderContainer != nil { | |
| 166 | - | for _, class := range ctx.cssClasses { | |
| 167 | - | webkit.WidgetAddCSSClass(ctx.borderContainer, class) | |
| 144 | + | targets := wm.borderTargets(node) | |
| 145 | + | for _, widget := range targets { | |
| 146 | + | if widget == nil { | |
| 147 | + | continue | |
| 148 | + | } | |
| 149 | + | // Only add CSS class if it doesn't already exist to prevent bloom filter errors | |
| 150 | + | if !webkit.WidgetHasCSSClass(widget, activePaneClass) { | |
| 151 | + | webkit.WidgetAddCSSClass(widget, activePaneClass) | |
| 152 | + | log.Printf("[workspace] Applied active border to pane %p (widget=%p)", node, widget) | |
| 168 | 153 | } | |
| 169 | 154 | } | |
| 170 | - | ||
| 171 | - | log.Printf("[workspace] Applied active border: type=%s webView=%p container=%p", | |
| 172 | - | ctx.paneType, ctx.webViewWidget, ctx.borderContainer) | |
| 173 | 155 | } | |
| 174 | 156 | ||
| 175 | - | // removeActivePaneBorder removes the active pane border from a node | |
| 157 | + | // removeActivePaneBorder clears the active styling from a pane. | |
| 176 | 158 | func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) { | |
| 177 | - | if node == nil { | |
| 159 | + | if wm == nil || node == nil { | |
| 178 | 160 | return | |
| 179 | 161 | } | |
| 180 | 162 | ||
| 181 | - | ctx := wm.determineBorderContext(node) | |
| 182 | - | if ctx == nil { | |
| 183 | - | return | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | // Remove margin from WebView widget | |
| 187 | - | if ctx.webViewWidget != nil { | |
| 188 | - | webkit.WidgetSetMargin(ctx.webViewWidget, 0) | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | // Remove CSS classes from border container | |
| 192 | - | if ctx.borderContainer != nil { | |
| 193 | - | for _, class := range ctx.cssClasses { | |
| 194 | - | webkit.WidgetRemoveCSSClass(ctx.borderContainer, class) | |
| 163 | + | targets := wm.borderTargets(node) | |
| 164 | + | for _, widget := range targets { | |
| 165 | + | if widget == nil { | |
| 166 | + | continue | |
| 167 | + | } | |
| 168 | + | // Only remove CSS class if it exists to prevent bloom filter errors | |
| 169 | + | if webkit.WidgetHasCSSClass(widget, activePaneClass) { | |
| 170 | + | webkit.WidgetRemoveCSSClass(widget, activePaneClass) | |
| 171 | + | log.Printf("[workspace] Removed active border from pane %p (widget=%p)", node, widget) | |
| 195 | 172 | } | |
| 196 | 173 | } | |
| 197 | - | ||
| 198 | - | log.Printf("[workspace] Removed active border: type=%s webView=%p container=%p", | |
| 199 | - | ctx.paneType, ctx.webViewWidget, ctx.borderContainer) | |
| 200 | 174 | } | |
| 201 | 175 | ||
| 202 | 176 | // Widget cleanup utilities | |
| @@ -10,11 +10,6 @@ import ( | |||||
| 10 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | 10 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | // hasMultiplePanes returns true if there are multiple panes in the workspace | |||
| 14 | - | func (wm *WorkspaceManager) hasMultiplePanes() bool { | |||
| 15 | - | return wm != nil && wm.app != nil && len(wm.app.panes) > 1 | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | // UpdateTitleBar updates the title bar label for a WebView in stacked panes | 13 | // UpdateTitleBar updates the title bar label for a WebView in stacked panes | ||
| 19 | func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) { | 14 | func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) { | ||
| 20 | // Delegate to StackedPaneManager which has the correct logic | 15 | // Delegate to StackedPaneManager which has the correct logic | ||
| @@ -97,106 +92,85 @@ func (wm *WorkspaceManager) setStackWrapper(node *paneNode, widget gtk.Widgetter | |||||
| 97 | // Centralized Active Pane Border Management System | 92 | // Centralized Active Pane Border Management System | ||
| 98 | // Handles all pane types: regular, popup, stacked, split panes | 93 | // Handles all pane types: regular, popup, stacked, split panes | ||
| 99 | 94 | ||||
| 100 | - | // PaneBorderContext holds the context for applying borders to any pane type | 95 | + | // borderTargets returns the widgets that should reflect the pane border state. |
| 101 | - | type PaneBorderContext struct { | 96 | + | func (wm *WorkspaceManager) borderTargets(node *paneNode) []gtk.Widgetter { |
| 102 | - | webViewWidget gtk.Widgetter // The WebView's native widget (for margin) | |||
| 103 | - | borderContainer gtk.Widgetter // The container that gets background color | |||
| 104 | - | cssClasses []string // Additional CSS classes to apply | |||
| 105 | - | paneType string // Description for debugging | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | // determineBorderContext analyzes a pane node and determines the correct border context | |||
| 109 | - | func (wm *WorkspaceManager) determineBorderContext(node *paneNode) *PaneBorderContext { | |||
| 110 | if node == nil { | 97 | if node == nil { | ||
| 111 | return nil | 98 | return nil | ||
| 112 | } | 99 | } | ||
| 113 | 100 | ||||
| 114 | - | ctx := &PaneBorderContext{} | 101 | + | targets := make([]gtk.Widgetter, 0, 4) |
| 115 | - | 102 | + | seen := make(map[*gtk.Widget]struct{}) | |
| 116 | - | // Step 1: Get WebView widget for margin (same for all pane types) | 103 | + | add := func(widget gtk.Widgetter) { |
| 117 | - | // Use AsWidget() to get the actual WebView widget, not the container | 104 | + | if widget == nil { |
| 118 | - | if node.pane != nil && node.pane.webView != nil { | 105 | + | return |
| 119 | - | ctx.webViewWidget = node.pane.webView.AsWidget() | 106 | + | } |
| 107 | + | if base := gtk.BaseWidget(widget); base != nil { | |||
| 108 | + | if _, exists := seen[base]; exists { | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | seen[base] = struct{}{} | |||
| 112 | + | } | |||
| 113 | + | targets = append(targets, widget) | |||
| 120 | } | 114 | } | ||
| 121 | 115 | ||||
| 122 | - | // Step 2: Determine the border container based on pane type | 116 | + | // Primary container for this node. |
| 123 | - | switch { | 117 | + | add(node.container) |
| 124 | - | case node.windowType == webkit.WindowTypePopup: | 118 | + | add(node.stackWrapper) |
| 125 | - | // Popup panes: Border on popup container | |||
| 126 | - | ctx.borderContainer = node.container | |||
| 127 | - | ctx.cssClasses = []string{activePaneClass} | |||
| 128 | - | ctx.paneType = "popup" | |||
| 129 | - | ||||
| 130 | - | case node.parent != nil && node.parent.isStacked: | |||
| 131 | - | // Pane in stack: Border on stack wrapper container | |||
| 132 | - | ctx.borderContainer = node.parent.stackWrapper | |||
| 133 | - | ctx.cssClasses = []string{activePaneClass} | |||
| 134 | - | ctx.paneType = "stacked" | |||
| 135 | 119 | ||||
| 136 | - | default: | 120 | + | // Highlight stacks via their outer container as well. |
| 137 | - | // Regular pane: Border on pane container | 121 | + | if node.parent != nil && node.parent.isStacked { |
| 138 | - | ctx.borderContainer = node.container | 122 | + | add(node.parent.container) |
| 139 | - | ctx.cssClasses = []string{activePaneClass} | 123 | + | add(node.parent.stackWrapper) |
| 140 | - | ctx.paneType = "regular" | |||
| 141 | } | 124 | } | ||
| 142 | 125 | ||||
| 143 | - | return ctx | 126 | + | // Fallback to the WebView root widget to cover any edge cases where the |
| 144 | - | } | 127 | + | // container reference is temporarily nil. |
| 145 | - | 128 | + | if node.pane != nil && node.pane.webView != nil { | |
| 146 | - | // applyActivePaneBorder applies the active pane border using the border context | 129 | + | add(node.pane.webView.RootWidget()) |
| 147 | - | func (wm *WorkspaceManager) applyActivePaneBorder(ctx *PaneBorderContext) { | |||
| 148 | - | if ctx == nil { | |||
| 149 | - | log.Printf("[workspace] applyActivePaneBorder: nil context") | |||
| 150 | - | return | |||
| 151 | } | 130 | } | ||
| 152 | 131 | ||||
| 153 | - | // Skip applying border if there's only one pane | 132 | + | if len(targets) == 0 { |
| 154 | - | if !wm.hasMultiplePanes() { | 133 | + | return nil |
| 155 | - | log.Printf("[workspace] Skipping active border: only one pane exists") | |||
| 156 | - | return | |||
| 157 | } | 134 | } | ||
| 135 | + | return targets | |||
| 136 | + | } | |||
| 158 | 137 | ||||
| 159 | - | // Apply margin to WebView widget (creates space for border) | 138 | + | // applyActivePaneBorder marks the target widgets as active for CSS styling. |
| 160 | - | if ctx.webViewWidget != nil { | 139 | + | func (wm *WorkspaceManager) applyActivePaneBorder(node *paneNode) { |
| 161 | - | webkit.WidgetSetMargin(ctx.webViewWidget, 2) | 140 | + | if wm == nil || node == nil { |
| 141 | + | return | |||
| 162 | } | 142 | } | ||
| 163 | 143 | ||||
| 164 | - | // Apply CSS classes to border container | 144 | + | targets := wm.borderTargets(node) |
| 165 | - | if ctx.borderContainer != nil { | 145 | + | for _, widget := range targets { |
| 166 | - | for _, class := range ctx.cssClasses { | 146 | + | if widget == nil { |
| 167 | - | webkit.WidgetAddCSSClass(ctx.borderContainer, class) | 147 | + | continue |
| 148 | + | } | |||
| 149 | + | // Only add CSS class if it doesn't already exist to prevent bloom filter errors | |||
| 150 | + | if !webkit.WidgetHasCSSClass(widget, activePaneClass) { | |||
| 151 | + | webkit.WidgetAddCSSClass(widget, activePaneClass) | |||
| 152 | + | log.Printf("[workspace] Applied active border to pane %p (widget=%p)", node, widget) | |||
| 168 | } | 153 | } | ||
| 169 | } | 154 | } | ||
| 170 | - | ||||
| 171 | - | log.Printf("[workspace] Applied active border: type=%s webView=%p container=%p", | |||
| 172 | - | ctx.paneType, ctx.webViewWidget, ctx.borderContainer) | |||
| 173 | } | 155 | } | ||
| 174 | 156 | ||||
| 175 | - | // removeActivePaneBorder removes the active pane border from a node | 157 | + | // removeActivePaneBorder clears the active styling from a pane. |
| 176 | func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) { | 158 | func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) { | ||
| 177 | - | if node == nil { | 159 | + | if wm == nil || node == nil { |
| 178 | return | 160 | return | ||
| 179 | } | 161 | } | ||
| 180 | 162 | ||||
| 181 | - | ctx := wm.determineBorderContext(node) | 163 | + | targets := wm.borderTargets(node) |
| 182 | - | if ctx == nil { | 164 | + | for _, widget := range targets { |
| 183 | - | return | 165 | + | if widget == nil { |
| 184 | - | } | 166 | + | continue |
| 185 | - | 167 | + | } | |
| 186 | - | // Remove margin from WebView widget | 168 | + | // Only remove CSS class if it exists to prevent bloom filter errors |
| 187 | - | if ctx.webViewWidget != nil { | 169 | + | if webkit.WidgetHasCSSClass(widget, activePaneClass) { |
| 188 | - | webkit.WidgetSetMargin(ctx.webViewWidget, 0) | 170 | + | webkit.WidgetRemoveCSSClass(widget, activePaneClass) |
| 189 | - | } | 171 | + | log.Printf("[workspace] Removed active border from pane %p (widget=%p)", node, widget) |
| 190 | - | ||||
| 191 | - | // Remove CSS classes from border container | |||
| 192 | - | if ctx.borderContainer != nil { | |||
| 193 | - | for _, class := range ctx.cssClasses { | |||
| 194 | - | webkit.WidgetRemoveCSSClass(ctx.borderContainer, class) | |||
| 195 | } | 172 | } | ||
| 196 | } | 173 | } | ||
| 197 | - | ||||
| 198 | - | log.Printf("[workspace] Removed active border: type=%s webView=%p container=%p", | |||
| 199 | - | ctx.paneType, ctx.webViewWidget, ctx.borderContainer) | |||
| 200 | } | 174 | } | ||
| 201 | 175 | ||||
| 202 | // Widget cleanup utilities | 176 | // Widget cleanup utilities | ||
M pkg/webkit/helpers.go +25 -0
| @@ -200,6 +200,31 @@ func WidgetRemoveFocusController(w gtk.Widgetter, controller *gtk.EventControlle | |||
| 200 | 200 | } | |
| 201 | 201 | } | |
| 202 | 202 | ||
| 203 | + | // WidgetAddHoverHandler registers a hover callback for the given widget and | |
| 204 | + | // returns a controller that can be used to detach it later. | |
| 205 | + | func WidgetAddHoverHandler(w gtk.Widgetter, fn func()) *gtk.EventControllerMotion { | |
| 206 | + | if widget := getWidget(w); widget != nil && fn != nil { | |
| 207 | + | motion := gtk.NewEventControllerMotion() | |
| 208 | + | motion.SetPropagationPhase(gtk.PhaseCapture) | |
| 209 | + | ||
| 210 | + | // Connect to enter signal - triggered when mouse enters the widget | |
| 211 | + | motion.ConnectEnter(func(x, y float64) { | |
| 212 | + | fn() | |
| 213 | + | }) | |
| 214 | + | ||
| 215 | + | widget.AddController(motion) | |
| 216 | + | return motion | |
| 217 | + | } | |
| 218 | + | return nil | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // WidgetRemoveHoverHandler removes a previously registered hover controller | |
| 222 | + | func WidgetRemoveHoverHandler(w gtk.Widgetter, controller *gtk.EventControllerMotion) { | |
| 223 | + | if widget := getWidget(w); widget != nil && controller != nil { | |
| 224 | + | widget.RemoveController(controller) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 203 | 228 | // WidgetAddController adds an event controller to a widget | |
| 204 | 229 | func WidgetAddController(w gtk.Widgetter, controller gtk.EventControllerer) { | |
| 205 | 230 | if widget := getWidget(w); widget != nil && controller != nil { | |
| @@ -200,6 +200,31 @@ func WidgetRemoveFocusController(w gtk.Widgetter, controller *gtk.EventControlle | |||||
| 200 | } | 200 | } | ||
| 201 | } | 201 | } | ||
| 202 | 202 | ||||
| 203 | + | // WidgetAddHoverHandler registers a hover callback for the given widget and | |||
| 204 | + | // returns a controller that can be used to detach it later. | |||
| 205 | + | func WidgetAddHoverHandler(w gtk.Widgetter, fn func()) *gtk.EventControllerMotion { | |||
| 206 | + | if widget := getWidget(w); widget != nil && fn != nil { | |||
| 207 | + | motion := gtk.NewEventControllerMotion() | |||
| 208 | + | motion.SetPropagationPhase(gtk.PhaseCapture) | |||
| 209 | + | ||||
| 210 | + | // Connect to enter signal - triggered when mouse enters the widget | |||
| 211 | + | motion.ConnectEnter(func(x, y float64) { | |||
| 212 | + | fn() | |||
| 213 | + | }) | |||
| 214 | + | ||||
| 215 | + | widget.AddController(motion) | |||
| 216 | + | return motion | |||
| 217 | + | } | |||
| 218 | + | return nil | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // WidgetRemoveHoverHandler removes a previously registered hover controller | |||
| 222 | + | func WidgetRemoveHoverHandler(w gtk.Widgetter, controller *gtk.EventControllerMotion) { | |||
| 223 | + | if widget := getWidget(w); widget != nil && controller != nil { | |||
| 224 | + | widget.RemoveController(controller) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 203 | // WidgetAddController adds an event controller to a widget | 228 | // WidgetAddController adds an event controller to a widget | ||
| 204 | func WidgetAddController(w gtk.Widgetter, controller gtk.EventControllerer) { | 229 | func WidgetAddController(w gtk.Widgetter, controller gtk.EventControllerer) { | ||
| 205 | if widget := getWidget(w); widget != nil && controller != nil { | 230 | if widget := getWidget(w); widget != nil && controller != nil { | ||