From fe9b990c4c7c307a9c7d780ecb9f43d2aeb79f72 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Aug 2026 09:32:43 +0200 Subject: [PATCH] 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) --- pkg/gui/context.go | 5 +++-- pkg/gui/context/base_context.go | 4 ++++ pkg/gui/context/menu_context.go | 24 ++++++++++++++++++++++ pkg/gui/controllers/filter_controller.go | 10 +++++++++ pkg/gui/controllers/menu_controller.go | 16 +++++++++++++++ pkg/gui/editors.go | 26 ++++++++++++++++++++++++ pkg/gui/gui.go | 1 + pkg/gui/layout.go | 11 ++++++++++ pkg/gui/types/context.go | 4 ++++ pkg/gui/view_helpers.go | 2 +- pkg/gui/views.go | 2 ++ pkg/i18n/english.go | 2 ++ 12 files changed, 104 insertions(+), 3 deletions(-) diff --git a/pkg/gui/context.go b/pkg/gui/context.go index 1adfec35c..cd959274c 100644 --- a/pkg/gui/context.go +++ b/pkg/gui/context.go @@ -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) } diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 67b4654a6..51d2473c3 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -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 } diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 4b393169d..63439bc67 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -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 diff --git a/pkg/gui/controllers/filter_controller.go b/pkg/gui/controllers/filter_controller.go index b428c178a..830a5bbc6 100644 --- a/pkg/gui/controllers/filter_controller.go +++ b/pkg/gui/controllers/filter_controller.go @@ -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), diff --git a/pkg/gui/controllers/menu_controller.go b/pkg/gui/controllers/menu_controller.go index 283c2bbdf..9966d6c68 100644 --- a/pkg/gui/controllers/menu_controller.go +++ b/pkg/gui/controllers/menu_controller.go @@ -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 } diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index 37eacf416..7c658265b 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -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() diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e10705748..4de37fec2 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -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 { diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index c845d8074..ccc83b1e9 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -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() { diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 128c4f08f..e8b33a7d8 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -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 diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 4e14e6c48..2151a5692 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -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 diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 895260fd9..7b6fa93eb 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -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 diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 6d41d31d6..896c40670 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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",