1package layout
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/bnema/dumber/internal/logging"
9 "github.com/bnema/puregotk/v4/glib"
10 "github.com/rs/zerolog"
11)
12
13// SplitView wraps a GTK Paned widget for creating split pane layouts.
14// It manages two child widgets separated by a draggable divider.
15type SplitView struct {
16 paned PanedWidget
17 orientation Orientation
18 startChild Widget
19 endChild Widget
20 ratio float64 // 0.0-1.0, relative position of divider
21 logger zerolog.Logger
22
23 onRatioChanged func(ratio float64)
24 idleAdd func(*glib.SourceFunc, uintptr) uint
25 notifyDebounceTimer *time.Timer
26 notifyGeneration uint64
27 tickCallbackID uint
28 cleanedUp bool
29
30 hasAppliedRatio bool
31 suppressNotifyUntil time.Time
32
33 mu sync.RWMutex
34}
35
36// maxRetryFrames is the maximum number of frames to wait for allocation to stabilize.
37// At 60fps, 120 frames = ~2 seconds timeout.
38const maxRetryFrames = 120
39
40const notifyPositionDebounceDelay = 100 * time.Millisecond
41const notifyPositionSuppressAfterApplyDelay = 50 * time.Millisecond
42const splitAllocationSettledTolerance = 2
43
44func childAllocation(widget Widget) (int, int) {
45 if widget == nil {
46 return 0, 0
47 }
48 return widget.GetAllocatedWidth(), widget.GetAllocatedHeight()
49}
50
51func splitAllocationsSettled(
52 orientation Orientation, totalSize, targetPosition, actualPosition int,
53 startWidth, startHeight, endWidth, endHeight int,
54) bool {
55 if absInt(actualPosition-targetPosition) > splitAllocationSettledTolerance {
56 return false
57 }
58
59 if targetPosition <= 0 || targetPosition >= totalSize {
60 return true
61 }
62
63 startSize, endSize := startWidth, endWidth
64 if orientation == OrientationVertical {
65 startSize, endSize = startHeight, endHeight
66 }
67
68 if startSize <= splitAllocationSettledTolerance || endSize <= splitAllocationSettledTolerance {
69 return false
70 }
71
72 expectedStart := targetPosition
73 expectedEnd := totalSize - targetPosition
74 if absInt(startSize-expectedStart) > splitAllocationSettledTolerance {
75 return false
76 }
77 if absInt(endSize-expectedEnd) > splitAllocationSettledTolerance {
78 return false
79 }
80
81 return true
82}
83
84func absInt(v int) int {
85 if v < 0 {
86 return -v
87 }
88 return v
89}
90
91// NewSplitView creates a new split view with the given orientation and children.
92// The ratio determines the initial divider position (0.0-1.0).
93func NewSplitView(
94 ctx context.Context, factory WidgetFactory, orientation Orientation,
95 startChild, endChild Widget, ratio float64,
96) *SplitView {
97 log := logging.FromContext(ctx)
98
99 paned := factory.NewPaned(orientation)
100
101 // Configure resize behavior - both children should resize
102 // Note: Do NOT set SetShrinkStartChild/SetShrinkEndChild to false
103 // as that prevents GTK from shrinking children to fit the ratio
104 paned.SetResizeStartChild(true)
105 paned.SetResizeEndChild(true)
106 paned.SetVisible(true)
107
108 sv := &SplitView{
109 paned: paned,
110 orientation: orientation,
111 startChild: startChild,
112 endChild: endChild,
113 ratio: clampRatio(ratio),
114 logger: log.With().Str("component", "split-view").Logger(),
115 idleAdd: glib.IdleAdd,
116 }
117
118 // Set children
119 if startChild != nil {
120 startChild.SetVisible(true)
121 paned.SetStartChild(startChild)
122 }
123 if endChild != nil {
124 endChild.SetVisible(true)
125 paned.SetEndChild(endChild)
126 }
127
128 paned.ConnectNotifyPosition(func() {
129 position := paned.GetPosition()
130 var totalSize int
131 if orientation == OrientationHorizontal {
132 totalSize = paned.GetAllocatedWidth()
133 } else {
134 totalSize = paned.GetAllocatedHeight()
135 }
136 if totalSize <= 0 {
137 return
138 }
139
140 now := time.Now()
141
142 sv.mu.Lock()
143 // Ignore early notifications before we've applied the snapshot ratio.
144 // GTK often emits an initial notify::position (typically 50/50) during allocation.
145 if !sv.hasAppliedRatio {
146 sv.mu.Unlock()
147 return
148 }
149 if !sv.suppressNotifyUntil.IsZero() && now.Before(sv.suppressNotifyUntil) {
150 sv.mu.Unlock()
151 return
152 }
153 sv.mu.Unlock()
154
155 ratio := clampRatio(float64(position) / float64(totalSize))
156
157 sv.mu.Lock()
158 if sv.cleanedUp {
159 sv.mu.Unlock()
160 return
161 }
162 sv.ratio = ratio
163 sv.notifyGeneration++
164 generation := sv.notifyGeneration
165
166 if sv.notifyDebounceTimer != nil {
167 sv.notifyDebounceTimer.Stop()
168 }
169 sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() {
170 sv.mu.RLock()
171 valid := !sv.cleanedUp && generation == sv.notifyGeneration
172 sv.mu.RUnlock()
173 if !valid {
174 return
175 }
176
177 cb := glib.SourceFunc(func(_ uintptr) bool {
178 sv.mu.RLock()
179 if sv.cleanedUp || generation != sv.notifyGeneration {
180 sv.mu.RUnlock()
181 return false
182 }
183 onRatioChanged := sv.onRatioChanged
184 sv.mu.RUnlock()
185
186 if onRatioChanged != nil {
187 onRatioChanged(ratio)
188 }
189 return false
190 })
191 sv.idleAdd(&cb, 0)
192 })
193
194 sv.mu.Unlock()
195 })
196
197 // Try to apply ratio immediately (in case widget is already allocated)
198 if sv.ApplyRatio() {
199 return sv
200 }
201
202 // Apply ratio after widget is mapped (when we know the allocated size)
203 paned.ConnectMap(func() {
204 sv.ApplyRatio()
205 })
206
207 // Add tick callback to retry applying ratio every frame until successful.
208 // This handles cases where allocation isn't ready even after Map signal.
209 frames := 0
210 sv.tickCallbackID = paned.AddTickCallback(func() bool {
211 frames++
212 if sv.ApplyRatio() {
213 sv.logger.Debug().
214 Int("frames", frames).
215 Float64("ratio", sv.GetRatio()).
216 Msg("tick callback: ratio applied successfully")
217 return false // Stop ticking - ratio applied successfully
218 }
219 if frames >= maxRetryFrames {
220 sv.logger.Warn().
221 Int("frames", frames).
222 Msg("tick callback: timeout reached, allocation never stabilized")
223 return false // Stop ticking - timeout reached
224 }
225 return true // Continue ticking
226 })
227
228 return sv
229}
230
231// SetRatio updates the divider position.
232// The ratio is clamped to the range [0.0, 1.0].
233// Note: This sets the position based on ratio; actual pixel position
234// depends on the allocated size of the paned widget.
235// Cleanup releases callbacks owned by this split. It is idempotent because
236// tree rebuild and GTK teardown can both reach the same view.
237func (sv *SplitView) Cleanup() {
238 sv.mu.Lock()
239 if sv.cleanedUp {
240 sv.mu.Unlock()
241 return
242 }
243 sv.cleanedUp = true
244 sv.notifyGeneration++
245 sv.onRatioChanged = nil
246 if sv.notifyDebounceTimer != nil {
247 sv.notifyDebounceTimer.Stop()
248 sv.notifyDebounceTimer = nil
249 }
250 tickCallbackID := sv.tickCallbackID
251 sv.tickCallbackID = 0
252 sv.mu.Unlock()
253
254 if tickCallbackID != 0 {
255 sv.paned.RemoveTickCallback(tickCallbackID)
256 }
257}
258
259func (sv *SplitView) SetRatio(ratio float64) {
260 sv.mu.Lock()
261 defer sv.mu.Unlock()
262
263 sv.ratio = clampRatio(ratio)
264}
265
266// GetRatio returns the current ratio setting.
267func (sv *SplitView) GetRatio() float64 {
268 sv.mu.RLock()
269 defer sv.mu.RUnlock()
270
271 return sv.ratio
272}
273
274func (sv *SplitView) SetOnRatioChanged(fn func(ratio float64)) {
275 sv.mu.Lock()
276 defer sv.mu.Unlock()
277
278 sv.onRatioChanged = fn
279}
280
281// ApplyRatio converts the ratio to a pixel position and applies it.
282// This should be called after the widget has been mapped and has an allocated size.
283// Returns true if the ratio was successfully applied, false if the widget is not yet allocated.
284func (sv *SplitView) ApplyRatio() bool {
285 sv.mu.RLock()
286 ratio := sv.ratio
287 orientation := sv.orientation
288 startChild := sv.startChild
289 endChild := sv.endChild
290 sv.mu.RUnlock()
291
292 // Get the relevant dimension based on orientation
293 var totalSize int
294 var orientStr string
295 if orientation == OrientationHorizontal {
296 totalSize = sv.paned.GetAllocatedWidth()
297 orientStr = "horizontal"
298 } else {
299 totalSize = sv.paned.GetAllocatedHeight()
300 orientStr = "vertical"
301 }
302
303 if totalSize <= 0 {
304 sv.logger.Debug().
305 Str("orientation", orientStr).
306 Int("total_size", totalSize).
307 Msg("ApplyRatio: not yet allocated")
308 return false // Not yet allocated
309 }
310
311 // Calculate position from ratio
312 position := int(float64(totalSize) * ratio)
313 startWidthBefore, startHeightBefore := childAllocation(startChild)
314 endWidthBefore, endHeightBefore := childAllocation(endChild)
315 currentPositionBeforeSet := sv.paned.GetPosition()
316 sv.logger.Debug().
317 Str("orientation", orientStr).
318 Int("total_size", totalSize).
319 Float64("ratio", ratio).
320 Int("position", position).
321 Int("current_position_before_set", currentPositionBeforeSet).
322 Int("start_child_alloc_width", startWidthBefore).
323 Int("start_child_alloc_height", startHeightBefore).
324 Int("end_child_alloc_width", endWidthBefore).
325 Int("end_child_alloc_height", endHeightBefore).
326 Msg("ApplyRatio: setting position")
327
328 // Avoid treating programmatic SetPosition as a user resize.
329 // We suppress notify::position briefly; GTK may emit during/after SetPosition.
330 sv.mu.Lock()
331 sv.suppressNotifyUntil = time.Now().Add(notifyPositionSuppressAfterApplyDelay)
332 sv.mu.Unlock()
333
334 sv.paned.SetPosition(position)
335
336 startWidthAfter, startHeightAfter := childAllocation(startChild)
337 endWidthAfter, endHeightAfter := childAllocation(endChild)
338 actualPositionAfterSet := sv.paned.GetPosition()
339 childAllocationsSettled := splitAllocationsSettled(
340 orientation, totalSize, position, actualPositionAfterSet,
341 startWidthAfter, startHeightAfter, endWidthAfter, endHeightAfter,
342 )
343 sv.logger.Debug().
344 Str("orientation", orientStr).
345 Int("total_size", totalSize).
346 Int("target_position", position).
347 Int("actual_position_after_set", actualPositionAfterSet).
348 Int("start_child_alloc_width", startWidthAfter).
349 Int("start_child_alloc_height", startHeightAfter).
350 Int("end_child_alloc_width", endWidthAfter).
351 Int("end_child_alloc_height", endHeightAfter).
352 Bool("child_allocations_settled", childAllocationsSettled).
353 Msg("ApplyRatio: position applied")
354
355 if !childAllocationsSettled {
356 return false
357 }
358
359 sv.mu.Lock()
360 sv.hasAppliedRatio = true
361 sv.mu.Unlock()
362
363 return true
364}
365
366// SetPosition sets the divider position in pixels.
367func (sv *SplitView) SetPosition(position int) {
368 sv.paned.SetPosition(position)
369}
370
371// GetPosition returns the current divider position in pixels.
372func (sv *SplitView) GetPosition() int {
373 return sv.paned.GetPosition()
374}
375
376// SwapStart replaces the start (left/top) child widget.
377// The old child is unparented before the new child is set.
378func (sv *SplitView) SwapStart(newWidget Widget) {
379 sv.mu.Lock()
380 defer sv.mu.Unlock()
381
382 // Remove old child from paned
383 if sv.startChild != nil {
384 sv.paned.SetStartChild(nil)
385 }
386
387 sv.startChild = newWidget
388
389 if newWidget != nil {
390 sv.paned.SetStartChild(newWidget)
391 }
392}
393
394// SwapEnd replaces the end (right/bottom) child widget.
395// The old child is unparented before the new child is set.
396func (sv *SplitView) SwapEnd(newWidget Widget) {
397 sv.mu.Lock()
398 defer sv.mu.Unlock()
399
400 // Remove old child from paned
401 if sv.endChild != nil {
402 sv.paned.SetEndChild(nil)
403 }
404
405 sv.endChild = newWidget
406
407 if newWidget != nil {
408 sv.paned.SetEndChild(newWidget)
409 }
410}
411
412// StartChild returns the current start (left/top) child.
413func (sv *SplitView) StartChild() Widget {
414 sv.mu.RLock()
415 defer sv.mu.RUnlock()
416
417 return sv.startChild
418}
419
420// EndChild returns the current end (right/bottom) child.
421func (sv *SplitView) EndChild() Widget {
422 sv.mu.RLock()
423 defer sv.mu.RUnlock()
424
425 return sv.endChild
426}
427
428// Orientation returns the split orientation.
429func (sv *SplitView) Orientation() Orientation {
430 return sv.orientation
431}
432
433// SetWideHandle sets whether the handle has a wide appearance.
434func (sv *SplitView) SetWideHandle(wide bool) {
435 sv.paned.SetWideHandle(wide)
436}
437
438// Widget returns the underlying PanedWidget for embedding in containers.
439func (sv *SplitView) Widget() Widget {
440 return sv.paned
441}
442
443// Paned returns the underlying PanedWidget for direct access.
444func (sv *SplitView) Paned() PanedWidget {
445 return sv.paned
446}
447
448// clampRatio ensures the ratio is within [0.0, 1.0].
449func clampRatio(ratio float64) float64 {
450 if ratio < 0.0 {
451 return 0.0
452 }
453 if ratio > 1.0 {
454 return 1.0
455 }
456 return ratio
457}