diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 8129aa420..4b393169d 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -45,6 +45,13 @@ func NewMenuContext( getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment }, getNonModelItems: viewModel.GetNonModelItems, }, + // While the filter row is showing, its top border covers the menu's bottom + // border, so the footer has to be rendered on the row instead. + renderFooter: func(footer string) { + onFilterRow := viewModel.FilterStarted() + c.Views().Menu.Footer = lo.Ternary(onFilterRow, "", footer) + c.Views().MenuFilterFrame.Footer = lo.Ternary(onFilterRow, footer, "") + }, c: c, }, } @@ -58,6 +65,8 @@ type MenuViewModel struct { columnAlignment []utils.Alignment allowFilteringKeybindings bool keybindingsTakePrecedence bool + filterAsYouType bool + filterStarted bool onCancel func() error *FilteredListViewModel[*types.MenuItem] } @@ -128,10 +137,34 @@ func (self *MenuViewModel) SetAllowFilteringKeybindings(allow bool) { self.allowFilteringKeybindings = allow } +func (self *MenuViewModel) AllowFilteringKeybindings() bool { + return self.allowFilteringKeybindings +} + func (self *MenuViewModel) SetKeybindingsTakePrecedence(value bool) { self.keybindingsTakePrecedence = value } +// Whether this menu has a filter row that filters the items as the user types, +// instead of being filtered through the search prompt. +func (self *MenuViewModel) SetFilterAsYouType(value bool) { + self.filterAsYouType = value + self.SetFilterStarted(false) +} + +func (self *MenuViewModel) FilterAsYouType() bool { + return self.filterAsYouType +} + +// Whether the user has started to filter, which is when the filter row appears. +func (self *MenuViewModel) SetFilterStarted(value bool) { + self.filterStarted = value +} + +func (self *MenuViewModel) FilterStarted() bool { + return self.filterStarted +} + // TODO: move into presentation package func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string { menuItems := self.FilteredListViewModel.GetItems() diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index beffeb5e2..f525c1d8a 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) type ConfirmationHelper struct { @@ -323,19 +324,70 @@ func (self *ConfirmationHelper) ResizeCurrentPopupPanels() { } } +// The rows that a filter row adds to a menu popup: one for the input, and one +// for its bottom border. Its top border is the menu's bottom border. +const menuFilterRowHeight = 2 + +// The prompts for the filter row, from the most to the least informative. The +// keybindings menu can also filter by keybinding, which is worth spelling out +// when there is room for it. +func (self *ConfirmationHelper) menuFilterPromptCandidates() []string { + if self.c.Contexts().Menu.AllowFilteringKeybindings() { + return []string{self.c.Tr.FilterPrefixMenu, self.c.Tr.FilterPrefix} + } + + return []string{self.c.Tr.FilterPrefix} +} + +// Returns the first prompt that still leaves room to type in, or no prompt at +// all if the row is too narrow even for the shortest one. +func menuFilterPrompt(candidates []string, contentWidth int) string { + const minimumInputWidth = 4 + + for _, candidate := range candidates { + if utils.StringWidth(candidate)+minimumInputWidth <= contentWidth { + return candidate + } + } + + return "" +} + func (self *ConfirmationHelper) resizeMenu(parentPopupContext types.Context) { + menuContext := self.c.Contexts().Menu // we want the unfiltered length here so that if we're filtering we don't // resize the window - itemCount := self.c.Contexts().Menu.UnfilteredLen() + itemCount := menuContext.UnfilteredLen() offset := 3 panelWidth := self.getPopupPanelWidth(90) contentWidth := panelWidth - 2 // minus 2 for the frame promptLinesCount := self.layoutMenuPrompt(contentWidth) - x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(contentWidth, itemCount+offset+promptLinesCount, parentPopupContext) - menuBottom := y1 - offset + // The row is reserved for the whole time the menu is open, even though it only + // becomes visible once the user starts typing, so that revealing it doesn't + // move the menu. + filterRowHeight := lo.Ternary(menuContext.FilterAsYouType(), menuFilterRowHeight, 0) + x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight( + contentWidth, itemCount+offset+promptLinesCount+filterRowHeight, parentPopupContext) + menuBottom := y1 - offset - filterRowHeight _, _ = self.c.GocuiGui().SetView(self.c.Views().Menu.Name(), x0, y0, x1, menuBottom, 0) tooltipTop := menuBottom + 1 + if menuContext.FilterAsYouType() { + filterRowBottom := menuBottom + filterRowHeight + // The row hangs off the bottom of the menu, sharing its bottom border. + _, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilterFrame.Name(), x0, menuBottom, x1, filterRowBottom, 0) + + prompt := menuFilterPrompt(self.menuFilterPromptCandidates(), contentWidth) + self.c.Views().MenuFilterFrame.SetContent(prompt) + // A view's content starts one column inside its bounds, so the input field + // starts one column to the left of where its text is to appear. + inputLeft := x0 + utils.StringWidth(prompt) + _, _ = self.c.GocuiGui().SetView(self.c.Views().MenuFilter.Name(), inputLeft, menuBottom, x1, filterRowBottom, 0) + + if menuContext.FilterStarted() { + tooltipTop = filterRowBottom + 1 + } + } tooltip := "" selectedItem := self.c.Contexts().Menu.GetSelected() if selectedItem != nil { diff --git a/pkg/gui/controllers/helpers/confirmation_helper_test.go b/pkg/gui/controllers/helpers/confirmation_helper_test.go new file mode 100644 index 000000000..fc62722d0 --- /dev/null +++ b/pkg/gui/controllers/helpers/confirmation_helper_test.go @@ -0,0 +1,31 @@ +package helpers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMenuFilterPrompt(t *testing.T) { + longPrompt := "Filter ('@' for keybindings): " + shortPrompt := "Filter: " + + tests := []struct { + name string + candidates []string + contentWidth int + expected string + }{ + {name: "room for four characters", candidates: []string{shortPrompt}, contentWidth: 12, expected: shortPrompt}, + {name: "room for three characters", candidates: []string{shortPrompt}, contentWidth: 11, expected: ""}, + {name: "prefers the first candidate", candidates: []string{longPrompt, shortPrompt}, contentWidth: 34, expected: longPrompt}, + {name: "falls back to the next one", candidates: []string{longPrompt, shortPrompt}, contentWidth: 33, expected: shortPrompt}, + {name: "measures display width", candidates: []string{"篩選: "}, contentWidth: 9, expected: ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, menuFilterPrompt(test.candidates, test.contentWidth)) + }) + } +} diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 67e695f2b..c845d8074 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -144,15 +144,14 @@ func (gui *Gui) layout(g *gocui.Gui) error { } } - // When the screen is too short the side panels are squashed, with the - // unfocused ones taking one row each and the focused one taking the rest. The - // more panels there are, the more rows the unfocused ones reserve, so the - // floor below which there's no room left for the focused panel grows with the - // panel count. Keep the historical floor of 9 for the default five panels. - minimumHeight := max(9, len(gui.helpers.Window.SideWindows())+4) + menuWithFilterRowVisible := gui.Views.Menu.Visible && gui.State.Contexts.Menu.FilterAsYouType() + minimumHeight := minimumScreenHeight(len(gui.helpers.Window.SideWindows()), menuWithFilterRowVisible) minimumWidth := 10 gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth + filterRowVisible := gui.Views.Menu.Visible && gui.State.Contexts.Menu.FilterStarted() + gui.Views.MenuFilterFrame.Visible = filterRowVisible + gui.Views.MenuFilter.Visible = filterRowVisible gui.Views.Tooltip.Visible = gui.Views.Menu.Visible && gui.Views.Tooltip.Buffer() != "" for _, context := range gui.transientContexts() { @@ -229,6 +228,26 @@ outer: return nil } +// The height below which we show the "not enough space" view instead of the +// layout. +func minimumScreenHeight(sideWindowCount int, menuWithFilterRowVisible bool) int { + // When the screen is too short the side panels are squashed, with the + // unfocused ones taking one row each and the focused one taking the rest. The + // more panels there are, the more rows the unfocused ones reserve, so the + // floor below which there's no room left for the focused panel grows with the + // panel count. Keep the historical floor of 9 for the default five panels. + minimumHeight := max(9, sideWindowCount+4) + + // A menu popup gets three quarters of the screen, of which its frame, the + // tooltip gap below it and a reserved filter row take seven rows, so below 11 + // rows there is no room left for even one menu item. + if menuWithFilterRowVisible { + minimumHeight = max(minimumHeight, 11) + } + + return minimumHeight +} + func (gui *Gui) prepareView(viewName string) (*gocui.View, error) { // arbitrarily giving the view enough size so that we don't get an error, but // it's expected that the view will be given the correct size before being shown diff --git a/pkg/gui/layout_test.go b/pkg/gui/layout_test.go new file mode 100644 index 000000000..1495bcd49 --- /dev/null +++ b/pkg/gui/layout_test.go @@ -0,0 +1,14 @@ +package gui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMinimumScreenHeight(t *testing.T) { + assert.Equal(t, 9, minimumScreenHeight(5, false)) + assert.Equal(t, 12, minimumScreenHeight(8, false)) + assert.Equal(t, 11, minimumScreenHeight(5, true)) + assert.Equal(t, 12, minimumScreenHeight(8, true)) +} diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 1fb755c46..1bfdb4581 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -69,9 +69,13 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { gui.State.Contexts.Menu.SetPrompt(opts.Prompt) gui.State.Contexts.Menu.SetAllowFilteringKeybindings(opts.AllowFilteringKeybindings) gui.State.Contexts.Menu.SetKeybindingsTakePrecedence(!opts.KeepConflictingKeybindings) + gui.State.Contexts.Menu.SetFilterAsYouType(opts.FilterAsYouType) gui.State.Contexts.Menu.SetOnCancel(opts.OnCancel) gui.State.Contexts.Menu.SetSelection(0) + gui.Views.MenuFilter.ClearTextArea() + gui.Views.MenuFilter.RenderTextArea() + gui.Views.Menu.Title = opts.Title gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 7ff43ff28..58ccf6d60 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -213,6 +213,11 @@ type CreateMenuOptions struct { ColumnAlignment []utils.Alignment AllowFilteringKeybindings bool KeepConflictingKeybindings bool // if true, the keybindings that match essential bindings such as confirm or return will not be removed from menu items + // if true, the menu has a filter row of its own and filters its items as the + // user types, instead of being filtered through the search prompt. Only for + // menus whose items don't have keybindings of their own, because those keys + // would clash with typing. + FilterAsYouType bool } type CreatePopupPanelOpts struct {