Lay out the filter row of a menu that filters as you type

The row is reserved for as long as such a menu is open, even while it is
still hidden, so that it can appear without moving the menu. That costs
two rows of the popup, which is why the screen has to be a little taller
before a menu is worth showing at all.

The prompt in front of the input field is dropped when the row gets too
narrow to type in, and the keybindings menu says what '@' does when it
still fits.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-08-25 09:24:21 +02:00
parent c80035d7ee
commit 1fb5f87d05
7 changed files with 167 additions and 9 deletions

View file

@ -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()

View file

@ -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 {

View file

@ -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))
})
}
}

View file

@ -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

14
pkg/gui/layout_test.go Normal file
View file

@ -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))
}

View file

@ -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

View file

@ -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 {