mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-02-25 18:55:28 -06:00
use boxlayout from lazycore
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/boxlayout"
|
||||
"github.com/jesseduffield/lazycore/pkg/boxlayout"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package boxlayout
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/generics/slices"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type Dimensions struct {
|
||||
X0 int
|
||||
X1 int
|
||||
Y0 int
|
||||
Y1 int
|
||||
}
|
||||
|
||||
type Direction int
|
||||
|
||||
const (
|
||||
ROW Direction = iota
|
||||
COLUMN
|
||||
)
|
||||
|
||||
// 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.
|
||||
// If a box has children, it needs to specify how it wants to arrange those children: ROW or COLUMN.
|
||||
// If a box represents a window, you can put the window name in the Window field.
|
||||
// When determining how to divvy-up the available height (for row children) or width (for column children), we first
|
||||
// give the boxes with a static `size` the space that they want. Then we apportion
|
||||
// the remaining space based on the weights of the dynamic boxes (you can't define
|
||||
// both size and weight at the same time: you gotta pick one). If there are two
|
||||
// boxes, one with weight 1 and the other with weight 2, the first one gets 33%
|
||||
// of the available space and the second one gets the remaining 66%
|
||||
|
||||
type Box struct {
|
||||
// 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.
|
||||
Direction Direction
|
||||
|
||||
// function which takes the width and height assigned to the box and decides which orientation it will have
|
||||
ConditionalDirection func(width int, height int) Direction
|
||||
|
||||
Children []*Box
|
||||
|
||||
// function which takes the width and height assigned to the box and decides the layout of the children.
|
||||
ConditionalChildren func(width int, height int) []*Box
|
||||
|
||||
// Window refers to the name of the window this box represents, if there is one
|
||||
Window string
|
||||
|
||||
// static Size. If parent box's direction is ROW this refers to height, otherwise width
|
||||
Size int
|
||||
|
||||
// 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
|
||||
// TODO: consider making there be one int and a type enum so we can't have size and Weight simultaneously defined
|
||||
Weight int
|
||||
}
|
||||
|
||||
func ArrangeWindows(root *Box, x0, y0, width, height int) map[string]Dimensions {
|
||||
children := root.getChildren(width, height)
|
||||
if len(children) == 0 {
|
||||
// leaf node
|
||||
if root.Window != "" {
|
||||
dimensionsForWindow := Dimensions{X0: x0, Y0: y0, X1: x0 + width - 1, Y1: y0 + height - 1}
|
||||
return map[string]Dimensions{root.Window: dimensionsForWindow}
|
||||
}
|
||||
return map[string]Dimensions{}
|
||||
}
|
||||
|
||||
direction := root.getDirection(width, height)
|
||||
|
||||
var availableSize int
|
||||
if direction == COLUMN {
|
||||
availableSize = width
|
||||
} else {
|
||||
availableSize = height
|
||||
}
|
||||
|
||||
sizes := calcSizes(children, availableSize)
|
||||
|
||||
result := map[string]Dimensions{}
|
||||
offset := 0
|
||||
for i, child := range children {
|
||||
boxSize := sizes[i]
|
||||
|
||||
var resultForChild map[string]Dimensions
|
||||
if direction == COLUMN {
|
||||
resultForChild = ArrangeWindows(child, x0+offset, y0, boxSize, height)
|
||||
} else {
|
||||
resultForChild = ArrangeWindows(child, x0, y0+offset, width, boxSize)
|
||||
}
|
||||
|
||||
result = mergeDimensionMaps(result, resultForChild)
|
||||
offset += boxSize
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func calcSizes(boxes []*Box, availableSpace int) []int {
|
||||
normalizedWeights := normalizeWeights(slices.Map(boxes, func(box *Box) int { return box.Weight }))
|
||||
|
||||
totalWeight := 0
|
||||
reservedSpace := 0
|
||||
for i, box := range boxes {
|
||||
if box.isStatic() {
|
||||
reservedSpace += box.Size
|
||||
} else {
|
||||
totalWeight += normalizedWeights[i]
|
||||
}
|
||||
}
|
||||
|
||||
dynamicSpace := utils.Max(0, availableSpace-reservedSpace)
|
||||
|
||||
unitSize := 0
|
||||
extraSpace := 0
|
||||
if totalWeight > 0 {
|
||||
unitSize = dynamicSpace / totalWeight
|
||||
extraSpace = dynamicSpace % totalWeight
|
||||
}
|
||||
|
||||
result := make([]int, len(boxes))
|
||||
for i, box := range boxes {
|
||||
if box.isStatic() {
|
||||
// assuming that only one static child can have a size greater than the
|
||||
// available space. In that case we just crop the size to what's available
|
||||
result[i] = utils.Min(availableSpace, box.Size)
|
||||
} else {
|
||||
result[i] = unitSize * normalizedWeights[i]
|
||||
}
|
||||
}
|
||||
|
||||
// distribute the remainder across dynamic boxes.
|
||||
for extraSpace > 0 {
|
||||
for i, weight := range normalizedWeights {
|
||||
if weight > 0 {
|
||||
result[i]++
|
||||
extraSpace--
|
||||
normalizedWeights[i]--
|
||||
|
||||
if extraSpace == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// removes common multiple from weights e.g. if we get 2, 4, 4 we return 1, 2, 2.
|
||||
func normalizeWeights(weights []int) []int {
|
||||
if len(weights) == 0 {
|
||||
return []int{}
|
||||
}
|
||||
|
||||
// to spare us some computation we'll exit early if any of our weights is 1
|
||||
if slices.Some(weights, func(weight int) bool { return weight == 1 }) {
|
||||
return weights
|
||||
}
|
||||
|
||||
// map weights to factorSlices and find the lowest common factor
|
||||
positiveWeights := slices.Filter(weights, func(weight int) bool { return weight > 0 })
|
||||
factorSlices := slices.Map(positiveWeights, func(weight int) []int { return calcFactors(weight) })
|
||||
commonFactors := factorSlices[0]
|
||||
for _, factors := range factorSlices {
|
||||
commonFactors = lo.Intersect(commonFactors, factors)
|
||||
}
|
||||
|
||||
if len(commonFactors) == 0 {
|
||||
return weights
|
||||
}
|
||||
|
||||
newWeights := slices.Map(weights, func(weight int) int { return weight / commonFactors[0] })
|
||||
|
||||
return normalizeWeights(newWeights)
|
||||
}
|
||||
|
||||
func calcFactors(n int) []int {
|
||||
factors := []int{}
|
||||
for i := 2; i <= n; i++ {
|
||||
if n%i == 0 {
|
||||
factors = append(factors, i)
|
||||
}
|
||||
}
|
||||
return factors
|
||||
}
|
||||
|
||||
func (b *Box) isStatic() bool {
|
||||
return b.Size > 0
|
||||
}
|
||||
|
||||
func (b *Box) getDirection(width int, height int) Direction {
|
||||
if b.ConditionalDirection != nil {
|
||||
return b.ConditionalDirection(width, height)
|
||||
}
|
||||
return b.Direction
|
||||
}
|
||||
|
||||
func (b *Box) getChildren(width int, height int) []*Box {
|
||||
if b.ConditionalChildren != nil {
|
||||
return b.ConditionalChildren(width, height)
|
||||
}
|
||||
return b.Children
|
||||
}
|
||||
|
||||
func mergeDimensionMaps(a map[string]Dimensions, b map[string]Dimensions) map[string]Dimensions {
|
||||
result := map[string]Dimensions{}
|
||||
for _, dimensionMap := range []map[string]Dimensions{a, b} {
|
||||
for k, v := range dimensionMap {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
package boxlayout
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestArrangeWindows(t *testing.T) {
|
||||
type scenario struct {
|
||||
testName string
|
||||
root *Box
|
||||
x0 int
|
||||
y0 int
|
||||
width int
|
||||
height int
|
||||
test func(result map[string]Dimensions)
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{
|
||||
testName: "Empty box",
|
||||
root: &Box{},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(t, result, map[string]Dimensions{})
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Box with static and dynamic panel",
|
||||
root: &Box{Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic"}}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"dynamic": {X0: 0, X1: 9, Y0: 1, Y1: 9},
|
||||
"static": {X0: 0, X1: 9, Y0: 0, Y1: 0},
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Box with static and two dynamic panels",
|
||||
root: &Box{Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"static": {X0: 0, X1: 9, Y0: 0, Y1: 0},
|
||||
"dynamic1": {X0: 0, X1: 9, Y0: 1, Y1: 3},
|
||||
"dynamic2": {X0: 0, X1: 9, Y0: 4, Y1: 9},
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Box with COLUMN direction",
|
||||
root: &Box{Direction: COLUMN, Children: []*Box{{Size: 1, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"static": {X0: 0, X1: 0, Y0: 0, Y1: 9},
|
||||
"dynamic1": {X0: 1, X1: 3, Y0: 0, Y1: 9},
|
||||
"dynamic2": {X0: 4, X1: 9, Y0: 0, Y1: 9},
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Box with COLUMN direction only on wide boxes with narrow box",
|
||||
root: &Box{ConditionalDirection: func(width int, height int) Direction {
|
||||
if width > 4 {
|
||||
return COLUMN
|
||||
} else {
|
||||
return ROW
|
||||
}
|
||||
}, Children: []*Box{{Weight: 1, Window: "dynamic1"}, {Weight: 1, Window: "dynamic2"}}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 4,
|
||||
height: 4,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"dynamic1": {X0: 0, X1: 3, Y0: 0, Y1: 1},
|
||||
"dynamic2": {X0: 0, X1: 3, Y0: 2, Y1: 3},
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Box with COLUMN direction only on wide boxes with wide box",
|
||||
root: &Box{ConditionalDirection: func(width int, height int) Direction {
|
||||
if width > 4 {
|
||||
return COLUMN
|
||||
} else {
|
||||
return ROW
|
||||
}
|
||||
}, Children: []*Box{{Weight: 1, Window: "dynamic1"}, {Weight: 1, Window: "dynamic2"}}},
|
||||
// 5 / 2 = 2 remainder 1. That remainder goes to the first box.
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 5,
|
||||
height: 5,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"dynamic1": {X0: 0, X1: 2, Y0: 0, Y1: 4},
|
||||
"dynamic2": {X0: 3, X1: 4, Y0: 0, Y1: 4},
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Box with conditional children where box is wide",
|
||||
root: &Box{ConditionalChildren: func(width int, height int) []*Box {
|
||||
if width > 4 {
|
||||
return []*Box{{Window: "wide", Weight: 1}}
|
||||
} else {
|
||||
return []*Box{{Window: "narrow", Weight: 1}}
|
||||
}
|
||||
}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 5,
|
||||
height: 5,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"wide": {X0: 0, X1: 4, Y0: 0, Y1: 4},
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Box with conditional children where box is narrow",
|
||||
root: &Box{ConditionalChildren: func(width int, height int) []*Box {
|
||||
if width > 4 {
|
||||
return []*Box{{Window: "wide", Weight: 1}}
|
||||
} else {
|
||||
return []*Box{{Window: "narrow", Weight: 1}}
|
||||
}
|
||||
}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 4,
|
||||
height: 4,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"narrow": {X0: 0, X1: 3, Y0: 0, Y1: 3},
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Box with static child with size too large",
|
||||
root: &Box{Direction: COLUMN, Children: []*Box{{Size: 11, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"static": {X0: 0, X1: 9, Y0: 0, Y1: 9},
|
||||
// not sure if X0: 10, X1: 9 makes any sense, but testing this in the
|
||||
// actual GUI it seems harmless
|
||||
"dynamic1": {X0: 10, X1: 9, Y0: 0, Y1: 9},
|
||||
"dynamic2": {X0: 10, X1: 9, Y0: 0, Y1: 9},
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// 10 total space minus 2 from the status box leaves us with 8.
|
||||
// Total weight is 3, 8 / 3 = 2 with 2 remainder.
|
||||
// We want to end up with 2, 3, 5 (one unit from remainder to each dynamic box)
|
||||
testName: "Distributing remainder across weighted boxes",
|
||||
root: &Box{Direction: COLUMN, Children: []*Box{{Size: 2, Window: "static"}, {Weight: 1, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"static": {X0: 0, X1: 1, Y0: 0, Y1: 9}, // 2
|
||||
"dynamic1": {X0: 2, X1: 4, Y0: 0, Y1: 9}, // 3
|
||||
"dynamic2": {X0: 5, X1: 9, Y0: 0, Y1: 9}, // 5
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// 9 total space.
|
||||
// total weight is 5, 9 / 5 = 1 with 4 remainder
|
||||
// we want to give 2 of that remainder to the first, 1 to the second, and 1 to the last.
|
||||
// Reason being that we just give units to each box evenly and consider weight in subsequent passes.
|
||||
testName: "Distributing remainder across weighted boxes 2",
|
||||
root: &Box{Direction: COLUMN, Children: []*Box{{Weight: 2, Window: "dynamic1"}, {Weight: 2, Window: "dynamic2"}, {Weight: 1, Window: "dynamic3"}}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 9,
|
||||
height: 10,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"dynamic1": {X0: 0, X1: 3, Y0: 0, Y1: 9}, // 4
|
||||
"dynamic2": {X0: 4, X1: 6, Y0: 0, Y1: 9}, // 3
|
||||
"dynamic3": {X0: 7, X1: 8, Y0: 0, Y1: 9}, // 2
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// 9 total space.
|
||||
// total weight is 5, 9 / 5 = 1 with 4 remainder
|
||||
// we want to give 2 of that remainder to the first, 1 to the second, and 1 to the last.
|
||||
// Reason being that we just give units to each box evenly and consider weight in subsequent passes.
|
||||
testName: "Distributing remainder across weighted boxes with unnormalized weights",
|
||||
root: &Box{Direction: COLUMN, Children: []*Box{{Weight: 4, Window: "dynamic1"}, {Weight: 4, Window: "dynamic2"}, {Weight: 2, Window: "dynamic3"}}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 9,
|
||||
height: 10,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"dynamic1": {X0: 0, X1: 3, Y0: 0, Y1: 9}, // 4
|
||||
"dynamic2": {X0: 4, X1: 6, Y0: 0, Y1: 9}, // 3
|
||||
"dynamic3": {X0: 7, X1: 8, Y0: 0, Y1: 9}, // 2
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Another distribution test",
|
||||
root: &Box{Direction: COLUMN, Children: []*Box{
|
||||
{Weight: 3, Window: "dynamic1"},
|
||||
{Weight: 1, Window: "dynamic2"},
|
||||
{Weight: 1, Window: "dynamic3"},
|
||||
}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 9,
|
||||
height: 10,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"dynamic1": {X0: 0, X1: 4, Y0: 0, Y1: 9}, // 5
|
||||
"dynamic2": {X0: 5, X1: 6, Y0: 0, Y1: 9}, // 2
|
||||
"dynamic3": {X0: 7, X1: 8, Y0: 0, Y1: 9}, // 2
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Box with zero weight",
|
||||
root: &Box{Direction: COLUMN, Children: []*Box{
|
||||
{Weight: 1, Window: "dynamic1"},
|
||||
{Weight: 0, Window: "dynamic2"},
|
||||
}},
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
test: func(result map[string]Dimensions) {
|
||||
assert.EqualValues(
|
||||
t,
|
||||
result,
|
||||
map[string]Dimensions{
|
||||
"dynamic1": {X0: 0, X1: 9, Y0: 0, Y1: 9},
|
||||
"dynamic2": {X0: 10, X1: 9, Y0: 0, Y1: 9}, // when X0 > X1, we will hide the window
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
s.test(ArrangeWindows(s.root, s.x0, s.y0, s.width, s.height))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWeights(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
testName string
|
||||
input []int
|
||||
expected []int
|
||||
}{
|
||||
{
|
||||
testName: "empty",
|
||||
input: []int{},
|
||||
expected: []int{},
|
||||
},
|
||||
{
|
||||
testName: "one item of value 1",
|
||||
input: []int{1},
|
||||
expected: []int{1},
|
||||
},
|
||||
{
|
||||
testName: "one item of value greater than 1",
|
||||
input: []int{2},
|
||||
expected: []int{1},
|
||||
},
|
||||
{
|
||||
testName: "slice contains 1",
|
||||
input: []int{2, 1},
|
||||
expected: []int{2, 1},
|
||||
},
|
||||
{
|
||||
testName: "slice contains 2 and 2",
|
||||
input: []int{2, 2},
|
||||
expected: []int{1, 1},
|
||||
},
|
||||
{
|
||||
testName: "no common multiple",
|
||||
input: []int{2, 3},
|
||||
expected: []int{2, 3},
|
||||
},
|
||||
{
|
||||
testName: "complex case",
|
||||
input: []int{10, 10, 20},
|
||||
expected: []int{1, 1, 2},
|
||||
},
|
||||
{
|
||||
testName: "when a zero weight is included it is ignored",
|
||||
input: []int{10, 10, 20, 0},
|
||||
expected: []int{1, 1, 2, 0},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
s := s
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
assert.EqualValues(t, s.expected, normalizeWeights(s.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user