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