diff --git a/docs-master/Config.md b/docs-master/Config.md index 98d64f1a2..1d101be18 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -110,6 +110,11 @@ gui: # is true. expandedSidePanelWeight: 2 + # If true, don't give a side panel more height than it needs to show its + # content; when all panels fit, the leftover height is shared among them so that + # they still fill the screen. + shrinkSidePanelsToContent: false + # The side panels, in the order they appear from top to bottom. # Each entry is a list of one or more names that share a single panel as tabs # (cycle through them with the next-tab/previous-tab keys). diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 8314701ba..30ce0377d 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -111,6 +111,8 @@ type GuiConfig struct { ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"` // The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true. ExpandedSidePanelWeight int `yaml:"expandedSidePanelWeight"` + // If true, don't give a side panel more height than it needs to show its content; when all panels fit, the leftover height is shared among them so that they still fill the screen. + ShrinkSidePanelsToContent bool `yaml:"shrinkSidePanelsToContent"` // The side panels, in the order they appear from top to bottom. // Each entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys). // Omit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel. @@ -847,18 +849,19 @@ func GetDefaultConfig() *UserConfig { func GetDefaultConfigForPlatform(platform string) *UserConfig { return &UserConfig{ Gui: GuiConfig{ - ScrollHeight: 2, - ScrollPastBottom: true, - ScrollOffMargin: 2, - ScrollOffBehavior: "margin", - TabWidth: 4, - MouseEvents: true, - SkipAmendWarning: false, - SkipDiscardChangeWarning: false, - SkipStashWarning: false, - SidePanelWidth: 0.3333, - ExpandFocusedSidePanel: false, - ExpandedSidePanelWeight: 2, + ScrollHeight: 2, + ScrollPastBottom: true, + ScrollOffMargin: 2, + ScrollOffBehavior: "margin", + TabWidth: 4, + MouseEvents: true, + SkipAmendWarning: false, + SkipDiscardChangeWarning: false, + SkipStashWarning: false, + SidePanelWidth: 0.3333, + ExpandFocusedSidePanel: false, + ExpandedSidePanelWeight: 2, + ShrinkSidePanelsToContent: false, SidePanels: []SidePanel{ {"status"}, {"files", "worktrees", "submodules"}, diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 37a4465b1..90a651809 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -55,6 +55,9 @@ type WindowArrangementArgs struct { // stash height special-cases key off (rather than the window itself, whose // name is just its first tab). ActiveViewForWindow func(window string) string + // Returns the number of content lines of the view currently shown in the given + // window. Used by the shrink-to-content feature to size a panel to its content. + ContentHeightForWindow func(window string) int // Whether the main panel is split (as is the case e.g. when a file has both // staged and unstaged changes) SplitMainPanel bool @@ -97,15 +100,18 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, CurrentWindow: self.c.Context().CurrentStatic().GetWindowName(), CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(), ActiveViewForWindow: self.windowHelper.GetViewNameForWindow, - 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, + ContentHeightForWindow: func(window string) int { + return self.windowHelper.GetContextForWindow(window).TotalContentHeight() + }, + 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) @@ -464,6 +470,12 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ return boxForEachWindow(fullHeightBox) } else if height >= minHeightForNormalLayout { + if args.UserConfig.Gui.ShrinkSidePanelsToContent { + if boxes, ok := shrinkToContentSidePanelBoxes(args, windows, height); ok { + return boxes + } + } + accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box { if accordionMode && defaultBox.Window == args.CurrentSideWindow { @@ -517,3 +529,144 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ return boxForEachWindow(squashedSidePanelBox) } } + +// shrinkToContentSidePanelBoxes implements the gui.shrinkSidePanelsToContent +// feature: rather than giving every side panel an equal share of the height, we +// size each panel to its own content (plus one blank line, so it's clear there's +// nothing more below), which stops panels with little content from wasting space. +// +// The height freed up by a small panel flows to the panels that have more content +// than their share; those grow up to their own content and then scroll. If every +// panel fits its content with room to spare, there's nothing to absorb the +// leftover, so it's shared among all panels by weight (which, in accordion mode, +// gives the focused panel more of it). +// +// The status panel, and the stash panel when it's not focused, keep their +// constant height and don't take part; ok is false when there are no panels to +// size (so the caller falls back to the normal weighted layout). +func shrinkToContentSidePanelBoxes(args WindowArrangementArgs, windows []string, height int) ([]*boxlayout.Box, bool) { + const frameSize = 2 + + accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel + + // A flexible panel is one we size to its content. Fixed panels (the status + // panel, and the stash panel when unfocused) get their constant height and + // are excluded from the distribution below. + type flexiblePanel struct { + boxIndex int + desired int // target height: content rows (see below) plus the frame + weight int + height int // final height, only computed for the room-to-spare case + capped bool // true once it fits its content within its share + } + + boxes := make([]*boxlayout.Box, len(windows)) + flexible := []*flexiblePanel{} + availableForFlexible := height + for i, window := range windows { + focused := window == args.CurrentSideWindow + + // The status and stash sizing is a property of those views, so we key off + // the tab the window is currently showing, not the window's name (its first + // tab); see the comment on normalBox in sidePanelChildren. + activeView := args.ActiveViewForWindow(window) + if activeView == "status" || (activeView == "stash" && !focused) { + boxes[i] = &boxlayout.Box{Window: window, Size: 3} + availableForFlexible -= 3 + continue + } + + weight := 1 + if accordionMode && focused { + weight = args.UserConfig.Gui.ExpandedSidePanelWeight + } + // Show the content plus a blank line, so it's clear there's nothing more + // below, but never fewer than two rows: a lone blank row looks cramped, + // and an empty Files panel is the common state right after launching. + contentRows := max(args.ContentHeightForWindow(window)+1, 2) + flexible = append(flexible, &flexiblePanel{ + boxIndex: i, + desired: contentRows + frameSize, + weight: weight, + }) + } + + if len(flexible) == 0 || availableForFlexible <= 0 { + return nil, false + } + + // Water-filling: repeatedly cap the panels whose desired height is no more + // than their weighted share of what's left. Capping a panel only raises the + // others' shares, so this converges once no further panel fits its content. + // Whatever remains is what the still-uncapped panels have to share. + remaining := availableForFlexible + for { + totalWeight := 0 + for _, p := range flexible { + if !p.capped { + totalWeight += p.weight + } + } + if totalWeight == 0 { + break + } + + newlyCapped := []*flexiblePanel{} + for _, p := range flexible { + if !p.capped && p.desired*totalWeight <= remaining*p.weight { + newlyCapped = append(newlyCapped, p) + } + } + if len(newlyCapped) == 0 { + break + } + for _, p := range newlyCapped { + p.capped = true + remaining -= p.desired + } + } + + anyUncapped := false + for _, p := range flexible { + if !p.capped { + anyUncapped = true + } + } + + if anyUncapped { + // Some panels have more content than fits: give the ones that fit exactly + // their content, and let boxlayout share what's left among the rest by + // weight (they'll scroll). This is the common, real-world case. + for _, p := range flexible { + if p.capped { + boxes[p.boxIndex] = &boxlayout.Box{Window: windows[p.boxIndex], Size: p.desired} + } else { + boxes[p.boxIndex] = &boxlayout.Box{Window: windows[p.boxIndex], Weight: p.weight} + } + } + return boxes, true + } + + // Every panel fits its content with room to spare, so no panel needs to + // scroll. Share the leftover equally among them, regardless of focus and + // accordion mode: enlarging the focused panel here reveals no more content + // (it already fits) and would only make panels jump around as focus moves. + // Deal out the rounding remainder one row at a time so the heights fill the + // available space exactly. + base := remaining / len(flexible) + extra := remaining % len(flexible) + for i, p := range flexible { + p.height = p.desired + base + if i < extra { + p.height++ + } + } + + // boxlayout can't lay out a set of boxes that are all statically sized (it + // needs a weighted box to absorb the space), so we hand it the heights as + // weights: they sum to the available height, so it reproduces them exactly. + for _, p := range flexible { + boxes[p.boxIndex] = &boxlayout.Box{Window: windows[p.boxIndex], Weight: p.height} + } + return boxes, true +} diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go index 63d7642b6..365d7f104 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go @@ -13,6 +13,14 @@ import ( "github.com/samber/lo" ) +// contentHeights builds a ContentHeightForWindow function from a map of window +// name to content height; windows not in the map report a height of 0. +func contentHeights(heights map[string]int) func(window string) int { + return func(window string) int { + return heights[window] + } +} + // 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 @@ -710,6 +718,188 @@ func TestGetWindowDimensions(t *testing.T) { B: statusSpacer2 `, }, + { + name: "shrink to content, one panel overflows", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.ShrinkSidePanelsToContent = true + args.ContentHeightForWindow = contentHeights(map[string]int{ + "files": 2, + "branches": 1, + "commits": 100, + }) + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭branches───────────────╮│ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭stash──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, + { + name: "shrink to content, everything fits with room to spare", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.ShrinkSidePanelsToContent = true + args.ContentHeightForWindow = contentHeights(map[string]int{ + "files": 2, + "branches": 1, + "commits": 3, + }) + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭branches───────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭stash──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, + { + name: "shrink to content, accordion doesn't resize panels when everything fits", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.ShrinkSidePanelsToContent = true + args.UserConfig.Gui.ExpandFocusedSidePanel = true + args.CurrentSideWindow = "branches" + args.ContentHeightForWindow = contentHeights(map[string]int{ + "files": 2, + "branches": 1, + "commits": 3, + }) + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭branches───────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭stash──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, + { + name: "shrink to content, empty panel keeps two rows rather than one", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.ShrinkSidePanelsToContent = true + args.ContentHeightForWindow = contentHeights(map[string]int{ + "files": 0, + "branches": 1, + "commits": 100, + }) + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭branches───────────────╮│ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭stash──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, } for _, test := range tests { diff --git a/schema-master/config.json b/schema-master/config.json index 0dd1d5d20..82dbebb0b 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -590,6 +590,11 @@ "description": "The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.", "default": 2 }, + "shrinkSidePanelsToContent": { + "type": "boolean", + "description": "If true, don't give a side panel more height than it needs to show its content; when all panels fit, the leftover height is shared among them so that they still fill the screen.", + "default": false + }, "sidePanels": { "items": { "$ref": "#/$defs/SidePanel"