fix(split): release tick callback during cleanup
- commit
- 519071b96f07158a21b2e2ad290c935be691a681
- author
- brice · · 2026-07-14 06:17 UTC
- committer
- brice · · 2026-07-14 07:06 UTC
- parent
- a784a53
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +69 -3
M internal/ui/layout/gtk_widgets.go +2 -0
| @@ -182,6 +182,8 @@ func (p *gtkPaned) AddTickCallback(callback func() bool) uint { | |||
| 182 | 182 | return p.inner.AddTickCallback(&cb, 0, nil) | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | + | func (p *gtkPaned) RemoveTickCallback(id uint) { p.inner.RemoveTickCallback(id) } | |
| 186 | + | ||
| 185 | 187 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | |
| 186 | 188 | func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() } | |
| 187 | 189 | func (p *gtkPaned) AddController(c *gtk.EventController) { p.inner.AddController(c) } | |
| @@ -182,6 +182,8 @@ func (p *gtkPaned) AddTickCallback(callback func() bool) uint { | |||||
| 182 | return p.inner.AddTickCallback(&cb, 0, nil) | 182 | return p.inner.AddTickCallback(&cb, 0, nil) | ||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | + | func (p *gtkPaned) RemoveTickCallback(id uint) { p.inner.RemoveTickCallback(id) } | |||
| 186 | + | ||||
| 185 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | 187 | func (p *gtkPaned) GetAllocatedWidth() int { return p.inner.GetAllocatedWidth() } | ||
| 186 | func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() } | 188 | func (p *gtkPaned) GetAllocatedHeight() int { return p.inner.GetAllocatedHeight() } | ||
| 187 | func (p *gtkPaned) AddController(c *gtk.EventController) { p.inner.AddController(c) } | 189 | func (p *gtkPaned) AddController(c *gtk.EventController) { p.inner.AddController(c) } | ||
M internal/ui/layout/mocks/widgets.go +40 -0
| @@ -2425,6 +2425,46 @@ func (_c *MockPanedWidget_RemoveCssClass_Call) RunAndReturn(run func(cssClass st | |||
| 2425 | 2425 | return _c | |
| 2426 | 2426 | } | |
| 2427 | 2427 | ||
| 2428 | + | // RemoveTickCallback provides a mock function for the type MockPanedWidget | |
| 2429 | + | func (_mock *MockPanedWidget) RemoveTickCallback(id uint) { | |
| 2430 | + | _mock.Called(id) | |
| 2431 | + | return | |
| 2432 | + | } | |
| 2433 | + | ||
| 2434 | + | // MockPanedWidget_RemoveTickCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveTickCallback' | |
| 2435 | + | type MockPanedWidget_RemoveTickCallback_Call struct { | |
| 2436 | + | *mock.Call | |
| 2437 | + | } | |
| 2438 | + | ||
| 2439 | + | // RemoveTickCallback is a helper method to define mock.On call | |
| 2440 | + | // - id uint | |
| 2441 | + | func (_e *MockPanedWidget_Expecter) RemoveTickCallback(id any) *MockPanedWidget_RemoveTickCallback_Call { | |
| 2442 | + | return &MockPanedWidget_RemoveTickCallback_Call{Call: _e.mock.On("RemoveTickCallback", id)} | |
| 2443 | + | } | |
| 2444 | + | ||
| 2445 | + | func (_c *MockPanedWidget_RemoveTickCallback_Call) Run(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call { | |
| 2446 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2447 | + | var arg0 uint | |
| 2448 | + | if args[0] != nil { | |
| 2449 | + | arg0 = args[0].(uint) | |
| 2450 | + | } | |
| 2451 | + | run( | |
| 2452 | + | arg0, | |
| 2453 | + | ) | |
| 2454 | + | }) | |
| 2455 | + | return _c | |
| 2456 | + | } | |
| 2457 | + | ||
| 2458 | + | func (_c *MockPanedWidget_RemoveTickCallback_Call) Return() *MockPanedWidget_RemoveTickCallback_Call { | |
| 2459 | + | _c.Call.Return() | |
| 2460 | + | return _c | |
| 2461 | + | } | |
| 2462 | + | ||
| 2463 | + | func (_c *MockPanedWidget_RemoveTickCallback_Call) RunAndReturn(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call { | |
| 2464 | + | _c.Run(run) | |
| 2465 | + | return _c | |
| 2466 | + | } | |
| 2467 | + | ||
| 2428 | 2468 | // SetCanFocus provides a mock function for the type MockPanedWidget | |
| 2429 | 2469 | func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) { | |
| 2430 | 2470 | _mock.Called(canFocus) | |
| @@ -2425,6 +2425,46 @@ func (_c *MockPanedWidget_RemoveCssClass_Call) RunAndReturn(run func(cssClass st | |||||
| 2425 | return _c | 2425 | return _c | ||
| 2426 | } | 2426 | } | ||
| 2427 | 2427 | ||||
| 2428 | + | // RemoveTickCallback provides a mock function for the type MockPanedWidget | |||
| 2429 | + | func (_mock *MockPanedWidget) RemoveTickCallback(id uint) { | |||
| 2430 | + | _mock.Called(id) | |||
| 2431 | + | return | |||
| 2432 | + | } | |||
| 2433 | + | ||||
| 2434 | + | // MockPanedWidget_RemoveTickCallback_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveTickCallback' | |||
| 2435 | + | type MockPanedWidget_RemoveTickCallback_Call struct { | |||
| 2436 | + | *mock.Call | |||
| 2437 | + | } | |||
| 2438 | + | ||||
| 2439 | + | // RemoveTickCallback is a helper method to define mock.On call | |||
| 2440 | + | // - id uint | |||
| 2441 | + | func (_e *MockPanedWidget_Expecter) RemoveTickCallback(id any) *MockPanedWidget_RemoveTickCallback_Call { | |||
| 2442 | + | return &MockPanedWidget_RemoveTickCallback_Call{Call: _e.mock.On("RemoveTickCallback", id)} | |||
| 2443 | + | } | |||
| 2444 | + | ||||
| 2445 | + | func (_c *MockPanedWidget_RemoveTickCallback_Call) Run(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call { | |||
| 2446 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2447 | + | var arg0 uint | |||
| 2448 | + | if args[0] != nil { | |||
| 2449 | + | arg0 = args[0].(uint) | |||
| 2450 | + | } | |||
| 2451 | + | run( | |||
| 2452 | + | arg0, | |||
| 2453 | + | ) | |||
| 2454 | + | }) | |||
| 2455 | + | return _c | |||
| 2456 | + | } | |||
| 2457 | + | ||||
| 2458 | + | func (_c *MockPanedWidget_RemoveTickCallback_Call) Return() *MockPanedWidget_RemoveTickCallback_Call { | |||
| 2459 | + | _c.Call.Return() | |||
| 2460 | + | return _c | |||
| 2461 | + | } | |||
| 2462 | + | ||||
| 2463 | + | func (_c *MockPanedWidget_RemoveTickCallback_Call) RunAndReturn(run func(id uint)) *MockPanedWidget_RemoveTickCallback_Call { | |||
| 2464 | + | _c.Run(run) | |||
| 2465 | + | return _c | |||
| 2466 | + | } | |||
| 2467 | + | ||||
| 2428 | // SetCanFocus provides a mock function for the type MockPanedWidget | 2468 | // SetCanFocus provides a mock function for the type MockPanedWidget | ||
| 2429 | func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) { | 2469 | func (_mock *MockPanedWidget) SetCanFocus(canFocus bool) { | ||
| 2430 | _mock.Called(canFocus) | 2470 | _mock.Called(canFocus) | ||
M internal/ui/layout/split.go +22 -1
| @@ -23,6 +23,8 @@ type SplitView struct { | |||
| 23 | 23 | onRatioChanged func(ratio float64) | |
| 24 | 24 | pendingNotifyRatio float64 | |
| 25 | 25 | notifyDebounceTimer *time.Timer | |
| 26 | + | tickCallbackID uint | |
| 27 | + | cleanedUp bool | |
| 26 | 28 | ||
| 27 | 29 | hasAppliedRatio bool | |
| 28 | 30 | suppressNotifyUntil time.Time | |
| @@ -188,7 +190,7 @@ func NewSplitView( | |||
| 188 | 190 | // Add tick callback to retry applying ratio every frame until successful. | |
| 189 | 191 | // This handles cases where allocation isn't ready even after Map signal. | |
| 190 | 192 | frames := 0 | |
| 191 | - | paned.AddTickCallback(func() bool { | |
| 193 | + | sv.tickCallbackID = paned.AddTickCallback(func() bool { | |
| 192 | 194 | frames++ | |
| 193 | 195 | if sv.ApplyRatio() { | |
| 194 | 196 | sv.logger.Debug(). | |
| @@ -213,6 +215,25 @@ func NewSplitView( | |||
| 213 | 215 | // The ratio is clamped to the range [0.0, 1.0]. | |
| 214 | 216 | // Note: This sets the position based on ratio; actual pixel position | |
| 215 | 217 | // depends on the allocated size of the paned widget. | |
| 218 | + | // Cleanup releases callbacks owned by this split. It is idempotent because | |
| 219 | + | // tree rebuild and GTK teardown can both reach the same view. | |
| 220 | + | func (sv *SplitView) Cleanup() { | |
| 221 | + | sv.mu.Lock() | |
| 222 | + | defer sv.mu.Unlock() | |
| 223 | + | if sv.cleanedUp { | |
| 224 | + | return | |
| 225 | + | } | |
| 226 | + | sv.cleanedUp = true | |
| 227 | + | if sv.notifyDebounceTimer != nil { | |
| 228 | + | sv.notifyDebounceTimer.Stop() | |
| 229 | + | sv.notifyDebounceTimer = nil | |
| 230 | + | } | |
| 231 | + | if sv.tickCallbackID != 0 { | |
| 232 | + | sv.paned.RemoveTickCallback(sv.tickCallbackID) | |
| 233 | + | sv.tickCallbackID = 0 | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 216 | 237 | func (sv *SplitView) SetRatio(ratio float64) { | |
| 217 | 238 | sv.mu.Lock() | |
| 218 | 239 | defer sv.mu.Unlock() | |
| @@ -23,6 +23,8 @@ type SplitView struct { | |||||
| 23 | onRatioChanged func(ratio float64) | 23 | onRatioChanged func(ratio float64) | ||
| 24 | pendingNotifyRatio float64 | 24 | pendingNotifyRatio float64 | ||
| 25 | notifyDebounceTimer *time.Timer | 25 | notifyDebounceTimer *time.Timer | ||
| 26 | + | tickCallbackID uint | |||
| 27 | + | cleanedUp bool | |||
| 26 | 28 | ||||
| 27 | hasAppliedRatio bool | 29 | hasAppliedRatio bool | ||
| 28 | suppressNotifyUntil time.Time | 30 | suppressNotifyUntil time.Time | ||
| @@ -188,7 +190,7 @@ func NewSplitView( | |||||
| 188 | // Add tick callback to retry applying ratio every frame until successful. | 190 | // Add tick callback to retry applying ratio every frame until successful. | ||
| 189 | // This handles cases where allocation isn't ready even after Map signal. | 191 | // This handles cases where allocation isn't ready even after Map signal. | ||
| 190 | frames := 0 | 192 | frames := 0 | ||
| 191 | - | paned.AddTickCallback(func() bool { | 193 | + | sv.tickCallbackID = paned.AddTickCallback(func() bool { |
| 192 | frames++ | 194 | frames++ | ||
| 193 | if sv.ApplyRatio() { | 195 | if sv.ApplyRatio() { | ||
| 194 | sv.logger.Debug(). | 196 | sv.logger.Debug(). | ||
| @@ -213,6 +215,25 @@ func NewSplitView( | |||||
| 213 | // The ratio is clamped to the range [0.0, 1.0]. | 215 | // The ratio is clamped to the range [0.0, 1.0]. | ||
| 214 | // Note: This sets the position based on ratio; actual pixel position | 216 | // Note: This sets the position based on ratio; actual pixel position | ||
| 215 | // depends on the allocated size of the paned widget. | 217 | // depends on the allocated size of the paned widget. | ||
| 218 | + | // Cleanup releases callbacks owned by this split. It is idempotent because | |||
| 219 | + | // tree rebuild and GTK teardown can both reach the same view. | |||
| 220 | + | func (sv *SplitView) Cleanup() { | |||
| 221 | + | sv.mu.Lock() | |||
| 222 | + | defer sv.mu.Unlock() | |||
| 223 | + | if sv.cleanedUp { | |||
| 224 | + | return | |||
| 225 | + | } | |||
| 226 | + | sv.cleanedUp = true | |||
| 227 | + | if sv.notifyDebounceTimer != nil { | |||
| 228 | + | sv.notifyDebounceTimer.Stop() | |||
| 229 | + | sv.notifyDebounceTimer = nil | |||
| 230 | + | } | |||
| 231 | + | if sv.tickCallbackID != 0 { | |||
| 232 | + | sv.paned.RemoveTickCallback(sv.tickCallbackID) | |||
| 233 | + | sv.tickCallbackID = 0 | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 216 | func (sv *SplitView) SetRatio(ratio float64) { | 237 | func (sv *SplitView) SetRatio(ratio float64) { | ||
| 217 | sv.mu.Lock() | 238 | sv.mu.Lock() | ||
| 218 | defer sv.mu.Unlock() | 239 | defer sv.mu.Unlock() | ||
M internal/ui/layout/widgets.go +5 -2
| @@ -99,9 +99,12 @@ type PanedWidget interface { | |||
| 99 | 99 | ConnectMap(callback func()) uint | |
| 100 | 100 | ConnectNotifyPosition(callback func()) uint | |
| 101 | 101 | ||
| 102 | - | // Tick callback for frame-based updates (returns signal ID) | |
| 103 | - | // Callback returns true to continue, false to stop | |
| 102 | + | // Tick callback for frame-based updates (returns signal ID). | |
| 103 | + | // Callback returns true to continue, false to stop. | |
| 104 | 104 | AddTickCallback(callback func() bool) uint | |
| 105 | + | // RemoveTickCallback releases an active callback slot. It is safe to call | |
| 106 | + | // once after the callback has stopped itself. | |
| 107 | + | RemoveTickCallback(id uint) | |
| 105 | 108 | ||
| 106 | 109 | // Handle appearance | |
| 107 | 110 | SetWideHandle(wide bool) | |
| @@ -99,9 +99,12 @@ type PanedWidget interface { | |||||
| 99 | ConnectMap(callback func()) uint | 99 | ConnectMap(callback func()) uint | ||
| 100 | ConnectNotifyPosition(callback func()) uint | 100 | ConnectNotifyPosition(callback func()) uint | ||
| 101 | 101 | ||||
| 102 | - | // Tick callback for frame-based updates (returns signal ID) | 102 | + | // Tick callback for frame-based updates (returns signal ID). |
| 103 | - | // Callback returns true to continue, false to stop | 103 | + | // Callback returns true to continue, false to stop. |
| 104 | AddTickCallback(callback func() bool) uint | 104 | AddTickCallback(callback func() bool) uint | ||
| 105 | + | // RemoveTickCallback releases an active callback slot. It is safe to call | |||
| 106 | + | // once after the callback has stopped itself. | |||
| 107 | + | RemoveTickCallback(id uint) | |||
| 105 | 108 | ||||
| 106 | // Handle appearance | 109 | // Handle appearance | ||
| 107 | SetWideHandle(wide bool) | 110 | SetWideHandle(wide bool) | ||