1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-10-30 23:57:43 +02:00
Files
lazygit/pkg/gui/controllers/helpers/window_arrangement_helper_test.go
Stefan Haller 8eeb16c8da Fix window arrangement when a popup or the search prompt is open
When focusing the main view, going into full screen mode by pressing '+' twice,
and then opening the search prompt ('/') or a menu (e.g. '?' or ':'), the full
screen display would switch to the focused side panel.

Fix this by always excluding popups from the window arrangement logic. No popup
should ever have any influence on how the views beneath it are laid out.
2025-10-14 12:22:58 +02:00

631 lines
40 KiB
Go

package helpers
import (
"cmp"
"fmt"
"slices"
"strings"
"testing"
"github.com/jesseduffield/lazycore/pkg/boxlayout"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
// The best way to add test cases here is to set your args and then get the
// test to fail and copy+paste the output into the test case's expected string.
// TODO: add more test cases
func TestGetWindowDimensions(t *testing.T) {
getDefaultArgs := func() WindowArrangementArgs {
return WindowArrangementArgs{
Width: 75,
Height: 30,
UserConfig: config.GetDefaultConfig(),
CurrentWindow: "files",
CurrentSideWindow: "files",
SplitMainPanel: false,
ScreenMode: types.SCREEN_NORMAL,
AppStatus: "",
InformationStr: "information",
ShowExtrasWindow: false,
InDemo: false,
IsAnyModeActive: false,
InSearchPrompt: false,
SearchPrefix: "",
}
}
type Test struct {
name string
mutateArgs func(*WindowArrangementArgs)
expected string
}
tests := []Test{
{
name: "default",
mutateArgs: func(args *WindowArrangementArgs) {},
expected: `
╭status─────────────────╮╭main────────────────────────────────────────────╮
│ ││ │
╰───────────────────────╯│ │
╭files──────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭branches───────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭commits────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭stash──────────────────╮│ │
│ ││ │
╰───────────────────────╯╰────────────────────────────────────────────────╯
<options──────────────────────────────────────────────────────>A<B────────>
A: statusSpacer1
B: information
`,
},
{
name: "stash focused",
mutateArgs: func(args *WindowArrangementArgs) {
args.CurrentSideWindow = "stash"
},
expected: `
╭status─────────────────╮╭main────────────────────────────────────────────╮
│ ││ │
╰───────────────────────╯│ │
╭files──────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭branches───────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭commits────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭stash──────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯╰────────────────────────────────────────────────╯
<options──────────────────────────────────────────────────────>A<B────────>
A: statusSpacer1
B: information
`,
},
{
name: "expandFocusedSidePanel",
mutateArgs: func(args *WindowArrangementArgs) {
args.UserConfig.Gui.ExpandFocusedSidePanel = true
},
expected: `
╭status─────────────────╮╭main────────────────────────────────────────────╮
│ ││ │
╰───────────────────────╯│ │
╭files──────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭branches───────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭commits────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭stash──────────────────╮│ │
│ ││ │
╰───────────────────────╯╰────────────────────────────────────────────────╯
<options──────────────────────────────────────────────────────>A<B────────>
A: statusSpacer1
B: information
`,
},
{
name: "expandSidePanelWeight",
mutateArgs: func(args *WindowArrangementArgs) {
args.UserConfig.Gui.ExpandFocusedSidePanel = true
args.UserConfig.Gui.ExpandedSidePanelWeight = 4
},
expected: `
╭status─────────────────╮╭main────────────────────────────────────────────╮
│ ││ │
╰───────────────────────╯│ │
╭files──────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭branches───────────────╮│ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭commits────────────────╮│ │
│ ││ │
│ ││ │
╰───────────────────────╯│ │
╭stash──────────────────╮│ │
│ ││ │
╰───────────────────────╯╰────────────────────────────────────────────────╯
<options──────────────────────────────────────────────────────>A<B────────>
A: statusSpacer1
B: information
`,
},
{
name: "0.5 SidePanelWidth",
mutateArgs: func(args *WindowArrangementArgs) {
args.UserConfig.Gui.SidePanelWidth = 0.5
},
expected: `
╭status──────────────────────────────╮╭main───────────────────────────────╮
│ ││ │
╰────────────────────────────────────╯│ │
╭files───────────────────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────────────────────╯│ │
╭branches────────────────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────────────────────╯│ │
╭commits─────────────────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────────────────────╯│ │
╭stash───────────────────────────────╮│ │
│ ││ │
╰────────────────────────────────────╯╰───────────────────────────────────╯
<options──────────────────────────────────────────────────────>A<B────────>
A: statusSpacer1
B: information
`,
},
{
name: "0.8 SidePanelWidth",
mutateArgs: func(args *WindowArrangementArgs) {
args.UserConfig.Gui.SidePanelWidth = 0.8
},
expected: `
╭status────────────────────────────────────────────────────╮╭main─────────╮
│ ││ │
╰──────────────────────────────────────────────────────────╯│ │
╭files─────────────────────────────────────────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰──────────────────────────────────────────────────────────╯│ │
╭branches──────────────────────────────────────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰──────────────────────────────────────────────────────────╯│ │
╭commits───────────────────────────────────────────────────╮│ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰──────────────────────────────────────────────────────────╯│ │
╭stash─────────────────────────────────────────────────────╮│ │
│ ││ │
╰──────────────────────────────────────────────────────────╯╰─────────────╯
<options──────────────────────────────────────────────────────>A<B────────>
A: statusSpacer1
B: information
`,
},
{
name: "half screen mode, enlargedSideViewLocation left",
mutateArgs: func(args *WindowArrangementArgs) {
args.Height = 20 // smaller height because we don't more here
args.ScreenMode = types.SCREEN_HALF
args.UserConfig.Gui.EnlargedSideViewLocation = "left"
},
expected: `
╭status──────────────────────────────╮╭main───────────────────────────────╮
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────────────────────╯╰───────────────────────────────────╯
<options──────────────────────────────────────────────────────>A<B────────>
A: statusSpacer1
B: information
`,
},
{
name: "half screen mode, enlargedSideViewLocation top",
mutateArgs: func(args *WindowArrangementArgs) {
args.Height = 20 // smaller height because we don't more here
args.ScreenMode = types.SCREEN_HALF
args.UserConfig.Gui.EnlargedSideViewLocation = "top"
},
expected: `
╭status───────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
╰─────────────────────────────────────────────────────────────────────────╯
╭main─────────────────────────────────────────────────────────────────────╮
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
╰─────────────────────────────────────────────────────────────────────────╯
<options──────────────────────────────────────────────────────>A<B────────>
A: statusSpacer1
B: information
`,
},
{
name: "search mode",
mutateArgs: func(args *WindowArrangementArgs) {
args.InSearchPrompt = true
args.SearchPrefix = "Search: "
args.Height = 6 // small height cos we only care about the bottom line
},
expected: `
<status─────────────────>╭main────────────────────────────────────────────╮
<files──────────────────>│ │
<branches───────────────>│ │
<commits────────────────>│ │
<stash──────────────────>╰────────────────────────────────────────────────╯
<A─────><search───────────────────────────────────────────────────────────>
A: searchPrefix
`,
},
{
name: "app status present",
mutateArgs: func(args *WindowArrangementArgs) {
args.AppStatus = "Rebasing /"
args.Height = 6 // small height cos we only care about the bottom line
},
// We expect single-character spacers between the windows of the bottom line
expected: `
<status─────────────────>╭main────────────────────────────────────────────╮
<files──────────────────>│ │
<branches───────────────>│ │
<commits────────────────>│ │
<stash──────────────────>╰────────────────────────────────────────────────╯
<A───────>B<options───────────────────────────────────────────>C<D────────>
A: appStatus
B: statusSpacer2
C: statusSpacer1
D: information
`,
},
{
name: "information present without options",
mutateArgs: func(args *WindowArrangementArgs) {
args.Height = 6 // small height cos we only care about the bottom line
args.UserConfig.Gui.ShowBottomLine = false // this hides the options window
args.IsAnyModeActive = true // this means we show the bottom line despite the user config
},
// We expect a spacer on the left of the bottom line so that the information
// window is right-aligned
expected: `
<status─────────────────>╭main────────────────────────────────────────────╮
<files──────────────────>│ │
<branches───────────────>│ │
<commits────────────────>│ │
<stash──────────────────>╰────────────────────────────────────────────────╯
<statusSpacer1────────────────────────────────────────────────>A<B────────>
A: statusSpacer2
B: information
`,
},
{
name: "app status present without information or options",
mutateArgs: func(args *WindowArrangementArgs) {
args.Height = 6 // small height cos we only care about the bottom line
args.UserConfig.Gui.ShowBottomLine = false // this hides the options window
args.IsAnyModeActive = false
args.AppStatus = "Rebasing /"
},
// We expect the app status window to take up all the available space
expected: `
<status─────────────────>╭main────────────────────────────────────────────╮
<files──────────────────>│ │
<branches───────────────>│ │
<commits────────────────>│ │
<stash──────────────────>╰────────────────────────────────────────────────╯
<appStatus────────────────────────────────────────────────────────────────>
`,
},
{
name: "app status present with information but without options",
mutateArgs: func(args *WindowArrangementArgs) {
args.Height = 6 // small height cos we only care about the bottom line
args.UserConfig.Gui.ShowBottomLine = false // this hides the options window
args.IsAnyModeActive = true
args.AppStatus = "Rebasing /"
},
expected: `
<status─────────────────>╭main────────────────────────────────────────────╮
<files──────────────────>│ │
<branches───────────────>│ │
<commits────────────────>│ │
<stash──────────────────>╰────────────────────────────────────────────────╯
<A───────><statusSpacer1──────────────────────────────────────>B<C────────>
A: appStatus
B: statusSpacer2
C: information
`,
},
{
name: "app status present with very long information but without options",
mutateArgs: func(args *WindowArrangementArgs) {
args.Height = 6 // small height cos we only care about the bottom line
args.Width = 55 // smaller width so that not all bottom line views fit
args.UserConfig.Gui.ShowBottomLine = false // this hides the options window
args.IsAnyModeActive = true
args.AppStatus = "Rebasing /"
args.InformationStr = "Showing output for: git diff deadbeef fa1afe1 -- (Reset)"
},
expected: `
<status───────────>╭main──────────────────────────────╮
<files────────────>│ │
<branches─────────>│ │
<commits──────────>│ │
<stash────────────>╰──────────────────────────────────╯
<A───────>B<information──────────────────────────────────────────>
A: appStatus
B: statusSpacer2
`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
args := getDefaultArgs()
test.mutateArgs(&args)
windows := GetWindowDimensions(args)
output := renderLayout(windows)
// removing tabs so that it's easier to paste the expected output
expected := strings.ReplaceAll(test.expected, "\t", "")
expected = strings.TrimSpace(expected)
if output != expected {
fmt.Println(output)
t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, output)
}
})
}
}
func renderLayout(windows map[string]boxlayout.Dimensions) string {
// Each window will be represented by a letter.
windowMarkers := map[string]string{}
shortLabels := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"}
currentShortLabelIdx := 0
windowNames := lo.Keys(windows)
// Sort first by name, then by position. This means our short labels will
// increment in the order that the windows appear on the screen.
slices.Sort(windowNames)
slices.SortStableFunc(windowNames, func(a, b string) int {
dimensionsA := windows[a]
dimensionsB := windows[b]
if dimensionsA.Y0 != dimensionsB.Y0 {
return cmp.Compare(dimensionsA.Y0, dimensionsB.Y0)
}
return cmp.Compare(dimensionsA.X0, dimensionsB.X0)
})
// Uniquify windows by dimensions (so perfectly overlapping windows are de-duped). This prevents getting 'fileshes' as a label where the files and branches windows overlap.
// branches windows overlap.
windowNames = lo.UniqBy(windowNames, func(windowName string) boxlayout.Dimensions {
return windows[windowName]
})
// excluding the limit window because it overlaps with everything. In future
// we should have a concept of layers and then our test can assert against
// each layer.
windowNames = lo.Without(windowNames, "limit")
// get width/height by getting the max values of the dimensions
width := 0
height := 0
for _, dimensions := range windows {
if dimensions.X1+1 > width {
width = dimensions.X1 + 1
}
if dimensions.Y1+1 > height {
height = dimensions.Y1 + 1
}
}
screen := make([][]string, height)
for i := range screen {
screen[i] = make([]string, width)
}
// Draw each window
for _, windowName := range windowNames {
dimensions := windows[windowName]
zeroWidth := dimensions.X0 == dimensions.X1+1
if zeroWidth {
continue
}
singleRow := dimensions.Y0 == dimensions.Y1
oneOrTwoColumns := dimensions.X0 == dimensions.X1 || dimensions.X0+1 == dimensions.X1
assignShortLabel := func(windowName string) string {
windowMarkers[windowName] = shortLabels[currentShortLabelIdx]
currentShortLabelIdx++
return windowMarkers[windowName]
}
if singleRow {
y := dimensions.Y0
// If our window only occupies one (or two) columns we'll just use the short
// label once (or twice) i.e. 'A' or 'AA'.
if oneOrTwoColumns {
shortLabel := assignShortLabel(windowName)
for x := dimensions.X0; x <= dimensions.X1; x++ {
screen[y][x] = shortLabel
}
} else {
screen[y][dimensions.X0] = "<"
screen[y][dimensions.X1] = ">"
for x := dimensions.X0 + 1; x < dimensions.X1; x++ {
screen[y][x] = "─"
}
// Now add the label
label := windowName
// If we can't fit the label we'll use a one-character short label
if len(label) > dimensions.X1-dimensions.X0-1 {
label = assignShortLabel(windowName)
}
for i, char := range label {
screen[y][dimensions.X0+1+i] = string(char)
}
}
} else {
// Draw box border
for y := dimensions.Y0; y <= dimensions.Y1; y++ {
for x := dimensions.X0; x <= dimensions.X1; x++ {
if x == dimensions.X0 && y == dimensions.Y0 {
screen[y][x] = "╭"
} else if x == dimensions.X1 && y == dimensions.Y0 {
screen[y][x] = "╮"
} else if x == dimensions.X0 && y == dimensions.Y1 {
screen[y][x] = "╰"
} else if x == dimensions.X1 && y == dimensions.Y1 {
screen[y][x] = "╯"
} else if y == dimensions.Y0 || y == dimensions.Y1 {
screen[y][x] = "─"
} else if x == dimensions.X0 || x == dimensions.X1 {
screen[y][x] = "│"
} else {
screen[y][x] = " "
}
}
}
// Add the label
label := windowName
// If we can't fit the label we'll use a one-character short label
if len(label) > dimensions.X1-dimensions.X0-1 {
label = assignShortLabel(windowName)
}
for i, char := range label {
screen[dimensions.Y0][dimensions.X0+1+i] = string(char)
}
}
}
// Draw the screen
output := ""
for _, row := range screen {
for _, marker := range row {
output += marker
}
output += "\n"
}
// Add a legend
for _, windowName := range windowNames {
if !lo.Contains(lo.Keys(windowMarkers), windowName) {
continue
}
marker := windowMarkers[windowName]
output += fmt.Sprintf("%s: %s\n", marker, windowName)
}
output = strings.TrimSpace(output)
return output
}