mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-04-23 12:18:51 +02:00
This technically is a breaking change for some existing numbers, but it stays the same for default case, and isn't much different for others
502 lines
14 KiB
Go
502 lines
14 KiB
Go
package helpers
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
|
|
"github.com/jesseduffield/lazycore/pkg/boxlayout"
|
|
"github.com/jesseduffield/lazygit/pkg/config"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"golang.org/x/exp/slices"
|
|
)
|
|
|
|
// In this file we use the boxlayout package, along with knowledge about the app's state,
|
|
// to arrange the windows (i.e. panels) on the screen.
|
|
|
|
type WindowArrangementHelper struct {
|
|
c *HelperCommon
|
|
windowHelper *WindowHelper
|
|
modeHelper *ModeHelper
|
|
appStatusHelper *AppStatusHelper
|
|
}
|
|
|
|
func NewWindowArrangementHelper(
|
|
c *HelperCommon,
|
|
windowHelper *WindowHelper,
|
|
modeHelper *ModeHelper,
|
|
appStatusHelper *AppStatusHelper,
|
|
) *WindowArrangementHelper {
|
|
return &WindowArrangementHelper{
|
|
c: c,
|
|
windowHelper: windowHelper,
|
|
modeHelper: modeHelper,
|
|
appStatusHelper: appStatusHelper,
|
|
}
|
|
}
|
|
|
|
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.ScreenMode
|
|
// 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) GetWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions {
|
|
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.Context().CurrentSide().GetWindowName(),
|
|
CurrentStaticWindow: self.c.Context().CurrentStatic().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 {
|
|
if args.ScreenMode == types.SCREEN_HALF {
|
|
return args.UserConfig.Gui.EnlargedSideViewLocation == "top"
|
|
}
|
|
|
|
switch args.UserConfig.Gui.PortraitMode {
|
|
case "never":
|
|
return false
|
|
case "always":
|
|
return true
|
|
default: // "auto" or any garbage values in PortraitMode value
|
|
return args.Width <= 84 && args.Height > 45
|
|
}
|
|
}
|
|
|
|
func GetWindowDimensions(args WindowArrangementArgs) map[string]boxlayout.Dimensions {
|
|
sideSectionWeight, mainSectionWeight := getMidSectionWeights(args)
|
|
|
|
sidePanelsDirection := boxlayout.COLUMN
|
|
if shouldUsePortraitMode(args) {
|
|
sidePanelsDirection = boxlayout.ROW
|
|
}
|
|
|
|
showInfoSection := args.UserConfig.Gui.ShowBottomLine ||
|
|
args.InSearchPrompt ||
|
|
args.IsAnyModeActive ||
|
|
args.AppStatus != ""
|
|
infoSectionSize := 0
|
|
if showInfoSection {
|
|
infoSectionSize = 1
|
|
}
|
|
|
|
root := &boxlayout.Box{
|
|
Direction: boxlayout.ROW,
|
|
Children: []*boxlayout.Box{
|
|
{
|
|
Direction: sidePanelsDirection,
|
|
Weight: 1,
|
|
Children: []*boxlayout.Box{
|
|
{
|
|
Direction: boxlayout.ROW,
|
|
Weight: sideSectionWeight,
|
|
ConditionalChildren: sidePanelChildren(args),
|
|
},
|
|
{
|
|
Direction: boxlayout.ROW,
|
|
Weight: mainSectionWeight,
|
|
Children: mainPanelChildren(args),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Direction: boxlayout.COLUMN,
|
|
Size: infoSectionSize,
|
|
Children: infoSectionChildren(args),
|
|
},
|
|
},
|
|
}
|
|
|
|
layerOneWindows := boxlayout.ArrangeWindows(root, 0, 0, args.Width, args.Height)
|
|
limitWindows := boxlayout.ArrangeWindows(&boxlayout.Box{Window: "limit"}, 0, 0, args.Width, args.Height)
|
|
|
|
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 {
|
|
for key, value := range currMap {
|
|
result[key] = value
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func mainSectionChildren(args WindowArrangementArgs) []*boxlayout.Box {
|
|
// 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
|
|
if !args.SplitMainPanel || (args.ScreenMode == types.SCREEN_FULL && args.CurrentWindow == "main") {
|
|
return []*boxlayout.Box{
|
|
{
|
|
Window: "main",
|
|
Weight: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
if args.CurrentWindow == "secondary" && args.ScreenMode == types.SCREEN_FULL {
|
|
return []*boxlayout.Box{
|
|
{
|
|
Window: "secondary",
|
|
Weight: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
return []*boxlayout.Box{
|
|
{
|
|
Window: "main",
|
|
Weight: 1,
|
|
},
|
|
{
|
|
Window: "secondary",
|
|
Weight: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
func getMidSectionWeights(args WindowArrangementArgs) (int, int) {
|
|
sidePanelWidthRatio := args.UserConfig.Gui.SidePanelWidth
|
|
// Using 120 so that the default of 0.3333 will remain consistent with previous behavior
|
|
const maxColumnCount = 120
|
|
mainSectionWeight := int(math.Round(maxColumnCount * (1 - sidePanelWidthRatio)))
|
|
sideSectionWeight := int(math.Round(maxColumnCount * sidePanelWidthRatio))
|
|
|
|
if splitMainPanelSideBySide(args) {
|
|
mainSectionWeight = sideSectionWeight * 5 // need to shrink side panel to make way for main panels if side-by-side
|
|
}
|
|
|
|
if args.CurrentWindow == "main" || args.CurrentWindow == "secondary" {
|
|
if args.ScreenMode == types.SCREEN_HALF || args.ScreenMode == types.SCREEN_FULL {
|
|
sideSectionWeight = 0
|
|
}
|
|
} else {
|
|
if args.ScreenMode == types.SCREEN_HALF {
|
|
if args.UserConfig.Gui.EnlargedSideViewLocation == "top" {
|
|
mainSectionWeight = sideSectionWeight * 2
|
|
} else {
|
|
mainSectionWeight = sideSectionWeight
|
|
}
|
|
} else if args.ScreenMode == types.SCREEN_FULL {
|
|
mainSectionWeight = 0
|
|
}
|
|
}
|
|
|
|
return sideSectionWeight, mainSectionWeight
|
|
}
|
|
|
|
func infoSectionChildren(args WindowArrangementArgs) []*boxlayout.Box {
|
|
if args.InSearchPrompt {
|
|
return []*boxlayout.Box{
|
|
{
|
|
Window: "searchPrefix",
|
|
Size: utils.StringWidth(args.SearchPrefix),
|
|
},
|
|
{
|
|
Window: "search",
|
|
Weight: 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
statusSpacerPrefix := "statusSpacer"
|
|
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 spacerBoxIndex > maxSpacerBoxIndex {
|
|
panic("Too many spacer boxes")
|
|
}
|
|
|
|
return &boxlayout.Box{Window: fmt.Sprintf("%s%d", statusSpacerPrefix, spacerBoxIndex), Size: 1}
|
|
}
|
|
|
|
// Returns a box with weight 1 to be used as flexible padding between views
|
|
flexibleSpacerBox := func() *boxlayout.Box {
|
|
spacerBoxIndex++
|
|
|
|
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: utils.StringWidth(args.AppStatus)})
|
|
}
|
|
}
|
|
|
|
if args.UserConfig.Gui.ShowBottomLine {
|
|
result = append(result, &boxlayout.Box{Window: "options", Weight: 1})
|
|
}
|
|
|
|
if (!args.InDemo && args.UserConfig.Gui.ShowBottomLine) || args.IsAnyModeActive {
|
|
result = append(result,
|
|
&boxlayout.Box{
|
|
Window: "information",
|
|
// unlike appStatus, informationStr has various colors so we need to decolorise before taking the length
|
|
Size: utils.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
|
|
}
|
|
|
|
func splitMainPanelSideBySide(args WindowArrangementArgs) bool {
|
|
if !args.SplitMainPanel {
|
|
return false
|
|
}
|
|
|
|
mainPanelSplitMode := args.UserConfig.Gui.MainPanelSplitMode
|
|
switch mainPanelSplitMode {
|
|
case "vertical":
|
|
return false
|
|
case "horizontal":
|
|
return true
|
|
default:
|
|
if args.Width < 200 && args.Height > 30 { // 2 80 character width panels + 40 width for side panel
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
func getExtrasWindowSize(args WindowArrangementArgs) int {
|
|
var baseSize int
|
|
// The 'extras' window contains the command log context
|
|
if args.CurrentStaticWindow == "extras" {
|
|
baseSize = 1000 // my way of saying 'fill the available space'
|
|
} else if args.Height < 40 {
|
|
baseSize = 1
|
|
} else {
|
|
baseSize = args.UserConfig.Gui.CommandLogSize
|
|
}
|
|
|
|
frameSize := 2
|
|
return baseSize + frameSize
|
|
}
|
|
|
|
// The stash window by default only contains one line so that it's not hogging
|
|
// 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
|
|
// then when it's accessed it will have weight 2, not 1.
|
|
func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box {
|
|
box := &boxlayout.Box{Window: "stash"}
|
|
// if the stash window is anywhere in our stack we should enlargen it
|
|
if args.CurrentSideWindow == "stash" {
|
|
box.Weight = 1
|
|
} else {
|
|
box.Size = 3
|
|
}
|
|
|
|
return box
|
|
}
|
|
|
|
func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) []*boxlayout.Box {
|
|
return func(width int, height int) []*boxlayout.Box {
|
|
if args.ScreenMode == types.SCREEN_FULL || args.ScreenMode == types.SCREEN_HALF {
|
|
fullHeightBox := func(window string) *boxlayout.Box {
|
|
if window == args.CurrentSideWindow {
|
|
return &boxlayout.Box{
|
|
Window: window,
|
|
Weight: 1,
|
|
}
|
|
} else {
|
|
return &boxlayout.Box{
|
|
Window: window,
|
|
Size: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
return []*boxlayout.Box{
|
|
fullHeightBox("status"),
|
|
fullHeightBox("files"),
|
|
fullHeightBox("branches"),
|
|
fullHeightBox("commits"),
|
|
fullHeightBox("stash"),
|
|
}
|
|
} else if height >= 28 {
|
|
accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel
|
|
accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box {
|
|
if accordionMode && defaultBox.Window == args.CurrentSideWindow {
|
|
return &boxlayout.Box{
|
|
Window: defaultBox.Window,
|
|
Weight: args.UserConfig.Gui.ExpandedSidePanelWeight,
|
|
}
|
|
}
|
|
|
|
return defaultBox
|
|
}
|
|
|
|
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{
|
|
squashedSidePanelBox("status"),
|
|
squashedSidePanelBox("files"),
|
|
squashedSidePanelBox("branches"),
|
|
squashedSidePanelBox("commits"),
|
|
squashedSidePanelBox("stash"),
|
|
}
|
|
}
|
|
}
|
|
}
|