feat(layout): support weighted split geometry
- commit
- cbe74eb0791f6372a373b126d80d6bb70d0658fd
- author
- brice · · 2026-07-24 15:01 UTC
- committer
- brice · · 2026-07-25 04:08 UTC
- parent
- c240dab
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +612 -49
M internal/usecase/layout/geometry.go +201 -30
| @@ -1,12 +1,25 @@ | |||
| 1 | 1 | package layout | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/internal/domain" | |
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "sort" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 4 | 9 | ||
| 5 | 10 | // Solve returns deterministic pane placements for root inside area. | |
| 6 | 11 | func Solve(root *Node, area domain.Rect) ([]Placement, bool) { | |
| 7 | 12 | if root == nil || area.Width <= 0 || area.Height <= 0 { | |
| 8 | 13 | return nil, false | |
| 9 | 14 | } | |
| 15 | + | minWidth, ok := minimumExtent(root, Horizontal) | |
| 16 | + | if !ok || area.Width < minWidth { | |
| 17 | + | return nil, false | |
| 18 | + | } | |
| 19 | + | minHeight, ok := minimumExtent(root, Vertical) | |
| 20 | + | if !ok || area.Height < minHeight { | |
| 21 | + | return nil, false | |
| 22 | + | } | |
| 10 | 23 | var out []Placement | |
| 11 | 24 | if !solve(root, area, &out) { | |
| 12 | 25 | return nil, false | |
| @@ -32,50 +45,208 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool { | |||
| 32 | 45 | } | |
| 33 | 46 | ||
| 34 | 47 | func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool { | |
| 48 | + | rects, ok := splitChildRects(n, r) | |
| 49 | + | if !ok { | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | for i, child := range n.Children { | |
| 53 | + | if !solve(child, rects[i], out) { | |
| 54 | + | return false | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return true | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func splitChildRects(n *Node, r domain.Rect) ([]domain.Rect, bool) { | |
| 35 | 61 | count := len(n.Children) | |
| 36 | 62 | if count == 0 { | |
| 37 | - | return false | |
| 63 | + | return nil, false | |
| 38 | 64 | } | |
| 39 | 65 | if count == 1 { | |
| 40 | - | return solve(n.Children[0], r, out) | |
| 66 | + | return []domain.Rect{r}, true | |
| 41 | 67 | } | |
| 42 | - | if n.Dir == Horizontal { | |
| 43 | - | usable := r.Width - (count - 1) | |
| 44 | - | if usable < count*MinPaneCols || r.Height < MinPaneRows { | |
| 45 | - | return false | |
| 68 | + | ||
| 69 | + | total := r.Width | |
| 70 | + | if n.Dir == Vertical { | |
| 71 | + | total = r.Height | |
| 72 | + | } | |
| 73 | + | usable := total - (count - 1) | |
| 74 | + | minimums := make([]int, count) | |
| 75 | + | weights := make([]float64, count) | |
| 76 | + | for i, child := range n.Children { | |
| 77 | + | minimum, ok := minimumExtent(child, n.Dir) | |
| 78 | + | if !ok { | |
| 79 | + | return nil, false | |
| 80 | + | } | |
| 81 | + | minimums[i] = minimum | |
| 82 | + | weights[i] = effectiveWeight(child.Weight) | |
| 83 | + | } | |
| 84 | + | extents, ok := distribute(usable, minimums, weights) | |
| 85 | + | if !ok { | |
| 86 | + | return nil, false | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | rects := make([]domain.Rect, count) | |
| 90 | + | x, y := r.X, r.Y | |
| 91 | + | for i, extent := range extents { | |
| 92 | + | if n.Dir == Horizontal { | |
| 93 | + | rects[i] = domain.Rect{X: x, Y: r.Y, Width: extent, Height: r.Height} | |
| 94 | + | x += extent + 1 | |
| 95 | + | continue | |
| 96 | + | } | |
| 97 | + | rects[i] = domain.Rect{X: r.X, Y: y, Width: r.Width, Height: extent} | |
| 98 | + | y += extent + 1 | |
| 99 | + | } | |
| 100 | + | return rects, true | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func effectiveWeight(weight float64) float64 { | |
| 104 | + | if weight <= 0 || math.IsNaN(weight) || math.IsInf(weight, 0) { | |
| 105 | + | return 1 | |
| 106 | + | } | |
| 107 | + | return weight | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func minimumExtent(n *Node, axis SplitDir) (int, bool) { | |
| 111 | + | if n == nil { | |
| 112 | + | return 0, false | |
| 113 | + | } | |
| 114 | + | switch n.Kind { | |
| 115 | + | case Leaf: | |
| 116 | + | if axis == Horizontal { | |
| 117 | + | return MinPaneCols, true | |
| 118 | + | } | |
| 119 | + | return MinPaneRows, true | |
| 120 | + | case Stack: | |
| 121 | + | if len(n.Children) == 0 { | |
| 122 | + | return 0, false | |
| 123 | + | } | |
| 124 | + | for _, child := range n.Children { | |
| 125 | + | if child == nil || child.Kind != Leaf { | |
| 126 | + | return 0, false | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | if axis == Horizontal { | |
| 130 | + | return MinPaneCols, true | |
| 131 | + | } | |
| 132 | + | return len(n.Children) + 1, true | |
| 133 | + | case Split: | |
| 134 | + | if len(n.Children) == 0 { | |
| 135 | + | return 0, false | |
| 46 | 136 | } | |
| 47 | - | base, rem := usable/count, usable%count | |
| 48 | - | x := r.X | |
| 49 | - | for i, child := range n.Children { | |
| 50 | - | w := base | |
| 51 | - | if i < rem { | |
| 52 | - | w++ | |
| 137 | + | if n.Dir == axis { | |
| 138 | + | total := len(n.Children) - 1 | |
| 139 | + | for _, child := range n.Children { | |
| 140 | + | minimum, ok := minimumExtent(child, axis) | |
| 141 | + | if !ok { | |
| 142 | + | return 0, false | |
| 143 | + | } | |
| 144 | + | total += minimum | |
| 53 | 145 | } | |
| 54 | - | if !solve(child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, out) { | |
| 55 | - | return false | |
| 146 | + | return total, true | |
| 147 | + | } | |
| 148 | + | largest := 0 | |
| 149 | + | for _, child := range n.Children { | |
| 150 | + | minimum, ok := minimumExtent(child, axis) | |
| 151 | + | if !ok { | |
| 152 | + | return 0, false | |
| 56 | 153 | } | |
| 57 | - | x += w + 1 | |
| 154 | + | largest = max(largest, minimum) | |
| 58 | 155 | } | |
| 59 | - | return true | |
| 156 | + | return largest, true | |
| 157 | + | default: | |
| 158 | + | return 0, false | |
| 60 | 159 | } | |
| 160 | + | } | |
| 61 | 161 | ||
| 62 | - | usable := r.Height - (count - 1) | |
| 63 | - | if usable < count*MinPaneRows || r.Width < MinPaneCols { | |
| 64 | - | return false | |
| 162 | + | // distribute allocates total integer cells proportionally while respecting each | |
| 163 | + | // child's minimum. It uses stable largest-remainder rounding after all | |
| 164 | + | // constrained children have been pinned. | |
| 165 | + | func distribute(total int, minimums []int, weights []float64) ([]int, bool) { | |
| 166 | + | if total < 0 || len(minimums) == 0 || len(minimums) != len(weights) { | |
| 167 | + | return nil, false | |
| 65 | 168 | } | |
| 66 | - | base, rem := usable/count, usable%count | |
| 67 | - | y := r.Y | |
| 68 | - | for i, child := range n.Children { | |
| 69 | - | h := base | |
| 70 | - | if i < rem { | |
| 71 | - | h++ | |
| 169 | + | minimumTotal := 0 | |
| 170 | + | for _, minimum := range minimums { | |
| 171 | + | if minimum < 0 { | |
| 172 | + | return nil, false | |
| 72 | 173 | } | |
| 73 | - | if !solve(child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, out) { | |
| 74 | - | return false | |
| 174 | + | minimumTotal += minimum | |
| 175 | + | } | |
| 176 | + | if minimumTotal > total { | |
| 177 | + | return nil, false | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | allocations := make([]int, len(minimums)) | |
| 181 | + | active := make([]bool, len(minimums)) | |
| 182 | + | for i := range active { | |
| 183 | + | active[i] = true | |
| 184 | + | } | |
| 185 | + | remaining := total | |
| 186 | + | ||
| 187 | + | for { | |
| 188 | + | normalized, sum := normalizedActiveWeights(weights, active) | |
| 189 | + | pinned := false | |
| 190 | + | for i := range active { | |
| 191 | + | if !active[i] { | |
| 192 | + | continue | |
| 193 | + | } | |
| 194 | + | quota := float64(remaining) * normalized[i] / sum | |
| 195 | + | if quota < float64(minimums[i]) { | |
| 196 | + | allocations[i] = minimums[i] | |
| 197 | + | remaining -= minimums[i] | |
| 198 | + | active[i] = false | |
| 199 | + | pinned = true | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | if !pinned { | |
| 203 | + | break | |
| 75 | 204 | } | |
| 76 | - | y += h + 1 | |
| 77 | 205 | } | |
| 78 | - | return true | |
| 206 | + | ||
| 207 | + | normalized, sum := normalizedActiveWeights(weights, active) | |
| 208 | + | type remainder struct { | |
| 209 | + | index int | |
| 210 | + | fraction float64 | |
| 211 | + | } | |
| 212 | + | remainders := make([]remainder, 0, len(weights)) | |
| 213 | + | allocated := total - remaining | |
| 214 | + | for i := range active { | |
| 215 | + | if !active[i] { | |
| 216 | + | continue | |
| 217 | + | } | |
| 218 | + | quota := float64(remaining) * normalized[i] / sum | |
| 219 | + | whole := int(math.Floor(quota)) | |
| 220 | + | allocations[i] = whole | |
| 221 | + | allocated += whole | |
| 222 | + | remainders = append(remainders, remainder{index: i, fraction: quota - float64(whole)}) | |
| 223 | + | } | |
| 224 | + | sort.SliceStable(remainders, func(i, j int) bool { | |
| 225 | + | return remainders[i].fraction > remainders[j].fraction | |
| 226 | + | }) | |
| 227 | + | for cells, i := total-allocated, 0; cells > 0; cells, i = cells-1, i+1 { | |
| 228 | + | allocations[remainders[i%len(remainders)].index]++ | |
| 229 | + | } | |
| 230 | + | return allocations, true | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func normalizedActiveWeights(weights []float64, active []bool) ([]float64, float64) { | |
| 234 | + | largest := 0.0 | |
| 235 | + | for i, weight := range weights { | |
| 236 | + | if active[i] { | |
| 237 | + | largest = math.Max(largest, effectiveWeight(weight)) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | normalized := make([]float64, len(weights)) | |
| 241 | + | sum := 0.0 | |
| 242 | + | for i, weight := range weights { | |
| 243 | + | if !active[i] { | |
| 244 | + | continue | |
| 245 | + | } | |
| 246 | + | normalized[i] = effectiveWeight(weight) / largest | |
| 247 | + | sum += normalized[i] | |
| 248 | + | } | |
| 249 | + | return normalized, sum | |
| 79 | 250 | } | |
| 80 | 251 | ||
| 81 | 252 | func solveStack(n *Node, r domain.Rect, out *[]Placement) bool { | |
| @@ -1,12 +1,25 @@ | |||||
| 1 | package layout | 1 | package layout | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/internal/domain" | 3 | + | import ( |
| 4 | + | "math" | |||
| 5 | + | "sort" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | ) | |||
| 4 | 9 | ||||
| 5 | // Solve returns deterministic pane placements for root inside area. | 10 | // Solve returns deterministic pane placements for root inside area. | ||
| 6 | func Solve(root *Node, area domain.Rect) ([]Placement, bool) { | 11 | func Solve(root *Node, area domain.Rect) ([]Placement, bool) { | ||
| 7 | if root == nil || area.Width <= 0 || area.Height <= 0 { | 12 | if root == nil || area.Width <= 0 || area.Height <= 0 { | ||
| 8 | return nil, false | 13 | return nil, false | ||
| 9 | } | 14 | } | ||
| 15 | + | minWidth, ok := minimumExtent(root, Horizontal) | |||
| 16 | + | if !ok || area.Width < minWidth { | |||
| 17 | + | return nil, false | |||
| 18 | + | } | |||
| 19 | + | minHeight, ok := minimumExtent(root, Vertical) | |||
| 20 | + | if !ok || area.Height < minHeight { | |||
| 21 | + | return nil, false | |||
| 22 | + | } | |||
| 10 | var out []Placement | 23 | var out []Placement | ||
| 11 | if !solve(root, area, &out) { | 24 | if !solve(root, area, &out) { | ||
| 12 | return nil, false | 25 | return nil, false | ||
| @@ -32,50 +45,208 @@ func solve(n *Node, r domain.Rect, out *[]Placement) bool { | |||||
| 32 | } | 45 | } | ||
| 33 | 46 | ||||
| 34 | func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool { | 47 | func solveSplit(n *Node, r domain.Rect, out *[]Placement) bool { | ||
| 48 | + | rects, ok := splitChildRects(n, r) | |||
| 49 | + | if !ok { | |||
| 50 | + | return false | |||
| 51 | + | } | |||
| 52 | + | for i, child := range n.Children { | |||
| 53 | + | if !solve(child, rects[i], out) { | |||
| 54 | + | return false | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | return true | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func splitChildRects(n *Node, r domain.Rect) ([]domain.Rect, bool) { | |||
| 35 | count := len(n.Children) | 61 | count := len(n.Children) | ||
| 36 | if count == 0 { | 62 | if count == 0 { | ||
| 37 | - | return false | 63 | + | return nil, false |
| 38 | } | 64 | } | ||
| 39 | if count == 1 { | 65 | if count == 1 { | ||
| 40 | - | return solve(n.Children[0], r, out) | 66 | + | return []domain.Rect{r}, true |
| 41 | } | 67 | } | ||
| 42 | - | if n.Dir == Horizontal { | 68 | + | |
| 43 | - | usable := r.Width - (count - 1) | 69 | + | total := r.Width |
| 44 | - | if usable < count*MinPaneCols || r.Height < MinPaneRows { | 70 | + | if n.Dir == Vertical { |
| 45 | - | return false | 71 | + | total = r.Height |
| 72 | + | } | |||
| 73 | + | usable := total - (count - 1) | |||
| 74 | + | minimums := make([]int, count) | |||
| 75 | + | weights := make([]float64, count) | |||
| 76 | + | for i, child := range n.Children { | |||
| 77 | + | minimum, ok := minimumExtent(child, n.Dir) | |||
| 78 | + | if !ok { | |||
| 79 | + | return nil, false | |||
| 80 | + | } | |||
| 81 | + | minimums[i] = minimum | |||
| 82 | + | weights[i] = effectiveWeight(child.Weight) | |||
| 83 | + | } | |||
| 84 | + | extents, ok := distribute(usable, minimums, weights) | |||
| 85 | + | if !ok { | |||
| 86 | + | return nil, false | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | rects := make([]domain.Rect, count) | |||
| 90 | + | x, y := r.X, r.Y | |||
| 91 | + | for i, extent := range extents { | |||
| 92 | + | if n.Dir == Horizontal { | |||
| 93 | + | rects[i] = domain.Rect{X: x, Y: r.Y, Width: extent, Height: r.Height} | |||
| 94 | + | x += extent + 1 | |||
| 95 | + | continue | |||
| 96 | + | } | |||
| 97 | + | rects[i] = domain.Rect{X: r.X, Y: y, Width: r.Width, Height: extent} | |||
| 98 | + | y += extent + 1 | |||
| 99 | + | } | |||
| 100 | + | return rects, true | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func effectiveWeight(weight float64) float64 { | |||
| 104 | + | if weight <= 0 || math.IsNaN(weight) || math.IsInf(weight, 0) { | |||
| 105 | + | return 1 | |||
| 106 | + | } | |||
| 107 | + | return weight | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func minimumExtent(n *Node, axis SplitDir) (int, bool) { | |||
| 111 | + | if n == nil { | |||
| 112 | + | return 0, false | |||
| 113 | + | } | |||
| 114 | + | switch n.Kind { | |||
| 115 | + | case Leaf: | |||
| 116 | + | if axis == Horizontal { | |||
| 117 | + | return MinPaneCols, true | |||
| 118 | + | } | |||
| 119 | + | return MinPaneRows, true | |||
| 120 | + | case Stack: | |||
| 121 | + | if len(n.Children) == 0 { | |||
| 122 | + | return 0, false | |||
| 123 | + | } | |||
| 124 | + | for _, child := range n.Children { | |||
| 125 | + | if child == nil || child.Kind != Leaf { | |||
| 126 | + | return 0, false | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | if axis == Horizontal { | |||
| 130 | + | return MinPaneCols, true | |||
| 131 | + | } | |||
| 132 | + | return len(n.Children) + 1, true | |||
| 133 | + | case Split: | |||
| 134 | + | if len(n.Children) == 0 { | |||
| 135 | + | return 0, false | |||
| 46 | } | 136 | } | ||
| 47 | - | base, rem := usable/count, usable%count | 137 | + | if n.Dir == axis { |
| 48 | - | x := r.X | 138 | + | total := len(n.Children) - 1 |
| 49 | - | for i, child := range n.Children { | 139 | + | for _, child := range n.Children { |
| 50 | - | w := base | 140 | + | minimum, ok := minimumExtent(child, axis) |
| 51 | - | if i < rem { | 141 | + | if !ok { |
| 52 | - | w++ | 142 | + | return 0, false |
| 143 | + | } | |||
| 144 | + | total += minimum | |||
| 53 | } | 145 | } | ||
| 54 | - | if !solve(child, domain.Rect{X: x, Y: r.Y, Width: w, Height: r.Height}, out) { | 146 | + | return total, true |
| 55 | - | return false | 147 | + | } |
| 148 | + | largest := 0 | |||
| 149 | + | for _, child := range n.Children { | |||
| 150 | + | minimum, ok := minimumExtent(child, axis) | |||
| 151 | + | if !ok { | |||
| 152 | + | return 0, false | |||
| 56 | } | 153 | } | ||
| 57 | - | x += w + 1 | 154 | + | largest = max(largest, minimum) |
| 58 | } | 155 | } | ||
| 59 | - | return true | 156 | + | return largest, true |
| 157 | + | default: | |||
| 158 | + | return 0, false | |||
| 60 | } | 159 | } | ||
| 160 | + | } | |||
| 61 | 161 | ||||
| 62 | - | usable := r.Height - (count - 1) | 162 | + | // distribute allocates total integer cells proportionally while respecting each |
| 63 | - | if usable < count*MinPaneRows || r.Width < MinPaneCols { | 163 | + | // child's minimum. It uses stable largest-remainder rounding after all |
| 64 | - | return false | 164 | + | // constrained children have been pinned. |
| 165 | + | func distribute(total int, minimums []int, weights []float64) ([]int, bool) { | |||
| 166 | + | if total < 0 || len(minimums) == 0 || len(minimums) != len(weights) { | |||
| 167 | + | return nil, false | |||
| 65 | } | 168 | } | ||
| 66 | - | base, rem := usable/count, usable%count | 169 | + | minimumTotal := 0 |
| 67 | - | y := r.Y | 170 | + | for _, minimum := range minimums { |
| 68 | - | for i, child := range n.Children { | 171 | + | if minimum < 0 { |
| 69 | - | h := base | 172 | + | return nil, false |
| 70 | - | if i < rem { | |||
| 71 | - | h++ | |||
| 72 | } | 173 | } | ||
| 73 | - | if !solve(child, domain.Rect{X: r.X, Y: y, Width: r.Width, Height: h}, out) { | 174 | + | minimumTotal += minimum |
| 74 | - | return false | 175 | + | } |
| 176 | + | if minimumTotal > total { | |||
| 177 | + | return nil, false | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | allocations := make([]int, len(minimums)) | |||
| 181 | + | active := make([]bool, len(minimums)) | |||
| 182 | + | for i := range active { | |||
| 183 | + | active[i] = true | |||
| 184 | + | } | |||
| 185 | + | remaining := total | |||
| 186 | + | ||||
| 187 | + | for { | |||
| 188 | + | normalized, sum := normalizedActiveWeights(weights, active) | |||
| 189 | + | pinned := false | |||
| 190 | + | for i := range active { | |||
| 191 | + | if !active[i] { | |||
| 192 | + | continue | |||
| 193 | + | } | |||
| 194 | + | quota := float64(remaining) * normalized[i] / sum | |||
| 195 | + | if quota < float64(minimums[i]) { | |||
| 196 | + | allocations[i] = minimums[i] | |||
| 197 | + | remaining -= minimums[i] | |||
| 198 | + | active[i] = false | |||
| 199 | + | pinned = true | |||
| 200 | + | } | |||
| 201 | + | } | |||
| 202 | + | if !pinned { | |||
| 203 | + | break | |||
| 75 | } | 204 | } | ||
| 76 | - | y += h + 1 | |||
| 77 | } | 205 | } | ||
| 78 | - | return true | 206 | + | |
| 207 | + | normalized, sum := normalizedActiveWeights(weights, active) | |||
| 208 | + | type remainder struct { | |||
| 209 | + | index int | |||
| 210 | + | fraction float64 | |||
| 211 | + | } | |||
| 212 | + | remainders := make([]remainder, 0, len(weights)) | |||
| 213 | + | allocated := total - remaining | |||
| 214 | + | for i := range active { | |||
| 215 | + | if !active[i] { | |||
| 216 | + | continue | |||
| 217 | + | } | |||
| 218 | + | quota := float64(remaining) * normalized[i] / sum | |||
| 219 | + | whole := int(math.Floor(quota)) | |||
| 220 | + | allocations[i] = whole | |||
| 221 | + | allocated += whole | |||
| 222 | + | remainders = append(remainders, remainder{index: i, fraction: quota - float64(whole)}) | |||
| 223 | + | } | |||
| 224 | + | sort.SliceStable(remainders, func(i, j int) bool { | |||
| 225 | + | return remainders[i].fraction > remainders[j].fraction | |||
| 226 | + | }) | |||
| 227 | + | for cells, i := total-allocated, 0; cells > 0; cells, i = cells-1, i+1 { | |||
| 228 | + | allocations[remainders[i%len(remainders)].index]++ | |||
| 229 | + | } | |||
| 230 | + | return allocations, true | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func normalizedActiveWeights(weights []float64, active []bool) ([]float64, float64) { | |||
| 234 | + | largest := 0.0 | |||
| 235 | + | for i, weight := range weights { | |||
| 236 | + | if active[i] { | |||
| 237 | + | largest = math.Max(largest, effectiveWeight(weight)) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | normalized := make([]float64, len(weights)) | |||
| 241 | + | sum := 0.0 | |||
| 242 | + | for i, weight := range weights { | |||
| 243 | + | if !active[i] { | |||
| 244 | + | continue | |||
| 245 | + | } | |||
| 246 | + | normalized[i] = effectiveWeight(weight) / largest | |||
| 247 | + | sum += normalized[i] | |||
| 248 | + | } | |||
| 249 | + | return normalized, sum | |||
| 79 | } | 250 | } | ||
| 80 | 251 | ||||
| 81 | func solveStack(n *Node, r domain.Rect, out *[]Placement) bool { | 252 | func solveStack(n *Node, r domain.Rect, out *[]Placement) bool { | ||
M internal/usecase/layout/ops.go +70 -19
| @@ -18,7 +18,7 @@ var ( | |||
| 18 | 18 | // is placed after target on the chosen axis. | |
| 19 | 19 | func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error { | |
| 20 | 20 | candidate := t.clone() | |
| 21 | - | if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID) { | |
| 21 | + | if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID, area) { | |
| 22 | 22 | return ErrNotFound | |
| 23 | 23 | } | |
| 24 | 24 | candidate.Focus = newID | |
| @@ -29,14 +29,16 @@ func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, are | |||
| 29 | 29 | return nil | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | - | func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool { | |
| 32 | + | func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID, area domain.Rect) bool { | |
| 33 | 33 | if n == nil { | |
| 34 | 34 | return false | |
| 35 | 35 | } | |
| 36 | + | childAreas := nodeChildAreas(n, area) | |
| 36 | 37 | for i, child := range n.Children { | |
| 37 | 38 | if child.Kind == Leaf && child.Leaf == target { | |
| 38 | 39 | newLeaf := NewLeaf(newID) | |
| 39 | 40 | if n.Kind == Split && n.Dir == axis { | |
| 41 | + | normalizeChildWeightsFromArea(n, area) | |
| 40 | 42 | at := i | |
| 41 | 43 | if after { | |
| 42 | 44 | at++ | |
| @@ -47,36 +49,82 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID | |||
| 47 | 49 | return true | |
| 48 | 50 | } | |
| 49 | 51 | if n.Kind == Stack { | |
| 52 | + | weight := n.Weight | |
| 50 | 53 | stack := n.clone() | |
| 54 | + | stack.Weight = 0 | |
| 55 | + | clearChildWeights(stack) | |
| 51 | 56 | children := []*Node{stack, newLeaf} | |
| 52 | 57 | if !after { | |
| 53 | 58 | children = []*Node{newLeaf, stack} | |
| 54 | 59 | } | |
| 55 | - | *n = Node{Kind: Split, Dir: axis, Children: children} | |
| 60 | + | *n = Node{Kind: Split, Dir: axis, Children: children, Weight: weight} | |
| 56 | 61 | return true | |
| 57 | 62 | } | |
| 63 | + | weight := child.Weight | |
| 64 | + | child.Weight = 0 | |
| 58 | 65 | children := []*Node{child, newLeaf} | |
| 59 | 66 | if !after { | |
| 60 | 67 | children = []*Node{newLeaf, child} | |
| 61 | 68 | } | |
| 62 | - | n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children} | |
| 69 | + | n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children, Weight: weight} | |
| 63 | 70 | return true | |
| 64 | 71 | } | |
| 65 | - | if insertSplit(child, target, axis, after, newID) { | |
| 72 | + | childArea := area | |
| 73 | + | if i < len(childAreas) { | |
| 74 | + | childArea = childAreas[i] | |
| 75 | + | } | |
| 76 | + | if insertSplit(child, target, axis, after, newID, childArea) { | |
| 66 | 77 | return true | |
| 67 | 78 | } | |
| 68 | 79 | } | |
| 69 | 80 | if n.Kind == Leaf && n.Leaf == target { | |
| 70 | - | children := []*Node{n.clone(), NewLeaf(newID)} | |
| 81 | + | weight := n.Weight | |
| 82 | + | existing := n.clone() | |
| 83 | + | existing.Weight = 0 | |
| 84 | + | children := []*Node{existing, NewLeaf(newID)} | |
| 71 | 85 | if !after { | |
| 72 | - | children = []*Node{NewLeaf(newID), n.clone()} | |
| 86 | + | children = []*Node{NewLeaf(newID), existing} | |
| 73 | 87 | } | |
| 74 | - | *n = Node{Kind: Split, Dir: axis, Children: children} | |
| 88 | + | *n = Node{Kind: Split, Dir: axis, Children: children, Weight: weight} | |
| 75 | 89 | return true | |
| 76 | 90 | } | |
| 77 | 91 | return false | |
| 78 | 92 | } | |
| 79 | 93 | ||
| 94 | + | func nodeChildAreas(n *Node, area domain.Rect) []domain.Rect { | |
| 95 | + | if n.Kind == Split { | |
| 96 | + | rects, ok := splitChildRects(n, area) | |
| 97 | + | if ok { | |
| 98 | + | return rects | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | areas := make([]domain.Rect, len(n.Children)) | |
| 102 | + | for i := range areas { | |
| 103 | + | areas[i] = area | |
| 104 | + | } | |
| 105 | + | return areas | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func normalizeChildWeightsFromArea(n *Node, area domain.Rect) { | |
| 109 | + | rects, ok := splitChildRects(n, area) | |
| 110 | + | if !ok || len(rects) == 0 { | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | for i, rect := range rects { | |
| 114 | + | extent := rect.Width | |
| 115 | + | if n.Dir == Vertical { | |
| 116 | + | extent = rect.Height | |
| 117 | + | } | |
| 118 | + | n.Children[i].Weight = float64(extent) | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func clearChildWeights(n *Node) { | |
| 123 | + | for _, child := range n.Children { | |
| 124 | + | child.Weight = 0 | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 80 | 128 | // StackNew puts newID in target's stack, creating one if target is not stacked. | |
| 81 | 129 | func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | |
| 82 | 130 | candidate := t.clone() | |
| @@ -100,13 +148,16 @@ func stackNew(n *Node, target, newID PaneID) bool { | |||
| 100 | 148 | if child.Kind == Leaf && child.Leaf == target { | |
| 101 | 149 | n.Children = append(n.Children, NewLeaf(newID)) | |
| 102 | 150 | n.Expanded = newID | |
| 151 | + | clearChildWeights(n) | |
| 103 | 152 | return true | |
| 104 | 153 | } | |
| 105 | 154 | } | |
| 106 | 155 | } | |
| 107 | 156 | for i, child := range n.Children { | |
| 108 | 157 | if child.Kind == Leaf && child.Leaf == target { | |
| 109 | - | n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID} | |
| 158 | + | weight := child.Weight | |
| 159 | + | child.Weight = 0 | |
| 160 | + | n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID, Weight: weight} | |
| 110 | 161 | return true | |
| 111 | 162 | } | |
| 112 | 163 | if stackNew(child, target, newID) { | |
| @@ -114,7 +165,8 @@ func stackNew(n *Node, target, newID PaneID) bool { | |||
| 114 | 165 | } | |
| 115 | 166 | } | |
| 116 | 167 | if n.Kind == Leaf && n.Leaf == target { | |
| 117 | - | *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID} | |
| 168 | + | weight := n.Weight | |
| 169 | + | *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID, Weight: weight} | |
| 118 | 170 | return true | |
| 119 | 171 | } | |
| 120 | 172 | return false | |
| @@ -169,11 +221,13 @@ func toggleStack(n *Node, target PaneID) toggleResult { | |||
| 169 | 221 | n.Kind = Split | |
| 170 | 222 | n.Dir = Vertical | |
| 171 | 223 | n.Expanded = "" | |
| 224 | + | clearChildWeights(n) | |
| 172 | 225 | return toggleToggled | |
| 173 | 226 | } | |
| 174 | 227 | if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) { | |
| 175 | 228 | n.Kind = Stack | |
| 176 | 229 | n.Expanded = target | |
| 230 | + | clearChildWeights(n) | |
| 177 | 231 | return toggleToggled | |
| 178 | 232 | } | |
| 179 | 233 | return toggleNotToggleable | |
| @@ -252,16 +306,13 @@ func closeNode(n *Node, target PaneID) (*Node, bool) { | |||
| 252 | 306 | if !removed { | |
| 253 | 307 | return n, false | |
| 254 | 308 | } | |
| 255 | - | if n.Kind == Stack { | |
| 256 | - | if n.Expanded == target && len(n.Children) > 0 { | |
| 257 | - | n.Expanded = firstLeaf(n.Children[0]) | |
| 258 | - | } | |
| 259 | - | if len(n.Children) == 1 { | |
| 260 | - | return n.Children[0], true | |
| 261 | - | } | |
| 309 | + | if n.Kind == Stack && n.Expanded == target && len(n.Children) > 0 { | |
| 310 | + | n.Expanded = firstLeaf(n.Children[0]) | |
| 262 | 311 | } | |
| 263 | - | if n.Kind == Split && len(n.Children) == 1 { | |
| 264 | - | return n.Children[0], true | |
| 312 | + | if (n.Kind == Stack || n.Kind == Split) && len(n.Children) == 1 { | |
| 313 | + | promoted := n.Children[0] | |
| 314 | + | promoted.Weight = n.Weight | |
| 315 | + | return promoted, true | |
| 265 | 316 | } | |
| 266 | 317 | return n, true | |
| 267 | 318 | } | |
| @@ -18,7 +18,7 @@ var ( | |||||
| 18 | // is placed after target on the chosen axis. | 18 | // 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 { | 19 | func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, area domain.Rect) error { | ||
| 20 | candidate := t.clone() | 20 | candidate := t.clone() | ||
| 21 | - | if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID) { | 21 | + | if candidate == nil || candidate.Root == nil || !insertSplit(candidate.Root, target, axisFor(dir), after, newID, area) { |
| 22 | return ErrNotFound | 22 | return ErrNotFound | ||
| 23 | } | 23 | } | ||
| 24 | candidate.Focus = newID | 24 | candidate.Focus = newID | ||
| @@ -29,14 +29,16 @@ func (t *Tree) Split(target PaneID, dir Direction, after bool, newID PaneID, are | |||||
| 29 | return nil | 29 | return nil | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | - | func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID) bool { | 32 | + | func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID, area domain.Rect) bool { |
| 33 | if n == nil { | 33 | if n == nil { | ||
| 34 | return false | 34 | return false | ||
| 35 | } | 35 | } | ||
| 36 | + | childAreas := nodeChildAreas(n, area) | |||
| 36 | for i, child := range n.Children { | 37 | for i, child := range n.Children { | ||
| 37 | if child.Kind == Leaf && child.Leaf == target { | 38 | if child.Kind == Leaf && child.Leaf == target { | ||
| 38 | newLeaf := NewLeaf(newID) | 39 | newLeaf := NewLeaf(newID) | ||
| 39 | if n.Kind == Split && n.Dir == axis { | 40 | if n.Kind == Split && n.Dir == axis { | ||
| 41 | + | normalizeChildWeightsFromArea(n, area) | |||
| 40 | at := i | 42 | at := i | ||
| 41 | if after { | 43 | if after { | ||
| 42 | at++ | 44 | at++ | ||
| @@ -47,36 +49,82 @@ func insertSplit(n *Node, target PaneID, axis SplitDir, after bool, newID PaneID | |||||
| 47 | return true | 49 | return true | ||
| 48 | } | 50 | } | ||
| 49 | if n.Kind == Stack { | 51 | if n.Kind == Stack { | ||
| 52 | + | weight := n.Weight | |||
| 50 | stack := n.clone() | 53 | stack := n.clone() | ||
| 54 | + | stack.Weight = 0 | |||
| 55 | + | clearChildWeights(stack) | |||
| 51 | children := []*Node{stack, newLeaf} | 56 | children := []*Node{stack, newLeaf} | ||
| 52 | if !after { | 57 | if !after { | ||
| 53 | children = []*Node{newLeaf, stack} | 58 | children = []*Node{newLeaf, stack} | ||
| 54 | } | 59 | } | ||
| 55 | - | *n = Node{Kind: Split, Dir: axis, Children: children} | 60 | + | *n = Node{Kind: Split, Dir: axis, Children: children, Weight: weight} |
| 56 | return true | 61 | return true | ||
| 57 | } | 62 | } | ||
| 63 | + | weight := child.Weight | |||
| 64 | + | child.Weight = 0 | |||
| 58 | children := []*Node{child, newLeaf} | 65 | children := []*Node{child, newLeaf} | ||
| 59 | if !after { | 66 | if !after { | ||
| 60 | children = []*Node{newLeaf, child} | 67 | children = []*Node{newLeaf, child} | ||
| 61 | } | 68 | } | ||
| 62 | - | n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children} | 69 | + | n.Children[i] = &Node{Kind: Split, Dir: axis, Children: children, Weight: weight} |
| 63 | return true | 70 | return true | ||
| 64 | } | 71 | } | ||
| 65 | - | if insertSplit(child, target, axis, after, newID) { | 72 | + | childArea := area |
| 73 | + | if i < len(childAreas) { | |||
| 74 | + | childArea = childAreas[i] | |||
| 75 | + | } | |||
| 76 | + | if insertSplit(child, target, axis, after, newID, childArea) { | |||
| 66 | return true | 77 | return true | ||
| 67 | } | 78 | } | ||
| 68 | } | 79 | } | ||
| 69 | if n.Kind == Leaf && n.Leaf == target { | 80 | if n.Kind == Leaf && n.Leaf == target { | ||
| 70 | - | children := []*Node{n.clone(), NewLeaf(newID)} | 81 | + | weight := n.Weight |
| 82 | + | existing := n.clone() | |||
| 83 | + | existing.Weight = 0 | |||
| 84 | + | children := []*Node{existing, NewLeaf(newID)} | |||
| 71 | if !after { | 85 | if !after { | ||
| 72 | - | children = []*Node{NewLeaf(newID), n.clone()} | 86 | + | children = []*Node{NewLeaf(newID), existing} |
| 73 | } | 87 | } | ||
| 74 | - | *n = Node{Kind: Split, Dir: axis, Children: children} | 88 | + | *n = Node{Kind: Split, Dir: axis, Children: children, Weight: weight} |
| 75 | return true | 89 | return true | ||
| 76 | } | 90 | } | ||
| 77 | return false | 91 | return false | ||
| 78 | } | 92 | } | ||
| 79 | 93 | ||||
| 94 | + | func nodeChildAreas(n *Node, area domain.Rect) []domain.Rect { | |||
| 95 | + | if n.Kind == Split { | |||
| 96 | + | rects, ok := splitChildRects(n, area) | |||
| 97 | + | if ok { | |||
| 98 | + | return rects | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | areas := make([]domain.Rect, len(n.Children)) | |||
| 102 | + | for i := range areas { | |||
| 103 | + | areas[i] = area | |||
| 104 | + | } | |||
| 105 | + | return areas | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func normalizeChildWeightsFromArea(n *Node, area domain.Rect) { | |||
| 109 | + | rects, ok := splitChildRects(n, area) | |||
| 110 | + | if !ok || len(rects) == 0 { | |||
| 111 | + | return | |||
| 112 | + | } | |||
| 113 | + | for i, rect := range rects { | |||
| 114 | + | extent := rect.Width | |||
| 115 | + | if n.Dir == Vertical { | |||
| 116 | + | extent = rect.Height | |||
| 117 | + | } | |||
| 118 | + | n.Children[i].Weight = float64(extent) | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func clearChildWeights(n *Node) { | |||
| 123 | + | for _, child := range n.Children { | |||
| 124 | + | child.Weight = 0 | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 80 | // StackNew puts newID in target's stack, creating one if target is not stacked. | 128 | // StackNew puts newID in target's stack, creating one if target is not stacked. | ||
| 81 | func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | 129 | func (t *Tree) StackNew(target, newID PaneID, area domain.Rect) error { | ||
| 82 | candidate := t.clone() | 130 | candidate := t.clone() | ||
| @@ -100,13 +148,16 @@ func stackNew(n *Node, target, newID PaneID) bool { | |||||
| 100 | if child.Kind == Leaf && child.Leaf == target { | 148 | if child.Kind == Leaf && child.Leaf == target { | ||
| 101 | n.Children = append(n.Children, NewLeaf(newID)) | 149 | n.Children = append(n.Children, NewLeaf(newID)) | ||
| 102 | n.Expanded = newID | 150 | n.Expanded = newID | ||
| 151 | + | clearChildWeights(n) | |||
| 103 | return true | 152 | return true | ||
| 104 | } | 153 | } | ||
| 105 | } | 154 | } | ||
| 106 | } | 155 | } | ||
| 107 | for i, child := range n.Children { | 156 | for i, child := range n.Children { | ||
| 108 | if child.Kind == Leaf && child.Leaf == target { | 157 | if child.Kind == Leaf && child.Leaf == target { | ||
| 109 | - | n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID} | 158 | + | weight := child.Weight |
| 159 | + | child.Weight = 0 | |||
| 160 | + | n.Children[i] = &Node{Kind: Stack, Children: []*Node{child, NewLeaf(newID)}, Expanded: newID, Weight: weight} | |||
| 110 | return true | 161 | return true | ||
| 111 | } | 162 | } | ||
| 112 | if stackNew(child, target, newID) { | 163 | if stackNew(child, target, newID) { | ||
| @@ -114,7 +165,8 @@ func stackNew(n *Node, target, newID PaneID) bool { | |||||
| 114 | } | 165 | } | ||
| 115 | } | 166 | } | ||
| 116 | if n.Kind == Leaf && n.Leaf == target { | 167 | if n.Kind == Leaf && n.Leaf == target { | ||
| 117 | - | *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID} | 168 | + | weight := n.Weight |
| 169 | + | *n = Node{Kind: Stack, Children: []*Node{NewLeaf(target), NewLeaf(newID)}, Expanded: newID, Weight: weight} | |||
| 118 | return true | 170 | return true | ||
| 119 | } | 171 | } | ||
| 120 | return false | 172 | return false | ||
| @@ -169,11 +221,13 @@ func toggleStack(n *Node, target PaneID) toggleResult { | |||||
| 169 | n.Kind = Split | 221 | n.Kind = Split | ||
| 170 | n.Dir = Vertical | 222 | n.Dir = Vertical | ||
| 171 | n.Expanded = "" | 223 | n.Expanded = "" | ||
| 224 | + | clearChildWeights(n) | |||
| 172 | return toggleToggled | 225 | return toggleToggled | ||
| 173 | } | 226 | } | ||
| 174 | if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) { | 227 | if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) { | ||
| 175 | n.Kind = Stack | 228 | n.Kind = Stack | ||
| 176 | n.Expanded = target | 229 | n.Expanded = target | ||
| 230 | + | clearChildWeights(n) | |||
| 177 | return toggleToggled | 231 | return toggleToggled | ||
| 178 | } | 232 | } | ||
| 179 | return toggleNotToggleable | 233 | return toggleNotToggleable | ||
| @@ -252,16 +306,13 @@ func closeNode(n *Node, target PaneID) (*Node, bool) { | |||||
| 252 | if !removed { | 306 | if !removed { | ||
| 253 | return n, false | 307 | return n, false | ||
| 254 | } | 308 | } | ||
| 255 | - | if n.Kind == Stack { | 309 | + | if n.Kind == Stack && n.Expanded == target && len(n.Children) > 0 { |
| 256 | - | if n.Expanded == target && len(n.Children) > 0 { | 310 | + | n.Expanded = firstLeaf(n.Children[0]) |
| 257 | - | n.Expanded = firstLeaf(n.Children[0]) | |||
| 258 | - | } | |||
| 259 | - | if len(n.Children) == 1 { | |||
| 260 | - | return n.Children[0], true | |||
| 261 | - | } | |||
| 262 | } | 311 | } | ||
| 263 | - | if n.Kind == Split && len(n.Children) == 1 { | 312 | + | if (n.Kind == Stack || n.Kind == Split) && len(n.Children) == 1 { |
| 264 | - | return n.Children[0], true | 313 | + | promoted := n.Children[0] |
| 314 | + | promoted.Weight = n.Weight | |||
| 315 | + | return promoted, true | |||
| 265 | } | 316 | } | ||
| 266 | return n, true | 317 | return n, true | ||
| 267 | } | 318 | } | ||
M internal/usecase/layout/tree.go +1 -0
| @@ -46,6 +46,7 @@ type Node struct { | |||
| 46 | 46 | Children []*Node | |
| 47 | 47 | Leaf PaneID | |
| 48 | 48 | Expanded PaneID | |
| 49 | + | Weight float64 | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 51 | 52 | // Tree is a pane layout plus its focused pane. | |
| @@ -46,6 +46,7 @@ type Node struct { | |||||
| 46 | Children []*Node | 46 | Children []*Node | ||
| 47 | Leaf PaneID | 47 | Leaf PaneID | ||
| 48 | Expanded PaneID | 48 | Expanded PaneID | ||
| 49 | + | Weight float64 | |||
| 49 | } | 50 | } | ||
| 50 | 51 | ||||
| 51 | // Tree is a pane layout plus its focused pane. | 52 | // Tree is a pane layout plus its focused pane. | ||
A internal/usecase/layout/weights_test.go +340 -0
| @@ -0,0 +1,340 @@ | |||
| 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 TestWeightedSplitAllocation(t *testing.T) { | |
| 11 | + | t.Parallel() | |
| 12 | + | ||
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | root *Node | |
| 16 | + | area domain.Rect | |
| 17 | + | want map[PaneID]int | |
| 18 | + | }{ | |
| 19 | + | { | |
| 20 | + | name: "zero weights preserve equal split and remainder order", | |
| 21 | + | root: split(Horizontal, NewLeaf("a"), NewLeaf("b"), NewLeaf("c")), | |
| 22 | + | area: domain.Rect{Width: 64, Height: 2}, | |
| 23 | + | want: map[PaneID]int{"a": 21, "b": 21, "c": 20}, | |
| 24 | + | }, | |
| 25 | + | { | |
| 26 | + | name: "two to one weights", | |
| 27 | + | root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), | |
| 28 | + | area: domain.Rect{Width: 61, Height: 2}, | |
| 29 | + | want: map[PaneID]int{"a": 40, "b": 20}, | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "largest remainder ties use child order", | |
| 33 | + | root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 2)), | |
| 34 | + | area: domain.Rect{Width: 84, Height: 2}, | |
| 35 | + | want: map[PaneID]int{"a": 21, "b": 20, "c": 41}, | |
| 36 | + | }, | |
| 37 | + | { | |
| 38 | + | name: "minimum constrained children pin and remainder redistributes proportionally", | |
| 39 | + | root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 8)), | |
| 40 | + | area: domain.Rect{Width: 102, Height: 2}, | |
| 41 | + | want: map[PaneID]int{"a": 20, "b": 20, "c": 60}, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | name: "negative and zero in memory weights use default", | |
| 45 | + | root: split(Horizontal, weightedLeaf("a", -4), weightedLeaf("b", 0), weightedLeaf("c", 2)), | |
| 46 | + | area: domain.Rect{Width: 82, Height: 2}, | |
| 47 | + | want: map[PaneID]int{"a": 20, "b": 20, "c": 40}, | |
| 48 | + | }, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | for _, tt := range tests { | |
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |
| 53 | + | placements, ok := Solve(tt.root, tt.area) | |
| 54 | + | require.True(t, ok) | |
| 55 | + | for id, want := range tt.want { | |
| 56 | + | require.Equal(t, want, contents(placements)[id].Width) | |
| 57 | + | } | |
| 58 | + | }) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestWeightedSplitRecursiveMinima(t *testing.T) { | |
| 63 | + | t.Parallel() | |
| 64 | + | ||
| 65 | + | tests := []struct { | |
| 66 | + | name string | |
| 67 | + | root *Node | |
| 68 | + | area domain.Rect | |
| 69 | + | wantOK bool | |
| 70 | + | want map[PaneID]domain.Rect | |
| 71 | + | }{ | |
| 72 | + | { | |
| 73 | + | name: "same axis nested minima sum children and separators", | |
| 74 | + | root: split(Horizontal, | |
| 75 | + | weightedNode(split(Horizontal, NewLeaf("a"), NewLeaf("b")), 1), | |
| 76 | + | weightedLeaf("c", 9), | |
| 77 | + | ), | |
| 78 | + | area: domain.Rect{Width: 102, Height: 2}, | |
| 79 | + | wantOK: true, | |
| 80 | + | want: map[PaneID]domain.Rect{ | |
| 81 | + | "a": {Width: 20, Height: 2}, | |
| 82 | + | "b": {X: 21, Width: 20, Height: 2}, | |
| 83 | + | "c": {X: 42, Width: 60, Height: 2}, | |
| 84 | + | }, | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "cross axis nested minima take maximum", | |
| 88 | + | root: split(Horizontal, | |
| 89 | + | split(Vertical, NewLeaf("a"), NewLeaf("b")), | |
| 90 | + | NewLeaf("c"), | |
| 91 | + | ), | |
| 92 | + | area: domain.Rect{Width: 41, Height: 5}, | |
| 93 | + | wantOK: true, | |
| 94 | + | }, | |
| 95 | + | { | |
| 96 | + | name: "stack vertical minimum is titles plus one content row", | |
| 97 | + | root: split(Vertical, | |
| 98 | + | weightedNode(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}, 1), | |
| 99 | + | weightedLeaf("d", 9), | |
| 100 | + | ), | |
| 101 | + | area: domain.Rect{Width: 20, Height: 7}, | |
| 102 | + | wantOK: true, | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | name: "insufficient same axis recursive minimum fails without placements", | |
| 106 | + | root: split(Horizontal, | |
| 107 | + | split(Horizontal, NewLeaf("a"), NewLeaf("b")), | |
| 108 | + | NewLeaf("c"), | |
| 109 | + | ), | |
| 110 | + | area: domain.Rect{Width: 61, Height: 2}, | |
| 111 | + | wantOK: false, | |
| 112 | + | }, | |
| 113 | + | { | |
| 114 | + | name: "insufficient cross axis recursive minimum fails without placements", | |
| 115 | + | root: split(Horizontal, | |
| 116 | + | split(Vertical, NewLeaf("a"), NewLeaf("b")), | |
| 117 | + | NewLeaf("c"), | |
| 118 | + | ), | |
| 119 | + | area: domain.Rect{Width: 41, Height: 4}, | |
| 120 | + | wantOK: false, | |
| 121 | + | }, | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | for _, tt := range tests { | |
| 125 | + | t.Run(tt.name, func(t *testing.T) { | |
| 126 | + | placements, ok := Solve(tt.root, tt.area) | |
| 127 | + | require.Equal(t, tt.wantOK, ok) | |
| 128 | + | if !tt.wantOK { | |
| 129 | + | require.Nil(t, placements) | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | if tt.want != nil { | |
| 133 | + | require.Equal(t, tt.want, contents(placements)) | |
| 134 | + | } | |
| 135 | + | }) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestWeightSameAxisSplitNormalizesSolvedExtentsBeforeInsertion(t *testing.T) { | |
| 140 | + | t.Parallel() | |
| 141 | + | ||
| 142 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"} | |
| 143 | + | area := domain.Rect{Width: 82, Height: 2} | |
| 144 | + | require.NoError(t, tr.Split("b", Right, true, "c", area)) | |
| 145 | + | ||
| 146 | + | require.Equal(t, 54.0, tr.Root.Children[0].Weight) | |
| 147 | + | require.Equal(t, 27.0, tr.Root.Children[1].Weight) | |
| 148 | + | require.Zero(t, tr.Root.Children[2].Weight) | |
| 149 | + | placements, ok := Solve(tr.Root, area) | |
| 150 | + | require.True(t, ok) | |
| 151 | + | require.Equal(t, map[PaneID]int{"a": 40, "b": 20, "c": 20}, placementWidths(placements)) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestWeightCrossAxisSplitTransfersParentShareAndClearsMembers(t *testing.T) { | |
| 155 | + | t.Parallel() | |
| 156 | + | ||
| 157 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"} | |
| 158 | + | require.NoError(t, tr.Split("a", Down, true, "c", domain.Rect{Width: 81, Height: 5})) | |
| 159 | + | ||
| 160 | + | wrapper := tr.Root.Children[0] | |
| 161 | + | require.Equal(t, Vertical, wrapper.Dir) | |
| 162 | + | require.Equal(t, 3.0, wrapper.Weight) | |
| 163 | + | require.Zero(t, wrapper.Children[0].Weight) | |
| 164 | + | require.Zero(t, wrapper.Children[1].Weight) | |
| 165 | + | placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 5}) | |
| 166 | + | require.True(t, ok) | |
| 167 | + | require.Equal(t, 60, contents(placements)["a"].Width) | |
| 168 | + | require.Equal(t, 20, contents(placements)["b"].Width) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestWeightStackNewTransfersParentShareAndClearsMembers(t *testing.T) { | |
| 172 | + | t.Parallel() | |
| 173 | + | ||
| 174 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"} | |
| 175 | + | require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 81, Height: 3})) | |
| 176 | + | ||
| 177 | + | stackNode := tr.Root.Children[0] | |
| 178 | + | require.Equal(t, Stack, stackNode.Kind) | |
| 179 | + | require.Equal(t, 3.0, stackNode.Weight) | |
| 180 | + | require.Zero(t, stackNode.Children[0].Weight) | |
| 181 | + | require.Zero(t, stackNode.Children[1].Weight) | |
| 182 | + | placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 3}) | |
| 183 | + | require.True(t, ok) | |
| 184 | + | require.Equal(t, 60, contents(placements)["c"].Width) | |
| 185 | + | require.Equal(t, 20, contents(placements)["b"].Width) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestWeightStackNewClearsExistingStackMemberWeights(t *testing.T) { | |
| 189 | + | t.Parallel() | |
| 190 | + | ||
| 191 | + | tr := &Tree{Root: &Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 3), weightedLeaf("b", 1)}, Expanded: "a", Weight: 4}, Focus: "a"} | |
| 192 | + | require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 20, Height: 4})) | |
| 193 | + | ||
| 194 | + | require.Equal(t, 4.0, tr.Root.Weight) | |
| 195 | + | for _, child := range tr.Root.Children { | |
| 196 | + | require.Zero(t, child.Weight) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestWeightToggleStackClearsMemberWeights(t *testing.T) { | |
| 201 | + | t.Parallel() | |
| 202 | + | ||
| 203 | + | tests := []struct { | |
| 204 | + | name string | |
| 205 | + | root *Node | |
| 206 | + | wantKind Kind | |
| 207 | + | }{ | |
| 208 | + | { | |
| 209 | + | name: "split to stack", | |
| 210 | + | root: &Node{Kind: Split, Dir: Vertical, Weight: 3, Children: []*Node{ | |
| 211 | + | weightedLeaf("a", 2), weightedLeaf("b", 1), | |
| 212 | + | }}, | |
| 213 | + | wantKind: Stack, | |
| 214 | + | }, | |
| 215 | + | { | |
| 216 | + | name: "stack to split", | |
| 217 | + | root: &Node{Kind: Stack, Weight: 3, Children: []*Node{ | |
| 218 | + | weightedLeaf("a", 2), weightedLeaf("b", 1), | |
| 219 | + | }, Expanded: "a"}, | |
| 220 | + | wantKind: Split, | |
| 221 | + | }, | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | for _, tt := range tests { | |
| 225 | + | t.Run(tt.name, func(t *testing.T) { | |
| 226 | + | tr := &Tree{Root: split(Horizontal, tt.root, weightedLeaf("c", 1)), Focus: "a"} | |
| 227 | + | area := domain.Rect{Width: 81, Height: 5} | |
| 228 | + | require.NoError(t, tr.ToggleStack("a", area)) | |
| 229 | + | container := tr.Root.Children[0] | |
| 230 | + | require.Equal(t, tt.wantKind, container.Kind) | |
| 231 | + | require.Equal(t, 3.0, container.Weight) | |
| 232 | + | for _, child := range container.Children { | |
| 233 | + | require.Zero(t, child.Weight) | |
| 234 | + | } | |
| 235 | + | placements, ok := Solve(tr.Root, area) | |
| 236 | + | require.True(t, ok) | |
| 237 | + | require.Equal(t, 60, contents(placements)["a"].Width) | |
| 238 | + | require.Equal(t, 20, contents(placements)["c"].Width) | |
| 239 | + | if tt.wantKind == Split { | |
| 240 | + | require.Equal(t, map[PaneID]int{"a": 2, "b": 2, "c": 5}, placementHeights(placements)) | |
| 241 | + | } | |
| 242 | + | }) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func TestWeightClosePromotionTransfersContainerShare(t *testing.T) { | |
| 247 | + | t.Parallel() | |
| 248 | + | ||
| 249 | + | tests := []struct { | |
| 250 | + | name string | |
| 251 | + | container *Node | |
| 252 | + | }{ | |
| 253 | + | { | |
| 254 | + | name: "split", | |
| 255 | + | container: split(Vertical, NewLeaf("a"), NewLeaf("b")), | |
| 256 | + | }, | |
| 257 | + | { | |
| 258 | + | name: "stack", | |
| 259 | + | container: &Node{Kind: Stack, Children: []*Node{ | |
| 260 | + | NewLeaf("a"), NewLeaf("b"), | |
| 261 | + | }, Expanded: "a"}, | |
| 262 | + | }, | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | for _, tt := range tests { | |
| 266 | + | t.Run(tt.name, func(t *testing.T) { | |
| 267 | + | tr := &Tree{Root: split(Horizontal, | |
| 268 | + | weightedNode(tt.container, 3), | |
| 269 | + | weightedLeaf("c", 1), | |
| 270 | + | ), Focus: "a"} | |
| 271 | + | area := domain.Rect{Width: 81, Height: 5} | |
| 272 | + | before, ok := Solve(tr.Root, area) | |
| 273 | + | require.True(t, ok) | |
| 274 | + | require.Equal(t, 60, contents(before)["a"].Width) | |
| 275 | + | ||
| 276 | + | require.NoError(t, tr.Close("a")) | |
| 277 | + | require.Equal(t, PaneID("b"), tr.Root.Children[0].Leaf) | |
| 278 | + | require.Equal(t, 3.0, tr.Root.Children[0].Weight) | |
| 279 | + | after, ok := Solve(tr.Root, area) | |
| 280 | + | require.True(t, ok) | |
| 281 | + | require.Equal(t, 60, contents(after)["b"].Width) | |
| 282 | + | require.Equal(t, 20, contents(after)["c"].Width) | |
| 283 | + | }) | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func TestWeightSplitStackWrapperTransfersShare(t *testing.T) { | |
| 288 | + | t.Parallel() | |
| 289 | + | ||
| 290 | + | tr := &Tree{Root: &Node{Kind: Stack, Weight: 4, Children: []*Node{ | |
| 291 | + | weightedLeaf("a", 2), weightedLeaf("b", 1), | |
| 292 | + | }, Expanded: "a"}, Focus: "a"} | |
| 293 | + | require.NoError(t, tr.Split("a", Right, true, "c", domain.Rect{Width: 41, Height: 3})) | |
| 294 | + | ||
| 295 | + | require.Equal(t, 4.0, tr.Root.Weight) | |
| 296 | + | require.Zero(t, tr.Root.Children[0].Weight) | |
| 297 | + | require.Equal(t, Stack, tr.Root.Children[0].Kind) | |
| 298 | + | for _, member := range tr.Root.Children[0].Children { | |
| 299 | + | require.Zero(t, member.Weight) | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | |
| 304 | + | t.Parallel() | |
| 305 | + | ||
| 306 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"} | |
| 307 | + | clone := tr.Clone() | |
| 308 | + | require.Equal(t, tr, clone) | |
| 309 | + | clone.Root.Children[0].Weight = 9 | |
| 310 | + | require.Equal(t, 2.0, tr.Root.Children[0].Weight) | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func split(dir SplitDir, children ...*Node) *Node { | |
| 314 | + | return &Node{Kind: Split, Dir: dir, Children: children} | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func weightedLeaf(id PaneID, weight float64) *Node { | |
| 318 | + | return &Node{Kind: Leaf, Leaf: id, Weight: weight} | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func weightedNode(n *Node, weight float64) *Node { | |
| 322 | + | n.Weight = weight | |
| 323 | + | return n | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func placementWidths(placements []Placement) map[PaneID]int { | |
| 327 | + | out := make(map[PaneID]int, len(placements)) | |
| 328 | + | for _, placement := range placements { | |
| 329 | + | out[placement.ID] = placement.Content.Width | |
| 330 | + | } | |
| 331 | + | return out | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func placementHeights(placements []Placement) map[PaneID]int { | |
| 335 | + | out := make(map[PaneID]int, len(placements)) | |
| 336 | + | for _, placement := range placements { | |
| 337 | + | out[placement.ID] = placement.Content.Height | |
| 338 | + | } | |
| 339 | + | return out | |
| 340 | + | } | |
| @@ -0,0 +1,340 @@ | |||||
| 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 TestWeightedSplitAllocation(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | tests := []struct { | |||
| 14 | + | name string | |||
| 15 | + | root *Node | |||
| 16 | + | area domain.Rect | |||
| 17 | + | want map[PaneID]int | |||
| 18 | + | }{ | |||
| 19 | + | { | |||
| 20 | + | name: "zero weights preserve equal split and remainder order", | |||
| 21 | + | root: split(Horizontal, NewLeaf("a"), NewLeaf("b"), NewLeaf("c")), | |||
| 22 | + | area: domain.Rect{Width: 64, Height: 2}, | |||
| 23 | + | want: map[PaneID]int{"a": 21, "b": 21, "c": 20}, | |||
| 24 | + | }, | |||
| 25 | + | { | |||
| 26 | + | name: "two to one weights", | |||
| 27 | + | root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), | |||
| 28 | + | area: domain.Rect{Width: 61, Height: 2}, | |||
| 29 | + | want: map[PaneID]int{"a": 40, "b": 20}, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "largest remainder ties use child order", | |||
| 33 | + | root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 2)), | |||
| 34 | + | area: domain.Rect{Width: 84, Height: 2}, | |||
| 35 | + | want: map[PaneID]int{"a": 21, "b": 20, "c": 41}, | |||
| 36 | + | }, | |||
| 37 | + | { | |||
| 38 | + | name: "minimum constrained children pin and remainder redistributes proportionally", | |||
| 39 | + | root: split(Horizontal, weightedLeaf("a", 1), weightedLeaf("b", 1), weightedLeaf("c", 8)), | |||
| 40 | + | area: domain.Rect{Width: 102, Height: 2}, | |||
| 41 | + | want: map[PaneID]int{"a": 20, "b": 20, "c": 60}, | |||
| 42 | + | }, | |||
| 43 | + | { | |||
| 44 | + | name: "negative and zero in memory weights use default", | |||
| 45 | + | root: split(Horizontal, weightedLeaf("a", -4), weightedLeaf("b", 0), weightedLeaf("c", 2)), | |||
| 46 | + | area: domain.Rect{Width: 82, Height: 2}, | |||
| 47 | + | want: map[PaneID]int{"a": 20, "b": 20, "c": 40}, | |||
| 48 | + | }, | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | for _, tt := range tests { | |||
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 53 | + | placements, ok := Solve(tt.root, tt.area) | |||
| 54 | + | require.True(t, ok) | |||
| 55 | + | for id, want := range tt.want { | |||
| 56 | + | require.Equal(t, want, contents(placements)[id].Width) | |||
| 57 | + | } | |||
| 58 | + | }) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestWeightedSplitRecursiveMinima(t *testing.T) { | |||
| 63 | + | t.Parallel() | |||
| 64 | + | ||||
| 65 | + | tests := []struct { | |||
| 66 | + | name string | |||
| 67 | + | root *Node | |||
| 68 | + | area domain.Rect | |||
| 69 | + | wantOK bool | |||
| 70 | + | want map[PaneID]domain.Rect | |||
| 71 | + | }{ | |||
| 72 | + | { | |||
| 73 | + | name: "same axis nested minima sum children and separators", | |||
| 74 | + | root: split(Horizontal, | |||
| 75 | + | weightedNode(split(Horizontal, NewLeaf("a"), NewLeaf("b")), 1), | |||
| 76 | + | weightedLeaf("c", 9), | |||
| 77 | + | ), | |||
| 78 | + | area: domain.Rect{Width: 102, Height: 2}, | |||
| 79 | + | wantOK: true, | |||
| 80 | + | want: map[PaneID]domain.Rect{ | |||
| 81 | + | "a": {Width: 20, Height: 2}, | |||
| 82 | + | "b": {X: 21, Width: 20, Height: 2}, | |||
| 83 | + | "c": {X: 42, Width: 60, Height: 2}, | |||
| 84 | + | }, | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "cross axis nested minima take maximum", | |||
| 88 | + | root: split(Horizontal, | |||
| 89 | + | split(Vertical, NewLeaf("a"), NewLeaf("b")), | |||
| 90 | + | NewLeaf("c"), | |||
| 91 | + | ), | |||
| 92 | + | area: domain.Rect{Width: 41, Height: 5}, | |||
| 93 | + | wantOK: true, | |||
| 94 | + | }, | |||
| 95 | + | { | |||
| 96 | + | name: "stack vertical minimum is titles plus one content row", | |||
| 97 | + | root: split(Vertical, | |||
| 98 | + | weightedNode(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: "b"}, 1), | |||
| 99 | + | weightedLeaf("d", 9), | |||
| 100 | + | ), | |||
| 101 | + | area: domain.Rect{Width: 20, Height: 7}, | |||
| 102 | + | wantOK: true, | |||
| 103 | + | }, | |||
| 104 | + | { | |||
| 105 | + | name: "insufficient same axis recursive minimum fails without placements", | |||
| 106 | + | root: split(Horizontal, | |||
| 107 | + | split(Horizontal, NewLeaf("a"), NewLeaf("b")), | |||
| 108 | + | NewLeaf("c"), | |||
| 109 | + | ), | |||
| 110 | + | area: domain.Rect{Width: 61, Height: 2}, | |||
| 111 | + | wantOK: false, | |||
| 112 | + | }, | |||
| 113 | + | { | |||
| 114 | + | name: "insufficient cross axis recursive minimum fails without placements", | |||
| 115 | + | root: split(Horizontal, | |||
| 116 | + | split(Vertical, NewLeaf("a"), NewLeaf("b")), | |||
| 117 | + | NewLeaf("c"), | |||
| 118 | + | ), | |||
| 119 | + | area: domain.Rect{Width: 41, Height: 4}, | |||
| 120 | + | wantOK: false, | |||
| 121 | + | }, | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | for _, tt := range tests { | |||
| 125 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 126 | + | placements, ok := Solve(tt.root, tt.area) | |||
| 127 | + | require.Equal(t, tt.wantOK, ok) | |||
| 128 | + | if !tt.wantOK { | |||
| 129 | + | require.Nil(t, placements) | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | if tt.want != nil { | |||
| 133 | + | require.Equal(t, tt.want, contents(placements)) | |||
| 134 | + | } | |||
| 135 | + | }) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestWeightSameAxisSplitNormalizesSolvedExtentsBeforeInsertion(t *testing.T) { | |||
| 140 | + | t.Parallel() | |||
| 141 | + | ||||
| 142 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"} | |||
| 143 | + | area := domain.Rect{Width: 82, Height: 2} | |||
| 144 | + | require.NoError(t, tr.Split("b", Right, true, "c", area)) | |||
| 145 | + | ||||
| 146 | + | require.Equal(t, 54.0, tr.Root.Children[0].Weight) | |||
| 147 | + | require.Equal(t, 27.0, tr.Root.Children[1].Weight) | |||
| 148 | + | require.Zero(t, tr.Root.Children[2].Weight) | |||
| 149 | + | placements, ok := Solve(tr.Root, area) | |||
| 150 | + | require.True(t, ok) | |||
| 151 | + | require.Equal(t, map[PaneID]int{"a": 40, "b": 20, "c": 20}, placementWidths(placements)) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestWeightCrossAxisSplitTransfersParentShareAndClearsMembers(t *testing.T) { | |||
| 155 | + | t.Parallel() | |||
| 156 | + | ||||
| 157 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"} | |||
| 158 | + | require.NoError(t, tr.Split("a", Down, true, "c", domain.Rect{Width: 81, Height: 5})) | |||
| 159 | + | ||||
| 160 | + | wrapper := tr.Root.Children[0] | |||
| 161 | + | require.Equal(t, Vertical, wrapper.Dir) | |||
| 162 | + | require.Equal(t, 3.0, wrapper.Weight) | |||
| 163 | + | require.Zero(t, wrapper.Children[0].Weight) | |||
| 164 | + | require.Zero(t, wrapper.Children[1].Weight) | |||
| 165 | + | placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 5}) | |||
| 166 | + | require.True(t, ok) | |||
| 167 | + | require.Equal(t, 60, contents(placements)["a"].Width) | |||
| 168 | + | require.Equal(t, 20, contents(placements)["b"].Width) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestWeightStackNewTransfersParentShareAndClearsMembers(t *testing.T) { | |||
| 172 | + | t.Parallel() | |||
| 173 | + | ||||
| 174 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 3), weightedLeaf("b", 1)), Focus: "a"} | |||
| 175 | + | require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 81, Height: 3})) | |||
| 176 | + | ||||
| 177 | + | stackNode := tr.Root.Children[0] | |||
| 178 | + | require.Equal(t, Stack, stackNode.Kind) | |||
| 179 | + | require.Equal(t, 3.0, stackNode.Weight) | |||
| 180 | + | require.Zero(t, stackNode.Children[0].Weight) | |||
| 181 | + | require.Zero(t, stackNode.Children[1].Weight) | |||
| 182 | + | placements, ok := Solve(tr.Root, domain.Rect{Width: 81, Height: 3}) | |||
| 183 | + | require.True(t, ok) | |||
| 184 | + | require.Equal(t, 60, contents(placements)["c"].Width) | |||
| 185 | + | require.Equal(t, 20, contents(placements)["b"].Width) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestWeightStackNewClearsExistingStackMemberWeights(t *testing.T) { | |||
| 189 | + | t.Parallel() | |||
| 190 | + | ||||
| 191 | + | tr := &Tree{Root: &Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 3), weightedLeaf("b", 1)}, Expanded: "a", Weight: 4}, Focus: "a"} | |||
| 192 | + | require.NoError(t, tr.StackNew("a", "c", domain.Rect{Width: 20, Height: 4})) | |||
| 193 | + | ||||
| 194 | + | require.Equal(t, 4.0, tr.Root.Weight) | |||
| 195 | + | for _, child := range tr.Root.Children { | |||
| 196 | + | require.Zero(t, child.Weight) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestWeightToggleStackClearsMemberWeights(t *testing.T) { | |||
| 201 | + | t.Parallel() | |||
| 202 | + | ||||
| 203 | + | tests := []struct { | |||
| 204 | + | name string | |||
| 205 | + | root *Node | |||
| 206 | + | wantKind Kind | |||
| 207 | + | }{ | |||
| 208 | + | { | |||
| 209 | + | name: "split to stack", | |||
| 210 | + | root: &Node{Kind: Split, Dir: Vertical, Weight: 3, Children: []*Node{ | |||
| 211 | + | weightedLeaf("a", 2), weightedLeaf("b", 1), | |||
| 212 | + | }}, | |||
| 213 | + | wantKind: Stack, | |||
| 214 | + | }, | |||
| 215 | + | { | |||
| 216 | + | name: "stack to split", | |||
| 217 | + | root: &Node{Kind: Stack, Weight: 3, Children: []*Node{ | |||
| 218 | + | weightedLeaf("a", 2), weightedLeaf("b", 1), | |||
| 219 | + | }, Expanded: "a"}, | |||
| 220 | + | wantKind: Split, | |||
| 221 | + | }, | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | for _, tt := range tests { | |||
| 225 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 226 | + | tr := &Tree{Root: split(Horizontal, tt.root, weightedLeaf("c", 1)), Focus: "a"} | |||
| 227 | + | area := domain.Rect{Width: 81, Height: 5} | |||
| 228 | + | require.NoError(t, tr.ToggleStack("a", area)) | |||
| 229 | + | container := tr.Root.Children[0] | |||
| 230 | + | require.Equal(t, tt.wantKind, container.Kind) | |||
| 231 | + | require.Equal(t, 3.0, container.Weight) | |||
| 232 | + | for _, child := range container.Children { | |||
| 233 | + | require.Zero(t, child.Weight) | |||
| 234 | + | } | |||
| 235 | + | placements, ok := Solve(tr.Root, area) | |||
| 236 | + | require.True(t, ok) | |||
| 237 | + | require.Equal(t, 60, contents(placements)["a"].Width) | |||
| 238 | + | require.Equal(t, 20, contents(placements)["c"].Width) | |||
| 239 | + | if tt.wantKind == Split { | |||
| 240 | + | require.Equal(t, map[PaneID]int{"a": 2, "b": 2, "c": 5}, placementHeights(placements)) | |||
| 241 | + | } | |||
| 242 | + | }) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func TestWeightClosePromotionTransfersContainerShare(t *testing.T) { | |||
| 247 | + | t.Parallel() | |||
| 248 | + | ||||
| 249 | + | tests := []struct { | |||
| 250 | + | name string | |||
| 251 | + | container *Node | |||
| 252 | + | }{ | |||
| 253 | + | { | |||
| 254 | + | name: "split", | |||
| 255 | + | container: split(Vertical, NewLeaf("a"), NewLeaf("b")), | |||
| 256 | + | }, | |||
| 257 | + | { | |||
| 258 | + | name: "stack", | |||
| 259 | + | container: &Node{Kind: Stack, Children: []*Node{ | |||
| 260 | + | NewLeaf("a"), NewLeaf("b"), | |||
| 261 | + | }, Expanded: "a"}, | |||
| 262 | + | }, | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | for _, tt := range tests { | |||
| 266 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 267 | + | tr := &Tree{Root: split(Horizontal, | |||
| 268 | + | weightedNode(tt.container, 3), | |||
| 269 | + | weightedLeaf("c", 1), | |||
| 270 | + | ), Focus: "a"} | |||
| 271 | + | area := domain.Rect{Width: 81, Height: 5} | |||
| 272 | + | before, ok := Solve(tr.Root, area) | |||
| 273 | + | require.True(t, ok) | |||
| 274 | + | require.Equal(t, 60, contents(before)["a"].Width) | |||
| 275 | + | ||||
| 276 | + | require.NoError(t, tr.Close("a")) | |||
| 277 | + | require.Equal(t, PaneID("b"), tr.Root.Children[0].Leaf) | |||
| 278 | + | require.Equal(t, 3.0, tr.Root.Children[0].Weight) | |||
| 279 | + | after, ok := Solve(tr.Root, area) | |||
| 280 | + | require.True(t, ok) | |||
| 281 | + | require.Equal(t, 60, contents(after)["b"].Width) | |||
| 282 | + | require.Equal(t, 20, contents(after)["c"].Width) | |||
| 283 | + | }) | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func TestWeightSplitStackWrapperTransfersShare(t *testing.T) { | |||
| 288 | + | t.Parallel() | |||
| 289 | + | ||||
| 290 | + | tr := &Tree{Root: &Node{Kind: Stack, Weight: 4, Children: []*Node{ | |||
| 291 | + | weightedLeaf("a", 2), weightedLeaf("b", 1), | |||
| 292 | + | }, Expanded: "a"}, Focus: "a"} | |||
| 293 | + | require.NoError(t, tr.Split("a", Right, true, "c", domain.Rect{Width: 41, Height: 3})) | |||
| 294 | + | ||||
| 295 | + | require.Equal(t, 4.0, tr.Root.Weight) | |||
| 296 | + | require.Zero(t, tr.Root.Children[0].Weight) | |||
| 297 | + | require.Equal(t, Stack, tr.Root.Children[0].Kind) | |||
| 298 | + | for _, member := range tr.Root.Children[0].Children { | |||
| 299 | + | require.Zero(t, member.Weight) | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | |||
| 304 | + | t.Parallel() | |||
| 305 | + | ||||
| 306 | + | tr := &Tree{Root: split(Horizontal, weightedLeaf("a", 2), weightedLeaf("b", 1)), Focus: "a"} | |||
| 307 | + | clone := tr.Clone() | |||
| 308 | + | require.Equal(t, tr, clone) | |||
| 309 | + | clone.Root.Children[0].Weight = 9 | |||
| 310 | + | require.Equal(t, 2.0, tr.Root.Children[0].Weight) | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func split(dir SplitDir, children ...*Node) *Node { | |||
| 314 | + | return &Node{Kind: Split, Dir: dir, Children: children} | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func weightedLeaf(id PaneID, weight float64) *Node { | |||
| 318 | + | return &Node{Kind: Leaf, Leaf: id, Weight: weight} | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func weightedNode(n *Node, weight float64) *Node { | |||
| 322 | + | n.Weight = weight | |||
| 323 | + | return n | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func placementWidths(placements []Placement) map[PaneID]int { | |||
| 327 | + | out := make(map[PaneID]int, len(placements)) | |||
| 328 | + | for _, placement := range placements { | |||
| 329 | + | out[placement.ID] = placement.Content.Width | |||
| 330 | + | } | |||
| 331 | + | return out | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | func placementHeights(placements []Placement) map[PaneID]int { | |||
| 335 | + | out := make(map[PaneID]int, len(placements)) | |||
| 336 | + | for _, placement := range placements { | |||
| 337 | + | out[placement.ID] = placement.Content.Height | |||
| 338 | + | } | |||
| 339 | + | return out | |||
| 340 | + | } | |||