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",