feat(layout): add directional pane consume and expel
- commit
- 3f0da0daf2d7d20b897f5546de7685d8ed477fac
- author
- brice · · 2026-07-27 13:28 UTC
- parent
- c26f90f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +730 -0
A internal/usecase/layout/consume_expel.go +260 -0
| @@ -0,0 +1,260 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | var ( | |
| 10 | + | ErrUnsupportedColumnLayout = errors.New("unsupported column layout") | |
| 11 | + | ErrInvalidDirection = errors.New("invalid direction") | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ConsumeOrExpelPane moves target horizontally between canonical root columns. | |
| 15 | + | // A singleton column is consumed into its immediate neighbor; a member of a | |
| 16 | + | // vertical or stack column is expelled into a new singleton column. | |
| 17 | + | func (t *Tree) ConsumeOrExpelPane(target PaneID, dir Direction, area domain.Rect) (bool, error) { | |
| 18 | + | if dir != Left && dir != Right { | |
| 19 | + | return false, ErrInvalidDirection | |
| 20 | + | } | |
| 21 | + | if !validCanonicalColumnTree(t) { | |
| 22 | + | return false, ErrUnsupportedColumnLayout | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | columns := canonicalColumns(t.Root) | |
| 26 | + | columnIndex, memberIndex, found := findColumnMember(columns, target) | |
| 27 | + | if !found { | |
| 28 | + | return false, ErrNotFound | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | if columns[columnIndex].Kind == Leaf { | |
| 32 | + | destinationIndex := columnIndex - 1 | |
| 33 | + | if dir == Right { | |
| 34 | + | destinationIndex = columnIndex + 1 | |
| 35 | + | } | |
| 36 | + | if destinationIndex < 0 || destinationIndex >= len(columns) { | |
| 37 | + | return false, nil | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | candidate := t.clone() | |
| 42 | + | if _, solved := Solve(candidate.Root, area); !solved { | |
| 43 | + | return false, ErrTooSmall | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | columns = canonicalColumns(candidate.Root) | |
| 47 | + | columnIndex, memberIndex, _ = findColumnMember(columns, target) | |
| 48 | + | if candidate.Root.Kind == Split && candidate.Root.Dir == Horizontal { | |
| 49 | + | normalizeChildWeightsFromArea(candidate.Root, area) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | if columns[columnIndex].Kind == Leaf { | |
| 53 | + | consumeSingletonColumn(candidate, columnIndex, dir, area) | |
| 54 | + | } else { | |
| 55 | + | expelColumnMember(candidate, columnIndex, memberIndex, dir) | |
| 56 | + | } | |
| 57 | + | candidate.Focus = target | |
| 58 | + | ||
| 59 | + | if _, solved := Solve(candidate.Root, area); !solved { | |
| 60 | + | return false, ErrTooSmall | |
| 61 | + | } | |
| 62 | + | *t = *candidate | |
| 63 | + | return true, nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func validCanonicalColumnTree(t *Tree) bool { | |
| 67 | + | if t == nil || t.Root == nil { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | columns := canonicalColumns(t.Root) | |
| 72 | + | if len(columns) == 0 { | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | if t.Root.Kind == Split && t.Root.Dir == Horizontal && len(columns) < 2 { | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | ids := make(map[PaneID]struct{}) | |
| 80 | + | for _, column := range columns { | |
| 81 | + | if !validCanonicalColumn(column, ids) { | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | if t.Focus == "" { | |
| 86 | + | return false | |
| 87 | + | } | |
| 88 | + | _, focused := ids[t.Focus] | |
| 89 | + | return focused | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func canonicalColumns(root *Node) []*Node { | |
| 93 | + | if root == nil { | |
| 94 | + | return nil | |
| 95 | + | } | |
| 96 | + | if root.Kind == Split && root.Dir == Horizontal { | |
| 97 | + | return root.Children | |
| 98 | + | } | |
| 99 | + | return []*Node{root} | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func validCanonicalColumn(column *Node, ids map[PaneID]struct{}) bool { | |
| 103 | + | if column == nil { | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | switch column.Kind { | |
| 108 | + | case Leaf: | |
| 109 | + | if len(column.Children) != 0 { | |
| 110 | + | return false | |
| 111 | + | } | |
| 112 | + | return addUniqueLeafID(ids, column.Leaf) | |
| 113 | + | case Split: | |
| 114 | + | if column.Dir != Vertical || len(column.Children) < 2 { | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | case Stack: | |
| 118 | + | if len(column.Children) < 2 { | |
| 119 | + | return false | |
| 120 | + | } | |
| 121 | + | default: | |
| 122 | + | return false | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | expanded := column.Kind != Stack | |
| 126 | + | for _, member := range column.Children { | |
| 127 | + | if member == nil || member.Kind != Leaf || len(member.Children) != 0 || !addUniqueLeafID(ids, member.Leaf) { | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | if column.Kind == Stack && member.Leaf == column.Expanded { | |
| 131 | + | expanded = true | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | return expanded | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func addUniqueLeafID(ids map[PaneID]struct{}, id PaneID) bool { | |
| 138 | + | if id == "" { | |
| 139 | + | return false | |
| 140 | + | } | |
| 141 | + | if _, exists := ids[id]; exists { | |
| 142 | + | return false | |
| 143 | + | } | |
| 144 | + | ids[id] = struct{}{} | |
| 145 | + | return true | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func findColumnMember(columns []*Node, target PaneID) (int, int, bool) { | |
| 149 | + | for columnIndex, column := range columns { | |
| 150 | + | if column.Kind == Leaf { | |
| 151 | + | if column.Leaf == target { | |
| 152 | + | return columnIndex, -1, true | |
| 153 | + | } | |
| 154 | + | continue | |
| 155 | + | } | |
| 156 | + | for memberIndex, member := range column.Children { | |
| 157 | + | if member.Leaf == target { | |
| 158 | + | return columnIndex, memberIndex, true | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | return 0, 0, false | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) { | |
| 166 | + | root := t.Root | |
| 167 | + | destinationIndex := sourceIndex - 1 | |
| 168 | + | if dir == Right { | |
| 169 | + | destinationIndex = sourceIndex + 1 | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | columnAreas, _ := splitChildRects(root, area) | |
| 173 | + | moved := root.Children[sourceIndex] | |
| 174 | + | moved.Weight = 0 | |
| 175 | + | destination := root.Children[destinationIndex] | |
| 176 | + | ||
| 177 | + | switch destination.Kind { | |
| 178 | + | case Leaf: | |
| 179 | + | weight := destination.Weight | |
| 180 | + | destination.Weight = 0 | |
| 181 | + | destination = &Node{ | |
| 182 | + | Kind: Split, | |
| 183 | + | Dir: Vertical, | |
| 184 | + | Children: []*Node{destination, moved}, | |
| 185 | + | Weight: weight, | |
| 186 | + | } | |
| 187 | + | case Split: | |
| 188 | + | normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex]) | |
| 189 | + | destination.Children = append(destination.Children, moved) | |
| 190 | + | case Stack: | |
| 191 | + | destination.Children = append(destination.Children, moved) | |
| 192 | + | destination.Expanded = moved.Leaf | |
| 193 | + | clearChildWeights(destination) | |
| 194 | + | } | |
| 195 | + | root.Children[destinationIndex] = destination | |
| 196 | + | root.Children = removeNodeAt(root.Children, sourceIndex) | |
| 197 | + | ||
| 198 | + | if len(root.Children) == 1 { | |
| 199 | + | promoted := root.Children[0] | |
| 200 | + | promoted.Weight = root.Weight | |
| 201 | + | t.Root = promoted | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) { | |
| 206 | + | root := t.Root | |
| 207 | + | rootIsHorizontal := root.Kind == Split && root.Dir == Horizontal | |
| 208 | + | columns := canonicalColumns(root) | |
| 209 | + | source := columns[columnIndex] | |
| 210 | + | wrapperWeight := source.Weight | |
| 211 | + | if !rootIsHorizontal { | |
| 212 | + | wrapperWeight = root.Weight | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | moved := source.Children[memberIndex] | |
| 216 | + | moved.Weight = 0 | |
| 217 | + | source.Children = removeNodeAt(source.Children, memberIndex) | |
| 218 | + | if source.Kind == Stack { | |
| 219 | + | if source.Expanded == moved.Leaf { | |
| 220 | + | source.Expanded = source.Children[0].Leaf | |
| 221 | + | } | |
| 222 | + | clearChildWeights(source) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | remaining := source | |
| 226 | + | if len(source.Children) == 1 { | |
| 227 | + | remaining = source.Children[0] | |
| 228 | + | remaining.Weight = source.Weight | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | if !rootIsHorizontal { | |
| 232 | + | remaining.Weight = 0 | |
| 233 | + | children := []*Node{remaining, moved} | |
| 234 | + | if dir == Left { | |
| 235 | + | children[0], children[1] = children[1], children[0] | |
| 236 | + | } | |
| 237 | + | t.Root = &Node{Kind: Split, Dir: Horizontal, Children: children, Weight: wrapperWeight} | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | root.Children[columnIndex] = remaining | |
| 242 | + | insertionIndex := columnIndex | |
| 243 | + | if dir == Right { | |
| 244 | + | insertionIndex++ | |
| 245 | + | } | |
| 246 | + | root.Children = insertNodeAt(root.Children, insertionIndex, moved) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func removeNodeAt(nodes []*Node, index int) []*Node { | |
| 250 | + | copy(nodes[index:], nodes[index+1:]) | |
| 251 | + | nodes[len(nodes)-1] = nil | |
| 252 | + | return nodes[:len(nodes)-1] | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func insertNodeAt(nodes []*Node, index int, node *Node) []*Node { | |
| 256 | + | nodes = append(nodes, nil) | |
| 257 | + | copy(nodes[index+1:], nodes[index:]) | |
| 258 | + | nodes[index] = node | |
| 259 | + | return nodes | |
| 260 | + | } | |
| @@ -0,0 +1,260 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | var ( | |||
| 10 | + | ErrUnsupportedColumnLayout = errors.New("unsupported column layout") | |||
| 11 | + | ErrInvalidDirection = errors.New("invalid direction") | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // ConsumeOrExpelPane moves target horizontally between canonical root columns. | |||
| 15 | + | // A singleton column is consumed into its immediate neighbor; a member of a | |||
| 16 | + | // vertical or stack column is expelled into a new singleton column. | |||
| 17 | + | func (t *Tree) ConsumeOrExpelPane(target PaneID, dir Direction, area domain.Rect) (bool, error) { | |||
| 18 | + | if dir != Left && dir != Right { | |||
| 19 | + | return false, ErrInvalidDirection | |||
| 20 | + | } | |||
| 21 | + | if !validCanonicalColumnTree(t) { | |||
| 22 | + | return false, ErrUnsupportedColumnLayout | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | columns := canonicalColumns(t.Root) | |||
| 26 | + | columnIndex, memberIndex, found := findColumnMember(columns, target) | |||
| 27 | + | if !found { | |||
| 28 | + | return false, ErrNotFound | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | if columns[columnIndex].Kind == Leaf { | |||
| 32 | + | destinationIndex := columnIndex - 1 | |||
| 33 | + | if dir == Right { | |||
| 34 | + | destinationIndex = columnIndex + 1 | |||
| 35 | + | } | |||
| 36 | + | if destinationIndex < 0 || destinationIndex >= len(columns) { | |||
| 37 | + | return false, nil | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | candidate := t.clone() | |||
| 42 | + | if _, solved := Solve(candidate.Root, area); !solved { | |||
| 43 | + | return false, ErrTooSmall | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | columns = canonicalColumns(candidate.Root) | |||
| 47 | + | columnIndex, memberIndex, _ = findColumnMember(columns, target) | |||
| 48 | + | if candidate.Root.Kind == Split && candidate.Root.Dir == Horizontal { | |||
| 49 | + | normalizeChildWeightsFromArea(candidate.Root, area) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | if columns[columnIndex].Kind == Leaf { | |||
| 53 | + | consumeSingletonColumn(candidate, columnIndex, dir, area) | |||
| 54 | + | } else { | |||
| 55 | + | expelColumnMember(candidate, columnIndex, memberIndex, dir) | |||
| 56 | + | } | |||
| 57 | + | candidate.Focus = target | |||
| 58 | + | ||||
| 59 | + | if _, solved := Solve(candidate.Root, area); !solved { | |||
| 60 | + | return false, ErrTooSmall | |||
| 61 | + | } | |||
| 62 | + | *t = *candidate | |||
| 63 | + | return true, nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func validCanonicalColumnTree(t *Tree) bool { | |||
| 67 | + | if t == nil || t.Root == nil { | |||
| 68 | + | return false | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | columns := canonicalColumns(t.Root) | |||
| 72 | + | if len(columns) == 0 { | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | if t.Root.Kind == Split && t.Root.Dir == Horizontal && len(columns) < 2 { | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | ids := make(map[PaneID]struct{}) | |||
| 80 | + | for _, column := range columns { | |||
| 81 | + | if !validCanonicalColumn(column, ids) { | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | if t.Focus == "" { | |||
| 86 | + | return false | |||
| 87 | + | } | |||
| 88 | + | _, focused := ids[t.Focus] | |||
| 89 | + | return focused | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func canonicalColumns(root *Node) []*Node { | |||
| 93 | + | if root == nil { | |||
| 94 | + | return nil | |||
| 95 | + | } | |||
| 96 | + | if root.Kind == Split && root.Dir == Horizontal { | |||
| 97 | + | return root.Children | |||
| 98 | + | } | |||
| 99 | + | return []*Node{root} | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func validCanonicalColumn(column *Node, ids map[PaneID]struct{}) bool { | |||
| 103 | + | if column == nil { | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | switch column.Kind { | |||
| 108 | + | case Leaf: | |||
| 109 | + | if len(column.Children) != 0 { | |||
| 110 | + | return false | |||
| 111 | + | } | |||
| 112 | + | return addUniqueLeafID(ids, column.Leaf) | |||
| 113 | + | case Split: | |||
| 114 | + | if column.Dir != Vertical || len(column.Children) < 2 { | |||
| 115 | + | return false | |||
| 116 | + | } | |||
| 117 | + | case Stack: | |||
| 118 | + | if len(column.Children) < 2 { | |||
| 119 | + | return false | |||
| 120 | + | } | |||
| 121 | + | default: | |||
| 122 | + | return false | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | expanded := column.Kind != Stack | |||
| 126 | + | for _, member := range column.Children { | |||
| 127 | + | if member == nil || member.Kind != Leaf || len(member.Children) != 0 || !addUniqueLeafID(ids, member.Leaf) { | |||
| 128 | + | return false | |||
| 129 | + | } | |||
| 130 | + | if column.Kind == Stack && member.Leaf == column.Expanded { | |||
| 131 | + | expanded = true | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | return expanded | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func addUniqueLeafID(ids map[PaneID]struct{}, id PaneID) bool { | |||
| 138 | + | if id == "" { | |||
| 139 | + | return false | |||
| 140 | + | } | |||
| 141 | + | if _, exists := ids[id]; exists { | |||
| 142 | + | return false | |||
| 143 | + | } | |||
| 144 | + | ids[id] = struct{}{} | |||
| 145 | + | return true | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func findColumnMember(columns []*Node, target PaneID) (int, int, bool) { | |||
| 149 | + | for columnIndex, column := range columns { | |||
| 150 | + | if column.Kind == Leaf { | |||
| 151 | + | if column.Leaf == target { | |||
| 152 | + | return columnIndex, -1, true | |||
| 153 | + | } | |||
| 154 | + | continue | |||
| 155 | + | } | |||
| 156 | + | for memberIndex, member := range column.Children { | |||
| 157 | + | if member.Leaf == target { | |||
| 158 | + | return columnIndex, memberIndex, true | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | return 0, 0, false | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) { | |||
| 166 | + | root := t.Root | |||
| 167 | + | destinationIndex := sourceIndex - 1 | |||
| 168 | + | if dir == Right { | |||
| 169 | + | destinationIndex = sourceIndex + 1 | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | columnAreas, _ := splitChildRects(root, area) | |||
| 173 | + | moved := root.Children[sourceIndex] | |||
| 174 | + | moved.Weight = 0 | |||
| 175 | + | destination := root.Children[destinationIndex] | |||
| 176 | + | ||||
| 177 | + | switch destination.Kind { | |||
| 178 | + | case Leaf: | |||
| 179 | + | weight := destination.Weight | |||
| 180 | + | destination.Weight = 0 | |||
| 181 | + | destination = &Node{ | |||
| 182 | + | Kind: Split, | |||
| 183 | + | Dir: Vertical, | |||
| 184 | + | Children: []*Node{destination, moved}, | |||
| 185 | + | Weight: weight, | |||
| 186 | + | } | |||
| 187 | + | case Split: | |||
| 188 | + | normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex]) | |||
| 189 | + | destination.Children = append(destination.Children, moved) | |||
| 190 | + | case Stack: | |||
| 191 | + | destination.Children = append(destination.Children, moved) | |||
| 192 | + | destination.Expanded = moved.Leaf | |||
| 193 | + | clearChildWeights(destination) | |||
| 194 | + | } | |||
| 195 | + | root.Children[destinationIndex] = destination | |||
| 196 | + | root.Children = removeNodeAt(root.Children, sourceIndex) | |||
| 197 | + | ||||
| 198 | + | if len(root.Children) == 1 { | |||
| 199 | + | promoted := root.Children[0] | |||
| 200 | + | promoted.Weight = root.Weight | |||
| 201 | + | t.Root = promoted | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) { | |||
| 206 | + | root := t.Root | |||
| 207 | + | rootIsHorizontal := root.Kind == Split && root.Dir == Horizontal | |||
| 208 | + | columns := canonicalColumns(root) | |||
| 209 | + | source := columns[columnIndex] | |||
| 210 | + | wrapperWeight := source.Weight | |||
| 211 | + | if !rootIsHorizontal { | |||
| 212 | + | wrapperWeight = root.Weight | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | moved := source.Children[memberIndex] | |||
| 216 | + | moved.Weight = 0 | |||
| 217 | + | source.Children = removeNodeAt(source.Children, memberIndex) | |||
| 218 | + | if source.Kind == Stack { | |||
| 219 | + | if source.Expanded == moved.Leaf { | |||
| 220 | + | source.Expanded = source.Children[0].Leaf | |||
| 221 | + | } | |||
| 222 | + | clearChildWeights(source) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | remaining := source | |||
| 226 | + | if len(source.Children) == 1 { | |||
| 227 | + | remaining = source.Children[0] | |||
| 228 | + | remaining.Weight = source.Weight | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | if !rootIsHorizontal { | |||
| 232 | + | remaining.Weight = 0 | |||
| 233 | + | children := []*Node{remaining, moved} | |||
| 234 | + | if dir == Left { | |||
| 235 | + | children[0], children[1] = children[1], children[0] | |||
| 236 | + | } | |||
| 237 | + | t.Root = &Node{Kind: Split, Dir: Horizontal, Children: children, Weight: wrapperWeight} | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | root.Children[columnIndex] = remaining | |||
| 242 | + | insertionIndex := columnIndex | |||
| 243 | + | if dir == Right { | |||
| 244 | + | insertionIndex++ | |||
| 245 | + | } | |||
| 246 | + | root.Children = insertNodeAt(root.Children, insertionIndex, moved) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func removeNodeAt(nodes []*Node, index int) []*Node { | |||
| 250 | + | copy(nodes[index:], nodes[index+1:]) | |||
| 251 | + | nodes[len(nodes)-1] = nil | |||
| 252 | + | return nodes[:len(nodes)-1] | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func insertNodeAt(nodes []*Node, index int, node *Node) []*Node { | |||
| 256 | + | nodes = append(nodes, nil) | |||
| 257 | + | copy(nodes[index+1:], nodes[index:]) | |||
| 258 | + | nodes[index] = node | |||
| 259 | + | return nodes | |||
| 260 | + | } | |||
A internal/usecase/layout/consume_expel_test.go +304 -0
| @@ -0,0 +1,304 @@ | |||
| 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 TestConsumeOrExpelPaneTransitions(t *testing.T) { | |
| 11 | + | t.Parallel() | |
| 12 | + | ||
| 13 | + | area := domain.Rect{Width: 101, Height: 12} | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | tree *Tree | |
| 17 | + | target PaneID | |
| 18 | + | dir Direction | |
| 19 | + | want *Tree | |
| 20 | + | wantChange bool | |
| 21 | + | }{ | |
| 22 | + | { | |
| 23 | + | name: "consume singleton left into leaf", | |
| 24 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |
| 25 | + | target: "b", | |
| 26 | + | dir: Left, | |
| 27 | + | want: &Tree{Root: vertical("a", "b"), Focus: "b"}, | |
| 28 | + | wantChange: true, | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "consume singleton right into leaf", | |
| 32 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 33 | + | target: "a", | |
| 34 | + | dir: Right, | |
| 35 | + | want: &Tree{Root: vertical("b", "a"), Focus: "a"}, | |
| 36 | + | wantChange: true, | |
| 37 | + | }, | |
| 38 | + | { | |
| 39 | + | name: "consume singleton left into vertical column", | |
| 40 | + | tree: &Tree{Root: horizontal(vertical("a", "b"), NewLeaf("c")), Focus: "c"}, | |
| 41 | + | target: "c", | |
| 42 | + | dir: Left, | |
| 43 | + | want: &Tree{Root: verticalNodes( | |
| 44 | + | consumeExpelWeightedLeaf("a", 6), | |
| 45 | + | consumeExpelWeightedLeaf("b", 5), | |
| 46 | + | NewLeaf("c"), | |
| 47 | + | ), Focus: "c"}, | |
| 48 | + | wantChange: true, | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "consume singleton right into vertical column", | |
| 52 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), vertical("b", "c")), Focus: "a"}, | |
| 53 | + | target: "a", | |
| 54 | + | dir: Right, | |
| 55 | + | want: &Tree{Root: verticalNodes( | |
| 56 | + | consumeExpelWeightedLeaf("b", 6), | |
| 57 | + | consumeExpelWeightedLeaf("c", 5), | |
| 58 | + | NewLeaf("a"), | |
| 59 | + | ), Focus: "a"}, | |
| 60 | + | wantChange: true, | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | name: "consume singleton left into stack appends and expands", | |
| 64 | + | tree: &Tree{Root: horizontal(stack("a", "a", "b"), NewLeaf("c")), Focus: "c"}, | |
| 65 | + | target: "c", | |
| 66 | + | dir: Left, | |
| 67 | + | want: &Tree{Root: stack("c", "a", "b", "c"), Focus: "c"}, | |
| 68 | + | wantChange: true, | |
| 69 | + | }, | |
| 70 | + | { | |
| 71 | + | name: "consume singleton right into stack appends and expands", | |
| 72 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), stack("b", "b", "c")), Focus: "a"}, | |
| 73 | + | target: "a", | |
| 74 | + | dir: Right, | |
| 75 | + | want: &Tree{Root: stack("a", "b", "c", "a"), Focus: "a"}, | |
| 76 | + | wantChange: true, | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | name: "expel middle vertical member left from sole root column", | |
| 80 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"}, | |
| 81 | + | target: "b", | |
| 82 | + | dir: Left, | |
| 83 | + | want: &Tree{Root: horizontal(NewLeaf("b"), vertical("a", "c")), Focus: "b"}, | |
| 84 | + | wantChange: true, | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "expel middle vertical member right from sole root column", | |
| 88 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"}, | |
| 89 | + | target: "b", | |
| 90 | + | dir: Right, | |
| 91 | + | want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"}, | |
| 92 | + | wantChange: true, | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | name: "expel vertical member left beside its source column", | |
| 96 | + | tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"}, | |
| 97 | + | target: "b", | |
| 98 | + | dir: Left, | |
| 99 | + | want: &Tree{Root: horizontal( | |
| 100 | + | NewLeaf("b"), | |
| 101 | + | consumeExpelWeightedNode(vertical("a", "c"), 50), | |
| 102 | + | consumeExpelWeightedLeaf("d", 50), | |
| 103 | + | ), Focus: "b"}, | |
| 104 | + | wantChange: true, | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | name: "expel vertical member right beside its source column", | |
| 108 | + | tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"}, | |
| 109 | + | target: "b", | |
| 110 | + | dir: Right, | |
| 111 | + | want: &Tree{Root: horizontal( | |
| 112 | + | consumeExpelWeightedNode(vertical("a", "c"), 50), | |
| 113 | + | NewLeaf("b"), | |
| 114 | + | consumeExpelWeightedLeaf("d", 50), | |
| 115 | + | ), Focus: "b"}, | |
| 116 | + | wantChange: true, | |
| 117 | + | }, | |
| 118 | + | { | |
| 119 | + | name: "expel stack member left from sole root column", | |
| 120 | + | tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"}, | |
| 121 | + | target: "b", | |
| 122 | + | dir: Left, | |
| 123 | + | want: &Tree{Root: horizontal(NewLeaf("b"), stack("a", "a", "c")), Focus: "b"}, | |
| 124 | + | wantChange: true, | |
| 125 | + | }, | |
| 126 | + | { | |
| 127 | + | name: "expel stack member right from sole root column", | |
| 128 | + | tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"}, | |
| 129 | + | target: "b", | |
| 130 | + | dir: Right, | |
| 131 | + | want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"}, | |
| 132 | + | wantChange: true, | |
| 133 | + | }, | |
| 134 | + | { | |
| 135 | + | name: "expelling expanded stack member repairs source expansion", | |
| 136 | + | tree: &Tree{Root: stack("b", "a", "b", "c"), Focus: "b"}, | |
| 137 | + | target: "b", | |
| 138 | + | dir: Right, | |
| 139 | + | want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"}, | |
| 140 | + | wantChange: true, | |
| 141 | + | }, | |
| 142 | + | { | |
| 143 | + | name: "expel collapses two-member vertical column", | |
| 144 | + | tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, | |
| 145 | + | target: "a", | |
| 146 | + | dir: Left, | |
| 147 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 148 | + | wantChange: true, | |
| 149 | + | }, | |
| 150 | + | { | |
| 151 | + | name: "expel collapses two-member stack column", | |
| 152 | + | tree: &Tree{Root: stack("b", "a", "b"), Focus: "b"}, | |
| 153 | + | target: "b", | |
| 154 | + | dir: Right, | |
| 155 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |
| 156 | + | wantChange: true, | |
| 157 | + | }, | |
| 158 | + | { | |
| 159 | + | name: "real move focuses explicitly targeted non-focused pane", | |
| 160 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "a"}, | |
| 161 | + | target: "b", | |
| 162 | + | dir: Right, | |
| 163 | + | want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"}, | |
| 164 | + | wantChange: true, | |
| 165 | + | }, | |
| 166 | + | { | |
| 167 | + | name: "left edge singleton is exact no-op and preserves different focus", | |
| 168 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |
| 169 | + | target: "a", | |
| 170 | + | dir: Left, | |
| 171 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |
| 172 | + | wantChange: false, | |
| 173 | + | }, | |
| 174 | + | { | |
| 175 | + | name: "right edge singleton is exact no-op and preserves different focus", | |
| 176 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 177 | + | target: "b", | |
| 178 | + | dir: Right, | |
| 179 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 180 | + | wantChange: false, | |
| 181 | + | }, | |
| 182 | + | { | |
| 183 | + | name: "sole singleton root is exact no-op", | |
| 184 | + | tree: NewTree("a"), | |
| 185 | + | target: "a", | |
| 186 | + | dir: Left, | |
| 187 | + | want: NewTree("a"), | |
| 188 | + | wantChange: false, | |
| 189 | + | }, | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | for _, tt := range tests { | |
| 193 | + | t.Run(tt.name, func(t *testing.T) { | |
| 194 | + | before := tt.tree.Clone() | |
| 195 | + | beforeIDs := LeafIDs(tt.tree.Root) | |
| 196 | + | requireUniquePaneIDs(t, beforeIDs) | |
| 197 | + | ||
| 198 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, area) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | require.Equal(t, tt.wantChange, changed) | |
| 201 | + | require.Equal(t, tt.want, tt.tree) | |
| 202 | + | if !tt.wantChange { | |
| 203 | + | require.Equal(t, before, tt.tree, "a successful edge no-op must preserve the complete tree") | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | afterIDs := LeafIDs(tt.tree.Root) | |
| 207 | + | require.ElementsMatch(t, beforeIDs, afterIDs) | |
| 208 | + | require.Len(t, afterIDs, len(beforeIDs), "tree surgery must neither create nor lose pane IDs") | |
| 209 | + | requireUniquePaneIDs(t, afterIDs) | |
| 210 | + | _, solved := Solve(tt.tree.Root, area) | |
| 211 | + | require.True(t, solved, "every successful result must solve") | |
| 212 | + | }) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestConsumeOrExpelPaneRejectsMalformedOrUnsolvableLayoutsAtomically(t *testing.T) { | |
| 217 | + | t.Parallel() | |
| 218 | + | ||
| 219 | + | area := domain.Rect{Width: 101, Height: 12} | |
| 220 | + | tests := []struct { | |
| 221 | + | name string | |
| 222 | + | tree *Tree | |
| 223 | + | target PaneID | |
| 224 | + | dir Direction | |
| 225 | + | area domain.Rect | |
| 226 | + | want error | |
| 227 | + | }{ | |
| 228 | + | {name: "nil tree", tree: nil, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 229 | + | {name: "nil root", tree: &Tree{}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 230 | + | {name: "missing target", tree: NewTree("a"), target: "missing", dir: Left, area: area, want: ErrNotFound}, | |
| 231 | + | {name: "empty leaf ID", tree: &Tree{Root: NewLeaf(""), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 232 | + | {name: "duplicate leaf ID including duplicate target", tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("a")), Focus: "a"}, target: "a", dir: Right, area: area, want: ErrUnsupportedColumnLayout}, | |
| 233 | + | {name: "missing focus", tree: &Tree{Root: NewLeaf("a")}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 234 | + | {name: "dangling focus", tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 235 | + | {name: "empty horizontal root", tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 236 | + | {name: "empty vertical column", tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 237 | + | {name: "empty stack", tree: &Tree{Root: &Node{Kind: Stack}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 238 | + | {name: "nested horizontal root column", tree: &Tree{Root: horizontal(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 239 | + | {name: "vertical column has non-leaf member", tree: &Tree{Root: verticalNodes(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 240 | + | {name: "stack has non-leaf member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), vertical("b", "c")}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 241 | + | {name: "stack has nil member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), nil}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 242 | + | {name: "stack expanded member is missing", tree: &Tree{Root: stack("missing", "a", "b"), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 243 | + | {name: "unsupported root split direction", tree: &Tree{Root: &Node{Kind: Split, Dir: SplitDir(99), Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 244 | + | {name: "unsupported node kind", tree: &Tree{Root: &Node{Kind: Kind(99), Children: []*Node{NewLeaf("a")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 245 | + | {name: "invalid up direction", tree: NewTree("a"), target: "a", dir: Up, area: area, want: ErrInvalidDirection}, | |
| 246 | + | {name: "invalid down direction", tree: NewTree("a"), target: "a", dir: Down, area: area, want: ErrInvalidDirection}, | |
| 247 | + | {name: "invalid unknown direction", tree: NewTree("a"), target: "a", dir: Direction(99), area: area, want: ErrInvalidDirection}, | |
| 248 | + | {name: "too small after expel", tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, target: "a", dir: Right, area: domain.Rect{Width: 40, Height: 5}, want: ErrTooSmall}, | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | for _, tt := range tests { | |
| 252 | + | t.Run(tt.name, func(t *testing.T) { | |
| 253 | + | before := tt.tree.Clone() | |
| 254 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area) | |
| 255 | + | require.False(t, changed) | |
| 256 | + | require.ErrorIs(t, err, tt.want) | |
| 257 | + | require.Equal(t, before, tt.tree, "every refusal must preserve the exact original tree") | |
| 258 | + | }) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func horizontal(columns ...*Node) *Node { | |
| 263 | + | return &Node{Kind: Split, Dir: Horizontal, Children: columns} | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func vertical(ids ...PaneID) *Node { | |
| 267 | + | children := make([]*Node, 0, len(ids)) | |
| 268 | + | for _, id := range ids { | |
| 269 | + | children = append(children, NewLeaf(id)) | |
| 270 | + | } | |
| 271 | + | return verticalNodes(children...) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func verticalNodes(children ...*Node) *Node { | |
| 275 | + | return &Node{Kind: Split, Dir: Vertical, Children: children} | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func stack(expanded PaneID, ids ...PaneID) *Node { | |
| 279 | + | children := make([]*Node, 0, len(ids)) | |
| 280 | + | for _, id := range ids { | |
| 281 | + | children = append(children, NewLeaf(id)) | |
| 282 | + | } | |
| 283 | + | return &Node{Kind: Stack, Children: children, Expanded: expanded} | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func consumeExpelWeightedLeaf(id PaneID, weight float64) *Node { | |
| 287 | + | return &Node{Kind: Leaf, Leaf: id, Weight: weight} | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func consumeExpelWeightedNode(node *Node, weight float64) *Node { | |
| 291 | + | node.Weight = weight | |
| 292 | + | return node | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func requireUniquePaneIDs(t *testing.T, ids []PaneID) { | |
| 296 | + | t.Helper() | |
| 297 | + | seen := make(map[PaneID]struct{}, len(ids)) | |
| 298 | + | for _, id := range ids { | |
| 299 | + | require.NotEmpty(t, id) | |
| 300 | + | _, duplicate := seen[id] | |
| 301 | + | require.False(t, duplicate, "pane ID %q occurs more than once", id) | |
| 302 | + | seen[id] = struct{}{} | |
| 303 | + | } | |
| 304 | + | } | |
| @@ -0,0 +1,304 @@ | |||||
| 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 TestConsumeOrExpelPaneTransitions(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | area := domain.Rect{Width: 101, Height: 12} | |||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | tree *Tree | |||
| 17 | + | target PaneID | |||
| 18 | + | dir Direction | |||
| 19 | + | want *Tree | |||
| 20 | + | wantChange bool | |||
| 21 | + | }{ | |||
| 22 | + | { | |||
| 23 | + | name: "consume singleton left into leaf", | |||
| 24 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |||
| 25 | + | target: "b", | |||
| 26 | + | dir: Left, | |||
| 27 | + | want: &Tree{Root: vertical("a", "b"), Focus: "b"}, | |||
| 28 | + | wantChange: true, | |||
| 29 | + | }, | |||
| 30 | + | { | |||
| 31 | + | name: "consume singleton right into leaf", | |||
| 32 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 33 | + | target: "a", | |||
| 34 | + | dir: Right, | |||
| 35 | + | want: &Tree{Root: vertical("b", "a"), Focus: "a"}, | |||
| 36 | + | wantChange: true, | |||
| 37 | + | }, | |||
| 38 | + | { | |||
| 39 | + | name: "consume singleton left into vertical column", | |||
| 40 | + | tree: &Tree{Root: horizontal(vertical("a", "b"), NewLeaf("c")), Focus: "c"}, | |||
| 41 | + | target: "c", | |||
| 42 | + | dir: Left, | |||
| 43 | + | want: &Tree{Root: verticalNodes( | |||
| 44 | + | consumeExpelWeightedLeaf("a", 6), | |||
| 45 | + | consumeExpelWeightedLeaf("b", 5), | |||
| 46 | + | NewLeaf("c"), | |||
| 47 | + | ), Focus: "c"}, | |||
| 48 | + | wantChange: true, | |||
| 49 | + | }, | |||
| 50 | + | { | |||
| 51 | + | name: "consume singleton right into vertical column", | |||
| 52 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), vertical("b", "c")), Focus: "a"}, | |||
| 53 | + | target: "a", | |||
| 54 | + | dir: Right, | |||
| 55 | + | want: &Tree{Root: verticalNodes( | |||
| 56 | + | consumeExpelWeightedLeaf("b", 6), | |||
| 57 | + | consumeExpelWeightedLeaf("c", 5), | |||
| 58 | + | NewLeaf("a"), | |||
| 59 | + | ), Focus: "a"}, | |||
| 60 | + | wantChange: true, | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | name: "consume singleton left into stack appends and expands", | |||
| 64 | + | tree: &Tree{Root: horizontal(stack("a", "a", "b"), NewLeaf("c")), Focus: "c"}, | |||
| 65 | + | target: "c", | |||
| 66 | + | dir: Left, | |||
| 67 | + | want: &Tree{Root: stack("c", "a", "b", "c"), Focus: "c"}, | |||
| 68 | + | wantChange: true, | |||
| 69 | + | }, | |||
| 70 | + | { | |||
| 71 | + | name: "consume singleton right into stack appends and expands", | |||
| 72 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), stack("b", "b", "c")), Focus: "a"}, | |||
| 73 | + | target: "a", | |||
| 74 | + | dir: Right, | |||
| 75 | + | want: &Tree{Root: stack("a", "b", "c", "a"), Focus: "a"}, | |||
| 76 | + | wantChange: true, | |||
| 77 | + | }, | |||
| 78 | + | { | |||
| 79 | + | name: "expel middle vertical member left from sole root column", | |||
| 80 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"}, | |||
| 81 | + | target: "b", | |||
| 82 | + | dir: Left, | |||
| 83 | + | want: &Tree{Root: horizontal(NewLeaf("b"), vertical("a", "c")), Focus: "b"}, | |||
| 84 | + | wantChange: true, | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "expel middle vertical member right from sole root column", | |||
| 88 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"}, | |||
| 89 | + | target: "b", | |||
| 90 | + | dir: Right, | |||
| 91 | + | want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"}, | |||
| 92 | + | wantChange: true, | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | name: "expel vertical member left beside its source column", | |||
| 96 | + | tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"}, | |||
| 97 | + | target: "b", | |||
| 98 | + | dir: Left, | |||
| 99 | + | want: &Tree{Root: horizontal( | |||
| 100 | + | NewLeaf("b"), | |||
| 101 | + | consumeExpelWeightedNode(vertical("a", "c"), 50), | |||
| 102 | + | consumeExpelWeightedLeaf("d", 50), | |||
| 103 | + | ), Focus: "b"}, | |||
| 104 | + | wantChange: true, | |||
| 105 | + | }, | |||
| 106 | + | { | |||
| 107 | + | name: "expel vertical member right beside its source column", | |||
| 108 | + | tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"}, | |||
| 109 | + | target: "b", | |||
| 110 | + | dir: Right, | |||
| 111 | + | want: &Tree{Root: horizontal( | |||
| 112 | + | consumeExpelWeightedNode(vertical("a", "c"), 50), | |||
| 113 | + | NewLeaf("b"), | |||
| 114 | + | consumeExpelWeightedLeaf("d", 50), | |||
| 115 | + | ), Focus: "b"}, | |||
| 116 | + | wantChange: true, | |||
| 117 | + | }, | |||
| 118 | + | { | |||
| 119 | + | name: "expel stack member left from sole root column", | |||
| 120 | + | tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"}, | |||
| 121 | + | target: "b", | |||
| 122 | + | dir: Left, | |||
| 123 | + | want: &Tree{Root: horizontal(NewLeaf("b"), stack("a", "a", "c")), Focus: "b"}, | |||
| 124 | + | wantChange: true, | |||
| 125 | + | }, | |||
| 126 | + | { | |||
| 127 | + | name: "expel stack member right from sole root column", | |||
| 128 | + | tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"}, | |||
| 129 | + | target: "b", | |||
| 130 | + | dir: Right, | |||
| 131 | + | want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"}, | |||
| 132 | + | wantChange: true, | |||
| 133 | + | }, | |||
| 134 | + | { | |||
| 135 | + | name: "expelling expanded stack member repairs source expansion", | |||
| 136 | + | tree: &Tree{Root: stack("b", "a", "b", "c"), Focus: "b"}, | |||
| 137 | + | target: "b", | |||
| 138 | + | dir: Right, | |||
| 139 | + | want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"}, | |||
| 140 | + | wantChange: true, | |||
| 141 | + | }, | |||
| 142 | + | { | |||
| 143 | + | name: "expel collapses two-member vertical column", | |||
| 144 | + | tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, | |||
| 145 | + | target: "a", | |||
| 146 | + | dir: Left, | |||
| 147 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 148 | + | wantChange: true, | |||
| 149 | + | }, | |||
| 150 | + | { | |||
| 151 | + | name: "expel collapses two-member stack column", | |||
| 152 | + | tree: &Tree{Root: stack("b", "a", "b"), Focus: "b"}, | |||
| 153 | + | target: "b", | |||
| 154 | + | dir: Right, | |||
| 155 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |||
| 156 | + | wantChange: true, | |||
| 157 | + | }, | |||
| 158 | + | { | |||
| 159 | + | name: "real move focuses explicitly targeted non-focused pane", | |||
| 160 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "a"}, | |||
| 161 | + | target: "b", | |||
| 162 | + | dir: Right, | |||
| 163 | + | want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"}, | |||
| 164 | + | wantChange: true, | |||
| 165 | + | }, | |||
| 166 | + | { | |||
| 167 | + | name: "left edge singleton is exact no-op and preserves different focus", | |||
| 168 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |||
| 169 | + | target: "a", | |||
| 170 | + | dir: Left, | |||
| 171 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |||
| 172 | + | wantChange: false, | |||
| 173 | + | }, | |||
| 174 | + | { | |||
| 175 | + | name: "right edge singleton is exact no-op and preserves different focus", | |||
| 176 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 177 | + | target: "b", | |||
| 178 | + | dir: Right, | |||
| 179 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 180 | + | wantChange: false, | |||
| 181 | + | }, | |||
| 182 | + | { | |||
| 183 | + | name: "sole singleton root is exact no-op", | |||
| 184 | + | tree: NewTree("a"), | |||
| 185 | + | target: "a", | |||
| 186 | + | dir: Left, | |||
| 187 | + | want: NewTree("a"), | |||
| 188 | + | wantChange: false, | |||
| 189 | + | }, | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | for _, tt := range tests { | |||
| 193 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 194 | + | before := tt.tree.Clone() | |||
| 195 | + | beforeIDs := LeafIDs(tt.tree.Root) | |||
| 196 | + | requireUniquePaneIDs(t, beforeIDs) | |||
| 197 | + | ||||
| 198 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, area) | |||
| 199 | + | require.NoError(t, err) | |||
| 200 | + | require.Equal(t, tt.wantChange, changed) | |||
| 201 | + | require.Equal(t, tt.want, tt.tree) | |||
| 202 | + | if !tt.wantChange { | |||
| 203 | + | require.Equal(t, before, tt.tree, "a successful edge no-op must preserve the complete tree") | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | afterIDs := LeafIDs(tt.tree.Root) | |||
| 207 | + | require.ElementsMatch(t, beforeIDs, afterIDs) | |||
| 208 | + | require.Len(t, afterIDs, len(beforeIDs), "tree surgery must neither create nor lose pane IDs") | |||
| 209 | + | requireUniquePaneIDs(t, afterIDs) | |||
| 210 | + | _, solved := Solve(tt.tree.Root, area) | |||
| 211 | + | require.True(t, solved, "every successful result must solve") | |||
| 212 | + | }) | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func TestConsumeOrExpelPaneRejectsMalformedOrUnsolvableLayoutsAtomically(t *testing.T) { | |||
| 217 | + | t.Parallel() | |||
| 218 | + | ||||
| 219 | + | area := domain.Rect{Width: 101, Height: 12} | |||
| 220 | + | tests := []struct { | |||
| 221 | + | name string | |||
| 222 | + | tree *Tree | |||
| 223 | + | target PaneID | |||
| 224 | + | dir Direction | |||
| 225 | + | area domain.Rect | |||
| 226 | + | want error | |||
| 227 | + | }{ | |||
| 228 | + | {name: "nil tree", tree: nil, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 229 | + | {name: "nil root", tree: &Tree{}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 230 | + | {name: "missing target", tree: NewTree("a"), target: "missing", dir: Left, area: area, want: ErrNotFound}, | |||
| 231 | + | {name: "empty leaf ID", tree: &Tree{Root: NewLeaf(""), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 232 | + | {name: "duplicate leaf ID including duplicate target", tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("a")), Focus: "a"}, target: "a", dir: Right, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 233 | + | {name: "missing focus", tree: &Tree{Root: NewLeaf("a")}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 234 | + | {name: "dangling focus", tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 235 | + | {name: "empty horizontal root", tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 236 | + | {name: "empty vertical column", tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 237 | + | {name: "empty stack", tree: &Tree{Root: &Node{Kind: Stack}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 238 | + | {name: "nested horizontal root column", tree: &Tree{Root: horizontal(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 239 | + | {name: "vertical column has non-leaf member", tree: &Tree{Root: verticalNodes(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 240 | + | {name: "stack has non-leaf member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), vertical("b", "c")}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 241 | + | {name: "stack has nil member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), nil}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 242 | + | {name: "stack expanded member is missing", tree: &Tree{Root: stack("missing", "a", "b"), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 243 | + | {name: "unsupported root split direction", tree: &Tree{Root: &Node{Kind: Split, Dir: SplitDir(99), Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 244 | + | {name: "unsupported node kind", tree: &Tree{Root: &Node{Kind: Kind(99), Children: []*Node{NewLeaf("a")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 245 | + | {name: "invalid up direction", tree: NewTree("a"), target: "a", dir: Up, area: area, want: ErrInvalidDirection}, | |||
| 246 | + | {name: "invalid down direction", tree: NewTree("a"), target: "a", dir: Down, area: area, want: ErrInvalidDirection}, | |||
| 247 | + | {name: "invalid unknown direction", tree: NewTree("a"), target: "a", dir: Direction(99), area: area, want: ErrInvalidDirection}, | |||
| 248 | + | {name: "too small after expel", tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, target: "a", dir: Right, area: domain.Rect{Width: 40, Height: 5}, want: ErrTooSmall}, | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | for _, tt := range tests { | |||
| 252 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 253 | + | before := tt.tree.Clone() | |||
| 254 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area) | |||
| 255 | + | require.False(t, changed) | |||
| 256 | + | require.ErrorIs(t, err, tt.want) | |||
| 257 | + | require.Equal(t, before, tt.tree, "every refusal must preserve the exact original tree") | |||
| 258 | + | }) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func horizontal(columns ...*Node) *Node { | |||
| 263 | + | return &Node{Kind: Split, Dir: Horizontal, Children: columns} | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func vertical(ids ...PaneID) *Node { | |||
| 267 | + | children := make([]*Node, 0, len(ids)) | |||
| 268 | + | for _, id := range ids { | |||
| 269 | + | children = append(children, NewLeaf(id)) | |||
| 270 | + | } | |||
| 271 | + | return verticalNodes(children...) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func verticalNodes(children ...*Node) *Node { | |||
| 275 | + | return &Node{Kind: Split, Dir: Vertical, Children: children} | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func stack(expanded PaneID, ids ...PaneID) *Node { | |||
| 279 | + | children := make([]*Node, 0, len(ids)) | |||
| 280 | + | for _, id := range ids { | |||
| 281 | + | children = append(children, NewLeaf(id)) | |||
| 282 | + | } | |||
| 283 | + | return &Node{Kind: Stack, Children: children, Expanded: expanded} | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func consumeExpelWeightedLeaf(id PaneID, weight float64) *Node { | |||
| 287 | + | return &Node{Kind: Leaf, Leaf: id, Weight: weight} | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func consumeExpelWeightedNode(node *Node, weight float64) *Node { | |||
| 291 | + | node.Weight = weight | |||
| 292 | + | return node | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func requireUniquePaneIDs(t *testing.T, ids []PaneID) { | |||
| 296 | + | t.Helper() | |||
| 297 | + | seen := make(map[PaneID]struct{}, len(ids)) | |||
| 298 | + | for _, id := range ids { | |||
| 299 | + | require.NotEmpty(t, id) | |||
| 300 | + | _, duplicate := seen[id] | |||
| 301 | + | require.False(t, duplicate, "pane ID %q occurs more than once", id) | |||
| 302 | + | seen[id] = struct{}{} | |||
| 303 | + | } | |||
| 304 | + | } | |||
M internal/usecase/layout/weights_test.go +166 -0
| @@ -323,6 +323,172 @@ func TestWeightSplitStackWrapperTransfersShare(t *testing.T) { | |||
| 323 | 323 | } | |
| 324 | 324 | } | |
| 325 | 325 | ||
| 326 | + | func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) { | |
| 327 | + | t.Parallel() | |
| 328 | + | ||
| 329 | + | tests := []struct { | |
| 330 | + | name string | |
| 331 | + | tree *Tree | |
| 332 | + | target PaneID | |
| 333 | + | dir Direction | |
| 334 | + | area domain.Rect | |
| 335 | + | check func(*testing.T, *Tree, []Placement, []Placement) | |
| 336 | + | }{ | |
| 337 | + | { | |
| 338 | + | name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight", | |
| 339 | + | tree: &Tree{Root: weightedNode(horizontal( | |
| 340 | + | weightedNode(vertical("a", "b"), 3), | |
| 341 | + | weightedLeaf("c", 1), | |
| 342 | + | ), 11), Focus: "b"}, | |
| 343 | + | target: "b", | |
| 344 | + | dir: Right, | |
| 345 | + | area: domain.Rect{Width: 202, Height: 5}, | |
| 346 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |
| 347 | + | require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before)) | |
| 348 | + | require.Equal(t, 11.0, tree.Root.Weight) | |
| 349 | + | require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root)) | |
| 350 | + | require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root)) | |
| 351 | + | require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after)) | |
| 352 | + | }, | |
| 353 | + | }, | |
| 354 | + | { | |
| 355 | + | name: "vertical consume preserves solved member shares and gives moved pane default weight", | |
| 356 | + | tree: &Tree{Root: weightedNode(horizontal( | |
| 357 | + | weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2), | |
| 358 | + | weightedLeaf("c", 1), | |
| 359 | + | ), 9), Focus: "c"}, | |
| 360 | + | target: "c", | |
| 361 | + | dir: Left, | |
| 362 | + | area: domain.Rect{Width: 101, Height: 20}, | |
| 363 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |
| 364 | + | require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before)) | |
| 365 | + | require.Equal(t, Vertical, tree.Root.Dir) | |
| 366 | + | require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share") | |
| 367 | + | require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root)) | |
| 368 | + | require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after)) | |
| 369 | + | }, | |
| 370 | + | }, | |
| 371 | + | { | |
| 372 | + | name: "stack consume clears member weights and preserves normalized outer column share", | |
| 373 | + | tree: &Tree{Root: horizontal( | |
| 374 | + | weightedLeaf("x", 1), | |
| 375 | + | weightedNode(&Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 8), weightedLeaf("b", 4)}, Expanded: "a"}, 3), | |
| 376 | + | weightedLeaf("c", 1), | |
| 377 | + | ), Focus: "c"}, | |
| 378 | + | target: "c", | |
| 379 | + | dir: Left, | |
| 380 | + | area: domain.Rect{Width: 103, Height: 4}, | |
| 381 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |
| 382 | + | require.Equal(t, map[PaneID]int{"x": 20, "a": 61, "b": 0, "c": 20}, placementWidths(before)) | |
| 383 | + | require.Len(t, tree.Root.Children, 2) | |
| 384 | + | stackColumn := tree.Root.Children[1] | |
| 385 | + | require.Equal(t, Stack, stackColumn.Kind) | |
| 386 | + | require.Equal(t, 61.0, stackColumn.Weight) | |
| 387 | + | require.Equal(t, PaneID("c"), stackColumn.Expanded) | |
| 388 | + | require.Equal(t, []float64{0, 0, 0}, childWeights(stackColumn)) | |
| 389 | + | require.Equal(t, 77, placementSpanWidth(after, "a")) | |
| 390 | + | require.Equal(t, 77, placementSpanWidth(after, "b")) | |
| 391 | + | require.Equal(t, 77, placementSpanWidth(after, "c")) | |
| 392 | + | }, | |
| 393 | + | }, | |
| 394 | + | { | |
| 395 | + | name: "stack expel clears remaining weights and repairs expanded member", | |
| 396 | + | tree: &Tree{Root: weightedNode(&Node{Kind: Stack, Children: []*Node{ | |
| 397 | + | weightedLeaf("a", 5), weightedLeaf("b", 4), weightedLeaf("c", 3), | |
| 398 | + | }, Expanded: "b"}, 7), Focus: "b"}, | |
| 399 | + | target: "b", | |
| 400 | + | dir: Right, | |
| 401 | + | area: domain.Rect{Width: 101, Height: 4}, | |
| 402 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |
| 403 | + | require.Equal(t, Horizontal, tree.Root.Dir) | |
| 404 | + | require.Equal(t, 7.0, tree.Root.Weight) | |
| 405 | + | require.Equal(t, []float64{0, 0}, childWeights(tree.Root)) | |
| 406 | + | stackColumn := tree.Root.Children[0] | |
| 407 | + | require.Equal(t, PaneID("a"), stackColumn.Expanded) | |
| 408 | + | require.Equal(t, []float64{0, 0}, childWeights(stackColumn)) | |
| 409 | + | require.Equal(t, 50, placementSpanWidth(after, "a")) | |
| 410 | + | require.Equal(t, 50, placementSpanWidth(after, "c")) | |
| 411 | + | require.Equal(t, 50, placementSpanWidth(after, "b")) | |
| 412 | + | }, | |
| 413 | + | }, | |
| 414 | + | { | |
| 415 | + | name: "column collapse copies normalized container weight to promoted leaf", | |
| 416 | + | tree: &Tree{Root: horizontal( | |
| 417 | + | weightedLeaf("x", 1), | |
| 418 | + | weightedNode(vertical("a", "b"), 3), | |
| 419 | + | weightedLeaf("y", 1), | |
| 420 | + | ), Focus: "a"}, | |
| 421 | + | target: "a", | |
| 422 | + | dir: Left, | |
| 423 | + | area: domain.Rect{Width: 103, Height: 5}, | |
| 424 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |
| 425 | + | require.Equal(t, []PaneID{"x", "a", "b", "y"}, LeafIDs(tree.Root)) | |
| 426 | + | require.Equal(t, Leaf, tree.Root.Children[2].Kind) | |
| 427 | + | require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf) | |
| 428 | + | require.Equal(t, 61.0, tree.Root.Children[2].Weight) | |
| 429 | + | require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root)) | |
| 430 | + | require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after)) | |
| 431 | + | }, | |
| 432 | + | }, | |
| 433 | + | { | |
| 434 | + | name: "sole-column expel creates equal default horizontal children and retains wrapper weight", | |
| 435 | + | tree: &Tree{Root: weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 9), Focus: "a"}, | |
| 436 | + | target: "a", | |
| 437 | + | dir: Left, | |
| 438 | + | area: domain.Rect{Width: 101, Height: 5}, | |
| 439 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |
| 440 | + | require.Equal(t, Horizontal, tree.Root.Dir) | |
| 441 | + | require.Equal(t, 9.0, tree.Root.Weight) | |
| 442 | + | require.Equal(t, []float64{0, 0}, childWeights(tree.Root)) | |
| 443 | + | require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after)) | |
| 444 | + | }, | |
| 445 | + | }, | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | for _, tt := range tests { | |
| 449 | + | t.Run(tt.name, func(t *testing.T) { | |
| 450 | + | beforeIDs := LeafIDs(tt.tree.Root) | |
| 451 | + | requireUniquePaneIDs(t, beforeIDs) | |
| 452 | + | before, solved := Solve(tt.tree.Root, tt.area) | |
| 453 | + | require.True(t, solved, "weight fixture must solve before surgery") | |
| 454 | + | ||
| 455 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area) | |
| 456 | + | require.NoError(t, err) | |
| 457 | + | require.True(t, changed) | |
| 458 | + | require.Equal(t, tt.target, tt.tree.Focus) | |
| 459 | + | ||
| 460 | + | afterIDs := LeafIDs(tt.tree.Root) | |
| 461 | + | require.ElementsMatch(t, beforeIDs, afterIDs) | |
| 462 | + | require.Len(t, afterIDs, len(beforeIDs)) | |
| 463 | + | requireUniquePaneIDs(t, afterIDs) | |
| 464 | + | after, solved := Solve(tt.tree.Root, tt.area) | |
| 465 | + | require.True(t, solved, "weighted result must solve") | |
| 466 | + | tt.check(t, tt.tree, before, after) | |
| 467 | + | }) | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | func childWeights(node *Node) []float64 { | |
| 472 | + | weights := make([]float64, len(node.Children)) | |
| 473 | + | for i, child := range node.Children { | |
| 474 | + | weights[i] = child.Weight | |
| 475 | + | } | |
| 476 | + | return weights | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | func placementSpanWidth(placements []Placement, id PaneID) int { | |
| 480 | + | for _, placement := range placements { | |
| 481 | + | if placement.ID != id { | |
| 482 | + | continue | |
| 483 | + | } | |
| 484 | + | if placement.Content.Width > 0 { | |
| 485 | + | return placement.Content.Width | |
| 486 | + | } | |
| 487 | + | return placement.TitleBar.Width | |
| 488 | + | } | |
| 489 | + | return 0 | |
| 490 | + | } | |
| 491 | + | ||
| 326 | 492 | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | |
| 327 | 493 | t.Parallel() | |
| 328 | 494 | ||
| @@ -323,6 +323,172 @@ func TestWeightSplitStackWrapperTransfersShare(t *testing.T) { | |||||
| 323 | } | 323 | } | ||
| 324 | } | 324 | } | ||
| 325 | 325 | ||||
| 326 | + | func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) { | |||
| 327 | + | t.Parallel() | |||
| 328 | + | ||||
| 329 | + | tests := []struct { | |||
| 330 | + | name string | |||
| 331 | + | tree *Tree | |||
| 332 | + | target PaneID | |||
| 333 | + | dir Direction | |||
| 334 | + | area domain.Rect | |||
| 335 | + | check func(*testing.T, *Tree, []Placement, []Placement) | |||
| 336 | + | }{ | |||
| 337 | + | { | |||
| 338 | + | name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight", | |||
| 339 | + | tree: &Tree{Root: weightedNode(horizontal( | |||
| 340 | + | weightedNode(vertical("a", "b"), 3), | |||
| 341 | + | weightedLeaf("c", 1), | |||
| 342 | + | ), 11), Focus: "b"}, | |||
| 343 | + | target: "b", | |||
| 344 | + | dir: Right, | |||
| 345 | + | area: domain.Rect{Width: 202, Height: 5}, | |||
| 346 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |||
| 347 | + | require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before)) | |||
| 348 | + | require.Equal(t, 11.0, tree.Root.Weight) | |||
| 349 | + | require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root)) | |||
| 350 | + | require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root)) | |||
| 351 | + | require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after)) | |||
| 352 | + | }, | |||
| 353 | + | }, | |||
| 354 | + | { | |||
| 355 | + | name: "vertical consume preserves solved member shares and gives moved pane default weight", | |||
| 356 | + | tree: &Tree{Root: weightedNode(horizontal( | |||
| 357 | + | weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2), | |||
| 358 | + | weightedLeaf("c", 1), | |||
| 359 | + | ), 9), Focus: "c"}, | |||
| 360 | + | target: "c", | |||
| 361 | + | dir: Left, | |||
| 362 | + | area: domain.Rect{Width: 101, Height: 20}, | |||
| 363 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |||
| 364 | + | require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before)) | |||
| 365 | + | require.Equal(t, Vertical, tree.Root.Dir) | |||
| 366 | + | require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share") | |||
| 367 | + | require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root)) | |||
| 368 | + | require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after)) | |||
| 369 | + | }, | |||
| 370 | + | }, | |||
| 371 | + | { | |||
| 372 | + | name: "stack consume clears member weights and preserves normalized outer column share", | |||
| 373 | + | tree: &Tree{Root: horizontal( | |||
| 374 | + | weightedLeaf("x", 1), | |||
| 375 | + | weightedNode(&Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 8), weightedLeaf("b", 4)}, Expanded: "a"}, 3), | |||
| 376 | + | weightedLeaf("c", 1), | |||
| 377 | + | ), Focus: "c"}, | |||
| 378 | + | target: "c", | |||
| 379 | + | dir: Left, | |||
| 380 | + | area: domain.Rect{Width: 103, Height: 4}, | |||
| 381 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |||
| 382 | + | require.Equal(t, map[PaneID]int{"x": 20, "a": 61, "b": 0, "c": 20}, placementWidths(before)) | |||
| 383 | + | require.Len(t, tree.Root.Children, 2) | |||
| 384 | + | stackColumn := tree.Root.Children[1] | |||
| 385 | + | require.Equal(t, Stack, stackColumn.Kind) | |||
| 386 | + | require.Equal(t, 61.0, stackColumn.Weight) | |||
| 387 | + | require.Equal(t, PaneID("c"), stackColumn.Expanded) | |||
| 388 | + | require.Equal(t, []float64{0, 0, 0}, childWeights(stackColumn)) | |||
| 389 | + | require.Equal(t, 77, placementSpanWidth(after, "a")) | |||
| 390 | + | require.Equal(t, 77, placementSpanWidth(after, "b")) | |||
| 391 | + | require.Equal(t, 77, placementSpanWidth(after, "c")) | |||
| 392 | + | }, | |||
| 393 | + | }, | |||
| 394 | + | { | |||
| 395 | + | name: "stack expel clears remaining weights and repairs expanded member", | |||
| 396 | + | tree: &Tree{Root: weightedNode(&Node{Kind: Stack, Children: []*Node{ | |||
| 397 | + | weightedLeaf("a", 5), weightedLeaf("b", 4), weightedLeaf("c", 3), | |||
| 398 | + | }, Expanded: "b"}, 7), Focus: "b"}, | |||
| 399 | + | target: "b", | |||
| 400 | + | dir: Right, | |||
| 401 | + | area: domain.Rect{Width: 101, Height: 4}, | |||
| 402 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |||
| 403 | + | require.Equal(t, Horizontal, tree.Root.Dir) | |||
| 404 | + | require.Equal(t, 7.0, tree.Root.Weight) | |||
| 405 | + | require.Equal(t, []float64{0, 0}, childWeights(tree.Root)) | |||
| 406 | + | stackColumn := tree.Root.Children[0] | |||
| 407 | + | require.Equal(t, PaneID("a"), stackColumn.Expanded) | |||
| 408 | + | require.Equal(t, []float64{0, 0}, childWeights(stackColumn)) | |||
| 409 | + | require.Equal(t, 50, placementSpanWidth(after, "a")) | |||
| 410 | + | require.Equal(t, 50, placementSpanWidth(after, "c")) | |||
| 411 | + | require.Equal(t, 50, placementSpanWidth(after, "b")) | |||
| 412 | + | }, | |||
| 413 | + | }, | |||
| 414 | + | { | |||
| 415 | + | name: "column collapse copies normalized container weight to promoted leaf", | |||
| 416 | + | tree: &Tree{Root: horizontal( | |||
| 417 | + | weightedLeaf("x", 1), | |||
| 418 | + | weightedNode(vertical("a", "b"), 3), | |||
| 419 | + | weightedLeaf("y", 1), | |||
| 420 | + | ), Focus: "a"}, | |||
| 421 | + | target: "a", | |||
| 422 | + | dir: Left, | |||
| 423 | + | area: domain.Rect{Width: 103, Height: 5}, | |||
| 424 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |||
| 425 | + | require.Equal(t, []PaneID{"x", "a", "b", "y"}, LeafIDs(tree.Root)) | |||
| 426 | + | require.Equal(t, Leaf, tree.Root.Children[2].Kind) | |||
| 427 | + | require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf) | |||
| 428 | + | require.Equal(t, 61.0, tree.Root.Children[2].Weight) | |||
| 429 | + | require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root)) | |||
| 430 | + | require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after)) | |||
| 431 | + | }, | |||
| 432 | + | }, | |||
| 433 | + | { | |||
| 434 | + | name: "sole-column expel creates equal default horizontal children and retains wrapper weight", | |||
| 435 | + | tree: &Tree{Root: weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 9), Focus: "a"}, | |||
| 436 | + | target: "a", | |||
| 437 | + | dir: Left, | |||
| 438 | + | area: domain.Rect{Width: 101, Height: 5}, | |||
| 439 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |||
| 440 | + | require.Equal(t, Horizontal, tree.Root.Dir) | |||
| 441 | + | require.Equal(t, 9.0, tree.Root.Weight) | |||
| 442 | + | require.Equal(t, []float64{0, 0}, childWeights(tree.Root)) | |||
| 443 | + | require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after)) | |||
| 444 | + | }, | |||
| 445 | + | }, | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | for _, tt := range tests { | |||
| 449 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 450 | + | beforeIDs := LeafIDs(tt.tree.Root) | |||
| 451 | + | requireUniquePaneIDs(t, beforeIDs) | |||
| 452 | + | before, solved := Solve(tt.tree.Root, tt.area) | |||
| 453 | + | require.True(t, solved, "weight fixture must solve before surgery") | |||
| 454 | + | ||||
| 455 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area) | |||
| 456 | + | require.NoError(t, err) | |||
| 457 | + | require.True(t, changed) | |||
| 458 | + | require.Equal(t, tt.target, tt.tree.Focus) | |||
| 459 | + | ||||
| 460 | + | afterIDs := LeafIDs(tt.tree.Root) | |||
| 461 | + | require.ElementsMatch(t, beforeIDs, afterIDs) | |||
| 462 | + | require.Len(t, afterIDs, len(beforeIDs)) | |||
| 463 | + | requireUniquePaneIDs(t, afterIDs) | |||
| 464 | + | after, solved := Solve(tt.tree.Root, tt.area) | |||
| 465 | + | require.True(t, solved, "weighted result must solve") | |||
| 466 | + | tt.check(t, tt.tree, before, after) | |||
| 467 | + | }) | |||
| 468 | + | } | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | func childWeights(node *Node) []float64 { | |||
| 472 | + | weights := make([]float64, len(node.Children)) | |||
| 473 | + | for i, child := range node.Children { | |||
| 474 | + | weights[i] = child.Weight | |||
| 475 | + | } | |||
| 476 | + | return weights | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | func placementSpanWidth(placements []Placement, id PaneID) int { | |||
| 480 | + | for _, placement := range placements { | |||
| 481 | + | if placement.ID != id { | |||
| 482 | + | continue | |||
| 483 | + | } | |||
| 484 | + | if placement.Content.Width > 0 { | |||
| 485 | + | return placement.Content.Width | |||
| 486 | + | } | |||
| 487 | + | return placement.TitleBar.Width | |||
| 488 | + | } | |||
| 489 | + | return 0 | |||
| 490 | + | } | |||
| 491 | + | ||||
| 326 | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | 492 | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | ||
| 327 | t.Parallel() | 493 | t.Parallel() | ||
| 328 | 494 | ||||