split.go RawHistoryFolder
1package layout
2
3import (
4 "context"
5 "sync"
6
7 "github.com/bnema/dumber/internal/logging"
8 "github.com/rs/zerolog"
9)
10
11// SplitView wraps a GTK Paned widget for creating split pane layouts.
12// It manages two child widgets separated by a draggable divider.
13type SplitView struct {
14 paned PanedWidget
15 orientation Orientation
16 startChild Widget
17 endChild Widget
18 ratio float64 // 0.0-1.0, relative position of divider
19 logger zerolog.Logger
20
21 mu sync.RWMutex
22}
23
24// maxRetryFrames is the maximum number of frames to wait for allocation to stabilize.
25// At 60fps, 120 frames = ~2 seconds timeout.
26const maxRetryFrames = 120
27
28// NewSplitView creates a new split view with the given orientation and children.
29// The ratio determines the initial divider position (0.0-1.0).
30func NewSplitView(
31 ctx context.Context, factory WidgetFactory, orientation Orientation,
32 startChild, endChild Widget, ratio float64,
33) *SplitView {
34 log := logging.FromContext(ctx)
35
36 paned := factory.NewPaned(orientation)
37
38 // Configure resize behavior - both children should resize
39 // Note: Do NOT set SetShrinkStartChild/SetShrinkEndChild to false
40 // as that prevents GTK from shrinking children to fit the ratio
41 paned.SetResizeStartChild(true)
42 paned.SetResizeEndChild(true)
43 paned.SetVisible(true)
44
45 sv := &SplitView{
46 paned: paned,
47 orientation: orientation,
48 startChild: startChild,
49 endChild: endChild,
50 ratio: clampRatio(ratio),
51 logger: log.With().Str("component", "split-view").Logger(),
52 }
53
54 // Set children
55 if startChild != nil {
56 startChild.SetVisible(true)
57 paned.SetStartChild(startChild)
58 }
59 if endChild != nil {
60 endChild.SetVisible(true)
61 paned.SetEndChild(endChild)
62 }
63
64 // Try to apply ratio immediately (in case widget is already allocated)
65 if sv.ApplyRatio() {
66 return sv
67 }
68
69 // Apply ratio after widget is mapped (when we know the allocated size)
70 paned.ConnectMap(func() {
71 sv.ApplyRatio()
72 })
73
74 // Add tick callback to retry applying ratio every frame until successful.
75 // This handles cases where allocation isn't ready even after Map signal.
76 frames := 0
77 paned.AddTickCallback(func() bool {
78 frames++
79 if sv.ApplyRatio() {
80 sv.logger.Debug().
81 Int("frames", frames).
82 Float64("ratio", sv.GetRatio()).
83 Msg("tick callback: ratio applied successfully")
84 return false // Stop ticking - ratio applied successfully
85 }
86 if frames >= maxRetryFrames {
87 sv.logger.Warn().
88 Int("frames", frames).
89 Msg("tick callback: timeout reached, allocation never stabilized")
90 return false // Stop ticking - timeout reached
91 }
92 return true // Continue ticking
93 })
94
95 return sv
96}
97
98// SetRatio updates the divider position.
99// The ratio is clamped to the range [0.0, 1.0].
100// Note: This sets the position based on ratio; actual pixel position
101// depends on the allocated size of the paned widget.
102func (sv *SplitView) SetRatio(ratio float64) {
103 sv.mu.Lock()
104 defer sv.mu.Unlock()
105
106 sv.ratio = clampRatio(ratio)
107}
108
109// GetRatio returns the current ratio setting.
110func (sv *SplitView) GetRatio() float64 {
111 sv.mu.RLock()
112 defer sv.mu.RUnlock()
113
114 return sv.ratio
115}
116
117// ApplyRatio converts the ratio to a pixel position and applies it.
118// This should be called after the widget has been mapped and has an allocated size.
119// Returns true if the ratio was successfully applied, false if the widget is not yet allocated.
120func (sv *SplitView) ApplyRatio() bool {
121 sv.mu.RLock()
122 ratio := sv.ratio
123 orientation := sv.orientation
124 sv.mu.RUnlock()
125
126 // Get the relevant dimension based on orientation
127 var totalSize int
128 var orientStr string
129 if orientation == OrientationHorizontal {
130 totalSize = sv.paned.GetAllocatedWidth()
131 orientStr = "horizontal"
132 } else {
133 totalSize = sv.paned.GetAllocatedHeight()
134 orientStr = "vertical"
135 }
136
137 if totalSize <= 0 {
138 sv.logger.Debug().
139 Str("orientation", orientStr).
140 Int("total_size", totalSize).
141 Msg("ApplyRatio: not yet allocated")
142 return false // Not yet allocated
143 }
144
145 // Calculate position from ratio
146 position := int(float64(totalSize) * ratio)
147 sv.logger.Debug().
148 Str("orientation", orientStr).
149 Int("total_size", totalSize).
150 Float64("ratio", ratio).
151 Int("position", position).
152 Msg("ApplyRatio: setting position")
153 sv.paned.SetPosition(position)
154 return true
155}
156
157// SetPosition sets the divider position in pixels.
158func (sv *SplitView) SetPosition(position int) {
159 sv.paned.SetPosition(position)
160}
161
162// GetPosition returns the current divider position in pixels.
163func (sv *SplitView) GetPosition() int {
164 return sv.paned.GetPosition()
165}
166
167// SwapStart replaces the start (left/top) child widget.
168// The old child is unparented before the new child is set.
169func (sv *SplitView) SwapStart(newWidget Widget) {
170 sv.mu.Lock()
171 defer sv.mu.Unlock()
172
173 // Remove old child from paned
174 if sv.startChild != nil {
175 sv.paned.SetStartChild(nil)
176 }
177
178 sv.startChild = newWidget
179
180 if newWidget != nil {
181 sv.paned.SetStartChild(newWidget)
182 }
183}
184
185// SwapEnd replaces the end (right/bottom) child widget.
186// The old child is unparented before the new child is set.
187func (sv *SplitView) SwapEnd(newWidget Widget) {
188 sv.mu.Lock()
189 defer sv.mu.Unlock()
190
191 // Remove old child from paned
192 if sv.endChild != nil {
193 sv.paned.SetEndChild(nil)
194 }
195
196 sv.endChild = newWidget
197
198 if newWidget != nil {
199 sv.paned.SetEndChild(newWidget)
200 }
201}
202
203// StartChild returns the current start (left/top) child.
204func (sv *SplitView) StartChild() Widget {
205 sv.mu.RLock()
206 defer sv.mu.RUnlock()
207
208 return sv.startChild
209}
210
211// EndChild returns the current end (right/bottom) child.
212func (sv *SplitView) EndChild() Widget {
213 sv.mu.RLock()
214 defer sv.mu.RUnlock()
215
216 return sv.endChild
217}
218
219// Orientation returns the split orientation.
220func (sv *SplitView) Orientation() Orientation {
221 return sv.orientation
222}
223
224// SetWideHandle sets whether the handle has a wide appearance.
225func (sv *SplitView) SetWideHandle(wide bool) {
226 sv.paned.SetWideHandle(wide)
227}
228
229// Widget returns the underlying PanedWidget for embedding in containers.
230func (sv *SplitView) Widget() Widget {
231 return sv.paned
232}
233
234// Paned returns the underlying PanedWidget for direct access.
235func (sv *SplitView) Paned() PanedWidget {
236 return sv.paned
237}
238
239// clampRatio ensures the ratio is within [0.0, 1.0].
240func clampRatio(ratio float64) float64 {
241 if ratio < 0.0 {
242 return 0.0
243 }
244 if ratio > 1.0 {
245 return 1.0
246 }
247 return ratio
248}