Filter a menu by typing into it

The filter input is where the keyboard points for as long as such a menu
is open, so that the first printable key can go straight into it. The menu
still gets every key the input doesn't take, because the input view is
embedded in the menu view, and the two are drawn as one focused panel.

Which keys the input takes changes once there is a filter: until then
printable keys still drive the menu, so that the configured navigation
keys work as usual, and afterwards they are all filter text. A menu item's
own keys are never bound in such a menu, because typing one has to reach
the filter rather than execute the item.

Escape gives up the filter and leaves the menu open; the next one closes
it. The filter prompt behind '/' is gone from these menus: the row already
does that job, and a second filter would only be confusing.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller 2026-08-25 09:32:43 +02:00
parent 1fb5f87d05
commit fe9b990c4c
12 changed files with 104 additions and 3 deletions

View file

@ -179,11 +179,12 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
self.gui.helpers.Window.SetWindowContext(c)
self.gui.helpers.Window.MoveToTopOfWindow(c)
inputViewName := c.GetInputViewName()
oldView := self.gui.c.GocuiGui().CurrentView()
if oldView != nil && oldView.Name() != viewName {
if oldView != nil && oldView.Name() != inputViewName {
oldView.HighlightInactive = true
}
if _, err := self.gui.c.GocuiGui().SetCurrentView(viewName); err != nil {
if _, err := self.gui.c.GocuiGui().SetCurrentView(inputViewName); err != nil {
panic(err)
}

View file

@ -102,6 +102,10 @@ func (self *BaseContext) GetViewName() string {
return self.view.Name()
}
func (self *BaseContext) GetInputViewName() string {
return self.GetViewName()
}
func (self *BaseContext) GetView() *gocui.View {
return self.view
}

View file

@ -159,6 +159,9 @@ func (self *MenuViewModel) FilterAsYouType() bool {
// Whether the user has started to filter, which is when the filter row appears.
func (self *MenuViewModel) SetFilterStarted(value bool) {
self.filterStarted = value
// As long as there is nothing to type into, printable keys keep driving the
// menu, so that the configured navigation keys work like in any other menu.
self.c.Views().MenuFilter.KeybindOnEdit = !value
}
func (self *MenuViewModel) FilterStarted() bool {
@ -242,6 +245,16 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
basicBindings := self.ListContextTrait.GetKeybindings(opts)
if self.filterAsYouType {
// A menu item's keys are shown as a reminder of what they do outside the
// menu, but pressing one types it into the filter rather than executing the
// item, so we don't bind them at all. That leaves the bindings that drive
// the menu itself, and the printable ones among those give way to the filter
// as soon as there is something to type into (see View.KeybindOnEdit).
return basicBindings
}
menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool {
return len(item.Keys) > 0
})
@ -300,6 +313,17 @@ func (self *MenuContext) RangeSelectEnabled() bool {
return false
}
// A menu that filters as you type points the keyboard at its filter input, so
// that whatever the user types ends up there. Keys that the input doesn't take
// still reach the menu, because the input view is embedded in the menu view.
func (self *MenuContext) GetInputViewName() string {
if self.filterAsYouType {
return self.c.Views().MenuFilter.Name()
}
return self.GetViewName()
}
func (self *MenuContext) FilterPrefix(tr *i18n.TranslationSet) string {
if self.allowFilteringKeybindings {
return tr.FilterPrefixMenu

View file

@ -33,7 +33,17 @@ func (self *FilterController) Context() types.Context {
return self.context
}
// A context that filters as the user types has an input field of its own, so it
// has no use for the filter prompt.
type contextThatFiltersAsYouType interface {
FilterAsYouType() bool
}
func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
if context, ok := self.context.(contextThatFiltersAsYouType); ok && context.FilterAsYouType() {
return nil
}
return []*types.Binding{
{
Keys: opts.GetKeys(opts.Config.Universal.StartSearch),

View file

@ -73,6 +73,11 @@ func (self *MenuController) press(selectedItem *types.MenuItem) error {
}
func (self *MenuController) close() error {
if self.context().FilterStarted() {
self.stopFiltering()
return nil
}
if self.context().IsFiltering() {
self.c.Helpers().Search.Cancel()
return nil
@ -81,6 +86,17 @@ func (self *MenuController) close() error {
return self.context().OnMenuPress(nil)
}
// Hides the filter row again and puts the menu back the way it was, keeping the
// item that was selected. It takes another escape to close the menu.
func (self *MenuController) stopFiltering() {
self.c.Views().MenuFilter.ClearTextArea()
self.c.Views().MenuFilter.RenderTextArea()
self.context().SetFilterStarted(false)
self.context().ClearFilter()
self.c.PostRefreshUpdate(self.context())
}
func (self *MenuController) context() *context.MenuContext {
return self.c.Contexts().Menu
}

View file

@ -49,6 +49,32 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool {
return matched
}
func (gui *Gui) menuFilterEditor(v *gocui.View, key gocui.Key) bool {
contentBefore := v.TextArea.GetContent()
matched := gui.handleEditorKeypress(v, key, false)
if !matched {
// Give the global keybindings a chance at the key, e.g. so that ctrl-c
// still quits while a menu is open.
return false
}
v.RenderTextArea()
content := v.TextArea.GetContent()
if content == contentBefore {
// The key just moved the cursor around within the filter; refiltering would
// throw away the menu's selection for nothing.
return true
}
menuContext := gui.State.Contexts.Menu
menuContext.SetFilterStarted(true)
gui.helpers.Search.ApplyFilter(menuContext, content)
return true
}
func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key) bool {
matched := gui.handleEditorKeypress(v, key, false)
v.RenderTextArea()

View file

@ -928,6 +928,7 @@ func (gui *Gui) viewTabMap() map[string][]context.TabView {
var popupPanelViewGroups = [][]string{
{"commitMessage", "commitDescription"},
{"prompt", "suggestions"},
{"menu", "menuFilterFrame", "menuFilter"},
}
func viewsBelongToSamePopupPanel(viewName string, otherViewName string) bool {

View file

@ -152,6 +152,17 @@ func (gui *Gui) layout(g *gocui.Gui) error {
filterRowVisible := gui.Views.Menu.Visible && gui.State.Contexts.Menu.FilterStarted()
gui.Views.MenuFilterFrame.Visible = filterRowVisible
gui.Views.MenuFilter.Visible = filterRowVisible
if gui.Views.Menu.Visible {
// Until the user types something there is no filter row to advertise the
// filter, so the menu says that typing is a thing.
gui.Views.Menu.Subtitle = lo.Ternary(menuWithFilterRowVisible && !filterRowVisible, gui.c.Tr.MenuFilterHint, "")
}
if menuWithFilterRowVisible {
// The filter input is the current view for as long as such a menu is open,
// so without this the cursor would sit on the menu's bottom border, where
// the filter row is yet to appear.
gui.g.Cursor = filterRowVisible
}
gui.Views.Tooltip.Visible = gui.Views.Menu.Visible && gui.Views.Tooltip.Buffer() != ""
for _, context := range gui.transientContexts() {

View file

@ -57,6 +57,10 @@ type IBaseContext interface {
GetKind() ContextKind
GetViewName() string
// The view that keyboard input goes to while this context is focused. That is
// the context's own view, unless the context has an editable view embedded in
// it which takes the keyboard instead, like the menu's filter input.
GetInputViewName() string
GetView() *gocui.View
GetViewTrait() IViewTrait
GetWindowName() string

View file

@ -140,7 +140,7 @@ func (gui *Gui) postRefreshUpdate(c types.Context, opts types.OnFocusOpts) {
c.HandleRender()
if gui.currentViewName() == c.GetViewName() {
if gui.currentViewName() == c.GetInputViewName() {
c.HandleFocus(opts)
} else {
// The FocusLine call is included in the HandleFocus method which we

View file

@ -148,6 +148,8 @@ func (gui *Gui) createAllViews() error {
gui.Views.MenuFilterFrame.Visible = false
gui.Views.MenuFilter.Visible = false
gui.Views.MenuFilter.Frame = false
gui.Views.MenuFilter.Editable = true
gui.Views.MenuFilter.Editor = gocui.EditorFunc(gui.menuFilterEditor)
// The filter row belongs to the menu: it shares the menu's focus, and keys
// that the input field doesn't take are the menu's to handle.
gui.Views.MenuFilterFrame.ParentView = gui.Views.Menu

View file

@ -879,6 +879,7 @@ type TranslationSet struct {
SearchPrefix string
FilterPrefix string
FilterPrefixMenu string
MenuFilterHint string
ExitSearchMode string
ExitTextFilterMode string
Switch string
@ -2040,6 +2041,7 @@ func EnglishTranslationSet() *TranslationSet {
SearchPrefix: "Search: ",
FilterPrefix: "Filter: ",
FilterPrefixMenu: "Filter (prepend '@' to filter keybindings): ",
MenuFilterHint: "(Type to filter)",
WorktreesTitle: "Worktrees",
WorktreeTitle: "Worktree",
Switch: "Switch",