package helpers import ( "fmt" "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" "golang.org/x/exp/slices" ) // 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", CurrentStaticWindow: "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──────────────────╮│ │ │ ││ │ ╰───────────────────────╯╰────────────────────────────────────────────────╯ A A: statusSpacer1 B: information `, }, { name: "stash focused", mutateArgs: func(args *WindowArrangementArgs) { args.CurrentSideWindow = "stash" }, expected: ` ╭status─────────────────╮╭main────────────────────────────────────────────╮ │ ││ │ ╰───────────────────────╯│ │ ╭files──────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭branches───────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭commits────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯│ │ ╭stash──────────────────╮│ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰───────────────────────╯╰────────────────────────────────────────────────╯ A 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───────────────────────────────╮ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ │ ││ │ ╰────────────────────────────────────╯╰───────────────────────────────────╯ A 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─────────────────────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰─────────────────────────────────────────────────────────────────────────╯ A 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: ` ╭main────────────────────────────────────────────╮ │ │ │ │ │ │ ╰────────────────────────────────────────────────╯ 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: ` ╭main────────────────────────────────────────────╮ │ │ │ │ │ │ ╰────────────────────────────────────────────────╯ BC 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: ` ╭main────────────────────────────────────────────╮ │ │ │ │ │ │ ╰────────────────────────────────────────────────╯ A 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: ` ╭main────────────────────────────────────────────╮ │ │ │ │ │ │ ╰────────────────────────────────────────────────╯ `, }, { 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: ` ╭main────────────────────────────────────────────╮ │ │ │ │ │ │ ╰────────────────────────────────────────────────╯ B 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: ` ╭main──────────────────────────────╮ │ │ │ │ │ │ ╰──────────────────────────────────╯ B 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) bool { dimensionsA := windows[a] dimensionsB := windows[b] if dimensionsA.Y0 < dimensionsB.Y0 { return true } if dimensionsA.Y0 > dimensionsB.Y0 { return false } return dimensionsA.X0 < dimensionsB.X0 }) // Uniquefy 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 }