split.go RawHistoryFolder
1package layout
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/bnema/dumber/internal/logging"
9 "github.com/jwijenbergh/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
39
40// NewSplitView creates a new split view with the given orientation and children.
41// The ratio determines the initial divider position (0.0-1.0).
42func NewSplitView(
43 ctx context.Context, factory WidgetFactory, orientation Orientation,
44 startChild, endChild Widget, ratio float64,
45) *SplitView {
46 log := logging.FromContext(ctx)
47
48 paned := factory.NewPaned(orientation)
49
50 // Configure resize behavior - both children should resize
51 // Note: Do NOT set SetShrinkStartChild/SetShrinkEndChild to false
52 // as that prevents GTK from shrinking children to fit the ratio
53 paned.SetResizeStartChild(true)
54 paned.SetResizeEndChild(true)
55 paned.SetVisible(true)
56
57 sv := &SplitView{
58 paned: paned,
59 orientation: orientation,
60 startChild: startChild,
61 endChild: endChild,
62 ratio: clampRatio(ratio),
63 logger: log.With().Str("component", "split-view").Logger(),
64 }
65
66 // Set children
67 if startChild != nil {
68 startChild.SetVisible(true)
69 paned.SetStartChild(startChild)
70 }
71 if endChild != nil {
72 endChild.SetVisible(true)
73 paned.SetEndChild(endChild)
74 }
75
76 paned.ConnectNotifyPosition(func() {
77 position := paned.GetPosition()
78 var totalSize int
79 if orientation == OrientationHorizontal {
80 totalSize = paned.GetAllocatedWidth()
81 } else {
82 totalSize = paned.GetAllocatedHeight()
83 }
84 if totalSize <= 0 {
85 return
86 }
87
88 now := time.Now()
89
90 sv.mu.Lock()
91 // Ignore early notifications before we've applied the snapshot ratio.
92 // GTK often emits an initial notify::position (typically 50/50) during allocation.
93 if !sv.hasAppliedRatio {
94 sv.mu.Unlock()
95 return
96 }
97 if !sv.suppressNotifyUntil.IsZero() && now.Before(sv.suppressNotifyUntil) {
98 sv.mu.Unlock()
99 return
100 }
101 sv.mu.Unlock()
102
103 ratio := clampRatio(float64(position) / float64(totalSize))
104
105 sv.mu.Lock()
106 sv.ratio = ratio
107 sv.pendingNotifyRatio = ratio
108 onRatioChanged := sv.onRatioChanged
109
110 if sv.notifyDebounceTimer != nil {
111 sv.notifyDebounceTimer.Stop()
112 }
113 sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() {
114 if onRatioChanged == nil {
115 return
116 }
117 cb := glib.SourceFunc(func(_ uintptr) bool {
118 sv.mu.RLock()
119 pending := sv.pendingNotifyRatio
120 sv.mu.RUnlock()
121 onRatioChanged(pending)
122 return false
123 })
124 glib.IdleAdd(&cb, 0)
125 })
126
127 sv.mu.Unlock()
128 })
129
130 // Try to apply ratio immediately (in case widget is already allocated)
131 if sv.ApplyRatio() {
132 return sv
133 }
134
135 // Apply ratio after widget is mapped (when we know the allocated size)
136 paned.ConnectMap(func() {
137 sv.ApplyRatio()
138 })
139
140 // Add tick callback to retry applying ratio every frame until successful.
141 // This handles cases where allocation isn't ready even after Map signal.
142 frames := 0
143 paned.AddTickCallback(func() bool {
144 frames++
145 if sv.ApplyRatio() {
146 sv.logger.Debug().
147 Int("frames", frames).
148 Float64("ratio", sv.GetRatio()).
149 Msg("tick callback: ratio applied successfully")
150 return false // Stop ticking - ratio applied successfully
151 }
152 if frames >= maxRetryFrames {
153 sv.logger.Warn().
154 Int("frames", frames).
155 Msg("tick callback: timeout reached, allocation never stabilized")
156 return false // Stop ticking - timeout reached
157 }
158 return true // Continue ticking
159 })
160
161 return sv
162}
163
164// SetRatio updates the divider position.
165// The ratio is clamped to the range [0.0, 1.0].
166// Note: This sets the position based on ratio; actual pixel position
167// depends on the allocated size of the paned widget.
168func (sv *SplitView) SetRatio(ratio float64) {
169 sv.mu.Lock()
170 defer sv.mu.Unlock()
171
172 sv.ratio = clampRatio(ratio)
173}
174
175// GetRatio returns the current ratio setting.
176func (sv *SplitView) GetRatio() float64 {
177 sv.mu.RLock()
178 defer sv.mu.RUnlock()
179
180 return sv.ratio
181}
182
183func (sv *SplitView) SetOnRatioChanged(fn func(ratio float64)) {
184 sv.mu.Lock()
185 defer sv.mu.Unlock()
186
187 sv.onRatioChanged = fn
188}
189
190// ApplyRatio converts the ratio to a pixel position and applies it.
191// This should be called after the widget has been mapped and has an allocated size.
192// Returns true if the ratio was successfully applied, false if the widget is not yet allocated.
193func (sv *SplitView) ApplyRatio() bool {
194 sv.mu.RLock()
195 ratio := sv.ratio
196 orientation := sv.orientation
197 sv.mu.RUnlock()
198
199 // Get the relevant dimension based on orientation
200 var totalSize int
201 var orientStr string
202 if orientation == OrientationHorizontal {
203 totalSize = sv.paned.GetAllocatedWidth()
204 orientStr = "horizontal"
205 } else {
206 totalSize = sv.paned.GetAllocatedHeight()
207 orientStr = "vertical"
208 }
209
210 if totalSize <= 0 {
211 sv.logger.Debug().
212 Str("orientation", orientStr).
213 Int("total_size", totalSize).
214 Msg("ApplyRatio: not yet allocated")
215 return false // Not yet allocated
216 }
217
218 // Calculate position from ratio
219 position := int(float64(totalSize) * ratio)
220 sv.logger.Debug().
221 Str("orientation", orientStr).
222 Int("total_size", totalSize).
223 Float64("ratio", ratio).
224 Int("position", position).
225 Msg("ApplyRatio: setting position")
226
227 // Avoid treating programmatic SetPosition as a user resize.
228 // We suppress notify::position briefly; GTK may emit during/after SetPosition.
229 sv.mu.Lock()
230 sv.suppressNotifyUntil = time.Now().Add(notifyPositionSuppressAfterApplyDelay)
231 sv.mu.Unlock()
232
233 sv.paned.SetPosition(position)
234
235 sv.mu.Lock()
236 sv.hasAppliedRatio = true
237 sv.mu.Unlock()
238
239 return true
240}
241
242// SetPosition sets the divider position in pixels.
243func (sv *SplitView) SetPosition(position int) {
244 sv.paned.SetPosition(position)
245}
246
247// GetPosition returns the current divider position in pixels.
248func (sv *SplitView) GetPosition() int {
249 return sv.paned.GetPosition()
250}
251
252// SwapStart replaces the start (left/top) child widget.
253// The old child is unparented before the new child is set.
254func (sv *SplitView) SwapStart(newWidget Widget) {
255 sv.mu.Lock()
256 defer sv.mu.Unlock()
257
258 // Remove old child from paned
259 if sv.startChild != nil {
260 sv.paned.SetStartChild(nil)
261 }
262
263 sv.startChild = newWidget
264
265 if newWidget != nil {
266 sv.paned.SetStartChild(newWidget)
267 }
268}
269
270// SwapEnd replaces the end (right/bottom) child widget.
271// The old child is unparented before the new child is set.
272func (sv *SplitView) SwapEnd(newWidget Widget) {
273 sv.mu.Lock()
274 defer sv.mu.Unlock()
275
276 // Remove old child from paned
277 if sv.endChild != nil {
278 sv.paned.SetEndChild(nil)
279 }
280
281 sv.endChild = newWidget
282
283 if newWidget != nil {
284 sv.paned.SetEndChild(newWidget)
285 }
286}
287
288// StartChild returns the current start (left/top) child.
289func (sv *SplitView) StartChild() Widget {
290 sv.mu.RLock()
291 defer sv.mu.RUnlock()
292
293 return sv.startChild
294}
295
296// EndChild returns the current end (right/bottom) child.
297func (sv *SplitView) EndChild() Widget {
298 sv.mu.RLock()
299 defer sv.mu.RUnlock()
300
301 return sv.endChild
302}
303
304// Orientation returns the split orientation.
305func (sv *SplitView) Orientation() Orientation {
306 return sv.orientation
307}
308
309// SetWideHandle sets whether the handle has a wide appearance.
310func (sv *SplitView) SetWideHandle(wide bool) {
311 sv.paned.SetWideHandle(wide)
312}
313
314// Widget returns the underlying PanedWidget for embedding in containers.
315func (sv *SplitView) Widget() Widget {
316 return sv.paned
317}
318
319// Paned returns the underlying PanedWidget for direct access.
320func (sv *SplitView) Paned() PanedWidget {
321 return sv.paned
322}
323
324// clampRatio ensures the ratio is within [0.0, 1.0].
325func clampRatio(ratio float64) float64 {
326 if ratio < 0.0 {
327 return 0.0
328 }
329 if ratio > 1.0 {
330 return 1.0
331 }
332 return ratio
333}