detached 64098cd

fix(layout): halve the targeted pane on repeated splits

commit
64098cd08f645b63131d67adb8f55e74e8f65ee1
author
brice · · 2026-08-13 02:11 UTC
parent
99dc266
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +66 -3
M internal/usecase/layout/layout_test.go +56 -0
@@ -43,6 +43,62 @@ func TestSolveSplitGeometry(t *testing.T) {
4343 }
4444 }
4545
46+func TestRepeatedSplitHalvesFocusedPane(t *testing.T) {
47+ t.Parallel()
48+
49+ tests := []struct {
50+ name string
51+ dir Direction
52+ area domain.Rect
53+ first map[PaneID]domain.Rect
54+ second map[PaneID]domain.Rect
55+ }{
56+ {
57+ name: "horizontal",
58+ dir: Right,
59+ area: domain.Rect{Width: 123, Height: 5},
60+ first: map[PaneID]domain.Rect{
61+ "a": {Width: 61, Height: 5},
62+ "b": {X: 62, Width: 61, Height: 5},
63+ },
64+ second: map[PaneID]domain.Rect{
65+ "a": {Width: 61, Height: 5},
66+ "b": {X: 62, Width: 30, Height: 5},
67+ "c": {X: 93, Width: 30, Height: 5},
68+ },
69+ },
70+ {
71+ name: "vertical",
72+ dir: Down,
73+ area: domain.Rect{Width: 40, Height: 15},
74+ first: map[PaneID]domain.Rect{
75+ "a": {Width: 40, Height: 7},
76+ "b": {Y: 8, Width: 40, Height: 7},
77+ },
78+ second: map[PaneID]domain.Rect{
79+ "a": {Width: 40, Height: 7},
80+ "b": {Y: 8, Width: 40, Height: 3},
81+ "c": {Y: 12, Width: 40, Height: 3},
82+ },
83+ },
84+ }
85+
86+ for _, tt := range tests {
87+ t.Run(tt.name, func(t *testing.T) {
88+ tr := NewTree("a")
89+ require.NoError(t, tr.Split("a", tt.dir, true, "b", tt.area))
90+ first, ok := Solve(tr.Root, tt.area)
91+ require.True(t, ok)
92+ require.Equal(t, tt.first, contents(first))
93+
94+ require.NoError(t, tr.Split("b", tt.dir, true, "c", tt.area))
95+ second, ok := Solve(tr.Root, tt.area)
96+ require.True(t, ok)
97+ require.Equal(t, tt.second, contents(second))
98+ })
99+ }
100+}
101+
46102 func TestSolveNestedTrees(t *testing.T) {
47103 t.Parallel()
48104 root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
@@ -43,6 +43,62 @@ func TestSolveSplitGeometry(t *testing.T) {
43 }43 }
44 }44 }
45 45
46+func TestRepeatedSplitHalvesFocusedPane(t *testing.T) {
47+ t.Parallel()
48+
49+ tests := []struct {
50+ name string
51+ dir Direction
52+ area domain.Rect
53+ first map[PaneID]domain.Rect
54+ second map[PaneID]domain.Rect
55+ }{
56+ {
57+ name: "horizontal",
58+ dir: Right,
59+ area: domain.Rect{Width: 123, Height: 5},
60+ first: map[PaneID]domain.Rect{
61+ "a": {Width: 61, Height: 5},
62+ "b": {X: 62, Width: 61, Height: 5},
63+ },
64+ second: map[PaneID]domain.Rect{
65+ "a": {Width: 61, Height: 5},
66+ "b": {X: 62, Width: 30, Height: 5},
67+ "c": {X: 93, Width: 30, Height: 5},
68+ },
69+ },
70+ {
71+ name: "vertical",
72+ dir: Down,
73+ area: domain.Rect{Width: 40, Height: 15},
74+ first: map[PaneID]domain.Rect{
75+ "a": {Width: 40, Height: 7},
76+ "b": {Y: 8, Width: 40, Height: 7},
77+ },
78+ second: map[PaneID]domain.Rect{
79+ "a": {Width: 40, Height: 7},
80+ "b": {Y: 8, Width: 40, Height: 3},
81+ "c": {Y: 12, Width: 40, Height: 3},
82+ },
83+ },
84+ }
85+
86+ for _, tt := range tests {
87+ t.Run(tt.name, func(t *testing.T) {
88+ tr := NewTree("a")
89+ require.NoError(t, tr.Split("a", tt.dir, true, "b", tt.area))
90+ first, ok := Solve(tr.Root, tt.area)
91+ require.True(t, ok)
92+ require.Equal(t, tt.first, contents(first))
93+
94+ require.NoError(t, tr.Split("b", tt.dir, true, "c", tt.area))
95+ second, ok := Solve(tr.Root, tt.area)
96+ require.True(t, ok)
97+ require.Equal(t, tt.second, contents(second))
98+ })
99+ }
100+}
101+
46 func TestSolveNestedTrees(t *testing.T) {102 func TestSolveNestedTrees(t *testing.T) {
47 t.Parallel()103 t.Parallel()
48 root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{104 root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
M internal/usecase/layout/ops.go +7 -0
@@ -224,6 +224,13 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID
224224 newLeaf := NewLeaf(newID)
225225 if n.Kind == Split && n.Dir == axis {
226226 normalizeChildWeightsFromArea(n, area)
227+ targetExtent := childAreas[i].Width
228+ if axis == Vertical {
229+ targetExtent = childAreas[i].Height
230+ }
231+ firstExtent := (targetExtent - 1) / 2
232+ child.Weight = float64(firstExtent)
233+ newLeaf.Weight = float64(targetExtent - 1 - firstExtent)
227234 at := i
228235 if after {
229236 at++
@@ -224,6 +224,13 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID
224 newLeaf := NewLeaf(newID)224 newLeaf := NewLeaf(newID)
225 if n.Kind == Split && n.Dir == axis {225 if n.Kind == Split && n.Dir == axis {
226 normalizeChildWeightsFromArea(n, area)226 normalizeChildWeightsFromArea(n, area)
227+ targetExtent := childAreas[i].Width
228+ if axis == Vertical {
229+ targetExtent = childAreas[i].Height
230+ }
231+ firstExtent := (targetExtent - 1) / 2
232+ child.Weight = float64(firstExtent)
233+ newLeaf.Weight = float64(targetExtent - 1 - firstExtent)
227 at := i234 at := i
228 if after {235 if after {
229 at++236 at++
M internal/usecase/layout/weights_test.go +3 -3
@@ -159,7 +159,7 @@ func TestWeightedSplitRecursiveMinima(t *testing.T) {
159159 }
160160 }
161161
162-func TestWeightSameAxisSplitNormalizesSolvedExtentsBeforeInsertion(t *testing.T) {
162+func TestWeightSameAxisSplitHalvesTargetSolvedExtent(t *testing.T) {
163163 t.Parallel()
164164
165165 tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}
@@ -167,8 +167,8 @@ func TestWeightSameAxisSplitNormalizesSolvedExtentsBeforeInsertion(t *testing.T)
167167 require.NoError(t, tr.Split("b", Right, true, "c", area))
168168
169169 require.Equal(t, 54.0, tr.Root.Children[0].Weight)
170- require.Equal(t, 27.0, tr.Root.Children[1].Weight)
171- require.Zero(t, tr.Root.Children[2].Weight)
170+ require.Equal(t, 13.0, tr.Root.Children[1].Weight)
171+ require.Equal(t, 13.0, tr.Root.Children[2].Weight)
172172 placements, ok := Solve(tr.Root, area)
173173 require.True(t, ok)
174174 require.Equal(t, map[PaneID]int{"a": 40, "b": 20, "c": 20}, placementWidths(placements))
@@ -159,7 +159,7 @@ func TestWeightedSplitRecursiveMinima(t *testing.T) {
159 }159 }
160 }160 }
161 161
162-func TestWeightSameAxisSplitNormalizesSolvedExtentsBeforeInsertion(t *testing.T) {162+func TestWeightSameAxisSplitHalvesTargetSolvedExtent(t *testing.T) {
163 t.Parallel()163 t.Parallel()
164 164
165 tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}165 tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"}
@@ -167,8 +167,8 @@ func TestWeightSameAxisSplitNormalizesSolvedExtentsBeforeInsertion(t *testing.T)
167 require.NoError(t, tr.Split("b", Right, true, "c", area))167 require.NoError(t, tr.Split("b", Right, true, "c", area))
168 168
169 require.Equal(t, 54.0, tr.Root.Children[0].Weight)169 require.Equal(t, 54.0, tr.Root.Children[0].Weight)
170- require.Equal(t, 27.0, tr.Root.Children[1].Weight)170+ require.Equal(t, 13.0, tr.Root.Children[1].Weight)
171- require.Zero(t, tr.Root.Children[2].Weight)171+ require.Equal(t, 13.0, tr.Root.Children[2].Weight)
172 placements, ok := Solve(tr.Root, area)172 placements, ok := Solve(tr.Root, area)
173 require.True(t, ok)173 require.True(t, ok)
174 require.Equal(t, map[PaneID]int{"a": 40, "b": 20, "c": 20}, placementWidths(placements))174 require.Equal(t, map[PaneID]int{"a": 40, "b": 20, "c": 20}, placementWidths(placements))

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