v0.20.1 d9a391f

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}

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