v0.26.1 75cd777

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}

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