detached 917b52c

feat(ui): detect and propagate split pane resize events

commit
917b52c54753e357321bf8033aff0948954c7ff5
author
brice · · 2025-12-26 07:36 UTC
parent
dc9d28d
signature
unsigned
9 files · +161 -2
M internal/ui/component/workspace_view.go +17 -1
@@ -47,7 +47,8 @@ type WorkspaceView struct {
4747 workspace *entity.Workspace
4848 paneViews map[entity.PaneID]*PaneView
4949
50- onPaneFocused func(paneID entity.PaneID)
50+ onPaneFocused func(paneID entity.PaneID)
51+ onSplitRatioDragged func(nodeID string, ratio float64)
5152
5253 mu sync.RWMutex
5354 }
@@ -128,6 +129,14 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp
128129
129130 // Create tree renderer with our adapter as the pane view factory
130131 wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})
132+ wv.treeRenderer.SetOnSplitRatioChanged(func(nodeID string, ratio float64) {
133+ wv.mu.RLock()
134+ cb := wv.onSplitRatioDragged
135+ wv.mu.RUnlock()
136+ if cb != nil {
137+ cb(nodeID, ratio)
138+ }
139+ })
131140
132141 return wv
133142 }
@@ -303,6 +312,13 @@ func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
303312 wv.onPaneFocused = fn
304313 }
305314
315+func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
316+ wv.mu.Lock()
317+ defer wv.mu.Unlock()
318+
319+ wv.onSplitRatioDragged = fn
320+}
321+
306322 // Rebuild rebuilds the widget tree from the current workspace.
307323 // Use this after structural changes like splits or closes.
308324 func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
@@ -47,7 +47,8 @@ type WorkspaceView struct {
47 workspace *entity.Workspace47 workspace *entity.Workspace
48 paneViews map[entity.PaneID]*PaneView48 paneViews map[entity.PaneID]*PaneView
49 49
50- onPaneFocused func(paneID entity.PaneID)50+ onPaneFocused func(paneID entity.PaneID)
51+ onSplitRatioDragged func(nodeID string, ratio float64)
51 52
52 mu sync.RWMutex53 mu sync.RWMutex
53 }54 }
@@ -128,6 +129,14 @@ func NewWorkspaceView(ctx context.Context, factory layout.WidgetFactory) *Worksp
128 129
129 // Create tree renderer with our adapter as the pane view factory130 // Create tree renderer with our adapter as the pane view factory
130 wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})131 wv.treeRenderer = layout.NewTreeRenderer(ctx, factory, &paneViewFactoryAdapter{wv: wv, ctx: ctx})
132+ wv.treeRenderer.SetOnSplitRatioChanged(func(nodeID string, ratio float64) {
133+ wv.mu.RLock()
134+ cb := wv.onSplitRatioDragged
135+ wv.mu.RUnlock()
136+ if cb != nil {
137+ cb(nodeID, ratio)
138+ }
139+ })
131 140
132 return wv141 return wv
133 }142 }
@@ -303,6 +312,13 @@ func (wv *WorkspaceView) SetOnPaneFocused(fn func(paneID entity.PaneID)) {
303 wv.onPaneFocused = fn312 wv.onPaneFocused = fn
304 }313 }
305 314
315+func (wv *WorkspaceView) SetOnSplitRatioDragged(fn func(nodeID string, ratio float64)) {
316+ wv.mu.Lock()
317+ defer wv.mu.Unlock()
318+
319+ wv.onSplitRatioDragged = fn
320+}
321+
306 // Rebuild rebuilds the widget tree from the current workspace.322 // Rebuild rebuilds the widget tree from the current workspace.
307 // Use this after structural changes like splits or closes.323 // Use this after structural changes like splits or closes.
308 func (wv *WorkspaceView) Rebuild(ctx context.Context) error {324 func (wv *WorkspaceView) Rebuild(ctx context.Context) error {
M internal/ui/component/workspace_view_test.go +1 -0
@@ -199,6 +199,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
199199 mockStackBox2.EXPECT().SetVisible(true).Once()
200200 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()
201201 mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()
202+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
202203 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
203204 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
204205 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -199,6 +199,7 @@ func TestSetActivePaneID_UpdatesStyling(t *testing.T) {
199 mockStackBox2.EXPECT().SetVisible(true).Once()199 mockStackBox2.EXPECT().SetVisible(true).Once()
200 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()200 mockPaned.EXPECT().SetStartChild(mockStackBox1).Once()
201 mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()201 mockPaned.EXPECT().SetEndChild(mockStackBox2).Once()
202+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
202 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()203 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
203 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()204 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
204 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()205 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
M internal/ui/layout/gtk_widgets.go +9 -0
@@ -1,7 +1,9 @@
11 package layout
22
33 import (
4+ "github.com/jwijenbergh/purego"
45 "github.com/jwijenbergh/puregotk/v4/gdk"
6+ "github.com/jwijenbergh/puregotk/v4/gobject"
57 "github.com/jwijenbergh/puregotk/v4/graphene"
68 "github.com/jwijenbergh/puregotk/v4/gtk"
79 "github.com/jwijenbergh/puregotk/v4/pango"
@@ -162,6 +164,13 @@ func (p *gtkPaned) ConnectMap(callback func()) uint32 {
162164 return p.inner.ConnectMap(&cb)
163165 }
164166
167+func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint32 {
168+ cbPtr := purego.NewCallback(func() {
169+ callback()
170+ })
171+ return gobject.SignalConnect(p.inner.GoPointer(), "notify::position", cbPtr)
172+}
173+
165174 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
166175 cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool {
167176 return callback()
@@ -1,7 +1,9 @@
1 package layout1 package layout
2 2
3 import (3 import (
4+ "github.com/jwijenbergh/purego"
4 "github.com/jwijenbergh/puregotk/v4/gdk"5 "github.com/jwijenbergh/puregotk/v4/gdk"
6+ "github.com/jwijenbergh/puregotk/v4/gobject"
5 "github.com/jwijenbergh/puregotk/v4/graphene"7 "github.com/jwijenbergh/puregotk/v4/graphene"
6 "github.com/jwijenbergh/puregotk/v4/gtk"8 "github.com/jwijenbergh/puregotk/v4/gtk"
7 "github.com/jwijenbergh/puregotk/v4/pango"9 "github.com/jwijenbergh/puregotk/v4/pango"
@@ -162,6 +164,13 @@ func (p *gtkPaned) ConnectMap(callback func()) uint32 {
162 return p.inner.ConnectMap(&cb)164 return p.inner.ConnectMap(&cb)
163 }165 }
164 166
167+func (p *gtkPaned) ConnectNotifyPosition(callback func()) uint32 {
168+ cbPtr := purego.NewCallback(func() {
169+ callback()
170+ })
171+ return gobject.SignalConnect(p.inner.GoPointer(), "notify::position", cbPtr)
172+}
173+
165 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {174 func (p *gtkPaned) AddTickCallback(callback func() bool) uint {
166 cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool {175 cb := gtk.TickCallback(func(widget uintptr, frameClock uintptr, userData uintptr) bool {
167 return callback()176 return callback()
M internal/ui/layout/mocks/mocks.go +51 -0
@@ -1495,6 +1495,57 @@ func (_c *MockPanedWidget_ConnectMap_Call) RunAndReturn(run func(callback func()
14951495 return _c
14961496 }
14971497
1498+// ConnectNotifyPosition provides a mock function for the type MockPanedWidget
1499+func (_mock *MockPanedWidget) ConnectNotifyPosition(callback func()) uint32 {
1500+ ret := _mock.Called(callback)
1501+
1502+ if len(ret) == 0 {
1503+ panic("no return value specified for ConnectNotifyPosition")
1504+ }
1505+
1506+ var r0 uint32
1507+ if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok {
1508+ r0 = returnFunc(callback)
1509+ } else {
1510+ r0 = ret.Get(0).(uint32)
1511+ }
1512+ return r0
1513+}
1514+
1515+// MockPanedWidget_ConnectNotifyPosition_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectNotifyPosition'
1516+type MockPanedWidget_ConnectNotifyPosition_Call struct {
1517+ *mock.Call
1518+}
1519+
1520+// ConnectNotifyPosition is a helper method to define mock.On call
1521+// - callback func()
1522+func (_e *MockPanedWidget_Expecter) ConnectNotifyPosition(callback interface{}) *MockPanedWidget_ConnectNotifyPosition_Call {
1523+ return &MockPanedWidget_ConnectNotifyPosition_Call{Call: _e.mock.On("ConnectNotifyPosition", callback)}
1524+}
1525+
1526+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Run(run func(callback func())) *MockPanedWidget_ConnectNotifyPosition_Call {
1527+ _c.Call.Run(func(args mock.Arguments) {
1528+ var arg0 func()
1529+ if args[0] != nil {
1530+ arg0 = args[0].(func())
1531+ }
1532+ run(
1533+ arg0,
1534+ )
1535+ })
1536+ return _c
1537+}
1538+
1539+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Return(v uint32) *MockPanedWidget_ConnectNotifyPosition_Call {
1540+ _c.Call.Return(v)
1541+ return _c
1542+}
1543+
1544+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) RunAndReturn(run func(callback func()) uint32) *MockPanedWidget_ConnectNotifyPosition_Call {
1545+ _c.Call.Return(run)
1546+ return _c
1547+}
1548+
14981549 // GetAllocatedHeight provides a mock function for the type MockPanedWidget
14991550 func (_mock *MockPanedWidget) GetAllocatedHeight() int {
15001551 ret := _mock.Called()
@@ -1495,6 +1495,57 @@ func (_c *MockPanedWidget_ConnectMap_Call) RunAndReturn(run func(callback func()
1495 return _c1495 return _c
1496 }1496 }
1497 1497
1498+// ConnectNotifyPosition provides a mock function for the type MockPanedWidget
1499+func (_mock *MockPanedWidget) ConnectNotifyPosition(callback func()) uint32 {
1500+ ret := _mock.Called(callback)
1501+
1502+ if len(ret) == 0 {
1503+ panic("no return value specified for ConnectNotifyPosition")
1504+ }
1505+
1506+ var r0 uint32
1507+ if returnFunc, ok := ret.Get(0).(func(func()) uint32); ok {
1508+ r0 = returnFunc(callback)
1509+ } else {
1510+ r0 = ret.Get(0).(uint32)
1511+ }
1512+ return r0
1513+}
1514+
1515+// MockPanedWidget_ConnectNotifyPosition_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConnectNotifyPosition'
1516+type MockPanedWidget_ConnectNotifyPosition_Call struct {
1517+ *mock.Call
1518+}
1519+
1520+// ConnectNotifyPosition is a helper method to define mock.On call
1521+// - callback func()
1522+func (_e *MockPanedWidget_Expecter) ConnectNotifyPosition(callback interface{}) *MockPanedWidget_ConnectNotifyPosition_Call {
1523+ return &MockPanedWidget_ConnectNotifyPosition_Call{Call: _e.mock.On("ConnectNotifyPosition", callback)}
1524+}
1525+
1526+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Run(run func(callback func())) *MockPanedWidget_ConnectNotifyPosition_Call {
1527+ _c.Call.Run(func(args mock.Arguments) {
1528+ var arg0 func()
1529+ if args[0] != nil {
1530+ arg0 = args[0].(func())
1531+ }
1532+ run(
1533+ arg0,
1534+ )
1535+ })
1536+ return _c
1537+}
1538+
1539+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) Return(v uint32) *MockPanedWidget_ConnectNotifyPosition_Call {
1540+ _c.Call.Return(v)
1541+ return _c
1542+}
1543+
1544+func (_c *MockPanedWidget_ConnectNotifyPosition_Call) RunAndReturn(run func(callback func()) uint32) *MockPanedWidget_ConnectNotifyPosition_Call {
1545+ _c.Call.Return(run)
1546+ return _c
1547+}
1548+
1498 // GetAllocatedHeight provides a mock function for the type MockPanedWidget1549 // GetAllocatedHeight provides a mock function for the type MockPanedWidget
1499 func (_mock *MockPanedWidget) GetAllocatedHeight() int {1550 func (_mock *MockPanedWidget) GetAllocatedHeight() int {
1500 ret := _mock.Called()1551 ret := _mock.Called()
M internal/ui/layout/split.go +54 -0
@@ -3,8 +3,10 @@ package layout
33 import (
44 "context"
55 "sync"
6+ "time"
67
78 "github.com/bnema/dumber/internal/logging"
9+ "github.com/jwijenbergh/puregotk/v4/glib"
810 "github.com/rs/zerolog"
911 )
1012
@@ -18,6 +20,10 @@ type SplitView struct {
1820 ratio float64 // 0.0-1.0, relative position of divider
1921 logger zerolog.Logger
2022
23+ onRatioChanged func(ratio float64)
24+ pendingNotifyRatio float64
25+ notifyDebounceTimer *time.Timer
26+
2127 mu sync.RWMutex
2228 }
2329
@@ -25,6 +31,8 @@ type SplitView struct {
2531 // At 60fps, 120 frames = ~2 seconds timeout.
2632 const maxRetryFrames = 120
2733
34+const notifyPositionDebounceDelay = 100 * time.Millisecond
35+
2836 // NewSplitView creates a new split view with the given orientation and children.
2937 // The ratio determines the initial divider position (0.0-1.0).
3038 func NewSplitView(
@@ -61,6 +69,45 @@ func NewSplitView(
6169 paned.SetEndChild(endChild)
6270 }
6371
72+ paned.ConnectNotifyPosition(func() {
73+ position := paned.GetPosition()
74+ var totalSize int
75+ if orientation == OrientationHorizontal {
76+ totalSize = paned.GetAllocatedWidth()
77+ } else {
78+ totalSize = paned.GetAllocatedHeight()
79+ }
80+ if totalSize <= 0 {
81+ return
82+ }
83+
84+ ratio := clampRatio(float64(position) / float64(totalSize))
85+
86+ sv.mu.Lock()
87+ sv.ratio = ratio
88+ sv.pendingNotifyRatio = ratio
89+ onRatioChanged := sv.onRatioChanged
90+
91+ if sv.notifyDebounceTimer != nil {
92+ sv.notifyDebounceTimer.Stop()
93+ }
94+ sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() {
95+ if onRatioChanged == nil {
96+ return
97+ }
98+ cb := glib.SourceFunc(func(_ uintptr) bool {
99+ sv.mu.RLock()
100+ pending := sv.pendingNotifyRatio
101+ sv.mu.RUnlock()
102+ onRatioChanged(pending)
103+ return false
104+ })
105+ glib.IdleAdd(&cb, 0)
106+ })
107+
108+ sv.mu.Unlock()
109+ })
110+
64111 // Try to apply ratio immediately (in case widget is already allocated)
65112 if sv.ApplyRatio() {
66113 return sv
@@ -114,6 +161,13 @@ func (sv *SplitView) GetRatio() float64 {
114161 return sv.ratio
115162 }
116163
164+func (sv *SplitView) SetOnRatioChanged(fn func(ratio float64)) {
165+ sv.mu.Lock()
166+ defer sv.mu.Unlock()
167+
168+ sv.onRatioChanged = fn
169+}
170+
117171 // ApplyRatio converts the ratio to a pixel position and applies it.
118172 // This should be called after the widget has been mapped and has an allocated size.
119173 // Returns true if the ratio was successfully applied, false if the widget is not yet allocated.
@@ -3,8 +3,10 @@ package layout
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6+ "time"
6 7
7 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
9+ "github.com/jwijenbergh/puregotk/v4/glib"
8 "github.com/rs/zerolog"10 "github.com/rs/zerolog"
9 )11 )
10 12
@@ -18,6 +20,10 @@ type SplitView struct {
18 ratio float64 // 0.0-1.0, relative position of divider20 ratio float64 // 0.0-1.0, relative position of divider
19 logger zerolog.Logger21 logger zerolog.Logger
20 22
23+ onRatioChanged func(ratio float64)
24+ pendingNotifyRatio float64
25+ notifyDebounceTimer *time.Timer
26+
21 mu sync.RWMutex27 mu sync.RWMutex
22 }28 }
23 29
@@ -25,6 +31,8 @@ type SplitView struct {
25 // At 60fps, 120 frames = ~2 seconds timeout.31 // At 60fps, 120 frames = ~2 seconds timeout.
26 const maxRetryFrames = 12032 const maxRetryFrames = 120
27 33
34+const notifyPositionDebounceDelay = 100 * time.Millisecond
35+
28 // NewSplitView creates a new split view with the given orientation and children.36 // NewSplitView creates a new split view with the given orientation and children.
29 // The ratio determines the initial divider position (0.0-1.0).37 // The ratio determines the initial divider position (0.0-1.0).
30 func NewSplitView(38 func NewSplitView(
@@ -61,6 +69,45 @@ func NewSplitView(
61 paned.SetEndChild(endChild)69 paned.SetEndChild(endChild)
62 }70 }
63 71
72+ paned.ConnectNotifyPosition(func() {
73+ position := paned.GetPosition()
74+ var totalSize int
75+ if orientation == OrientationHorizontal {
76+ totalSize = paned.GetAllocatedWidth()
77+ } else {
78+ totalSize = paned.GetAllocatedHeight()
79+ }
80+ if totalSize <= 0 {
81+ return
82+ }
83+
84+ ratio := clampRatio(float64(position) / float64(totalSize))
85+
86+ sv.mu.Lock()
87+ sv.ratio = ratio
88+ sv.pendingNotifyRatio = ratio
89+ onRatioChanged := sv.onRatioChanged
90+
91+ if sv.notifyDebounceTimer != nil {
92+ sv.notifyDebounceTimer.Stop()
93+ }
94+ sv.notifyDebounceTimer = time.AfterFunc(notifyPositionDebounceDelay, func() {
95+ if onRatioChanged == nil {
96+ return
97+ }
98+ cb := glib.SourceFunc(func(_ uintptr) bool {
99+ sv.mu.RLock()
100+ pending := sv.pendingNotifyRatio
101+ sv.mu.RUnlock()
102+ onRatioChanged(pending)
103+ return false
104+ })
105+ glib.IdleAdd(&cb, 0)
106+ })
107+
108+ sv.mu.Unlock()
109+ })
110+
64 // Try to apply ratio immediately (in case widget is already allocated)111 // Try to apply ratio immediately (in case widget is already allocated)
65 if sv.ApplyRatio() {112 if sv.ApplyRatio() {
66 return sv113 return sv
@@ -114,6 +161,13 @@ func (sv *SplitView) GetRatio() float64 {
114 return sv.ratio161 return sv.ratio
115 }162 }
116 163
164+func (sv *SplitView) SetOnRatioChanged(fn func(ratio float64)) {
165+ sv.mu.Lock()
166+ defer sv.mu.Unlock()
167+
168+ sv.onRatioChanged = fn
169+}
170+
117 // ApplyRatio converts the ratio to a pixel position and applies it.171 // 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.172 // 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.173 // Returns true if the ratio was successfully applied, false if the widget is not yet allocated.
M internal/ui/layout/split_test.go +2 -0
@@ -29,6 +29,8 @@ func setupSplitViewMocks(mockPaned *mocks.MockPanedWidget, orientation layout.Or
2929 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
3030 }
3131
32+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
33+
3234 // Since allocation is 0, ApplyRatio returns false and callbacks are registered
3335 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
3436 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -29,6 +29,8 @@ func setupSplitViewMocks(mockPaned *mocks.MockPanedWidget, orientation layout.Or
29 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()29 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
30 }30 }
31 31
32+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
33+
32 // Since allocation is 0, ApplyRatio returns false and callbacks are registered34 // Since allocation is 0, ApplyRatio returns false and callbacks are registered
33 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()35 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
34 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()36 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
M internal/ui/layout/tree.go +19 -1
@@ -36,7 +36,9 @@ type TreeRenderer struct {
3636 // paneToStack maps pane IDs to their containing StackedView.
3737 // Every leaf pane is wrapped in a StackedView for easy stacking later.
3838 paneToStack map[string]*StackedView
39- mu sync.RWMutex
39+
40+ onSplitRatioChanged func(nodeID string, ratio float64)
41+ mu sync.RWMutex
4042 }
4143
4244 // NewTreeRenderer creates a new tree renderer.
@@ -54,6 +56,13 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
5456 }
5557 }
5658
59+func (tr *TreeRenderer) SetOnSplitRatioChanged(fn func(nodeID string, ratio float64)) {
60+ tr.mu.Lock()
61+ defer tr.mu.Unlock()
62+
63+ tr.onSplitRatioChanged = fn
64+}
65+
5766 // Build constructs the entire widget tree from a root PaneNode.
5867 // Returns the root widget that can be embedded in a container.
5968 func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widget, error) {
@@ -170,6 +179,15 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode)
170179
171180 // Create split view
172181 splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)
182+ nodeID := node.ID
183+ splitView.SetOnRatioChanged(func(ratio float64) {
184+ tr.mu.RLock()
185+ cb := tr.onSplitRatioChanged
186+ tr.mu.RUnlock()
187+ if cb != nil {
188+ cb(nodeID, ratio)
189+ }
190+ })
173191 tr.splitOrientation[node.ID] = orientation
174192
175193 return splitView.Widget(), nil
@@ -36,7 +36,9 @@ type TreeRenderer struct {
36 // paneToStack maps pane IDs to their containing StackedView.36 // paneToStack maps pane IDs to their containing StackedView.
37 // Every leaf pane is wrapped in a StackedView for easy stacking later.37 // Every leaf pane is wrapped in a StackedView for easy stacking later.
38 paneToStack map[string]*StackedView38 paneToStack map[string]*StackedView
39- mu sync.RWMutex39+
40+ onSplitRatioChanged func(nodeID string, ratio float64)
41+ mu sync.RWMutex
40 }42 }
41 43
42 // NewTreeRenderer creates a new tree renderer.44 // NewTreeRenderer creates a new tree renderer.
@@ -54,6 +56,13 @@ func NewTreeRenderer(ctx context.Context, factory WidgetFactory, paneViewFactory
54 }56 }
55 }57 }
56 58
59+func (tr *TreeRenderer) SetOnSplitRatioChanged(fn func(nodeID string, ratio float64)) {
60+ tr.mu.Lock()
61+ defer tr.mu.Unlock()
62+
63+ tr.onSplitRatioChanged = fn
64+}
65+
57 // Build constructs the entire widget tree from a root PaneNode.66 // Build constructs the entire widget tree from a root PaneNode.
58 // Returns the root widget that can be embedded in a container.67 // Returns the root widget that can be embedded in a container.
59 func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widget, error) {68 func (tr *TreeRenderer) Build(ctx context.Context, root *entity.PaneNode) (Widget, error) {
@@ -170,6 +179,15 @@ func (tr *TreeRenderer) renderSplit(ctx context.Context, node *entity.PaneNode)
170 179
171 // Create split view180 // Create split view
172 splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)181 splitView := NewSplitView(ctx, tr.factory, orientation, leftWidget, rightWidget, node.SplitRatio)
182+ nodeID := node.ID
183+ splitView.SetOnRatioChanged(func(ratio float64) {
184+ tr.mu.RLock()
185+ cb := tr.onSplitRatioChanged
186+ tr.mu.RUnlock()
187+ if cb != nil {
188+ cb(nodeID, ratio)
189+ }
190+ })
173 tr.splitOrientation[node.ID] = orientation191 tr.splitOrientation[node.ID] = orientation
174 192
175 return splitView.Widget(), nil193 return splitView.Widget(), nil
M internal/ui/layout/tree_test.go +7 -0
@@ -123,6 +123,7 @@ func TestBuild_HorizontalSplit(t *testing.T) {
123123 mockRightStackBox.EXPECT().SetVisible(true).Once()
124124 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
125125 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
126+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
126127 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
127128 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
128129 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -172,6 +173,7 @@ func TestBuild_VerticalSplit(t *testing.T) {
172173 mockBottomStackBox.EXPECT().SetVisible(true).Once()
173174 mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once()
174175 mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once()
176+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
175177 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
176178 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
177179 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -240,6 +242,7 @@ func TestBuild_NestedSplits(t *testing.T) {
240242 mockStackBox2.EXPECT().SetVisible(true).Once()
241243 mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once()
242244 mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once()
245+ mockInnerPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
243246 mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
244247 mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
245248 mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -253,6 +256,7 @@ func TestBuild_NestedSplits(t *testing.T) {
253256 mockStackBox3.EXPECT().SetVisible(true).Once()
254257 mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once()
255258 mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once()
259+ mockOuterPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
256260 mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
257261 mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
258262 mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -414,6 +418,7 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) {
414418 mockRightStackBox.EXPECT().SetVisible(true).Once()
415419 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
416420 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
421+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
417422 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
418423 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
419424 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -538,8 +543,10 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) {
538543 mockRightStackBox.EXPECT().SetVisible(true).Once()
539544 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
540545 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
546+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
541547 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice()
542548 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
549+
543550 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
544551
545552 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
@@ -123,6 +123,7 @@ func TestBuild_HorizontalSplit(t *testing.T) {
123 mockRightStackBox.EXPECT().SetVisible(true).Once()123 mockRightStackBox.EXPECT().SetVisible(true).Once()
124 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()124 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
125 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()125 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
126+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
126 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()127 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
127 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()128 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
128 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()129 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -172,6 +173,7 @@ func TestBuild_VerticalSplit(t *testing.T) {
172 mockBottomStackBox.EXPECT().SetVisible(true).Once()173 mockBottomStackBox.EXPECT().SetVisible(true).Once()
173 mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once()174 mockPaned.EXPECT().SetStartChild(mockTopStackBox).Once()
174 mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once()175 mockPaned.EXPECT().SetEndChild(mockBottomStackBox).Once()
176+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
175 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()177 mockPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
176 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()178 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
177 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()179 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -240,6 +242,7 @@ func TestBuild_NestedSplits(t *testing.T) {
240 mockStackBox2.EXPECT().SetVisible(true).Once()242 mockStackBox2.EXPECT().SetVisible(true).Once()
241 mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once()243 mockInnerPaned.EXPECT().SetStartChild(mockStackBox1).Once()
242 mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once()244 mockInnerPaned.EXPECT().SetEndChild(mockStackBox2).Once()
245+ mockInnerPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
243 mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once()246 mockInnerPaned.EXPECT().GetAllocatedHeight().Return(0).Once()
244 mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()247 mockInnerPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
245 mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()248 mockInnerPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -253,6 +256,7 @@ func TestBuild_NestedSplits(t *testing.T) {
253 mockStackBox3.EXPECT().SetVisible(true).Once()256 mockStackBox3.EXPECT().SetVisible(true).Once()
254 mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once()257 mockOuterPaned.EXPECT().SetStartChild(mockInnerPaned).Once()
255 mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once()258 mockOuterPaned.EXPECT().SetEndChild(mockStackBox3).Once()
259+ mockOuterPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
256 mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once()260 mockOuterPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
257 mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()261 mockOuterPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
258 mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()262 mockOuterPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -414,6 +418,7 @@ func TestGetNodeIDs_ReturnsAllIDs(t *testing.T) {
414 mockRightStackBox.EXPECT().SetVisible(true).Once()418 mockRightStackBox.EXPECT().SetVisible(true).Once()
415 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()419 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
416 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()420 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
421+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
417 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()422 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once()
418 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()423 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
419 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()424 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
@@ -538,8 +543,10 @@ func TestUpdateSplitRatio_ExistingNode(t *testing.T) {
538 mockRightStackBox.EXPECT().SetVisible(true).Once()543 mockRightStackBox.EXPECT().SetVisible(true).Once()
539 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()544 mockPaned.EXPECT().SetStartChild(mockLeftStackBox).Once()
540 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()545 mockPaned.EXPECT().SetEndChild(mockRightStackBox).Once()
546+ mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint32(0)).Once()
541 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice()547 mockPaned.EXPECT().GetAllocatedWidth().Return(0).Twice()
542 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()548 mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint32(0)).Once()
549+
543 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()550 mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once()
544 551
545 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)552 renderer := layout.NewTreeRenderer(ctx, mockFactory, mockPaneViewFactory)
M internal/ui/layout/widgets.go +1 -0
@@ -95,6 +95,7 @@ type PanedWidget interface {
9595
9696 // Signals
9797 ConnectMap(callback func()) uint32
98+ ConnectNotifyPosition(callback func()) uint32
9899
99100 // Tick callback for frame-based updates (returns signal ID)
100101 // Callback returns true to continue, false to stop
@@ -95,6 +95,7 @@ type PanedWidget interface {
95 95
96 // Signals96 // Signals
97 ConnectMap(callback func()) uint3297 ConnectMap(callback func()) uint32
98+ ConnectNotifyPosition(callback func()) uint32
98 99
99 // Tick callback for frame-based updates (returns signal ID)100 // Tick callback for frame-based updates (returns signal ID)
100 // Callback returns true to continue, false to stop101 // Callback returns true to continue, false to stop

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