Merge pull request #146 from bnema/fix/split-expel-resize
fix(layout): give moved pane its fair share on consume/expel
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) { | |||
| 162 | 162 | 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 | 178 | func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) { | |
| 166 | 179 | root := t.Root | |
| 167 | 180 | destinationIndex := sourceIndex - 1 | |
| @@ -186,6 +199,7 @@ func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain | |||
| 186 | 199 | } | |
| 187 | 200 | case Split: | |
| 188 | 201 | normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex]) | |
| 202 | + | moved.Weight = fairShareWeight(destination.Children) | |
| 189 | 203 | destination.Children = append(destination.Children, moved) | |
| 190 | 204 | case Stack: | |
| 191 | 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 | 255 | root.Children[columnIndex] = remaining | |
| 256 | + | moved.Weight = fairShareWeight(root.Children) | |
| 242 | 257 | insertionIndex := columnIndex | |
| 243 | 258 | if dir == Right { | |
| 244 | 259 | insertionIndex++ | |
| @@ -162,6 +162,19 @@ func findColumnMember(columns []*Node, target PaneID) (int, int, bool) { | |||||
| 162 | return 0, 0, false | 162 | 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.Root | 179 | root := t.Root | ||
| 167 | destinationIndex := sourceIndex - 1 | 180 | 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] = remaining | 255 | root.Children[columnIndex] = remaining | ||
| 256 | + | moved.Weight = fairShareWeight(root.Children) | |||
| 242 | insertionIndex := columnIndex | 257 | 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) { | |||
| 43 | 43 | want: &Tree{Root: verticalNodes( | |
| 44 | 44 | consumeExpelWeightedLeaf("a", 6), | |
| 45 | 45 | consumeExpelWeightedLeaf("b", 5), | |
| 46 | - | NewLeaf("c"), | |
| 46 | + | consumeExpelWeightedLeaf("c", 5.5), | |
| 47 | 47 | ), Focus: "c"}, | |
| 48 | 48 | wantChange: true, | |
| 49 | 49 | }, | |
| @@ -55,7 +55,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) { | |||
| 55 | 55 | want: &Tree{Root: verticalNodes( | |
| 56 | 56 | consumeExpelWeightedLeaf("b", 6), | |
| 57 | 57 | consumeExpelWeightedLeaf("c", 5), | |
| 58 | - | NewLeaf("a"), | |
| 58 | + | consumeExpelWeightedLeaf("a", 5.5), | |
| 59 | 59 | ), Focus: "a"}, | |
| 60 | 60 | wantChange: true, | |
| 61 | 61 | }, | |
| @@ -97,7 +97,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) { | |||
| 97 | 97 | target: "b", | |
| 98 | 98 | dir: Left, | |
| 99 | 99 | want: &Tree{Root: horizontal( | |
| 100 | - | NewLeaf("b"), | |
| 100 | + | consumeExpelWeightedLeaf("b", 50), | |
| 101 | 101 | consumeExpelWeightedNode(vertical("a", "c"), 50), | |
| 102 | 102 | consumeExpelWeightedLeaf("d", 50), | |
| 103 | 103 | ), Focus: "b"}, | |
| @@ -110,7 +110,7 @@ func TestConsumeOrExpelPaneTransitions(t *testing.T) { | |||
| 110 | 110 | dir: Right, | |
| 111 | 111 | want: &Tree{Root: horizontal( | |
| 112 | 112 | consumeExpelWeightedNode(vertical("a", "c"), 50), | |
| 113 | - | NewLeaf("b"), | |
| 113 | + | consumeExpelWeightedLeaf("b", 50), | |
| 114 | 114 | consumeExpelWeightedLeaf("d", 50), | |
| 115 | 115 | ), Focus: "b"}, | |
| 116 | 116 | 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) { | |||
| 335 | 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 | 339 | tree: &Tree{Root: weightedNode(horizontal( | |
| 340 | 340 | weightedNode(vertical("a", "b"), 3), | |
| 341 | 341 | weightedLeaf("c", 1), | |
| @@ -346,13 +346,13 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) { | |||
| 346 | 346 | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |
| 347 | 347 | require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before)) | |
| 348 | 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 | 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 | 356 | tree: &Tree{Root: weightedNode(horizontal( | |
| 357 | 357 | weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2), | |
| 358 | 358 | weightedLeaf("c", 1), | |
| @@ -364,8 +364,8 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) { | |||
| 364 | 364 | require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before)) | |
| 365 | 365 | require.Equal(t, Vertical, tree.Root.Dir) | |
| 366 | 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)) | |
| 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)) | |
| 369 | 369 | }, | |
| 370 | 370 | }, | |
| 371 | 371 | { | |
| @@ -426,8 +426,12 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) { | |||
| 426 | 426 | require.Equal(t, Leaf, tree.Root.Children[2].Kind) | |
| 427 | 427 | require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf) | |
| 428 | 428 | 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)) | |
| 431 | 435 | }, | |
| 432 | 436 | }, | |
| 433 | 437 | { | |
| @@ -443,6 +447,20 @@ func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) { | |||
| 443 | 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 | 466 | 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 { | ||