feat(layout): resize and equalize focused panes
- commit
- 8bef307c8823d8e75a56b02f1a3dc619a1174f3b
- author
- brice · · 2026-07-25 04:17 UTC
- parent
- cc7049a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +500 -0
M internal/usecase/layout/ops.go +180 -0
| @@ -11,9 +11,189 @@ var ( | |||
| 11 | 11 | ErrNotFound = errors.New("pane not found") | |
| 12 | 12 | ErrTooSmall = errors.New("layout too small") | |
| 13 | 13 | ErrNoPane = errors.New("no pane in direction") | |
| 14 | + | ErrNotInSplit = errors.New("pane is not in a split") | |
| 14 | 15 | ErrNotToggleable = errors.New("layout node is not toggleable") | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 18 | + | // ResizeFocus changes the focused pane subtree's share in the nearest split on | |
| 19 | + | // axis. It commits only after the candidate layout solves successfully. | |
| 20 | + | func (t *Tree) ResizeFocus(axis Axis, delta int, area domain.Rect) error { | |
| 21 | + | candidate := t.clone() | |
| 22 | + | if candidate == nil || candidate.Root == nil { | |
| 23 | + | return ErrNotInSplit | |
| 24 | + | } | |
| 25 | + | dir, ok := splitDirForAxis(axis) | |
| 26 | + | if !ok || !hasMatchingSplit(candidate.Root, candidate.Focus, dir) { | |
| 27 | + | return ErrNotInSplit | |
| 28 | + | } | |
| 29 | + | if _, solved := Solve(candidate.Root, area); !solved { | |
| 30 | + | return ErrTooSmall | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | splitNode, splitArea, targetIndex, found := nearestMatchingSplit(candidate.Root, candidate.Focus, dir, area) | |
| 34 | + | if !found { | |
| 35 | + | return ErrTooSmall | |
| 36 | + | } | |
| 37 | + | rects, solved := splitChildRects(splitNode, splitArea) | |
| 38 | + | if !solved { | |
| 39 | + | return ErrTooSmall | |
| 40 | + | } | |
| 41 | + | extents := make([]int, len(rects)) | |
| 42 | + | minimums := make([]int, len(rects)) | |
| 43 | + | for i, rect := range rects { | |
| 44 | + | extents[i] = rect.Width | |
| 45 | + | if dir == Vertical { | |
| 46 | + | extents[i] = rect.Height | |
| 47 | + | } | |
| 48 | + | minimum, valid := minimumExtent(splitNode.Children[i], dir) | |
| 49 | + | if !valid { | |
| 50 | + | return ErrTooSmall | |
| 51 | + | } | |
| 52 | + | minimums[i] = minimum | |
| 53 | + | splitNode.Children[i].Weight = float64(extents[i]) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | requested := delta | |
| 57 | + | if requested < 0 { | |
| 58 | + | requested = -requested | |
| 59 | + | } | |
| 60 | + | if requested == 0 { | |
| 61 | + | return ErrTooSmall | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | neighbor := -1 | |
| 65 | + | transfer := 0 | |
| 66 | + | if delta > 0 { | |
| 67 | + | for _, index := range preferredNeighbors(targetIndex, len(extents)) { | |
| 68 | + | available := extents[index] - minimums[index] | |
| 69 | + | if available <= 0 { | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | neighbor = index | |
| 73 | + | transfer = min(requested, available) | |
| 74 | + | break | |
| 75 | + | } | |
| 76 | + | } else { | |
| 77 | + | neighbors := preferredNeighbors(targetIndex, len(extents)) | |
| 78 | + | available := extents[targetIndex] - minimums[targetIndex] | |
| 79 | + | if len(neighbors) > 0 && available > 0 { | |
| 80 | + | neighbor = neighbors[0] | |
| 81 | + | transfer = min(requested, available) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | if neighbor < 0 || transfer == 0 { | |
| 85 | + | return ErrTooSmall | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | if delta > 0 { | |
| 89 | + | extents[targetIndex] += transfer | |
| 90 | + | extents[neighbor] -= transfer | |
| 91 | + | } else { | |
| 92 | + | extents[targetIndex] -= transfer | |
| 93 | + | extents[neighbor] += transfer | |
| 94 | + | } | |
| 95 | + | splitNode.Children[targetIndex].Weight = float64(extents[targetIndex]) | |
| 96 | + | splitNode.Children[neighbor].Weight = float64(extents[neighbor]) | |
| 97 | + | if _, solved := Solve(candidate.Root, area); !solved { | |
| 98 | + | return ErrTooSmall | |
| 99 | + | } | |
| 100 | + | *t = *candidate | |
| 101 | + | return nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Equalize resets every split's direct child shares to their defaults. The | |
| 105 | + | // candidate must remain solvable before it is committed. | |
| 106 | + | func (t *Tree) Equalize(area domain.Rect) error { | |
| 107 | + | candidate := t.clone() | |
| 108 | + | if candidate == nil || candidate.Root == nil { | |
| 109 | + | return ErrTooSmall | |
| 110 | + | } | |
| 111 | + | clearSplitChildWeights(candidate.Root) | |
| 112 | + | if _, ok := Solve(candidate.Root, area); !ok { | |
| 113 | + | return ErrTooSmall | |
| 114 | + | } | |
| 115 | + | *t = *candidate | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // CanResize reports whether the focused pane is contained by any split. | |
| 120 | + | func (t *Tree) CanResize() bool { | |
| 121 | + | if t == nil || t.Root == nil { | |
| 122 | + | return false | |
| 123 | + | } | |
| 124 | + | return hasMatchingSplit(t.Root, t.Focus, Horizontal) || hasMatchingSplit(t.Root, t.Focus, Vertical) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func splitDirForAxis(axis Axis) (SplitDir, bool) { | |
| 128 | + | switch axis { | |
| 129 | + | case Width: | |
| 130 | + | return Horizontal, true | |
| 131 | + | case Height: | |
| 132 | + | return Vertical, true | |
| 133 | + | default: | |
| 134 | + | return Horizontal, false | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func hasMatchingSplit(n *Node, focus PaneID, dir SplitDir) bool { | |
| 139 | + | if n == nil || !containsLeaf(n, focus) { | |
| 140 | + | return false | |
| 141 | + | } | |
| 142 | + | for _, child := range n.Children { | |
| 143 | + | if containsLeaf(child, focus) && hasMatchingSplit(child, focus, dir) { | |
| 144 | + | return true | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | return n.Kind == Split && n.Dir == dir | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func nearestMatchingSplit(n *Node, focus PaneID, dir SplitDir, area domain.Rect) (*Node, domain.Rect, int, bool) { | |
| 151 | + | if n == nil || !containsLeaf(n, focus) { | |
| 152 | + | return nil, domain.Rect{}, 0, false | |
| 153 | + | } | |
| 154 | + | childAreas := nodeChildAreas(n, area) | |
| 155 | + | for i, child := range n.Children { | |
| 156 | + | if !containsLeaf(child, focus) { | |
| 157 | + | continue | |
| 158 | + | } | |
| 159 | + | childArea := area | |
| 160 | + | if i < len(childAreas) { | |
| 161 | + | childArea = childAreas[i] | |
| 162 | + | } | |
| 163 | + | if match, matchArea, index, ok := nearestMatchingSplit(child, focus, dir, childArea); ok { | |
| 164 | + | return match, matchArea, index, true | |
| 165 | + | } | |
| 166 | + | if n.Kind == Split && n.Dir == dir { | |
| 167 | + | return n, area, i, true | |
| 168 | + | } | |
| 169 | + | return nil, domain.Rect{}, 0, false | |
| 170 | + | } | |
| 171 | + | return nil, domain.Rect{}, 0, false | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func preferredNeighbors(target, count int) []int { | |
| 175 | + | neighbors := make([]int, 0, 2) | |
| 176 | + | if target+1 < count { | |
| 177 | + | neighbors = append(neighbors, target+1) | |
| 178 | + | } | |
| 179 | + | if target > 0 { | |
| 180 | + | neighbors = append(neighbors, target-1) | |
| 181 | + | } | |
| 182 | + | return neighbors | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func clearSplitChildWeights(n *Node) { | |
| 186 | + | if n == nil { | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | if n.Kind == Split { | |
| 190 | + | clearChildWeights(n) | |
| 191 | + | } | |
| 192 | + | for _, child := range n.Children { | |
| 193 | + | clearSplitChildWeights(child) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 17 | 197 | // Split adds newID next to target. The after flag controls whether the new pane | |
| 18 | 198 | // is placed after target on the chosen axis. | |
| 19 | 199 | func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error { | |
| @@ -11,9 +11,189 @@ var ( | |||||
| 11 | ErrNotFound = errors.New("pane not found") | 11 | ErrNotFound = errors.New("pane not found") | ||
| 12 | ErrTooSmall = errors.New("layout too small") | 12 | ErrTooSmall = errors.New("layout too small") | ||
| 13 | ErrNoPane = errors.New("no pane in direction") | 13 | ErrNoPane = errors.New("no pane in direction") | ||
| 14 | + | ErrNotInSplit = errors.New("pane is not in a split") | |||
| 14 | ErrNotToggleable = errors.New("layout node is not toggleable") | 15 | ErrNotToggleable = errors.New("layout node is not toggleable") | ||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 18 | + | // ResizeFocus changes the focused pane subtree's share in the nearest split on | |||
| 19 | + | // axis. It commits only after the candidate layout solves successfully. | |||
| 20 | + | func (t *Tree) ResizeFocus(axis Axis, delta int, area domain.Rect) error { | |||
| 21 | + | candidate := t.clone() | |||
| 22 | + | if candidate == nil || candidate.Root == nil { | |||
| 23 | + | return ErrNotInSplit | |||
| 24 | + | } | |||
| 25 | + | dir, ok := splitDirForAxis(axis) | |||
| 26 | + | if !ok || !hasMatchingSplit(candidate.Root, candidate.Focus, dir) { | |||
| 27 | + | return ErrNotInSplit | |||
| 28 | + | } | |||
| 29 | + | if _, solved := Solve(candidate.Root, area); !solved { | |||
| 30 | + | return ErrTooSmall | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | splitNode, splitArea, targetIndex, found := nearestMatchingSplit(candidate.Root, candidate.Focus, dir, area) | |||
| 34 | + | if !found { | |||
| 35 | + | return ErrTooSmall | |||
| 36 | + | } | |||
| 37 | + | rects, solved := splitChildRects(splitNode, splitArea) | |||
| 38 | + | if !solved { | |||
| 39 | + | return ErrTooSmall | |||
| 40 | + | } | |||
| 41 | + | extents := make([]int, len(rects)) | |||
| 42 | + | minimums := make([]int, len(rects)) | |||
| 43 | + | for i, rect := range rects { | |||
| 44 | + | extents[i] = rect.Width | |||
| 45 | + | if dir == Vertical { | |||
| 46 | + | extents[i] = rect.Height | |||
| 47 | + | } | |||
| 48 | + | minimum, valid := minimumExtent(splitNode.Children[i], dir) | |||
| 49 | + | if !valid { | |||
| 50 | + | return ErrTooSmall | |||
| 51 | + | } | |||
| 52 | + | minimums[i] = minimum | |||
| 53 | + | splitNode.Children[i].Weight = float64(extents[i]) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | requested := delta | |||
| 57 | + | if requested < 0 { | |||
| 58 | + | requested = -requested | |||
| 59 | + | } | |||
| 60 | + | if requested == 0 { | |||
| 61 | + | return ErrTooSmall | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | neighbor := -1 | |||
| 65 | + | transfer := 0 | |||
| 66 | + | if delta > 0 { | |||
| 67 | + | for _, index := range preferredNeighbors(targetIndex, len(extents)) { | |||
| 68 | + | available := extents[index] - minimums[index] | |||
| 69 | + | if available <= 0 { | |||
| 70 | + | continue | |||
| 71 | + | } | |||
| 72 | + | neighbor = index | |||
| 73 | + | transfer = min(requested, available) | |||
| 74 | + | break | |||
| 75 | + | } | |||
| 76 | + | } else { | |||
| 77 | + | neighbors := preferredNeighbors(targetIndex, len(extents)) | |||
| 78 | + | available := extents[targetIndex] - minimums[targetIndex] | |||
| 79 | + | if len(neighbors) > 0 && available > 0 { | |||
| 80 | + | neighbor = neighbors[0] | |||
| 81 | + | transfer = min(requested, available) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | if neighbor < 0 || transfer == 0 { | |||
| 85 | + | return ErrTooSmall | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | if delta > 0 { | |||
| 89 | + | extents[targetIndex] += transfer | |||
| 90 | + | extents[neighbor] -= transfer | |||
| 91 | + | } else { | |||
| 92 | + | extents[targetIndex] -= transfer | |||
| 93 | + | extents[neighbor] += transfer | |||
| 94 | + | } | |||
| 95 | + | splitNode.Children[targetIndex].Weight = float64(extents[targetIndex]) | |||
| 96 | + | splitNode.Children[neighbor].Weight = float64(extents[neighbor]) | |||
| 97 | + | if _, solved := Solve(candidate.Root, area); !solved { | |||
| 98 | + | return ErrTooSmall | |||
| 99 | + | } | |||
| 100 | + | *t = *candidate | |||
| 101 | + | return nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Equalize resets every split's direct child shares to their defaults. The | |||
| 105 | + | // candidate must remain solvable before it is committed. | |||
| 106 | + | func (t *Tree) Equalize(area domain.Rect) error { | |||
| 107 | + | candidate := t.clone() | |||
| 108 | + | if candidate == nil || candidate.Root == nil { | |||
| 109 | + | return ErrTooSmall | |||
| 110 | + | } | |||
| 111 | + | clearSplitChildWeights(candidate.Root) | |||
| 112 | + | if _, ok := Solve(candidate.Root, area); !ok { | |||
| 113 | + | return ErrTooSmall | |||
| 114 | + | } | |||
| 115 | + | *t = *candidate | |||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // CanResize reports whether the focused pane is contained by any split. | |||
| 120 | + | func (t *Tree) CanResize() bool { | |||
| 121 | + | if t == nil || t.Root == nil { | |||
| 122 | + | return false | |||
| 123 | + | } | |||
| 124 | + | return hasMatchingSplit(t.Root, t.Focus, Horizontal) || hasMatchingSplit(t.Root, t.Focus, Vertical) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func splitDirForAxis(axis Axis) (SplitDir, bool) { | |||
| 128 | + | switch axis { | |||
| 129 | + | case Width: | |||
| 130 | + | return Horizontal, true | |||
| 131 | + | case Height: | |||
| 132 | + | return Vertical, true | |||
| 133 | + | default: | |||
| 134 | + | return Horizontal, false | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func hasMatchingSplit(n *Node, focus PaneID, dir SplitDir) bool { | |||
| 139 | + | if n == nil || !containsLeaf(n, focus) { | |||
| 140 | + | return false | |||
| 141 | + | } | |||
| 142 | + | for _, child := range n.Children { | |||
| 143 | + | if containsLeaf(child, focus) && hasMatchingSplit(child, focus, dir) { | |||
| 144 | + | return true | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | return n.Kind == Split && n.Dir == dir | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func nearestMatchingSplit(n *Node, focus PaneID, dir SplitDir, area domain.Rect) (*Node, domain.Rect, int, bool) { | |||
| 151 | + | if n == nil || !containsLeaf(n, focus) { | |||
| 152 | + | return nil, domain.Rect{}, 0, false | |||
| 153 | + | } | |||
| 154 | + | childAreas := nodeChildAreas(n, area) | |||
| 155 | + | for i, child := range n.Children { | |||
| 156 | + | if !containsLeaf(child, focus) { | |||
| 157 | + | continue | |||
| 158 | + | } | |||
| 159 | + | childArea := area | |||
| 160 | + | if i < len(childAreas) { | |||
| 161 | + | childArea = childAreas[i] | |||
| 162 | + | } | |||
| 163 | + | if match, matchArea, index, ok := nearestMatchingSplit(child, focus, dir, childArea); ok { | |||
| 164 | + | return match, matchArea, index, true | |||
| 165 | + | } | |||
| 166 | + | if n.Kind == Split && n.Dir == dir { | |||
| 167 | + | return n, area, i, true | |||
| 168 | + | } | |||
| 169 | + | return nil, domain.Rect{}, 0, false | |||
| 170 | + | } | |||
| 171 | + | return nil, domain.Rect{}, 0, false | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func preferredNeighbors(target, count int) []int { | |||
| 175 | + | neighbors := make([]int, 0, 2) | |||
| 176 | + | if target+1 < count { | |||
| 177 | + | neighbors = append(neighbors, target+1) | |||
| 178 | + | } | |||
| 179 | + | if target > 0 { | |||
| 180 | + | neighbors = append(neighbors, target-1) | |||
| 181 | + | } | |||
| 182 | + | return neighbors | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func clearSplitChildWeights(n *Node) { | |||
| 186 | + | if n == nil { | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | if n.Kind == Split { | |||
| 190 | + | clearChildWeights(n) | |||
| 191 | + | } | |||
| 192 | + | for _, child := range n.Children { | |||
| 193 | + | clearSplitChildWeights(child) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | ||||
| 17 | // Split adds newID next to target. The after flag controls whether the new pane | 197 | // Split adds newID next to target. The after flag controls whether the new pane | ||
| 18 | // is placed after target on the chosen axis. | 198 | // is placed after target on the chosen axis. | ||
| 19 | func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error { | 199 | func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error { | ||
A internal/usecase/layout/resize_test.go +312 -0
| @@ -0,0 +1,312 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResizeFocusTransfersSolvedExtent(t *testing.T) { | |
| 11 | + | t.Parallel() | |
| 12 | + | ||
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | root *Node | |
| 16 | + | focus PaneID | |
| 17 | + | axis Axis | |
| 18 | + | delta int | |
| 19 | + | area domain.Rect | |
| 20 | + | want map[PaneID]domain.Rect | |
| 21 | + | }{ | |
| 22 | + | { | |
| 23 | + | name: "grow width takes from right neighbor", | |
| 24 | + | root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), | |
| 25 | + | focus: "a", | |
| 26 | + | axis: Width, | |
| 27 | + | delta: 5, | |
| 28 | + | area: domain.Rect{Width: 51, Height: 2}, | |
| 29 | + | want: map[PaneID]domain.Rect{ | |
| 30 | + | "a": {Width: 30, Height: 2}, | |
| 31 | + | "b": {X: 31, Width: 20, Height: 2}, | |
| 32 | + | }, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "shrink width gives to right neighbor", | |
| 36 | + | root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)), | |
| 37 | + | focus: "a", | |
| 38 | + | axis: Width, | |
| 39 | + | delta: -5, | |
| 40 | + | area: domain.Rect{Width: 51, Height: 2}, | |
| 41 | + | want: map[PaneID]domain.Rect{ | |
| 42 | + | "a": {Width: 25, Height: 2}, | |
| 43 | + | "b": {X: 26, Width: 25, Height: 2}, | |
| 44 | + | }, | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | name: "grow height takes from bottom neighbor", | |
| 48 | + | root: split(Vertical, | |
| 49 | + | weightedLeaf("a", 4), weightedLeaf("b", 3), | |
| 50 | + | ), | |
| 51 | + | focus: "a", | |
| 52 | + | axis: Height, | |
| 53 | + | delta: 1, | |
| 54 | + | area: domain.Rect{Width: 20, Height: 8}, | |
| 55 | + | want: map[PaneID]domain.Rect{ | |
| 56 | + | "a": {Width: 20, Height: 5}, | |
| 57 | + | "b": {Y: 6, Width: 20, Height: 2}, | |
| 58 | + | }, | |
| 59 | + | }, | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | for _, tt := range tests { | |
| 63 | + | t.Run(tt.name, func(t *testing.T) { | |
| 64 | + | tr := &Tree{Root: tt.root, Focus: tt.focus} | |
| 65 | + | require.NoError(t, tr.ResizeFocus(tt.axis, tt.delta, tt.area)) | |
| 66 | + | placements, ok := Solve(tr.Root, tt.area) | |
| 67 | + | require.True(t, ok) | |
| 68 | + | require.Equal(t, tt.want, contents(placements)) | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestResizeFocusSelectsNearestTargetAndAdjacentSibling(t *testing.T) { | |
| 74 | + | t.Parallel() | |
| 75 | + | ||
| 76 | + | t.Run("nearest matching ancestor", func(t *testing.T) { | |
| 77 | + | inner := split(Horizontal, NewLeaf("a"), NewLeaf("b")) | |
| 78 | + | tr := &Tree{Root: split(Horizontal, weightedNode(inner, 2), weightedLeaf("c", 1)), Focus: "a"} | |
| 79 | + | area := domain.Rect{Width: 122, Height: 2} | |
| 80 | + | before, ok := Solve(tr.Root, area) | |
| 81 | + | require.True(t, ok) | |
| 82 | + | ||
| 83 | + | require.NoError(t, tr.ResizeFocus(Width, 5, area)) | |
| 84 | + | after, ok := Solve(tr.Root, area) | |
| 85 | + | require.True(t, ok) | |
| 86 | + | require.Equal(t, contents(before)["c"], contents(after)["c"]) | |
| 87 | + | require.Equal(t, contents(before)["a"].Width+5, contents(after)["a"].Width) | |
| 88 | + | require.Equal(t, contents(before)["b"].Width-5, contents(after)["b"].Width) | |
| 89 | + | }) | |
| 90 | + | ||
| 91 | + | t.Run("direct child subtree is resized as one target", func(t *testing.T) { | |
| 92 | + | subtree := split(Vertical, NewLeaf("a"), NewLeaf("d")) | |
| 93 | + | tr := &Tree{Root: split(Horizontal, subtree, NewLeaf("b")), Focus: "a"} | |
| 94 | + | area := domain.Rect{Width: 51, Height: 5} | |
| 95 | + | require.NoError(t, tr.ResizeFocus(Width, 5, area)) | |
| 96 | + | placements, ok := Solve(tr.Root, area) | |
| 97 | + | require.True(t, ok) | |
| 98 | + | require.Equal(t, 30, contents(placements)["a"].Width) | |
| 99 | + | require.Equal(t, 30, contents(placements)["d"].Width) | |
| 100 | + | require.Equal(t, 20, contents(placements)["b"].Width) | |
| 101 | + | }) | |
| 102 | + | ||
| 103 | + | t.Run("stack is resized as one target", func(t *testing.T) { | |
| 104 | + | stackNode := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("d")}, Expanded: "a"} | |
| 105 | + | tr := &Tree{Root: split(Horizontal, stackNode, NewLeaf("b")), Focus: "d"} | |
| 106 | + | area := domain.Rect{Width: 51, Height: 3} | |
| 107 | + | require.NoError(t, tr.ResizeFocus(Width, 5, area)) | |
| 108 | + | placements, ok := Solve(tr.Root, area) | |
| 109 | + | require.True(t, ok) | |
| 110 | + | require.Equal(t, 30, contents(placements)["a"].Width) | |
| 111 | + | var collapsedTitleWidth int | |
| 112 | + | for _, placement := range placements { | |
| 113 | + | if placement.ID == "d" { | |
| 114 | + | collapsedTitleWidth = placement.TitleBar.Width | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | require.Equal(t, 30, collapsedTitleWidth) | |
| 118 | + | require.Equal(t, 20, contents(placements)["b"].Width) | |
| 119 | + | }) | |
| 120 | + | ||
| 121 | + | t.Run("right donor at minimum falls back to left", func(t *testing.T) { | |
| 122 | + | tr := &Tree{Root: split(Horizontal, | |
| 123 | + | weightedLeaf("a", 40), weightedLeaf("b", 20), weightedLeaf("c", 20), | |
| 124 | + | ), Focus: "b"} | |
| 125 | + | area := domain.Rect{Width: 82, Height: 2} | |
| 126 | + | require.NoError(t, tr.ResizeFocus(Width, 5, area)) | |
| 127 | + | placements, ok := Solve(tr.Root, area) | |
| 128 | + | require.True(t, ok) | |
| 129 | + | require.Equal(t, map[PaneID]int{"a": 35, "b": 25, "c": 20}, placementWidths(placements)) | |
| 130 | + | }) | |
| 131 | + | ||
| 132 | + | t.Run("last target falls back to left neighbor", func(t *testing.T) { | |
| 133 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)), Focus: "b"} | |
| 134 | + | area := domain.Rect{Width: 51, Height: 2} | |
| 135 | + | require.NoError(t, tr.ResizeFocus(Width, 5, area)) | |
| 136 | + | placements, ok := Solve(tr.Root, area) | |
| 137 | + | require.True(t, ok) | |
| 138 | + | require.Equal(t, map[PaneID]int{"a": 25, "b": 25}, placementWidths(placements)) | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestResizeFocusMinimumAndTransactionalFailures(t *testing.T) { | |
| 143 | + | t.Parallel() | |
| 144 | + | ||
| 145 | + | tests := []struct { | |
| 146 | + | name string | |
| 147 | + | tree *Tree | |
| 148 | + | axis Axis | |
| 149 | + | delta int | |
| 150 | + | area domain.Rect | |
| 151 | + | err error | |
| 152 | + | want map[PaneID]int | |
| 153 | + | }{ | |
| 154 | + | { | |
| 155 | + | name: "partial growth uses every available donor cell", | |
| 156 | + | tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 20), weightedLeaf("b", 22)), Focus: "a"}, | |
| 157 | + | axis: Width, | |
| 158 | + | delta: 5, | |
| 159 | + | area: domain.Rect{Width: 43, Height: 2}, | |
| 160 | + | want: map[PaneID]int{"a": 22, "b": 20}, | |
| 161 | + | }, | |
| 162 | + | { | |
| 163 | + | name: "partial growth stops at recursive donor minimum", | |
| 164 | + | tree: &Tree{Root: split(Horizontal, | |
| 165 | + | weightedLeaf("a", 20), | |
| 166 | + | weightedNode(split(Horizontal, NewLeaf("b"), NewLeaf("d")), 43), | |
| 167 | + | ), Focus: "a"}, | |
| 168 | + | axis: Width, | |
| 169 | + | delta: 5, | |
| 170 | + | area: domain.Rect{Width: 64, Height: 2}, | |
| 171 | + | want: map[PaneID]int{"a": 22, "b": 20, "d": 20}, | |
| 172 | + | }, | |
| 173 | + | { | |
| 174 | + | name: "no donor capacity", | |
| 175 | + | tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 176 | + | axis: Width, | |
| 177 | + | delta: 2, | |
| 178 | + | area: domain.Rect{Width: 41, Height: 2}, | |
| 179 | + | err: ErrTooSmall, | |
| 180 | + | }, | |
| 181 | + | { | |
| 182 | + | name: "target cannot shrink", | |
| 183 | + | tree: &Tree{Root: split(Horizontal, NewLeaf("a"), weightedLeaf("b", 2)), Focus: "a"}, | |
| 184 | + | axis: Width, | |
| 185 | + | delta: -2, | |
| 186 | + | area: domain.Rect{Width: 61, Height: 2}, | |
| 187 | + | err: ErrTooSmall, | |
| 188 | + | }, | |
| 189 | + | { | |
| 190 | + | name: "wrong axis", | |
| 191 | + | tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 192 | + | axis: Height, | |
| 193 | + | delta: 1, | |
| 194 | + | area: domain.Rect{Width: 41, Height: 2}, | |
| 195 | + | err: ErrNotInSplit, | |
| 196 | + | }, | |
| 197 | + | { | |
| 198 | + | name: "single pane", | |
| 199 | + | tree: NewTree("a"), | |
| 200 | + | axis: Width, | |
| 201 | + | delta: 2, | |
| 202 | + | area: domain.Rect{Width: 20, Height: 2}, | |
| 203 | + | err: ErrNotInSplit, | |
| 204 | + | }, | |
| 205 | + | { | |
| 206 | + | name: "invalid area rolls back", | |
| 207 | + | tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 208 | + | axis: Width, | |
| 209 | + | delta: 2, | |
| 210 | + | area: domain.Rect{Width: 20, Height: 2}, | |
| 211 | + | err: ErrTooSmall, | |
| 212 | + | }, | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | for _, tt := range tests { | |
| 216 | + | t.Run(tt.name, func(t *testing.T) { | |
| 217 | + | before := tt.tree.Clone() | |
| 218 | + | err := tt.tree.ResizeFocus(tt.axis, tt.delta, tt.area) | |
| 219 | + | if tt.err != nil { | |
| 220 | + | require.ErrorIs(t, err, tt.err) | |
| 221 | + | require.Equal(t, before, tt.tree, "failed resize must not mutate the tree") | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | require.NoError(t, err) | |
| 225 | + | placements, ok := Solve(tt.tree.Root, tt.area) | |
| 226 | + | require.True(t, ok) | |
| 227 | + | require.Equal(t, tt.want, placementWidths(placements)) | |
| 228 | + | }) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestResizeFocusRetainsProportionsAcrossTerminalSizes(t *testing.T) { | |
| 233 | + | t.Parallel() | |
| 234 | + | ||
| 235 | + | tr := &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"} | |
| 236 | + | small := domain.Rect{Width: 81, Height: 2} | |
| 237 | + | require.NoError(t, tr.ResizeFocus(Width, 10, small)) | |
| 238 | + | ||
| 239 | + | placements, ok := Solve(tr.Root, domain.Rect{Width: 161, Height: 2}) | |
| 240 | + | require.True(t, ok) | |
| 241 | + | require.Equal(t, map[PaneID]int{"a": 100, "b": 60}, placementWidths(placements)) | |
| 242 | + | placements, ok = Solve(tr.Root, small) | |
| 243 | + | require.True(t, ok) | |
| 244 | + | require.Equal(t, map[PaneID]int{"a": 50, "b": 30}, placementWidths(placements)) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func TestEqualize(t *testing.T) { | |
| 248 | + | t.Parallel() | |
| 249 | + | ||
| 250 | + | tests := []struct { | |
| 251 | + | name string | |
| 252 | + | tree *Tree | |
| 253 | + | area domain.Rect | |
| 254 | + | want map[PaneID]int | |
| 255 | + | }{ | |
| 256 | + | { | |
| 257 | + | name: "weighted siblings become equal", | |
| 258 | + | tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}, | |
| 259 | + | area: domain.Rect{Width: 81, Height: 2}, | |
| 260 | + | want: map[PaneID]int{"a": 40, "b": 40}, | |
| 261 | + | }, | |
| 262 | + | { | |
| 263 | + | name: "recursive minimum constrains only the large child", | |
| 264 | + | tree: &Tree{Root: split(Horizontal, | |
| 265 | + | weightedNode(split(Horizontal, weightedLeaf("a", 5), weightedLeaf("b", 1)), 9), | |
| 266 | + | weightedLeaf("c", 1), weightedLeaf("d", 1), | |
| 267 | + | ), Focus: "a"}, | |
| 268 | + | area: domain.Rect{Width: 102, Height: 2}, | |
| 269 | + | want: map[PaneID]int{"a": 20, "b": 20, "c": 30, "d": 29}, | |
| 270 | + | }, | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | for _, tt := range tests { | |
| 274 | + | t.Run(tt.name, func(t *testing.T) { | |
| 275 | + | require.NoError(t, tt.tree.Equalize(tt.area)) | |
| 276 | + | placements, ok := Solve(tt.tree.Root, tt.area) | |
| 277 | + | require.True(t, ok) | |
| 278 | + | require.Equal(t, tt.want, placementWidths(placements)) | |
| 279 | + | }) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func TestEqualizeFailureIsTransactional(t *testing.T) { | |
| 284 | + | t.Parallel() | |
| 285 | + | ||
| 286 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"} | |
| 287 | + | before := tr.Clone() | |
| 288 | + | require.ErrorIs(t, tr.Equalize(domain.Rect{Width: 20, Height: 2}), ErrTooSmall) | |
| 289 | + | require.Equal(t, before, tr) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func TestCanResize(t *testing.T) { | |
| 293 | + | t.Parallel() | |
| 294 | + | ||
| 295 | + | tests := []struct { | |
| 296 | + | name string | |
| 297 | + | tree *Tree | |
| 298 | + | want bool | |
| 299 | + | }{ | |
| 300 | + | {name: "single pane", tree: NewTree("a"), want: false}, | |
| 301 | + | {name: "focused split child", tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, want: true}, | |
| 302 | + | {name: "focused stack member under split", tree: &Tree{Root: split(Horizontal, &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, NewLeaf("c")), Focus: "b"}, want: true}, | |
| 303 | + | {name: "stack without split", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, Focus: "a"}, want: false}, | |
| 304 | + | {name: "missing focus", tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "missing"}, want: false}, | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | for _, tt := range tests { | |
| 308 | + | t.Run(tt.name, func(t *testing.T) { | |
| 309 | + | require.Equal(t, tt.want, tt.tree.CanResize()) | |
| 310 | + | }) | |
| 311 | + | } | |
| 312 | + | } | |
| @@ -0,0 +1,312 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResizeFocusTransfersSolvedExtent(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | tests := []struct { | |||
| 14 | + | name string | |||
| 15 | + | root *Node | |||
| 16 | + | focus PaneID | |||
| 17 | + | axis Axis | |||
| 18 | + | delta int | |||
| 19 | + | area domain.Rect | |||
| 20 | + | want map[PaneID]domain.Rect | |||
| 21 | + | }{ | |||
| 22 | + | { | |||
| 23 | + | name: "grow width takes from right neighbor", | |||
| 24 | + | root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), | |||
| 25 | + | focus: "a", | |||
| 26 | + | axis: Width, | |||
| 27 | + | delta: 5, | |||
| 28 | + | area: domain.Rect{Width: 51, Height: 2}, | |||
| 29 | + | want: map[PaneID]domain.Rect{ | |||
| 30 | + | "a": {Width: 30, Height: 2}, | |||
| 31 | + | "b": {X: 31, Width: 20, Height: 2}, | |||
| 32 | + | }, | |||
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | name: "shrink width gives to right neighbor", | |||
| 36 | + | root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)), | |||
| 37 | + | focus: "a", | |||
| 38 | + | axis: Width, | |||
| 39 | + | delta: -5, | |||
| 40 | + | area: domain.Rect{Width: 51, Height: 2}, | |||
| 41 | + | want: map[PaneID]domain.Rect{ | |||
| 42 | + | "a": {Width: 25, Height: 2}, | |||
| 43 | + | "b": {X: 26, Width: 25, Height: 2}, | |||
| 44 | + | }, | |||
| 45 | + | }, | |||
| 46 | + | { | |||
| 47 | + | name: "grow height takes from bottom neighbor", | |||
| 48 | + | root: split(Vertical, | |||
| 49 | + | weightedLeaf("a", 4), weightedLeaf("b", 3), | |||
| 50 | + | ), | |||
| 51 | + | focus: "a", | |||
| 52 | + | axis: Height, | |||
| 53 | + | delta: 1, | |||
| 54 | + | area: domain.Rect{Width: 20, Height: 8}, | |||
| 55 | + | want: map[PaneID]domain.Rect{ | |||
| 56 | + | "a": {Width: 20, Height: 5}, | |||
| 57 | + | "b": {Y: 6, Width: 20, Height: 2}, | |||
| 58 | + | }, | |||
| 59 | + | }, | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | for _, tt := range tests { | |||
| 63 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 64 | + | tr := &Tree{Root: tt.root, Focus: tt.focus} | |||
| 65 | + | require.NoError(t, tr.ResizeFocus(tt.axis, tt.delta, tt.area)) | |||
| 66 | + | placements, ok := Solve(tr.Root, tt.area) | |||
| 67 | + | require.True(t, ok) | |||
| 68 | + | require.Equal(t, tt.want, contents(placements)) | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestResizeFocusSelectsNearestTargetAndAdjacentSibling(t *testing.T) { | |||
| 74 | + | t.Parallel() | |||
| 75 | + | ||||
| 76 | + | t.Run("nearest matching ancestor", func(t *testing.T) { | |||
| 77 | + | inner := split(Horizontal, NewLeaf("a"), NewLeaf("b")) | |||
| 78 | + | tr := &Tree{Root: split(Horizontal, weightedNode(inner, 2), weightedLeaf("c", 1)), Focus: "a"} | |||
| 79 | + | area := domain.Rect{Width: 122, Height: 2} | |||
| 80 | + | before, ok := Solve(tr.Root, area) | |||
| 81 | + | require.True(t, ok) | |||
| 82 | + | ||||
| 83 | + | require.NoError(t, tr.ResizeFocus(Width, 5, area)) | |||
| 84 | + | after, ok := Solve(tr.Root, area) | |||
| 85 | + | require.True(t, ok) | |||
| 86 | + | require.Equal(t, contents(before)["c"], contents(after)["c"]) | |||
| 87 | + | require.Equal(t, contents(before)["a"].Width+5, contents(after)["a"].Width) | |||
| 88 | + | require.Equal(t, contents(before)["b"].Width-5, contents(after)["b"].Width) | |||
| 89 | + | }) | |||
| 90 | + | ||||
| 91 | + | t.Run("direct child subtree is resized as one target", func(t *testing.T) { | |||
| 92 | + | subtree := split(Vertical, NewLeaf("a"), NewLeaf("d")) | |||
| 93 | + | tr := &Tree{Root: split(Horizontal, subtree, NewLeaf("b")), Focus: "a"} | |||
| 94 | + | area := domain.Rect{Width: 51, Height: 5} | |||
| 95 | + | require.NoError(t, tr.ResizeFocus(Width, 5, area)) | |||
| 96 | + | placements, ok := Solve(tr.Root, area) | |||
| 97 | + | require.True(t, ok) | |||
| 98 | + | require.Equal(t, 30, contents(placements)["a"].Width) | |||
| 99 | + | require.Equal(t, 30, contents(placements)["d"].Width) | |||
| 100 | + | require.Equal(t, 20, contents(placements)["b"].Width) | |||
| 101 | + | }) | |||
| 102 | + | ||||
| 103 | + | t.Run("stack is resized as one target", func(t *testing.T) { | |||
| 104 | + | stackNode := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("d")}, Expanded: "a"} | |||
| 105 | + | tr := &Tree{Root: split(Horizontal, stackNode, NewLeaf("b")), Focus: "d"} | |||
| 106 | + | area := domain.Rect{Width: 51, Height: 3} | |||
| 107 | + | require.NoError(t, tr.ResizeFocus(Width, 5, area)) | |||
| 108 | + | placements, ok := Solve(tr.Root, area) | |||
| 109 | + | require.True(t, ok) | |||
| 110 | + | require.Equal(t, 30, contents(placements)["a"].Width) | |||
| 111 | + | var collapsedTitleWidth int | |||
| 112 | + | for _, placement := range placements { | |||
| 113 | + | if placement.ID == "d" { | |||
| 114 | + | collapsedTitleWidth = placement.TitleBar.Width | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | require.Equal(t, 30, collapsedTitleWidth) | |||
| 118 | + | require.Equal(t, 20, contents(placements)["b"].Width) | |||
| 119 | + | }) | |||
| 120 | + | ||||
| 121 | + | t.Run("right donor at minimum falls back to left", func(t *testing.T) { | |||
| 122 | + | tr := &Tree{Root: split(Horizontal, | |||
| 123 | + | weightedLeaf("a", 40), weightedLeaf("b", 20), weightedLeaf("c", 20), | |||
| 124 | + | ), Focus: "b"} | |||
| 125 | + | area := domain.Rect{Width: 82, Height: 2} | |||
| 126 | + | require.NoError(t, tr.ResizeFocus(Width, 5, area)) | |||
| 127 | + | placements, ok := Solve(tr.Root, area) | |||
| 128 | + | require.True(t, ok) | |||
| 129 | + | require.Equal(t, map[PaneID]int{"a": 35, "b": 25, "c": 20}, placementWidths(placements)) | |||
| 130 | + | }) | |||
| 131 | + | ||||
| 132 | + | t.Run("last target falls back to left neighbor", func(t *testing.T) { | |||
| 133 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 30), weightedLeaf("b", 20)), Focus: "b"} | |||
| 134 | + | area := domain.Rect{Width: 51, Height: 2} | |||
| 135 | + | require.NoError(t, tr.ResizeFocus(Width, 5, area)) | |||
| 136 | + | placements, ok := Solve(tr.Root, area) | |||
| 137 | + | require.True(t, ok) | |||
| 138 | + | require.Equal(t, map[PaneID]int{"a": 25, "b": 25}, placementWidths(placements)) | |||
| 139 | + | }) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestResizeFocusMinimumAndTransactionalFailures(t *testing.T) { | |||
| 143 | + | t.Parallel() | |||
| 144 | + | ||||
| 145 | + | tests := []struct { | |||
| 146 | + | name string | |||
| 147 | + | tree *Tree | |||
| 148 | + | axis Axis | |||
| 149 | + | delta int | |||
| 150 | + | area domain.Rect | |||
| 151 | + | err error | |||
| 152 | + | want map[PaneID]int | |||
| 153 | + | }{ | |||
| 154 | + | { | |||
| 155 | + | name: "partial growth uses every available donor cell", | |||
| 156 | + | tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 20), weightedLeaf("b", 22)), Focus: "a"}, | |||
| 157 | + | axis: Width, | |||
| 158 | + | delta: 5, | |||
| 159 | + | area: domain.Rect{Width: 43, Height: 2}, | |||
| 160 | + | want: map[PaneID]int{"a": 22, "b": 20}, | |||
| 161 | + | }, | |||
| 162 | + | { | |||
| 163 | + | name: "partial growth stops at recursive donor minimum", | |||
| 164 | + | tree: &Tree{Root: split(Horizontal, | |||
| 165 | + | weightedLeaf("a", 20), | |||
| 166 | + | weightedNode(split(Horizontal, NewLeaf("b"), NewLeaf("d")), 43), | |||
| 167 | + | ), Focus: "a"}, | |||
| 168 | + | axis: Width, | |||
| 169 | + | delta: 5, | |||
| 170 | + | area: domain.Rect{Width: 64, Height: 2}, | |||
| 171 | + | want: map[PaneID]int{"a": 22, "b": 20, "d": 20}, | |||
| 172 | + | }, | |||
| 173 | + | { | |||
| 174 | + | name: "no donor capacity", | |||
| 175 | + | tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 176 | + | axis: Width, | |||
| 177 | + | delta: 2, | |||
| 178 | + | area: domain.Rect{Width: 41, Height: 2}, | |||
| 179 | + | err: ErrTooSmall, | |||
| 180 | + | }, | |||
| 181 | + | { | |||
| 182 | + | name: "target cannot shrink", | |||
| 183 | + | tree: &Tree{Root: split(Horizontal, NewLeaf("a"), weightedLeaf("b", 2)), Focus: "a"}, | |||
| 184 | + | axis: Width, | |||
| 185 | + | delta: -2, | |||
| 186 | + | area: domain.Rect{Width: 61, Height: 2}, | |||
| 187 | + | err: ErrTooSmall, | |||
| 188 | + | }, | |||
| 189 | + | { | |||
| 190 | + | name: "wrong axis", | |||
| 191 | + | tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 192 | + | axis: Height, | |||
| 193 | + | delta: 1, | |||
| 194 | + | area: domain.Rect{Width: 41, Height: 2}, | |||
| 195 | + | err: ErrNotInSplit, | |||
| 196 | + | }, | |||
| 197 | + | { | |||
| 198 | + | name: "single pane", | |||
| 199 | + | tree: NewTree("a"), | |||
| 200 | + | axis: Width, | |||
| 201 | + | delta: 2, | |||
| 202 | + | area: domain.Rect{Width: 20, Height: 2}, | |||
| 203 | + | err: ErrNotInSplit, | |||
| 204 | + | }, | |||
| 205 | + | { | |||
| 206 | + | name: "invalid area rolls back", | |||
| 207 | + | tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 208 | + | axis: Width, | |||
| 209 | + | delta: 2, | |||
| 210 | + | area: domain.Rect{Width: 20, Height: 2}, | |||
| 211 | + | err: ErrTooSmall, | |||
| 212 | + | }, | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | for _, tt := range tests { | |||
| 216 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 217 | + | before := tt.tree.Clone() | |||
| 218 | + | err := tt.tree.ResizeFocus(tt.axis, tt.delta, tt.area) | |||
| 219 | + | if tt.err != nil { | |||
| 220 | + | require.ErrorIs(t, err, tt.err) | |||
| 221 | + | require.Equal(t, before, tt.tree, "failed resize must not mutate the tree") | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 224 | + | require.NoError(t, err) | |||
| 225 | + | placements, ok := Solve(tt.tree.Root, tt.area) | |||
| 226 | + | require.True(t, ok) | |||
| 227 | + | require.Equal(t, tt.want, placementWidths(placements)) | |||
| 228 | + | }) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestResizeFocusRetainsProportionsAcrossTerminalSizes(t *testing.T) { | |||
| 233 | + | t.Parallel() | |||
| 234 | + | ||||
| 235 | + | tr := &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"} | |||
| 236 | + | small := domain.Rect{Width: 81, Height: 2} | |||
| 237 | + | require.NoError(t, tr.ResizeFocus(Width, 10, small)) | |||
| 238 | + | ||||
| 239 | + | placements, ok := Solve(tr.Root, domain.Rect{Width: 161, Height: 2}) | |||
| 240 | + | require.True(t, ok) | |||
| 241 | + | require.Equal(t, map[PaneID]int{"a": 100, "b": 60}, placementWidths(placements)) | |||
| 242 | + | placements, ok = Solve(tr.Root, small) | |||
| 243 | + | require.True(t, ok) | |||
| 244 | + | require.Equal(t, map[PaneID]int{"a": 50, "b": 30}, placementWidths(placements)) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func TestEqualize(t *testing.T) { | |||
| 248 | + | t.Parallel() | |||
| 249 | + | ||||
| 250 | + | tests := []struct { | |||
| 251 | + | name string | |||
| 252 | + | tree *Tree | |||
| 253 | + | area domain.Rect | |||
| 254 | + | want map[PaneID]int | |||
| 255 | + | }{ | |||
| 256 | + | { | |||
| 257 | + | name: "weighted siblings become equal", | |||
| 258 | + | tree: &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"}, | |||
| 259 | + | area: domain.Rect{Width: 81, Height: 2}, | |||
| 260 | + | want: map[PaneID]int{"a": 40, "b": 40}, | |||
| 261 | + | }, | |||
| 262 | + | { | |||
| 263 | + | name: "recursive minimum constrains only the large child", | |||
| 264 | + | tree: &Tree{Root: split(Horizontal, | |||
| 265 | + | weightedNode(split(Horizontal, weightedLeaf("a", 5), weightedLeaf("b", 1)), 9), | |||
| 266 | + | weightedLeaf("c", 1), weightedLeaf("d", 1), | |||
| 267 | + | ), Focus: "a"}, | |||
| 268 | + | area: domain.Rect{Width: 102, Height: 2}, | |||
| 269 | + | want: map[PaneID]int{"a": 20, "b": 20, "c": 30, "d": 29}, | |||
| 270 | + | }, | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | for _, tt := range tests { | |||
| 274 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 275 | + | require.NoError(t, tt.tree.Equalize(tt.area)) | |||
| 276 | + | placements, ok := Solve(tt.tree.Root, tt.area) | |||
| 277 | + | require.True(t, ok) | |||
| 278 | + | require.Equal(t, tt.want, placementWidths(placements)) | |||
| 279 | + | }) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | func TestEqualizeFailureIsTransactional(t *testing.T) { | |||
| 284 | + | t.Parallel() | |||
| 285 | + | ||||
| 286 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"} | |||
| 287 | + | before := tr.Clone() | |||
| 288 | + | require.ErrorIs(t, tr.Equalize(domain.Rect{Width: 20, Height: 2}), ErrTooSmall) | |||
| 289 | + | require.Equal(t, before, tr) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func TestCanResize(t *testing.T) { | |||
| 293 | + | t.Parallel() | |||
| 294 | + | ||||
| 295 | + | tests := []struct { | |||
| 296 | + | name string | |||
| 297 | + | tree *Tree | |||
| 298 | + | want bool | |||
| 299 | + | }{ | |||
| 300 | + | {name: "single pane", tree: NewTree("a"), want: false}, | |||
| 301 | + | {name: "focused split child", tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "a"}, want: true}, | |||
| 302 | + | {name: "focused stack member under split", tree: &Tree{Root: split(Horizontal, &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, NewLeaf("c")), Focus: "b"}, want: true}, | |||
| 303 | + | {name: "stack without split", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "a"}, Focus: "a"}, want: false}, | |||
| 304 | + | {name: "missing focus", tree: &Tree{Root: split(Horizontal, NewLeaf("a"), NewLeaf("b")), Focus: "missing"}, want: false}, | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | for _, tt := range tests { | |||
| 308 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 309 | + | require.Equal(t, tt.want, tt.tree.CanResize()) | |||
| 310 | + | }) | |||
| 311 | + | } | |||
| 312 | + | } | |||
M internal/usecase/layout/tree.go +8 -0
| @@ -37,6 +37,14 @@ const ( | |||
| 37 | 37 | MinPaneRows = 2 | |
| 38 | 38 | ) | |
| 39 | 39 | ||
| 40 | + | // Axis identifies the dimension changed by a resize operation. | |
| 41 | + | type Axis int | |
| 42 | + | ||
| 43 | + | const ( | |
| 44 | + | Width Axis = iota | |
| 45 | + | Height | |
| 46 | + | ) | |
| 47 | + | ||
| 40 | 48 | // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir | |
| 41 | 49 | // and Children are meaningful for Split nodes; Children and Expanded are | |
| 42 | 50 | // meaningful for Stack nodes. | |
| @@ -37,6 +37,14 @@ const ( | |||||
| 37 | MinPaneRows = 2 | 37 | MinPaneRows = 2 | ||
| 38 | ) | 38 | ) | ||
| 39 | 39 | ||||
| 40 | + | // Axis identifies the dimension changed by a resize operation. | |||
| 41 | + | type Axis int | |||
| 42 | + | ||||
| 43 | + | const ( | |||
| 44 | + | Width Axis = iota | |||
| 45 | + | Height | |||
| 46 | + | ) | |||
| 47 | + | ||||
| 40 | // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir | 48 | // Node is a recursive pane layout node. Leaf is meaningful for Leaf nodes; Dir | ||
| 41 | // and Children are meaningful for Split nodes; Children and Expanded are | 49 | // and Children are meaningful for Split nodes; Children and Expanded are | ||
| 42 | // meaningful for Stack nodes. | 50 | // meaningful for Stack nodes. | ||