├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum └── pkg ├── boxlayout ├── boxlayout.go └── boxlayout_test.go └── utils ├── once_writer.go ├── once_writer_test.go ├── utils.go └── utils_test.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | env: 4 | GO_VERSION: 1.18 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | pull_request: 11 | 12 | jobs: 13 | unit-tests: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: 18 | - ubuntu-latest 19 | - windows-latest 20 | include: 21 | - os: ubuntu-latest 22 | cache_path: ~/.cache/go-build 23 | - os: windows-latest 24 | cache_path: ~\AppData\Local\go-build 25 | name: ci - ${{matrix.os}} 26 | runs-on: ${{matrix.os}} 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v2 30 | - name: Setup Go 31 | uses: actions/setup-go@v1 32 | with: 33 | go-version: 1.18.x 34 | - name: Cache build 35 | uses: actions/cache@v3 36 | with: 37 | path: | 38 | ${{matrix.cache_path}} 39 | ~/go/pkg/mod 40 | key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-test 41 | restore-keys: | 42 | ${{runner.os}}-go- 43 | - name: Test code 44 | run: | 45 | go test ./... 46 | lint: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Checkout code 50 | uses: actions/checkout@v2 51 | - name: Setup Go 52 | uses: actions/setup-go@v1 53 | with: 54 | go-version: 1.18.x 55 | - name: Cache build 56 | uses: actions/cache@v1 57 | with: 58 | path: | 59 | ~/.cache/go-build 60 | ~/go/pkg/mod 61 | key: ${{runner.os}}-go-${{hashFiles('**/go.sum')}}-test 62 | restore-keys: | 63 | ${{runner.os}}-go- 64 | - name: Lint 65 | uses: golangci/golangci-lint-action@v3.1.0 66 | with: 67 | version: latest 68 | - name: errors 69 | run: golangci-lint run 70 | if: ${{ failure() }} 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable: 3 | - structcheck # gives false positives 4 | enable: 5 | - gofumpt 6 | - thelper 7 | - goimports 8 | - tparallel 9 | - wastedassign 10 | - exportloopref 11 | - unparam 12 | - prealloc 13 | - unconvert 14 | - exhaustive 15 | - makezero 16 | - nakedret 17 | # - goconst # TODO: enable and fix issues 18 | fast: false 19 | 20 | linters-settings: 21 | exhaustive: 22 | default-signifies-exhaustive: true 23 | 24 | nakedret: 25 | # the gods will judge me but I just don't like naked returns at all 26 | max-func-lines: 0 27 | 28 | run: 29 | go: 1.18 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jesse Duffield 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lazycore 2 | Shared functionality for lazygit, lazydocker, etc 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jesseduffield/lazycore 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/samber/lo v1.31.0 7 | github.com/stretchr/testify v1.8.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | golang.org/x/exp v0.0.0-20220317015231-48e79f11773a // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 5 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/samber/lo v1.31.0 h1:Sfa+/064Tdo4SvlohQUQzBhgSer9v/coGvKQI/XLWAM= 9 | github.com/samber/lo v1.31.0/go.mod h1:HLeWcJRRyLKp3+/XBJvOrerCQn9mhdKMHyd7IRlgeQ8= 10 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 11 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 12 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 13 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 14 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 15 | github.com/thoas/go-funk v0.9.1 h1:O549iLZqPpTUQ10ykd26sZhzD+rmR5pWhuElrhbC20M= 16 | golang.org/x/exp v0.0.0-20220317015231-48e79f11773a h1:DAzrdbxsb5tXNOhMCSwF7ZdfMbW46hE9fSVO6BsmUZM= 17 | golang.org/x/exp v0.0.0-20220317015231-48e79f11773a/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 20 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 21 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 22 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 23 | -------------------------------------------------------------------------------- /pkg/boxlayout/boxlayout.go: -------------------------------------------------------------------------------- 1 | package boxlayout 2 | 3 | import ( 4 | "github.com/jesseduffield/lazycore/pkg/utils" 5 | "github.com/samber/lo" 6 | ) 7 | 8 | type Dimensions struct { 9 | X0 int 10 | X1 int 11 | Y0 int 12 | Y1 int 13 | } 14 | 15 | type Direction int 16 | 17 | const ( 18 | ROW Direction = iota 19 | COLUMN 20 | ) 21 | 22 | // to give a high-level explanation of what's going on here. We layout our windows by arranging a bunch of boxes in the available space. 23 | // If a box has children, it needs to specify how it wants to arrange those children: ROW or COLUMN. 24 | // If a box represents a window, you can put the window name in the Window field. 25 | // When determining how to divvy-up the available height (for row children) or width (for column children), we first 26 | // give the boxes with a static `size` the space that they want. Then we apportion 27 | // the remaining space based on the weights of the dynamic boxes (you can't define 28 | // both size and weight at the same time: you gotta pick one). If there are two 29 | // boxes, one with weight 1 and the other with weight 2, the first one gets 33% 30 | // of the available space and the second one gets the remaining 66% 31 | 32 | type Box struct { 33 | // Direction decides how the children boxes are laid out. ROW means the children will each form a row i.e. that they will be stacked on top of eachother. 34 | Direction Direction 35 | 36 | // function which takes the width and height assigned to the box and decides which orientation it will have 37 | ConditionalDirection func(width int, height int) Direction 38 | 39 | Children []*Box 40 | 41 | // function which takes the width and height assigned to the box and decides the layout of the children. 42 | ConditionalChildren func(width int, height int) []*Box 43 | 44 | // Window refers to the name of the window this box represents, if there is one 45 | Window string 46 | 47 | // static Size. If parent box's direction is ROW this refers to height, otherwise width 48 | Size int 49 | 50 | // dynamic size. Once all statically sized children have been considered, Weight decides how much of the remaining space will be taken up by the box 51 | // TODO: consider making there be one int and a type enum so we can't have size and Weight simultaneously defined 52 | Weight int 53 | } 54 | 55 | func ArrangeWindows(root *Box, x0, y0, width, height int) map[string]Dimensions { 56 | children := root.getChildren(width, height) 57 | if len(children) == 0 { 58 | // leaf node 59 | if root.Window != "" { 60 | dimensionsForWindow := Dimensions{X0: x0, Y0: y0, X1: x0 + width - 1, Y1: y0 + height - 1} 61 | return map[string]Dimensions{root.Window: dimensionsForWindow} 62 | } 63 | return map[string]Dimensions{} 64 | } 65 | 66 | direction := root.getDirection(width, height) 67 | 68 | var availableSize int 69 | if direction == COLUMN { 70 | availableSize = width 71 | } else { 72 | availableSize = height 73 | } 74 | 75 | sizes := calcSizes(children, availableSize) 76 | 77 | result := map[string]Dimensions{} 78 | offset := 0 79 | for i, child := range children { 80 | boxSize := sizes[i] 81 | 82 | var resultForChild map[string]Dimensions 83 | if direction == COLUMN { 84 | resultForChild = ArrangeWindows(child, x0+offset, y0, boxSize, height) 85 | } else { 86 | resultForChild = ArrangeWindows(child, x0, y0+offset, width, boxSize) 87 | } 88 | 89 | result = mergeDimensionMaps(result, resultForChild) 90 | offset += boxSize 91 | } 92 | 93 | return result 94 | } 95 | 96 | func calcSizes(boxes []*Box, availableSpace int) []int { 97 | normalizedWeights := normalizeWeights(lo.Map(boxes, func(box *Box, _ int) int { return box.Weight })) 98 | 99 | totalWeight := 0 100 | reservedSpace := 0 101 | for i, box := range boxes { 102 | if box.isStatic() { 103 | reservedSpace += box.Size 104 | } else { 105 | totalWeight += normalizedWeights[i] 106 | } 107 | } 108 | 109 | dynamicSpace := utils.Max(0, availableSpace-reservedSpace) 110 | 111 | unitSize := 0 112 | extraSpace := 0 113 | if totalWeight > 0 { 114 | unitSize = dynamicSpace / totalWeight 115 | extraSpace = dynamicSpace % totalWeight 116 | } 117 | 118 | result := make([]int, len(boxes)) 119 | for i, box := range boxes { 120 | if box.isStatic() { 121 | // assuming that only one static child can have a size greater than the 122 | // available space. In that case we just crop the size to what's available 123 | result[i] = utils.Min(availableSpace, box.Size) 124 | } else { 125 | result[i] = unitSize * normalizedWeights[i] 126 | } 127 | } 128 | 129 | // distribute the remainder across dynamic boxes. 130 | for extraSpace > 0 { 131 | for i, weight := range normalizedWeights { 132 | if weight > 0 { 133 | result[i]++ 134 | extraSpace-- 135 | normalizedWeights[i]-- 136 | 137 | if extraSpace == 0 { 138 | break 139 | } 140 | } 141 | } 142 | } 143 | 144 | return result 145 | } 146 | 147 | // removes common multiple from weights e.g. if we get 2, 4, 4 we return 1, 2, 2. 148 | func normalizeWeights(weights []int) []int { 149 | if len(weights) == 0 { 150 | return []int{} 151 | } 152 | 153 | // to spare us some computation we'll exit early if any of our weights is 1 154 | if lo.SomeBy(weights, func(weight int) bool { return weight == 1 }) { 155 | return weights 156 | } 157 | 158 | // map weights to factorSlices and find the lowest common factor 159 | positiveWeights := lo.Filter(weights, func(weight int, _ int) bool { return weight > 0 }) 160 | factorSlices := lo.Map(positiveWeights, func(weight int, _ int) []int { return calcFactors(weight) }) 161 | commonFactors := factorSlices[0] 162 | for _, factors := range factorSlices { 163 | commonFactors = lo.Intersect(commonFactors, factors) 164 | } 165 | 166 | if len(commonFactors) == 0 { 167 | return weights 168 | } 169 | 170 | newWeights := lo.Map(weights, func(weight int, _ int) int { return weight / commonFactors[0] }) 171 | 172 | return normalizeWeights(newWeights) 173 | } 174 | 175 | func calcFactors(n int) []int { 176 | factors := []int{} 177 | for i := 2; i <= n; i++ { 178 | if n%i == 0 { 179 | factors = append(factors, i) 180 | } 181 | } 182 | return factors 183 | } 184 | 185 | func (b *Box) isStatic() bool { 186 | return b.Size > 0 187 | } 188 | 189 | func (b *Box) getDirection(width int, height int) Direction { 190 | if b.ConditionalDirection != nil { 191 | return b.ConditionalDirection(width, height) 192 | } 193 | return b.Direction 194 | } 195 | 196 | func (b *Box) getChildren(width int, height int) []*Box { 197 | if b.ConditionalChildren != nil { 198 | return b.ConditionalChildren(width, height) 199 | } 200 | return b.Children 201 | } 202 | 203 | func mergeDimensionMaps(a map[string]Dimensions, b map[string]Dimensions) map[string]Dimensions { 204 | result := map[string]Dimensions{} 205 | for _, dimensionMap := range []map[string]Dimensions{a, b} { 206 | for k, v := range dimensionMap { 207 | result[k] = v 208 | } 209 | } 210 | return result 211 | } 212 | -------------------------------------------------------------------------------- /pkg/boxlayout/boxlayout_test.go: -------------------------------------------------------------------------------- 1 | package boxlayout 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestArrangeWindows(t *testing.T) { 10 | type scenario struct { 11 | testName string 12 | root *Box 13 | x0 int 14 | y0 int 15 | width int 16 | height int 17 | test func(result map[string]Dimensions) 18 | } 19 | 20 | scenarios := []scenario{ 21 | { 22 | testName: "Empty box", 23 | root: &Box{}, 24 | x0: 0, 25 | y0: 0, 26 | width: 10, 27 | height: 10, 28 | test: func(result map[string]Dimensions) { 29 | assert.EqualValues(t, result, map[string]Dimensions{}) 30 | }, 31 | }, 32 | { 33 | testName: "Box with static and dynamic panel", 34 | root: &Box{Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic"}}}, 35 | x0: 0, 36 | y0: 0, 37 | width: 10, 38 | height: 10, 39 | test: func(result map[string]Dimensions) { 40 | assert.EqualValues( 41 | t, 42 | result, 43 | map[string]Dimensions{ 44 | "dynamic": {X0: 0, X1: 9, Y0: 1, Y1: 9}, 45 | "static": {X0: 0, X1: 9, Y0: 0, Y1: 0}, 46 | }, 47 | ) 48 | }, 49 | }, 50 | { 51 | testName: "Box with static and two dynamic panels", 52 | root: &Box{Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}}, 53 | x0: 0, 54 | y0: 0, 55 | width: 10, 56 | height: 10, 57 | test: func(result map[string]Dimensions) { 58 | assert.EqualValues( 59 | t, 60 | result, 61 | map[string]Dimensions{ 62 | "static": {X0: 0, X1: 9, Y0: 0, Y1: 0}, 63 | "dynamic1": {X0: 0, X1: 9, Y0: 1, Y1: 3}, 64 | "dynamic2": {X0: 0, X1: 9, Y0: 4, Y1: 9}, 65 | }, 66 | ) 67 | }, 68 | }, 69 | { 70 | testName: "Box with COLUMN direction", 71 | root: &Box{Direction: COLUMN, Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}}, 72 | x0: 0, 73 | y0: 0, 74 | width: 10, 75 | height: 10, 76 | test: func(result map[string]Dimensions) { 77 | assert.EqualValues( 78 | t, 79 | result, 80 | map[string]Dimensions{ 81 | "static": {X0: 0, X1: 0, Y0: 0, Y1: 9}, 82 | "dynamic1": {X0: 1, X1: 3, Y0: 0, Y1: 9}, 83 | "dynamic2": {X0: 4, X1: 9, Y0: 0, Y1: 9}, 84 | }, 85 | ) 86 | }, 87 | }, 88 | { 89 | testName: "Box with COLUMN direction only on wide boxes with narrow box", 90 | root: &Box{ConditionalDirection: func(width int, height int) Direction { 91 | if width > 4 { 92 | return COLUMN 93 | } else { 94 | return ROW 95 | } 96 | }, Children: []*Box{{Weight: 1, Window: "dynamic1"}, {Weight: 1, Window: "dynamic2"}}}, 97 | x0: 0, 98 | y0: 0, 99 | width: 4, 100 | height: 4, 101 | test: func(result map[string]Dimensions) { 102 | assert.EqualValues( 103 | t, 104 | result, 105 | map[string]Dimensions{ 106 | "dynamic1": {X0: 0, X1: 3, Y0: 0, Y1: 1}, 107 | "dynamic2": {X0: 0, X1: 3, Y0: 2, Y1: 3}, 108 | }, 109 | ) 110 | }, 111 | }, 112 | { 113 | testName: "Box with COLUMN direction only on wide boxes with wide box", 114 | root: &Box{ConditionalDirection: func(width int, height int) Direction { 115 | if width > 4 { 116 | return COLUMN 117 | } else { 118 | return ROW 119 | } 120 | }, Children: []*Box{{Weight: 1, Window: "dynamic1"}, {Weight: 1, Window: "dynamic2"}}}, 121 | // 5 / 2 = 2 remainder 1. That remainder goes to the first box. 122 | x0: 0, 123 | y0: 0, 124 | width: 5, 125 | height: 5, 126 | test: func(result map[string]Dimensions) { 127 | assert.EqualValues( 128 | t, 129 | result, 130 | map[string]Dimensions{ 131 | "dynamic1": {X0: 0, X1: 2, Y0: 0, Y1: 4}, 132 | "dynamic2": {X0: 3, X1: 4, Y0: 0, Y1: 4}, 133 | }, 134 | ) 135 | }, 136 | }, 137 | { 138 | testName: "Box with conditional children where box is wide", 139 | root: &Box{ConditionalChildren: func(width int, height int) []*Box { 140 | if width > 4 { 141 | return []*Box{{Window: "wide", Weight: 1}} 142 | } else { 143 | return []*Box{{Window: "narrow", Weight: 1}} 144 | } 145 | }}, 146 | x0: 0, 147 | y0: 0, 148 | width: 5, 149 | height: 5, 150 | test: func(result map[string]Dimensions) { 151 | assert.EqualValues( 152 | t, 153 | result, 154 | map[string]Dimensions{ 155 | "wide": {X0: 0, X1: 4, Y0: 0, Y1: 4}, 156 | }, 157 | ) 158 | }, 159 | }, 160 | { 161 | testName: "Box with conditional children where box is narrow", 162 | root: &Box{ConditionalChildren: func(width int, height int) []*Box { 163 | if width > 4 { 164 | return []*Box{{Window: "wide", Weight: 1}} 165 | } else { 166 | return []*Box{{Window: "narrow", Weight: 1}} 167 | } 168 | }}, 169 | x0: 0, 170 | y0: 0, 171 | width: 4, 172 | height: 4, 173 | test: func(result map[string]Dimensions) { 174 | assert.EqualValues( 175 | t, 176 | result, 177 | map[string]Dimensions{ 178 | "narrow": {X0: 0, X1: 3, Y0: 0, Y1: 3}, 179 | }, 180 | ) 181 | }, 182 | }, 183 | { 184 | testName: "Box with static child with size too large", 185 | root: &Box{Direction: COLUMN, Children: []*Box{{Size: 11, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}}, 186 | x0: 0, 187 | y0: 0, 188 | width: 10, 189 | height: 10, 190 | test: func(result map[string]Dimensions) { 191 | assert.EqualValues( 192 | t, 193 | result, 194 | map[string]Dimensions{ 195 | "static": {X0: 0, X1: 9, Y0: 0, Y1: 9}, 196 | // not sure if X0: 10, X1: 9 makes any sense, but testing this in the 197 | // actual GUI it seems harmless 198 | "dynamic1": {X0: 10, X1: 9, Y0: 0, Y1: 9}, 199 | "dynamic2": {X0: 10, X1: 9, Y0: 0, Y1: 9}, 200 | }, 201 | ) 202 | }, 203 | }, 204 | { 205 | // 10 total space minus 2 from the status box leaves us with 8. 206 | // Total weight is 3, 8 / 3 = 2 with 2 remainder. 207 | // We want to end up with 2, 3, 5 (one unit from remainder to each dynamic box) 208 | testName: "Distributing remainder across weighted boxes", 209 | root: &Box{Direction: COLUMN, Children: []*Box{{Size: 2, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}}, 210 | x0: 0, 211 | y0: 0, 212 | width: 10, 213 | height: 10, 214 | test: func(result map[string]Dimensions) { 215 | assert.EqualValues( 216 | t, 217 | result, 218 | map[string]Dimensions{ 219 | "static": {X0: 0, X1: 1, Y0: 0, Y1: 9}, // 2 220 | "dynamic1": {X0: 2, X1: 4, Y0: 0, Y1: 9}, // 3 221 | "dynamic2": {X0: 5, X1: 9, Y0: 0, Y1: 9}, // 5 222 | }, 223 | ) 224 | }, 225 | }, 226 | { 227 | // 9 total space. 228 | // total weight is 5, 9 / 5 = 1 with 4 remainder 229 | // we want to give 2 of that remainder to the first, 1 to the second, and 1 to the last. 230 | // Reason being that we just give units to each box evenly and consider weight in subsequent passes. 231 | testName: "Distributing remainder across weighted boxes 2", 232 | root: &Box{Direction: COLUMN, Children: []*Box{{Weight: 2, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}, {Weight: 1, Window: "dynamic3"}}}, 233 | x0: 0, 234 | y0: 0, 235 | width: 9, 236 | height: 10, 237 | test: func(result map[string]Dimensions) { 238 | assert.EqualValues( 239 | t, 240 | result, 241 | map[string]Dimensions{ 242 | "dynamic1": {X0: 0, X1: 3, Y0: 0, Y1: 9}, // 4 243 | "dynamic2": {X0: 4, X1: 6, Y0: 0, Y1: 9}, // 3 244 | "dynamic3": {X0: 7, X1: 8, Y0: 0, Y1: 9}, // 2 245 | }, 246 | ) 247 | }, 248 | }, 249 | { 250 | // 9 total space. 251 | // total weight is 5, 9 / 5 = 1 with 4 remainder 252 | // we want to give 2 of that remainder to the first, 1 to the second, and 1 to the last. 253 | // Reason being that we just give units to each box evenly and consider weight in subsequent passes. 254 | testName: "Distributing remainder across weighted boxes with unnormalized weights", 255 | root: &Box{Direction: COLUMN, Children: []*Box{{Weight: 4, Window: "dynamic1"}, {Weight: 4, Window: "dynamic2"}, {Weight: 2, Window: "dynamic3"}}}, 256 | x0: 0, 257 | y0: 0, 258 | width: 9, 259 | height: 10, 260 | test: func(result map[string]Dimensions) { 261 | assert.EqualValues( 262 | t, 263 | result, 264 | map[string]Dimensions{ 265 | "dynamic1": {X0: 0, X1: 3, Y0: 0, Y1: 9}, // 4 266 | "dynamic2": {X0: 4, X1: 6, Y0: 0, Y1: 9}, // 3 267 | "dynamic3": {X0: 7, X1: 8, Y0: 0, Y1: 9}, // 2 268 | }, 269 | ) 270 | }, 271 | }, 272 | { 273 | testName: "Another distribution test", 274 | root: &Box{Direction: COLUMN, Children: []*Box{ 275 | {Weight: 3, Window: "dynamic1"}, 276 | {Weight: 1, Window: "dynamic2"}, 277 | {Weight: 1, Window: "dynamic3"}, 278 | }}, 279 | x0: 0, 280 | y0: 0, 281 | width: 9, 282 | height: 10, 283 | test: func(result map[string]Dimensions) { 284 | assert.EqualValues( 285 | t, 286 | result, 287 | map[string]Dimensions{ 288 | "dynamic1": {X0: 0, X1: 4, Y0: 0, Y1: 9}, // 5 289 | "dynamic2": {X0: 5, X1: 6, Y0: 0, Y1: 9}, // 2 290 | "dynamic3": {X0: 7, X1: 8, Y0: 0, Y1: 9}, // 2 291 | }, 292 | ) 293 | }, 294 | }, 295 | { 296 | testName: "Box with zero weight", 297 | root: &Box{Direction: COLUMN, Children: []*Box{ 298 | {Weight: 1, Window: "dynamic1"}, 299 | {Weight: 0, Window: "dynamic2"}, 300 | }}, 301 | x0: 0, 302 | y0: 0, 303 | width: 10, 304 | height: 10, 305 | test: func(result map[string]Dimensions) { 306 | assert.EqualValues( 307 | t, 308 | result, 309 | map[string]Dimensions{ 310 | "dynamic1": {X0: 0, X1: 9, Y0: 0, Y1: 9}, 311 | "dynamic2": {X0: 10, X1: 9, Y0: 0, Y1: 9}, // when X0 > X1, we will hide the window 312 | }, 313 | ) 314 | }, 315 | }, 316 | } 317 | 318 | for _, s := range scenarios { 319 | s := s 320 | t.Run(s.testName, func(t *testing.T) { 321 | s.test(ArrangeWindows(s.root, s.x0, s.y0, s.width, s.height)) 322 | }) 323 | } 324 | } 325 | 326 | func TestNormalizeWeights(t *testing.T) { 327 | scenarios := []struct { 328 | testName string 329 | input []int 330 | expected []int 331 | }{ 332 | { 333 | testName: "empty", 334 | input: []int{}, 335 | expected: []int{}, 336 | }, 337 | { 338 | testName: "one item of value 1", 339 | input: []int{1}, 340 | expected: []int{1}, 341 | }, 342 | { 343 | testName: "one item of value greater than 1", 344 | input: []int{2}, 345 | expected: []int{1}, 346 | }, 347 | { 348 | testName: "slice contains 1", 349 | input: []int{2, 1}, 350 | expected: []int{2, 1}, 351 | }, 352 | { 353 | testName: "slice contains 2 and 2", 354 | input: []int{2, 2}, 355 | expected: []int{1, 1}, 356 | }, 357 | { 358 | testName: "no common multiple", 359 | input: []int{2, 3}, 360 | expected: []int{2, 3}, 361 | }, 362 | { 363 | testName: "complex case", 364 | input: []int{10, 10, 20}, 365 | expected: []int{1, 1, 2}, 366 | }, 367 | { 368 | testName: "when a zero weight is included it is ignored", 369 | input: []int{10, 10, 20, 0}, 370 | expected: []int{1, 1, 2, 0}, 371 | }, 372 | } 373 | 374 | for _, s := range scenarios { 375 | s := s 376 | t.Run(s.testName, func(t *testing.T) { 377 | assert.EqualValues(t, s.expected, normalizeWeights(s.input)) 378 | }) 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /pkg/utils/once_writer.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | "sync" 6 | ) 7 | 8 | // This wraps a writer and ensures that before we actually write anything we call a given function first 9 | 10 | type OnceWriter struct { 11 | writer io.Writer 12 | once sync.Once 13 | f func() 14 | } 15 | 16 | var _ io.Writer = &OnceWriter{} 17 | 18 | func NewOnceWriter(writer io.Writer, f func()) *OnceWriter { 19 | return &OnceWriter{ 20 | writer: writer, 21 | f: f, 22 | } 23 | } 24 | 25 | func (self *OnceWriter) Write(p []byte) (n int, err error) { 26 | self.once.Do(func() { 27 | self.f() 28 | }) 29 | 30 | return self.writer.Write(p) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/utils/once_writer_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestOnceWriter(t *testing.T) { 9 | innerWriter := bytes.NewBuffer(nil) 10 | counter := 0 11 | onceWriter := NewOnceWriter(innerWriter, func() { 12 | counter += 1 13 | }) 14 | _, _ = onceWriter.Write([]byte("hello")) 15 | _, _ = onceWriter.Write([]byte("hello")) 16 | if counter != 1 { 17 | t.Errorf("expected counter to be 1, got %d", counter) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkg/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | // Min returns the minimum of two integers 10 | func Min(x, y int) int { 11 | if x < y { 12 | return x 13 | } 14 | return y 15 | } 16 | 17 | // Max returns the maximum of two integers 18 | func Max(x, y int) int { 19 | if x > y { 20 | return x 21 | } 22 | return y 23 | } 24 | 25 | // Clamp returns a value x restricted between min and max 26 | func Clamp(x int, min int, max int) int { 27 | if x < min { 28 | return min 29 | } else if x > max { 30 | return max 31 | } 32 | return x 33 | } 34 | 35 | // GetLazyRootDirectory finds a lazy project root directory. 36 | // 37 | // It's used for cheatsheet scripts and integration tests. Not to be confused with finding the 38 | // root directory of _any_ random repo. 39 | func GetLazyRootDirectory() string { 40 | path, err := os.Getwd() 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | for { 46 | _, err := os.Stat(filepath.Join(path, ".git")) 47 | if err == nil { 48 | return path 49 | } 50 | 51 | if !os.IsNotExist(err) { 52 | panic(err) 53 | } 54 | 55 | path = filepath.Dir(path) 56 | 57 | if path == "/" { 58 | log.Fatal("must run in lazy project folder or child folder") 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /pkg/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | // TestMin is a function. 10 | func TestMin(t *testing.T) { 11 | type scenario struct { 12 | a int 13 | b int 14 | expected int 15 | } 16 | 17 | scenarios := []scenario{ 18 | { 19 | 1, 20 | 1, 21 | 1, 22 | }, 23 | { 24 | 1, 25 | 2, 26 | 1, 27 | }, 28 | { 29 | 2, 30 | 1, 31 | 1, 32 | }, 33 | } 34 | 35 | for _, s := range scenarios { 36 | assert.EqualValues(t, s.expected, Min(s.a, s.b)) 37 | } 38 | } 39 | 40 | func TestClamp(t *testing.T) { 41 | tests := []struct { 42 | name string 43 | x int 44 | min int 45 | max int 46 | want int 47 | }{ 48 | { 49 | "successX", 50 | 5, 51 | 1, 52 | 10, 53 | 5, 54 | }, 55 | { 56 | "successMin", 57 | -5, 58 | 1, 59 | 10, 60 | 1, 61 | }, 62 | { 63 | "successMax", 64 | 15, 65 | 1, 66 | 10, 67 | 10, 68 | }, 69 | } 70 | 71 | for _, tt := range tests { 72 | t.Run(tt.name, func(t *testing.T) { 73 | assert.Equal(t, tt.want, Clamp(tt.x, tt.min, tt.max)) 74 | }) 75 | } 76 | } 77 | 78 | func TestGetLazyRootDirectory(t *testing.T) { 79 | assert.NotPanics(t, func() { 80 | GetLazyRootDirectory() 81 | }) 82 | } 83 | --------------------------------------------------------------------------------