detached a6d29c6

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 {
6363 parentPane *paneNode // Parent for related views
6464 activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
6565 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)
7172
7273 // Stacked panes support - terminal branch nodes
7374 isStacked bool // Whether this node contains stacked panes
@@ -63,11 +63,12 @@ type paneNode struct {
63 parentPane *paneNode // Parent for related views63 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 success65 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 uintptr67+ hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus
68- focusControllerToken uintptr68+ focusControllerToken uintptr // Focus controller token (stored as native pointer)
69- pendingHoverReattach bool69+ pendingHoverReattach bool // Needs hover reattachment after reparent
70- pendingFocusReattach bool70+ 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 nodes73 // Stacked panes support - terminal branch nodes
73 isStacked bool // Whether this node contains stacked panes74 isStacked bool // Whether this node contains stacked panes
M internal/app/browser/workspace_utils.go +54 -80
@@ -10,11 +10,6 @@ import (
1010 "github.com/diamondburned/gotk4/pkg/gtk/v4"
1111 )
1212
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-
1813 // UpdateTitleBar updates the title bar label for a WebView in stacked panes
1914 func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) {
2015 // Delegate to StackedPaneManager which has the correct logic
@@ -97,106 +92,85 @@ func (wm *WorkspaceManager) setStackWrapper(node *paneNode, widget gtk.Widgetter
9792 // Centralized Active Pane Border Management System
9893 // Handles all pane types: regular, popup, stacked, split panes
9994
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 {
11097 if node == nil {
11198 return nil
11299 }
113100
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)
120114 }
121115
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)
135119
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)
141124 }
142125
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())
151130 }
152131
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
157134 }
135+ return targets
136+}
158137
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
162142 }
163143
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)
168153 }
169154 }
170-
171- log.Printf("[workspace] Applied active border: type=%s webView=%p container=%p",
172- ctx.paneType, ctx.webViewWidget, ctx.borderContainer)
173155 }
174156
175-// removeActivePaneBorder removes the active pane border from a node
157+// removeActivePaneBorder clears the active styling from a pane.
176158 func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) {
177- if node == nil {
159+ if wm == nil || node == nil {
178160 return
179161 }
180162
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)
195172 }
196173 }
197-
198- log.Printf("[workspace] Removed active border: type=%s webView=%p container=%p",
199- ctx.paneType, ctx.webViewWidget, ctx.borderContainer)
200174 }
201175
202176 // 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 panes13 // 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 logic15 // 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 System92 // Centralized Active Pane Border Management System
98 // Handles all pane types: regular, popup, stacked, split panes93 // Handles all pane types: regular, popup, stacked, split panes
99 94
100-// PaneBorderContext holds the context for applying borders to any pane type95+// 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 nil98 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 container104+ 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 type116+ // 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 container121+ if node.parent != nil && node.parent.isStacked {
138- ctx.borderContainer = node.container122+ add(node.parent.container)
139- ctx.cssClasses = []string{activePaneClass}123+ add(node.parent.stackWrapper)
140- ctx.paneType = "regular"
141 }124 }
142 125
143- return ctx126+ // 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 context129+ 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 pane132+ 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 container144+ 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 node157+// 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 return160 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- return165+ if widget == nil {
184- }166+ continue
185-167+ }
186- // Remove margin from WebView widget168+ // 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 utilities176 // Widget cleanup utilities
M pkg/webkit/helpers.go +25 -0
@@ -200,6 +200,31 @@ func WidgetRemoveFocusController(w gtk.Widgetter, controller *gtk.EventControlle
200200 }
201201 }
202202
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+
203228 // WidgetAddController adds an event controller to a widget
204229 func WidgetAddController(w gtk.Widgetter, controller gtk.EventControllerer) {
205230 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 widget228 // 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 {

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close