main 5041353

split.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close