v0.30.0 eb3068f

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	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}

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