diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 2923841e8..0d9b119e3 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -125,13 +125,6 @@ func GetWindowDimensions(args WindowArrangementArgs) map[string]boxlayout.Dimens sidePanelsDirection = boxlayout.ROW } - mainPanelsDirection := boxlayout.ROW - if splitMainPanelSideBySide(args) { - mainPanelsDirection = boxlayout.COLUMN - } - - extrasWindowSize := getExtrasWindowSize(args) - showInfoSection := args.UserConfig.Gui.ShowBottomLine || args.InSearchPrompt || args.IsAnyModeActive || @@ -156,17 +149,7 @@ func GetWindowDimensions(args WindowArrangementArgs) map[string]boxlayout.Dimens { Direction: boxlayout.ROW, Weight: mainSectionWeight, - Children: []*boxlayout.Box{ - { - Direction: mainPanelsDirection, - Children: mainSectionChildren(args), - Weight: 1, - }, - { - Window: "extras", - Size: extrasWindowSize, - }, - }, + Children: mainPanelChildren(args), }, }, }, @@ -184,6 +167,28 @@ func GetWindowDimensions(args WindowArrangementArgs) map[string]boxlayout.Dimens return MergeMaps(layerOneWindows, limitWindows) } +func mainPanelChildren(args WindowArrangementArgs) []*boxlayout.Box { + mainPanelsDirection := boxlayout.ROW + if splitMainPanelSideBySide(args) { + mainPanelsDirection = boxlayout.COLUMN + } + + result := []*boxlayout.Box{ + { + Direction: mainPanelsDirection, + Children: mainSectionChildren(args), + Weight: 1, + }, + } + if args.ShowExtrasWindow { + result = append(result, &boxlayout.Box{ + Window: "extras", + Size: getExtrasWindowSize(args), + }) + } + return result +} + func MergeMaps[K comparable, V any](maps ...map[K]V) map[K]V { result := map[K]V{} for _, currMap := range maps { @@ -370,10 +375,6 @@ func splitMainPanelSideBySide(args WindowArrangementArgs) bool { } func getExtrasWindowSize(args WindowArrangementArgs) int { - if !args.ShowExtrasWindow { - return 0 - } - var baseSize int // The 'extras' window contains the command log context if args.CurrentStaticWindow == "extras" { diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go new file mode 100644 index 000000000..4e299ec2e --- /dev/null +++ b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go @@ -0,0 +1,408 @@ +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: "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 +}