detached 0ba6c09

Merge pull request #146 from bnema/fix/split-expel-resize

fix(layout): give moved pane its fair share on consume/expel
commit
0ba6c091e77ff5283f5b96101077604ccfcad32b
author
bnema · · 2026-07-29 08:58 UTC
committer
GitHub · · 2026-07-29 08:58 UTC
merge of
7deb448 + 9dc0afe · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +45 -12
M internal/usecase/layout/consume_expel.go +15 -0
@@ -162,6 +162,19 @@ func findColumnMember(columns []*Node, target PaneID) (int, int, bool) {
162162 return 0, 0, false
163163 }
164164
165+// fairShareWeight returns the mean effective sibling weight so an inserted
166+// node lands with an equal share while siblings keep their proportions.
167+func fairShareWeight(siblings []*Node) float64 {
168+ if len(siblings) == 0 {
169+ return 0
170+ }
171+ total := 0.0
172+ for _, sibling := range siblings {
173+ total += effectiveWeight(sibling.Weight)
174+ }
175+ return total / float64(len(siblings))
176+}
177+
165178 func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) {
166179 root := t.Root
167180 destinationIndex := sourceIndex - 1
@@ -186,6 +199,7 @@ func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain
186199 }
187200 case Split:
188201 normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex])
202+ moved.Weight = fairShareWeight(destination.Children)
189203 destination.Children = append(destination.Children, moved)
190204 case Stack:
191205 destination.Children = append(destination.Children, moved)
@@ -239,6 +253,7 @@ func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) {
239253 }
240254
241255 root.Children[columnIndex] = remaining
256+ moved.Weight = fairShareWeight(root.Children)
242257 insertionIndex := columnIndex
243258 if dir == Right {
244259 insertionIndex++
@@ -162,6 +162,19 @@ func findColumnMember(columns []*Node, target PaneID) (int, int, bool) {
162 return 0, 0, false162 return 0, 0, false
163 }163 }
164 164
165+// fairShareWeight returns the mean effective sibling weight so an inserted
166+// node lands with an equal share while siblings keep their proportions.
167+func fairShareWeight(siblings []*Node) float64 {
168+ if len(siblings) == 0 {
169+ return 0
170+ }
171+ total := 0.0
172+ for _, sibling := range siblings {
173+ total += effectiveWeight(sibling.Weight)
174+ }
175+ return total / float64(len(siblings))
176+}
177+
165 func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) {178 func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) {
166 root := t.Root179 root := t.Root
167 destinationIndex := sourceIndex - 1180 destinationIndex := sourceIndex - 1
@@ -186,6 +199,7 @@ func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain
186 }199 }
187 case Split:200 case Split:
188 normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex])201 normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex])
202+ moved.Weight = fairShareWeight(destination.Children)
189 destination.Children = append(destination.Children, moved)203 destination.Children = append(destination.Children, moved)
190 case Stack:204 case Stack:
191 destination.Children = append(destination.Children, moved)205 destination.Children = append(destination.Children, moved)
@@ -239,6 +253,7 @@ func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) {
239 }253 }
240 254
241 root.Children[columnIndex] = remaining255 root.Children[columnIndex] = remaining
256+ moved.Weight = fairShareWeight(root.Children)
242 insertionIndex := columnIndex257 insertionIndex := columnIndex
243 if dir == Right {258 if dir == Right {
244 insertionIndex++259 insertionIndex++
M internal/usecase/layout/consume_expel_test.go +4 -4
@@ -43,7 +43,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) {
4343 want: &Tree{Root: verticalNodes(
4444 consumeExpelWeightedLeaf("a", 6),
4545 consumeExpelWeightedLeaf("b", 5),
46- NewLeaf("c"),
46+ consumeExpelWeightedLeaf("c", 5.5),
4747 ), Focus: "c"},
4848 wantChange: true,
4949 },
@@ -55,7 +55,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) {
5555 want: &Tree{Root: verticalNodes(
5656 consumeExpelWeightedLeaf("b", 6),
5757 consumeExpelWeightedLeaf("c", 5),
58- NewLeaf("a"),
58+ consumeExpelWeightedLeaf("a", 5.5),
5959 ), Focus: "a"},
6060 wantChange: true,
6161 },
@@ -97,7 +97,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) {
9797 target: "b",
9898 dir: Left,
9999 want: &Tree{Root: horizontal(
100- NewLeaf("b"),
100+ consumeExpelWeightedLeaf("b", 50),
101101 consumeExpelWeightedNode(vertical("a", "c"), 50),
102102 consumeExpelWeightedLeaf("d", 50),
103103 ), Focus: "b"},
@@ -110,7 +110,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) {
110110 dir: Right,
111111 want: &Tree{Root: horizontal(
112112 consumeExpelWeightedNode(vertical("a", "c"), 50),
113- NewLeaf("b"),
113+ consumeExpelWeightedLeaf("b", 50),
114114 consumeExpelWeightedLeaf("d", 50),
115115 ), Focus: "b"},
116116 wantChange: true,
@@ -43,7 +43,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) {
43 want: &Tree{Root: verticalNodes(43 want: &Tree{Root: verticalNodes(
44 consumeExpelWeightedLeaf("a", 6),44 consumeExpelWeightedLeaf("a", 6),
45 consumeExpelWeightedLeaf("b", 5),45 consumeExpelWeightedLeaf("b", 5),
46- NewLeaf("c"),46+ consumeExpelWeightedLeaf("c", 5.5),
47 ), Focus: "c"},47 ), Focus: "c"},
48 wantChange: true,48 wantChange: true,
49 },49 },
@@ -55,7 +55,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) {
55 want: &Tree{Root: verticalNodes(55 want: &Tree{Root: verticalNodes(
56 consumeExpelWeightedLeaf("b", 6),56 consumeExpelWeightedLeaf("b", 6),
57 consumeExpelWeightedLeaf("c", 5),57 consumeExpelWeightedLeaf("c", 5),
58- NewLeaf("a"),58+ consumeExpelWeightedLeaf("a", 5.5),
59 ), Focus: "a"},59 ), Focus: "a"},
60 wantChange: true,60 wantChange: true,
61 },61 },
@@ -97,7 +97,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) {
97 target: "b",97 target: "b",
98 dir: Left,98 dir: Left,
99 want: &Tree{Root: horizontal(99 want: &Tree{Root: horizontal(
100- NewLeaf("b"),100+ consumeExpelWeightedLeaf("b", 50),
101 consumeExpelWeightedNode(vertical("a", "c"), 50),101 consumeExpelWeightedNode(vertical("a", "c"), 50),
102 consumeExpelWeightedLeaf("d", 50),102 consumeExpelWeightedLeaf("d", 50),
103 ), Focus: "b"},103 ), Focus: "b"},
@@ -110,7 +110,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) {
110 dir: Right,110 dir: Right,
111 want: &Tree{Root: horizontal(111 want: &Tree{Root: horizontal(
112 consumeExpelWeightedNode(vertical("a", "c"), 50),112 consumeExpelWeightedNode(vertical("a", "c"), 50),
113- NewLeaf("b"),113+ consumeExpelWeightedLeaf("b", 50),
114 consumeExpelWeightedLeaf("d", 50),114 consumeExpelWeightedLeaf("d", 50),
115 ), Focus: "b"},115 ), Focus: "b"},
116 wantChange: true,116 wantChange: true,
M internal/usecase/layout/weights_test.go +26 -8
@@ -335,7 +335,7 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
335335 check func(*testing.T, *Tree, []Placement, []Placement)
336336 }{
337337 {
338- name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight",
338+ name: "horizontal insertion preserves normalized sibling shares and gives expelled column fair-share weight",
339339 tree: &Tree{Root: weightedNode(horizontal(
340340 weightedNode(vertical("a", "b"), 3),
341341 weightedLeaf("c", 1),
@@ -346,13 +346,13 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
346346 check: func(t *testing.T, tree *Tree, before, after []Placement) {
347347 require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before))
348348 require.Equal(t, 11.0, tree.Root.Weight)
349- require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root))
349+ require.Equal(t, []float64{151, 100.5, 50}, childWeights(tree.Root))
350350 require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
351- require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after))
351+ require.Equal(t, map[PaneID]int{"a": 100, "b": 67, "c": 33}, placementWidths(after))
352352 },
353353 },
354354 {
355- name: "vertical consume preserves solved member shares and gives moved pane default weight",
355+ name: "vertical consume preserves solved member shares and gives moved pane fair-share weight",
356356 tree: &Tree{Root: weightedNode(horizontal(
357357 weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2),
358358 weightedLeaf("c", 1),
@@ -364,8 +364,8 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
364364 require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before))
365365 require.Equal(t, Vertical, tree.Root.Dir)
366366 require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share")
367- require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root))
368- require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after))
367+ require.Equal(t, []float64{14, 5, 9.5}, childWeights(tree.Root))
368+ require.Equal(t, map[PaneID]int{"a": 9, "b": 3, "c": 6}, placementHeights(after))
369369 },
370370 },
371371 {
@@ -426,8 +426,12 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
426426 require.Equal(t, Leaf, tree.Root.Children[2].Kind)
427427 require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf)
428428 require.Equal(t, 61.0, tree.Root.Children[2].Weight)
429- require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root))
430- require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after))
429+ weights := childWeights(tree.Root)
430+ require.Equal(t, 20.0, weights[0])
431+ require.InDelta(t, 101.0/3.0, weights[1], 1e-9)
432+ require.Equal(t, 61.0, weights[2])
433+ require.Equal(t, 20.0, weights[3])
434+ require.Equal(t, map[PaneID]int{"x": 20, "a": 21, "b": 39, "y": 20}, placementWidths(after))
431435 },
432436 },
433437 {
@@ -443,6 +447,20 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
443447 require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after))
444448 },
445449 },
450+ {
451+ name: "expel from equal columns yields three homogeneous columns",
452+ tree: &Tree{Root: horizontal(
453+ NewLeaf("a"),
454+ verticalNodes(NewLeaf("b"), NewLeaf("c")),
455+ ), Focus: "c"},
456+ target: "c",
457+ dir: Right,
458+ area: domain.Rect{Width: 122, Height: 10},
459+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
460+ require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
461+ require.Equal(t, map[PaneID]int{"a": 40, "b": 40, "c": 40}, placementWidths(after))
462+ },
463+ },
446464 }
447465
448466 for _, tt := range tests {
@@ -335,7 +335,7 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
335 check func(*testing.T, *Tree, []Placement, []Placement)335 check func(*testing.T, *Tree, []Placement, []Placement)
336 }{336 }{
337 {337 {
338- name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight",338+ name: "horizontal insertion preserves normalized sibling shares and gives expelled column fair-share weight",
339 tree: &Tree{Root: weightedNode(horizontal(339 tree: &Tree{Root: weightedNode(horizontal(
340 weightedNode(vertical("a", "b"), 3),340 weightedNode(vertical("a", "b"), 3),
341 weightedLeaf("c", 1),341 weightedLeaf("c", 1),
@@ -346,13 +346,13 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
346 check: func(t *testing.T, tree *Tree, before, after []Placement) {346 check: func(t *testing.T, tree *Tree, before, after []Placement) {
347 require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before))347 require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before))
348 require.Equal(t, 11.0, tree.Root.Weight)348 require.Equal(t, 11.0, tree.Root.Weight)
349- require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root))349+ require.Equal(t, []float64{151, 100.5, 50}, childWeights(tree.Root))
350 require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))350 require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
351- require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after))351+ require.Equal(t, map[PaneID]int{"a": 100, "b": 67, "c": 33}, placementWidths(after))
352 },352 },
353 },353 },
354 {354 {
355- name: "vertical consume preserves solved member shares and gives moved pane default weight",355+ name: "vertical consume preserves solved member shares and gives moved pane fair-share weight",
356 tree: &Tree{Root: weightedNode(horizontal(356 tree: &Tree{Root: weightedNode(horizontal(
357 weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2),357 weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2),
358 weightedLeaf("c", 1),358 weightedLeaf("c", 1),
@@ -364,8 +364,8 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
364 require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before))364 require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before))
365 require.Equal(t, Vertical, tree.Root.Dir)365 require.Equal(t, Vertical, tree.Root.Dir)
366 require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share")366 require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share")
367- require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root))367+ require.Equal(t, []float64{14, 5, 9.5}, childWeights(tree.Root))
368- require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after))368+ require.Equal(t, map[PaneID]int{"a": 9, "b": 3, "c": 6}, placementHeights(after))
369 },369 },
370 },370 },
371 {371 {
@@ -426,8 +426,12 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
426 require.Equal(t, Leaf, tree.Root.Children[2].Kind)426 require.Equal(t, Leaf, tree.Root.Children[2].Kind)
427 require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf)427 require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf)
428 require.Equal(t, 61.0, tree.Root.Children[2].Weight)428 require.Equal(t, 61.0, tree.Root.Children[2].Weight)
429- require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root))429+ weights := childWeights(tree.Root)
430- require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after))430+ require.Equal(t, 20.0, weights[0])
431+ require.InDelta(t, 101.0/3.0, weights[1], 1e-9)
432+ require.Equal(t, 61.0, weights[2])
433+ require.Equal(t, 20.0, weights[3])
434+ require.Equal(t, map[PaneID]int{"x": 20, "a": 21, "b": 39, "y": 20}, placementWidths(after))
431 },435 },
432 },436 },
433 {437 {
@@ -443,6 +447,20 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
443 require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after))447 require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after))
444 },448 },
445 },449 },
450+ {
451+ name: "expel from equal columns yields three homogeneous columns",
452+ tree: &Tree{Root: horizontal(
453+ NewLeaf("a"),
454+ verticalNodes(NewLeaf("b"), NewLeaf("c")),
455+ ), Focus: "c"},
456+ target: "c",
457+ dir: Right,
458+ area: domain.Rect{Width: 122, Height: 10},
459+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
460+ require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
461+ require.Equal(t, map[PaneID]int{"a": 40, "b": 40, "c": 40}, placementWidths(after))
462+ },
463+ },
446 }464 }
447 465
448 for _, tt := range tests {466 for _, tt := range tests {

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 12

Tags 11

↑↓ moveenter openesc close