fix(workspace): prevent GTK focus chain warnings during pane close by clearing grandparent focus before reparenting
- commit
- a5cfffcc32bf7c879d347871bb185a8899511fb8
- author
- Brice · · 2025-10-04 19:31 UTC
- committer
- Brice · · 2025-10-05 13:54 UTC
- parent
- a06c390
- signature
- unsigned
3 files · +69 -1
M internal/app/browser/workspace_pane_ops.go +33 -1
| @@ -910,7 +910,7 @@ func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) { | |||
| 910 | 910 | ||
| 911 | 911 | if webkit.WidgetIsValid(grand.container) && webkit.WidgetIsValid(sibling.container) { | |
| 912 | 912 | // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent | |
| 913 | - | // Log current parent for debugging, but this is normal GTK4 behavior | |
| 913 | + | // Focus was already cleared before unparenting closed pane | |
| 914 | 914 | if parent := webkit.WidgetGetParent(sibling.container); parent != 0 && parent != grand.container { | |
| 915 | 915 | log.Printf("[workspace] widget %#x reparenting from %#x to %#x (GTK4 will auto-unparent)", sibling.container, parent, grand.container) | |
| 916 | 916 | } | |
| @@ -999,6 +999,17 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) { | |||
| 999 | 999 | webkit.WidgetSetVExpand(onlyChild.container, true) | |
| 1000 | 1000 | webkit.WidgetQueueAllocate(onlyChild.container) | |
| 1001 | 1001 | webkit.WidgetQueueAllocate(greatGrandparent.container) | |
| 1002 | + | wm.scheduleIdleGuarded(func() bool { | |
| 1003 | + | if onlyChild == nil || !onlyChild.widgetValid { | |
| 1004 | + | return false | |
| 1005 | + | } | |
| 1006 | + | if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) { | |
| 1007 | + | webkit.WidgetShow(onlyChild.container) | |
| 1008 | + | webkit.WidgetQueueResize(onlyChild.container) | |
| 1009 | + | webkit.WidgetQueueDraw(onlyChild.container) | |
| 1010 | + | } | |
| 1011 | + | return false | |
| 1012 | + | }, onlyChild) | |
| 1002 | 1013 | } | |
| 1003 | 1014 | ||
| 1004 | 1015 | // Cleanup the now-orphaned paned | |
| @@ -1160,6 +1171,12 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||
| 1160 | 1171 | sibling := wm.getSibling(node) | |
| 1161 | 1172 | grandparent := parent.parent | |
| 1162 | 1173 | ||
| 1174 | + | // Clear grandparent's focus child BEFORE unparenting closed pane | |
| 1175 | + | // GTK propagates focus events upward, so we must clear grandparent focus first | |
| 1176 | + | if grandparent != nil && grandparent.container != 0 && webkit.WidgetIsValid(grandparent.container) { | |
| 1177 | + | webkit.WidgetSetFocusChild(grandparent.container, 0) | |
| 1178 | + | } | |
| 1179 | + | ||
| 1163 | 1180 | // Unparent the closed pane's container from the paned BEFORE promoting sibling | |
| 1164 | 1181 | if node.container != 0 && webkit.WidgetIsValid(node.container) { | |
| 1165 | 1182 | if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container { | |
| @@ -1179,6 +1196,21 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||
| 1179 | 1196 | // PanedSetStartChild/EndChild and window.SetChild auto-unparent widgets | |
| 1180 | 1197 | wm.swapContainers(grandparent, sibling) | |
| 1181 | 1198 | ||
| 1199 | + | // Reconnect the promoted sibling's rendering pipeline after GTK reparenting. | |
| 1200 | + | if sibling != nil && sibling.container != 0 { | |
| 1201 | + | wm.scheduleIdleGuarded(func() bool { | |
| 1202 | + | if sibling == nil || !sibling.widgetValid { | |
| 1203 | + | return false | |
| 1204 | + | } | |
| 1205 | + | if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) { | |
| 1206 | + | webkit.WidgetShow(sibling.container) | |
| 1207 | + | webkit.WidgetQueueResize(sibling.container) | |
| 1208 | + | webkit.WidgetQueueDraw(sibling.container) | |
| 1209 | + | } | |
| 1210 | + | return false | |
| 1211 | + | }, sibling) | |
| 1212 | + | } | |
| 1213 | + | ||
| 1182 | 1214 | // STEP 7: Check if grandparent now has only one child (cascade promotion needed) | |
| 1183 | 1215 | if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) { | |
| 1184 | 1216 | // Grandparent has exactly one child - it should be promoted too | |
| @@ -910,7 +910,7 @@ func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) { | |||||
| 910 | 910 | ||||
| 911 | if webkit.WidgetIsValid(grand.container) && webkit.WidgetIsValid(sibling.container) { | 911 | if webkit.WidgetIsValid(grand.container) && webkit.WidgetIsValid(sibling.container) { | ||
| 912 | // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent | 912 | // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent | ||
| 913 | - | // Log current parent for debugging, but this is normal GTK4 behavior | 913 | + | // Focus was already cleared before unparenting closed pane |
| 914 | if parent := webkit.WidgetGetParent(sibling.container); parent != 0 && parent != grand.container { | 914 | if parent := webkit.WidgetGetParent(sibling.container); parent != 0 && parent != grand.container { | ||
| 915 | log.Printf("[workspace] widget %#x reparenting from %#x to %#x (GTK4 will auto-unparent)", sibling.container, parent, grand.container) | 915 | log.Printf("[workspace] widget %#x reparenting from %#x to %#x (GTK4 will auto-unparent)", sibling.container, parent, grand.container) | ||
| 916 | } | 916 | } | ||
| @@ -999,6 +999,17 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) { | |||||
| 999 | webkit.WidgetSetVExpand(onlyChild.container, true) | 999 | webkit.WidgetSetVExpand(onlyChild.container, true) | ||
| 1000 | webkit.WidgetQueueAllocate(onlyChild.container) | 1000 | webkit.WidgetQueueAllocate(onlyChild.container) | ||
| 1001 | webkit.WidgetQueueAllocate(greatGrandparent.container) | 1001 | webkit.WidgetQueueAllocate(greatGrandparent.container) | ||
| 1002 | + | wm.scheduleIdleGuarded(func() bool { | |||
| 1003 | + | if onlyChild == nil || !onlyChild.widgetValid { | |||
| 1004 | + | return false | |||
| 1005 | + | } | |||
| 1006 | + | if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) { | |||
| 1007 | + | webkit.WidgetShow(onlyChild.container) | |||
| 1008 | + | webkit.WidgetQueueResize(onlyChild.container) | |||
| 1009 | + | webkit.WidgetQueueDraw(onlyChild.container) | |||
| 1010 | + | } | |||
| 1011 | + | return false | |||
| 1012 | + | }, onlyChild) | |||
| 1002 | } | 1013 | } | ||
| 1003 | 1014 | ||||
| 1004 | // Cleanup the now-orphaned paned | 1015 | // Cleanup the now-orphaned paned | ||
| @@ -1160,6 +1171,12 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||||
| 1160 | sibling := wm.getSibling(node) | 1171 | sibling := wm.getSibling(node) | ||
| 1161 | grandparent := parent.parent | 1172 | grandparent := parent.parent | ||
| 1162 | 1173 | ||||
| 1174 | + | // Clear grandparent's focus child BEFORE unparenting closed pane | |||
| 1175 | + | // GTK propagates focus events upward, so we must clear grandparent focus first | |||
| 1176 | + | if grandparent != nil && grandparent.container != 0 && webkit.WidgetIsValid(grandparent.container) { | |||
| 1177 | + | webkit.WidgetSetFocusChild(grandparent.container, 0) | |||
| 1178 | + | } | |||
| 1179 | + | ||||
| 1163 | // Unparent the closed pane's container from the paned BEFORE promoting sibling | 1180 | // Unparent the closed pane's container from the paned BEFORE promoting sibling | ||
| 1164 | if node.container != 0 && webkit.WidgetIsValid(node.container) { | 1181 | if node.container != 0 && webkit.WidgetIsValid(node.container) { | ||
| 1165 | if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container { | 1182 | if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container { | ||
| @@ -1179,6 +1196,21 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||||
| 1179 | // PanedSetStartChild/EndChild and window.SetChild auto-unparent widgets | 1196 | // PanedSetStartChild/EndChild and window.SetChild auto-unparent widgets | ||
| 1180 | wm.swapContainers(grandparent, sibling) | 1197 | wm.swapContainers(grandparent, sibling) | ||
| 1181 | 1198 | ||||
| 1199 | + | // Reconnect the promoted sibling's rendering pipeline after GTK reparenting. | |||
| 1200 | + | if sibling != nil && sibling.container != 0 { | |||
| 1201 | + | wm.scheduleIdleGuarded(func() bool { | |||
| 1202 | + | if sibling == nil || !sibling.widgetValid { | |||
| 1203 | + | return false | |||
| 1204 | + | } | |||
| 1205 | + | if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) { | |||
| 1206 | + | webkit.WidgetShow(sibling.container) | |||
| 1207 | + | webkit.WidgetQueueResize(sibling.container) | |||
| 1208 | + | webkit.WidgetQueueDraw(sibling.container) | |||
| 1209 | + | } | |||
| 1210 | + | return false | |||
| 1211 | + | }, sibling) | |||
| 1212 | + | } | |||
| 1213 | + | ||||
| 1182 | // STEP 7: Check if grandparent now has only one child (cascade promotion needed) | 1214 | // STEP 7: Check if grandparent now has only one child (cascade promotion needed) | ||
| 1183 | if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) { | 1215 | if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) { | ||
| 1184 | // Grandparent has exactly one child - it should be promoted too | 1216 | // Grandparent has exactly one child - it should be promoted too | ||
M pkg/webkit/workspace_widgets_cgo.go +18 -0
| @@ -709,6 +709,24 @@ func WidgetGrabFocus(widget uintptr) { | |||
| 709 | 709 | C.gtk_widget_grab_focus((*C.GtkWidget)(unsafe.Pointer(widget))) | |
| 710 | 710 | } | |
| 711 | 711 | ||
| 712 | + | // WidgetSetFocusChild sets or clears the focus child of a widget. | |
| 713 | + | // Pass 0 as child to clear the focus child (prevents GTK focus chain warnings during reparenting). | |
| 714 | + | func WidgetSetFocusChild(widget uintptr, child uintptr) { | |
| 715 | + | if widget == 0 { | |
| 716 | + | return | |
| 717 | + | } | |
| 718 | + | if !widgetIsValid(widget) { | |
| 719 | + | log.Printf("[workspace] WidgetSetFocusChild skipped invalid widget=%#x", widget) | |
| 720 | + | return | |
| 721 | + | } | |
| 722 | + | var childPtr *C.GtkWidget | |
| 723 | + | if child != 0 { | |
| 724 | + | childPtr = (*C.GtkWidget)(unsafe.Pointer(child)) | |
| 725 | + | } | |
| 726 | + | C.gtk_widget_set_focus_child((*C.GtkWidget)(unsafe.Pointer(widget)), childPtr) | |
| 727 | + | log.Printf("[workspace] WidgetSetFocusChild widget=%#x child=%#x", widget, child) | |
| 728 | + | } | |
| 729 | + | ||
| 712 | 730 | // WidgetRef increments the reference count to keep the widget alive during reparenting. | |
| 713 | 731 | func WidgetRef(widget uintptr) bool { | |
| 714 | 732 | if widget == 0 { | |
| @@ -709,6 +709,24 @@ func WidgetGrabFocus(widget uintptr) { | |||||
| 709 | C.gtk_widget_grab_focus((*C.GtkWidget)(unsafe.Pointer(widget))) | 709 | C.gtk_widget_grab_focus((*C.GtkWidget)(unsafe.Pointer(widget))) | ||
| 710 | } | 710 | } | ||
| 711 | 711 | ||||
| 712 | + | // WidgetSetFocusChild sets or clears the focus child of a widget. | |||
| 713 | + | // Pass 0 as child to clear the focus child (prevents GTK focus chain warnings during reparenting). | |||
| 714 | + | func WidgetSetFocusChild(widget uintptr, child uintptr) { | |||
| 715 | + | if widget == 0 { | |||
| 716 | + | return | |||
| 717 | + | } | |||
| 718 | + | if !widgetIsValid(widget) { | |||
| 719 | + | log.Printf("[workspace] WidgetSetFocusChild skipped invalid widget=%#x", widget) | |||
| 720 | + | return | |||
| 721 | + | } | |||
| 722 | + | var childPtr *C.GtkWidget | |||
| 723 | + | if child != 0 { | |||
| 724 | + | childPtr = (*C.GtkWidget)(unsafe.Pointer(child)) | |||
| 725 | + | } | |||
| 726 | + | C.gtk_widget_set_focus_child((*C.GtkWidget)(unsafe.Pointer(widget)), childPtr) | |||
| 727 | + | log.Printf("[workspace] WidgetSetFocusChild widget=%#x child=%#x", widget, child) | |||
| 728 | + | } | |||
| 729 | + | ||||
| 712 | // WidgetRef increments the reference count to keep the widget alive during reparenting. | 730 | // WidgetRef increments the reference count to keep the widget alive during reparenting. | ||
| 713 | func WidgetRef(widget uintptr) bool { | 731 | func WidgetRef(widget uintptr) bool { | ||
| 714 | if widget == 0 { | 732 | if widget == 0 { | ||
M pkg/webkit/workspace_widgets_stub.go +18 -0
| @@ -279,6 +279,24 @@ func WidgetGrabFocus(widget uintptr) { | |||
| 279 | 279 | } | |
| 280 | 280 | } | |
| 281 | 281 | ||
| 282 | + | // WidgetSetFocusChild sets or clears the focus child of a widget. | |
| 283 | + | // Pass 0 as child to clear the focus child (prevents GTK focus chain warnings during reparenting). | |
| 284 | + | func WidgetSetFocusChild(widget uintptr, child uintptr) { | |
| 285 | + | widgetMu.Lock() | |
| 286 | + | defer widgetMu.Unlock() | |
| 287 | + | ||
| 288 | + | if widget == 0 { | |
| 289 | + | return | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | stub, ok := widgetState[widget] | |
| 293 | + | if !ok || stub.isDestroyed { | |
| 294 | + | return | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | stub.focusedChild = child | |
| 298 | + | } | |
| 299 | + | ||
| 282 | 300 | func WidgetRef(widget uintptr) bool { | |
| 283 | 301 | widgetMu.Lock() | |
| 284 | 302 | defer widgetMu.Unlock() | |
| @@ -279,6 +279,24 @@ func WidgetGrabFocus(widget uintptr) { | |||||
| 279 | } | 279 | } | ||
| 280 | } | 280 | } | ||
| 281 | 281 | ||||
| 282 | + | // WidgetSetFocusChild sets or clears the focus child of a widget. | |||
| 283 | + | // Pass 0 as child to clear the focus child (prevents GTK focus chain warnings during reparenting). | |||
| 284 | + | func WidgetSetFocusChild(widget uintptr, child uintptr) { | |||
| 285 | + | widgetMu.Lock() | |||
| 286 | + | defer widgetMu.Unlock() | |||
| 287 | + | ||||
| 288 | + | if widget == 0 { | |||
| 289 | + | return | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | stub, ok := widgetState[widget] | |||
| 293 | + | if !ok || stub.isDestroyed { | |||
| 294 | + | return | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | stub.focusedChild = child | |||
| 298 | + | } | |||
| 299 | + | ||||
| 282 | func WidgetRef(widget uintptr) bool { | 300 | func WidgetRef(widget uintptr) bool { | ||
| 283 | widgetMu.Lock() | 301 | widgetMu.Lock() | ||
| 284 | defer widgetMu.Unlock() | 302 | defer widgetMu.Unlock() | ||