1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-04-27 12:32:37 +02:00

Fix bottom line alignment (#3076)

This commit is contained in:
Stefan Haller 2023-12-09 11:57:06 +01:00 committed by GitHub
commit ca4b8b25f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 722 additions and 190 deletions

View File

@ -31,11 +31,13 @@ const (
MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts" MERGE_CONFLICTS_CONTEXT_KEY types.ContextKey = "mergeConflicts"
// these shouldn't really be needed for anything but I'm giving them unique keys nonetheless // these shouldn't really be needed for anything but I'm giving them unique keys nonetheless
OPTIONS_CONTEXT_KEY types.ContextKey = "options" OPTIONS_CONTEXT_KEY types.ContextKey = "options"
APP_STATUS_CONTEXT_KEY types.ContextKey = "appStatus" APP_STATUS_CONTEXT_KEY types.ContextKey = "appStatus"
SEARCH_PREFIX_CONTEXT_KEY types.ContextKey = "searchPrefix" SEARCH_PREFIX_CONTEXT_KEY types.ContextKey = "searchPrefix"
INFORMATION_CONTEXT_KEY types.ContextKey = "information" INFORMATION_CONTEXT_KEY types.ContextKey = "information"
LIMIT_CONTEXT_KEY types.ContextKey = "limit" LIMIT_CONTEXT_KEY types.ContextKey = "limit"
STATUS_SPACER1_CONTEXT_KEY types.ContextKey = "statusSpacer1"
STATUS_SPACER2_CONTEXT_KEY types.ContextKey = "statusSpacer2"
MENU_CONTEXT_KEY types.ContextKey = "menu" MENU_CONTEXT_KEY types.ContextKey = "menu"
CONFIRMATION_CONTEXT_KEY types.ContextKey = "confirmation" CONFIRMATION_CONTEXT_KEY types.ContextKey = "confirmation"
@ -109,12 +111,14 @@ type ContextTree struct {
CommandLog types.Context CommandLog types.Context
// display contexts // display contexts
AppStatus types.Context AppStatus types.Context
Options types.Context Options types.Context
SearchPrefix types.Context SearchPrefix types.Context
Search types.Context Search types.Context
Information types.Context Information types.Context
Limit types.Context Limit types.Context
StatusSpacer1 types.Context
StatusSpacer2 types.Context
} }
// the order of this decides which context is initially at the top of its window // the order of this decides which context is initially at the top of its window
@ -156,6 +160,8 @@ func (self *ContextTree) Flatten() []types.Context {
self.Search, self.Search,
self.Information, self.Information,
self.Limit, self.Limit,
self.StatusSpacer1,
self.StatusSpacer2,
} }
} }

View File

@ -138,10 +138,12 @@ func NewContextTree(c *ContextCommon) *ContextTree {
Focusable: true, Focusable: true,
}), }),
), ),
Options: NewDisplayContext(OPTIONS_CONTEXT_KEY, c.Views().Options, "options"), Options: NewDisplayContext(OPTIONS_CONTEXT_KEY, c.Views().Options, "options"),
AppStatus: NewDisplayContext(APP_STATUS_CONTEXT_KEY, c.Views().AppStatus, "appStatus"), AppStatus: NewDisplayContext(APP_STATUS_CONTEXT_KEY, c.Views().AppStatus, "appStatus"),
SearchPrefix: NewDisplayContext(SEARCH_PREFIX_CONTEXT_KEY, c.Views().SearchPrefix, "searchPrefix"), SearchPrefix: NewDisplayContext(SEARCH_PREFIX_CONTEXT_KEY, c.Views().SearchPrefix, "searchPrefix"),
Information: NewDisplayContext(INFORMATION_CONTEXT_KEY, c.Views().Information, "information"), Information: NewDisplayContext(INFORMATION_CONTEXT_KEY, c.Views().Information, "information"),
Limit: NewDisplayContext(LIMIT_CONTEXT_KEY, c.Views().Limit, "limit"), Limit: NewDisplayContext(LIMIT_CONTEXT_KEY, c.Views().Limit, "limit"),
StatusSpacer1: NewDisplayContext(STATUS_SPACER1_CONTEXT_KEY, c.Views().StatusSpacer1, "statusSpacer1"),
StatusSpacer2: NewDisplayContext(STATUS_SPACER2_CONTEXT_KEY, c.Views().StatusSpacer2, "statusSpacer2"),
} }
} }

View File

@ -1,11 +1,15 @@
package helpers package helpers
import ( import (
"fmt"
"strings"
"github.com/jesseduffield/lazycore/pkg/boxlayout" "github.com/jesseduffield/lazycore/pkg/boxlayout"
"github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils" "github.com/jesseduffield/lazygit/pkg/utils"
"github.com/mattn/go-runewidth" "github.com/mattn/go-runewidth"
"golang.org/x/exp/slices"
) )
// In this file we use the boxlayout package, along with knowledge about the app's state, // In this file we use the boxlayout package, along with knowledge about the app's state,
@ -32,42 +36,99 @@ func NewWindowArrangementHelper(
} }
} }
const INFO_SECTION_PADDING = " " type WindowArrangementArgs struct {
// Width of the screen (in characters)
Width int
// Height of the screen (in characters)
Height int
// User config
UserConfig *config.UserConfig
// Name of the currently focused window
CurrentWindow string
// Name of the current static window (meaning popups are ignored)
CurrentStaticWindow string
// Name of the current side window (i.e. the current window in the left
// section of the UI)
CurrentSideWindow string
// Whether the main panel is split (as is the case e.g. when a file has both
// staged and unstaged changes)
SplitMainPanel bool
// The current screen mode (normal, half, full)
ScreenMode types.WindowMaximisation
// The content shown on the bottom left of the screen when showing a loader
// or toast e.g. 'Rebasing /'
AppStatus string
// The content shown on the bottom right of the screen (e.g. the 'donate',
// 'ask question' links or a message about the current mode e.g. rebase mode)
InformationStr string
// Whether to show the extras window which contains the command log context
ShowExtrasWindow bool
// Whether we are in a demo (which is used for generating demo gifs for the
// repo's readme)
InDemo bool
// Whether any mode is active (e.g. rebasing, cherry picking, etc)
IsAnyModeActive bool
// Whether the search prompt is shown in the bottom left
InSearchPrompt bool
// One of '' (not searching), 'Search: ', and 'Filter: '
SearchPrefix string
}
func (self *WindowArrangementHelper) shouldUsePortraitMode(width, height int) bool { func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions {
switch self.c.UserConfig.Gui.PortraitMode { width, height := self.c.GocuiGui().Size()
repoState := self.c.State().GetRepoState()
var searchPrefix string
if repoState.GetSearchState().SearchType() == types.SearchTypeSearch {
searchPrefix = self.c.Tr.SearchPrefix
} else {
searchPrefix = self.c.Tr.FilterPrefix
}
args := WindowArrangementArgs{
Width: width,
Height: height,
UserConfig: self.c.UserConfig,
CurrentWindow: self.windowHelper.CurrentWindow(),
CurrentSideWindow: self.c.CurrentSideContext().GetWindowName(),
CurrentStaticWindow: self.c.CurrentStaticContext().GetWindowName(),
SplitMainPanel: repoState.GetSplitMainPanel(),
ScreenMode: repoState.GetScreenMode(),
AppStatus: appStatus,
InformationStr: informationStr,
ShowExtrasWindow: self.c.State().GetShowExtrasWindow(),
InDemo: self.c.InDemo(),
IsAnyModeActive: self.modeHelper.IsAnyModeActive(),
InSearchPrompt: repoState.InSearchPrompt(),
SearchPrefix: searchPrefix,
}
return GetWindowDimensions(args)
}
func shouldUsePortraitMode(args WindowArrangementArgs) bool {
switch args.UserConfig.Gui.PortraitMode {
case "never": case "never":
return false return false
case "always": case "always":
return true return true
default: // "auto" or any garbage values in PortraitMode value default: // "auto" or any garbage values in PortraitMode value
return width <= 84 && height > 45 return args.Width <= 84 && args.Height > 45
} }
} }
func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions { func GetWindowDimensions(args WindowArrangementArgs) map[string]boxlayout.Dimensions {
width, height := self.c.GocuiGui().Size() sideSectionWeight, mainSectionWeight := getMidSectionWeights(args)
sideSectionWeight, mainSectionWeight := self.getMidSectionWeights()
sidePanelsDirection := boxlayout.COLUMN sidePanelsDirection := boxlayout.COLUMN
if self.shouldUsePortraitMode(width, height) { if shouldUsePortraitMode(args) {
sidePanelsDirection = boxlayout.ROW sidePanelsDirection = boxlayout.ROW
} }
mainPanelsDirection := boxlayout.ROW showInfoSection := args.UserConfig.Gui.ShowBottomLine ||
if self.splitMainPanelSideBySide() { args.InSearchPrompt ||
mainPanelsDirection = boxlayout.COLUMN args.IsAnyModeActive ||
} args.AppStatus != ""
extrasWindowSize := self.getExtrasWindowSize(height)
self.c.Modes().Filtering.Active()
showInfoSection := self.c.UserConfig.Gui.ShowBottomLine ||
self.c.State().GetRepoState().InSearchPrompt() ||
self.modeHelper.IsAnyModeActive() ||
self.appStatusHelper.HasStatus()
infoSectionSize := 0 infoSectionSize := 0
if showInfoSection { if showInfoSection {
infoSectionSize = 1 infoSectionSize = 1
@ -83,39 +144,51 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
{ {
Direction: boxlayout.ROW, Direction: boxlayout.ROW,
Weight: sideSectionWeight, Weight: sideSectionWeight,
ConditionalChildren: self.sidePanelChildren, ConditionalChildren: sidePanelChildren(args),
}, },
{ {
Direction: boxlayout.ROW, Direction: boxlayout.ROW,
Weight: mainSectionWeight, Weight: mainSectionWeight,
Children: []*boxlayout.Box{ Children: mainPanelChildren(args),
{
Direction: mainPanelsDirection,
Children: self.mainSectionChildren(),
Weight: 1,
},
{
Window: "extras",
Size: extrasWindowSize,
},
},
}, },
}, },
}, },
{ {
Direction: boxlayout.COLUMN, Direction: boxlayout.COLUMN,
Size: infoSectionSize, Size: infoSectionSize,
Children: self.infoSectionChildren(informationStr, appStatus), Children: infoSectionChildren(args),
}, },
}, },
} }
layerOneWindows := boxlayout.ArrangeWindows(root, 0, 0, width, height) layerOneWindows := boxlayout.ArrangeWindows(root, 0, 0, args.Width, args.Height)
limitWindows := boxlayout.ArrangeWindows(&boxlayout.Box{Window: "limit"}, 0, 0, width, height) limitWindows := boxlayout.ArrangeWindows(&boxlayout.Box{Window: "limit"}, 0, 0, args.Width, args.Height)
return MergeMaps(layerOneWindows, limitWindows) 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 { func MergeMaps[K comparable, V any](maps ...map[K]V) map[K]V {
result := map[K]V{} result := map[K]V{}
for _, currMap := range maps { for _, currMap := range maps {
@ -127,12 +200,10 @@ func MergeMaps[K comparable, V any](maps ...map[K]V) map[K]V {
return result return result
} }
func (self *WindowArrangementHelper) mainSectionChildren() []*boxlayout.Box { func mainSectionChildren(args WindowArrangementArgs) []*boxlayout.Box {
currentWindow := self.windowHelper.CurrentWindow()
// if we're not in split mode we can just show the one main panel. Likewise if // if we're not in split mode we can just show the one main panel. Likewise if
// the main panel is focused and we're in full-screen mode // the main panel is focused and we're in full-screen mode
if !self.c.State().GetRepoState().GetSplitMainPanel() || (self.c.State().GetRepoState().GetScreenMode() == types.SCREEN_FULL && currentWindow == "main") { if !args.SplitMainPanel || (args.ScreenMode == types.SCREEN_FULL && args.CurrentWindow == "main") {
return []*boxlayout.Box{ return []*boxlayout.Box{
{ {
Window: "main", Window: "main",
@ -153,29 +224,25 @@ func (self *WindowArrangementHelper) mainSectionChildren() []*boxlayout.Box {
} }
} }
func (self *WindowArrangementHelper) getMidSectionWeights() (int, int) { func getMidSectionWeights(args WindowArrangementArgs) (int, int) {
currentWindow := self.windowHelper.CurrentWindow()
// we originally specified this as a ratio i.e. .20 would correspond to a weight of 1 against 4 // we originally specified this as a ratio i.e. .20 would correspond to a weight of 1 against 4
sidePanelWidthRatio := self.c.UserConfig.Gui.SidePanelWidth sidePanelWidthRatio := args.UserConfig.Gui.SidePanelWidth
// we could make this better by creating ratios like 2:3 rather than always 1:something // we could make this better by creating ratios like 2:3 rather than always 1:something
mainSectionWeight := int(1/sidePanelWidthRatio) - 1 mainSectionWeight := int(1/sidePanelWidthRatio) - 1
sideSectionWeight := 1 sideSectionWeight := 1
if self.splitMainPanelSideBySide() { if splitMainPanelSideBySide(args) {
mainSectionWeight = 5 // need to shrink side panel to make way for main panels if side-by-side mainSectionWeight = 5 // need to shrink side panel to make way for main panels if side-by-side
} }
screenMode := self.c.State().GetRepoState().GetScreenMode() if args.CurrentWindow == "main" {
if args.ScreenMode == types.SCREEN_HALF || args.ScreenMode == types.SCREEN_FULL {
if currentWindow == "main" {
if screenMode == types.SCREEN_HALF || screenMode == types.SCREEN_FULL {
sideSectionWeight = 0 sideSectionWeight = 0
} }
} else { } else {
if screenMode == types.SCREEN_HALF { if args.ScreenMode == types.SCREEN_HALF {
mainSectionWeight = 1 mainSectionWeight = 1
} else if screenMode == types.SCREEN_FULL { } else if args.ScreenMode == types.SCREEN_FULL {
mainSectionWeight = 0 mainSectionWeight = 0
} }
} }
@ -183,18 +250,12 @@ func (self *WindowArrangementHelper) getMidSectionWeights() (int, int) {
return sideSectionWeight, mainSectionWeight return sideSectionWeight, mainSectionWeight
} }
func (self *WindowArrangementHelper) infoSectionChildren(informationStr string, appStatus string) []*boxlayout.Box { func infoSectionChildren(args WindowArrangementArgs) []*boxlayout.Box {
if self.c.State().GetRepoState().InSearchPrompt() { if args.InSearchPrompt {
var prefix string
if self.c.State().GetRepoState().GetSearchState().SearchType() == types.SearchTypeSearch {
prefix = self.c.Tr.SearchPrefix
} else {
prefix = self.c.Tr.FilterPrefix
}
return []*boxlayout.Box{ return []*boxlayout.Box{
{ {
Window: "searchPrefix", Window: "searchPrefix",
Size: runewidth.StringWidth(prefix), Size: runewidth.StringWidth(args.SearchPrefix),
}, },
{ {
Window: "search", Window: "search",
@ -203,51 +264,109 @@ func (self *WindowArrangementHelper) infoSectionChildren(informationStr string,
} }
} }
appStatusBox := &boxlayout.Box{Window: "appStatus"} statusSpacerPrefix := "statusSpacer"
optionsBox := &boxlayout.Box{Window: "options"} spacerBoxIndex := 0
maxSpacerBoxIndex := 2 // See pkg/gui/types/views.go
// Returns a box with size 1 to be used as padding between views
spacerBox := func() *boxlayout.Box {
spacerBoxIndex++
if !self.c.UserConfig.Gui.ShowBottomLine { if spacerBoxIndex > maxSpacerBoxIndex {
optionsBox.Weight = 0 panic("Too many spacer boxes")
appStatusBox.Weight = 1 }
} else {
optionsBox.Weight = 1 return &boxlayout.Box{Window: fmt.Sprintf("%s%d", statusSpacerPrefix, spacerBoxIndex), Size: 1}
if self.c.InDemo() { }
// app status appears very briefly in demos and dislodges the caption,
// so better not to show it at all // Returns a box with weight 1 to be used as flexible padding between views
appStatusBox.Size = 0 flexibleSpacerBox := func() *boxlayout.Box {
} else { spacerBoxIndex++
appStatusBox.Size = runewidth.StringWidth(INFO_SECTION_PADDING) + runewidth.StringWidth(appStatus)
if spacerBoxIndex > maxSpacerBoxIndex {
panic("Too many spacer boxes")
}
return &boxlayout.Box{Window: fmt.Sprintf("%s%d", statusSpacerPrefix, spacerBoxIndex), Weight: 1}
}
// Adds spacer boxes inbetween given boxes
insertSpacerBoxes := func(boxes []*boxlayout.Box) []*boxlayout.Box {
for i := len(boxes) - 1; i >= 1; i-- {
// ignore existing spacer boxes
if !strings.HasPrefix(boxes[i].Window, statusSpacerPrefix) {
boxes = slices.Insert(boxes, i, spacerBox())
}
}
return boxes
}
// First collect the real views that we want to show, we'll add spacers in
// between at the end
var result []*boxlayout.Box
if !args.InDemo {
// app status appears very briefly in demos and dislodges the caption,
// so better not to show it at all
if args.AppStatus != "" {
result = append(result, &boxlayout.Box{Window: "appStatus", Size: runewidth.StringWidth(args.AppStatus)})
} }
} }
result := []*boxlayout.Box{appStatusBox, optionsBox} if args.UserConfig.Gui.ShowBottomLine {
result = append(result, &boxlayout.Box{Window: "options", Weight: 1})
}
if (!self.c.InDemo() && self.c.UserConfig.Gui.ShowBottomLine) || self.modeHelper.IsAnyModeActive() { if (!args.InDemo && args.UserConfig.Gui.ShowBottomLine) || args.IsAnyModeActive {
result = append(result, &boxlayout.Box{ result = append(result,
Window: "information", &boxlayout.Box{
// unlike appStatus, informationStr has various colors so we need to decolorise before taking the length Window: "information",
Size: runewidth.StringWidth(INFO_SECTION_PADDING) + runewidth.StringWidth(utils.Decolorise(informationStr)), // unlike appStatus, informationStr has various colors so we need to decolorise before taking the length
}) Size: runewidth.StringWidth(utils.Decolorise(args.InformationStr)),
})
}
if len(result) == 2 && result[0].Window == "appStatus" {
// Only status and information are showing; need to insert a flexible
// spacer between the two, so that information is right-aligned. Note
// that the call to insertSpacerBoxes below will still insert a 1-char
// spacer in addition (right after the flexible one); this is needed for
// the case that there's not enough room, to ensure there's always at
// least one space.
result = slices.Insert(result, 1, flexibleSpacerBox())
} else if len(result) == 1 {
if result[0].Window == "information" {
// Only information is showing; need to add a flexible spacer so
// that information is right-aligned
result = slices.Insert(result, 0, flexibleSpacerBox())
} else {
// Only status is showing; need to make it flexible so that it
// extends over the whole width
result[0].Size = 0
result[0].Weight = 1
}
}
if len(result) > 0 {
// If we have at least one view, insert 1-char wide spacer boxes between them.
result = insertSpacerBoxes(result)
} }
return result return result
} }
func (self *WindowArrangementHelper) splitMainPanelSideBySide() bool { func splitMainPanelSideBySide(args WindowArrangementArgs) bool {
if !self.c.State().GetRepoState().GetSplitMainPanel() { if !args.SplitMainPanel {
return false return false
} }
mainPanelSplitMode := self.c.UserConfig.Gui.MainPanelSplitMode mainPanelSplitMode := args.UserConfig.Gui.MainPanelSplitMode
width, height := self.c.GocuiGui().Size()
switch mainPanelSplitMode { switch mainPanelSplitMode {
case "vertical": case "vertical":
return false return false
case "horizontal": case "horizontal":
return true return true
default: default:
if width < 200 && height > 30 { // 2 80 character width panels + 40 width for side panel if args.Width < 200 && args.Height > 30 { // 2 80 character width panels + 40 width for side panel
return false return false
} else { } else {
return true return true
@ -255,18 +374,15 @@ func (self *WindowArrangementHelper) splitMainPanelSideBySide() bool {
} }
} }
func (self *WindowArrangementHelper) getExtrasWindowSize(screenHeight int) int { func getExtrasWindowSize(args WindowArrangementArgs) int {
if !self.c.State().GetShowExtrasWindow() {
return 0
}
var baseSize int var baseSize int
if self.c.CurrentStaticContext().GetKey() == context.COMMAND_LOG_CONTEXT_KEY { // The 'extras' window contains the command log context
if args.CurrentStaticWindow == "extras" {
baseSize = 1000 // my way of saying 'fill the available space' baseSize = 1000 // my way of saying 'fill the available space'
} else if screenHeight < 40 { } else if args.Height < 40 {
baseSize = 1 baseSize = 1
} else { } else {
baseSize = self.c.UserConfig.Gui.CommandLogSize baseSize = args.UserConfig.Gui.CommandLogSize
} }
frameSize := 2 frameSize := 2
@ -277,17 +393,10 @@ func (self *WindowArrangementHelper) getExtrasWindowSize(screenHeight int) int {
// too much space, but if you access it it should take up some space. This is // too much space, but if you access it it should take up some space. This is
// the default behaviour when accordion mode is NOT in effect. If it is in effect // the default behaviour when accordion mode is NOT in effect. If it is in effect
// then when it's accessed it will have weight 2, not 1. // then when it's accessed it will have weight 2, not 1.
func (self *WindowArrangementHelper) getDefaultStashWindowBox() *boxlayout.Box { func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box {
stashWindowAccessed := false
self.c.Context().ForEach(func(context types.Context) {
if context.GetWindowName() == "stash" {
stashWindowAccessed = true
}
})
box := &boxlayout.Box{Window: "stash"} box := &boxlayout.Box{Window: "stash"}
// if the stash window is anywhere in our stack we should enlargen it // if the stash window is anywhere in our stack we should enlargen it
if stashWindowAccessed { if args.CurrentSideWindow == "stash" {
box.Weight = 1 box.Weight = 1
} else { } else {
box.Size = 3 box.Size = 3
@ -296,81 +405,80 @@ func (self *WindowArrangementHelper) getDefaultStashWindowBox() *boxlayout.Box {
return box return box
} }
func (self *WindowArrangementHelper) sidePanelChildren(width int, height int) []*boxlayout.Box { func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) []*boxlayout.Box {
currentWindow := self.c.CurrentSideContext().GetWindowName() return func(width int, height int) []*boxlayout.Box {
if args.ScreenMode == types.SCREEN_FULL || args.ScreenMode == types.SCREEN_HALF {
screenMode := self.c.State().GetRepoState().GetScreenMode() fullHeightBox := func(window string) *boxlayout.Box {
if screenMode == types.SCREEN_FULL || screenMode == types.SCREEN_HALF { if window == args.CurrentSideWindow {
fullHeightBox := func(window string) *boxlayout.Box { return &boxlayout.Box{
if window == currentWindow { Window: window,
return &boxlayout.Box{ Weight: 1,
Window: window, }
Weight: 1, } else {
} return &boxlayout.Box{
} else { Window: window,
return &boxlayout.Box{ Size: 0,
Window: window, }
Size: 0,
}
}
}
return []*boxlayout.Box{
fullHeightBox("status"),
fullHeightBox("files"),
fullHeightBox("branches"),
fullHeightBox("commits"),
fullHeightBox("stash"),
}
} else if height >= 28 {
accordionMode := self.c.UserConfig.Gui.ExpandFocusedSidePanel
accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box {
if accordionMode && defaultBox.Window == currentWindow {
return &boxlayout.Box{
Window: defaultBox.Window,
Weight: 2,
} }
} }
return defaultBox return []*boxlayout.Box{
} fullHeightBox("status"),
fullHeightBox("files"),
return []*boxlayout.Box{ fullHeightBox("branches"),
{ fullHeightBox("commits"),
Window: "status", fullHeightBox("stash"),
Size: 3, }
}, } else if height >= 28 {
accordionBox(&boxlayout.Box{Window: "files", Weight: 1}), accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel
accordionBox(&boxlayout.Box{Window: "branches", Weight: 1}), accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box {
accordionBox(&boxlayout.Box{Window: "commits", Weight: 1}), if accordionMode && defaultBox.Window == args.CurrentSideWindow {
accordionBox(self.getDefaultStashWindowBox()), return &boxlayout.Box{
} Window: defaultBox.Window,
} else { Weight: 2,
squashedHeight := 1 }
if height >= 21 {
squashedHeight = 3
}
squashedSidePanelBox := func(window string) *boxlayout.Box {
if window == currentWindow {
return &boxlayout.Box{
Window: window,
Weight: 1,
} }
} else {
return &boxlayout.Box{ return defaultBox
Window: window, }
Size: squashedHeight,
return []*boxlayout.Box{
{
Window: "status",
Size: 3,
},
accordionBox(&boxlayout.Box{Window: "files", Weight: 1}),
accordionBox(&boxlayout.Box{Window: "branches", Weight: 1}),
accordionBox(&boxlayout.Box{Window: "commits", Weight: 1}),
accordionBox(getDefaultStashWindowBox(args)),
}
} else {
squashedHeight := 1
if height >= 21 {
squashedHeight = 3
}
squashedSidePanelBox := func(window string) *boxlayout.Box {
if window == args.CurrentSideWindow {
return &boxlayout.Box{
Window: window,
Weight: 1,
}
} else {
return &boxlayout.Box{
Window: window,
Size: squashedHeight,
}
} }
} }
}
return []*boxlayout.Box{ return []*boxlayout.Box{
squashedSidePanelBox("status"), squashedSidePanelBox("status"),
squashedSidePanelBox("files"), squashedSidePanelBox("files"),
squashedSidePanelBox("branches"), squashedSidePanelBox("branches"),
squashedSidePanelBox("commits"), squashedSidePanelBox("commits"),
squashedSidePanelBox("stash"), squashedSidePanelBox("stash"),
}
} }
} }
} }

View File

@ -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: `
statusmain
files
branches
commits
stash
<options>A<B>
A: statusSpacer1
B: information
`,
},
{
name: "stash focused",
mutateArgs: func(args *WindowArrangementArgs) {
args.CurrentSideWindow = "stash"
},
expected: `
statusmain
files
branches
commits
stash
<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) 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
}

View File

@ -34,6 +34,8 @@ type Views struct {
AppStatus *gocui.View AppStatus *gocui.View
Search *gocui.View Search *gocui.View
SearchPrefix *gocui.View SearchPrefix *gocui.View
StatusSpacer1 *gocui.View
StatusSpacer2 *gocui.View
Limit *gocui.View Limit *gocui.View
Suggestions *gocui.View Suggestions *gocui.View
Tooltip *gocui.View Tooltip *gocui.View

View File

@ -53,8 +53,11 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping {
{viewPtr: &gui.Views.AppStatus, name: "appStatus"}, {viewPtr: &gui.Views.AppStatus, name: "appStatus"},
{viewPtr: &gui.Views.Information, name: "information"}, {viewPtr: &gui.Views.Information, name: "information"},
{viewPtr: &gui.Views.Search, name: "search"}, {viewPtr: &gui.Views.Search, name: "search"},
// this view takes up one character. Its only purpose is to show the slash when searching // this view shows either the "Search:" prompt when searching, or the "Filter:" prompt when filtering
{viewPtr: &gui.Views.SearchPrefix, name: "searchPrefix"}, {viewPtr: &gui.Views.SearchPrefix, name: "searchPrefix"},
// these views contain one space, and are used as spacers between the various views in the bottom line
{viewPtr: &gui.Views.StatusSpacer1, name: "statusSpacer1"},
{viewPtr: &gui.Views.StatusSpacer2, name: "statusSpacer2"},
// popups. // popups.
{viewPtr: &gui.Views.CommitMessage, name: "commitMessage"}, {viewPtr: &gui.Views.CommitMessage, name: "commitMessage"},
@ -98,6 +101,9 @@ func (gui *Gui) createAllViews() error {
gui.Views.SearchPrefix.Frame = false gui.Views.SearchPrefix.Frame = false
gui.c.SetViewContent(gui.Views.SearchPrefix, gui.Tr.SearchPrefix) gui.c.SetViewContent(gui.Views.SearchPrefix, gui.Tr.SearchPrefix)
gui.Views.StatusSpacer1.Frame = false
gui.Views.StatusSpacer2.Frame = false
gui.Views.Search.BgColor = gocui.ColorDefault gui.Views.Search.BgColor = gocui.ColorDefault
gui.Views.Search.FgColor = gocui.ColorCyan gui.Views.Search.FgColor = gocui.ColorCyan
gui.Views.Search.Editable = true gui.Views.Search.Editable = true