diff --git a/docs-master/Searching.md b/docs-master/Searching.md index 589831c55..4cba775df 100644 --- a/docs-master/Searching.md +++ b/docs-master/Searching.md @@ -4,10 +4,14 @@ Depending on the currently focused view, hitting '/' will bring up a filter or search prompt. When filtering, the contents of the view will be filtered down to only those lines which match the query string. When searching, the contents of the view are not filtered, but matching lines are highlighted and you can iterate through matches with `n`/`N`. -We intend to support filtering for the files view soon, but at the moment it uses searching. We intend to continue using search for the commits view because you typically care about the commits that come before/after a matching commit. +In the commits view we don't filter, but search; this is deliberate because you typically care about the commits that come before/after a matching commit. If you would like both filtering and searching to be enabled on a given view, please raise an issue for this. +## Menu filtering + +The keybindings (`?`) and recent repositories menus can be filtered simply by typing. The filter field appears at the bottom of the menu while you type; there is no need to press `/` or confirm the filter before navigating the results. + ## Filtering files by status You can filter the files view to only show staged/unstaged files by pressing `` in the files view. diff --git a/pkg/gocui/double_click_test.go b/pkg/gocui/double_click_test.go index b8d9f5f9c..9c73da1e9 100644 --- a/pkg/gocui/double_click_test.go +++ b/pkg/gocui/double_click_test.go @@ -13,14 +13,14 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) { g := newTestGui(t) view, _ := g.SetView("list", 0, 0, 20, 10, 0) doubleClicks := []bool{} - assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{ + g.SetViewClickBinding(&ViewMouseBinding{ ViewName: "list", Key: MouseLeft, Handler: func(opts ViewMouseBindingOpts) error { doubleClicks = append(doubleClicks, opts.IsDoubleClick) return nil }, - })) + }) for _, event := range []GocuiEvent{ gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)), diff --git a/pkg/gocui/edit.go b/pkg/gocui/edit.go index 649379fb6..4263e0b5c 100644 --- a/pkg/gocui/edit.go +++ b/pkg/gocui/edit.go @@ -78,7 +78,7 @@ func SimpleEditor(v *View, key Key) bool { v.TextArea.GoToEndOfLine() case key.Equals(NewKeyStrMod("y", ModCtrl)): v.TextArea.Yank() - case key.Str() != "" && key.Mod() == 0: + case key.IsPrintable(): v.TextArea.TypeCharacter(key.Str()) default: return false diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index b4cf107de..67fbd1aa2 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -675,19 +675,15 @@ func (g *Gui) DeleteViewKeybindings(viewname string) { } // SetTabClickBinding sets a binding for a tab click event -func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) error { +func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) { g.tabClickBindings = append(g.tabClickBindings, &tabClickBinding{ viewName: viewName, handler: handler, }) - - return nil } -func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error { +func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) { g.viewMouseBindings = append(g.viewMouseBindings, binding) - - return nil } // captureMouse routes subsequent mouse events to view until the mouse button is @@ -1619,6 +1615,20 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error { return g.flushContentOnly(views) } +// hasFocus reports whether a view is drawn as focused. Views that are embedded +// in one another (see View.ParentView) form a single unit, so they are all drawn +// as focused while any one of them is the current view. +func (g *Gui) hasFocus(v *View) bool { + return g.currentView != nil && outermostView(v) == outermostView(g.currentView) +} + +func outermostView(v *View) *View { + for v.ParentView != nil { + v = v.ParentView + } + return v +} + // draw manages the cursor and calls the draw function of a view. func (g *Gui) draw(v *View) error { if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 { @@ -1643,7 +1653,7 @@ func (g *Gui) draw(v *View) error { if v.Frame { var fgColor, bgColor, frameColor Attribute - if g.Highlight && v == g.currentView && g.IsFocused() { + if g.Highlight && g.hasFocus(v) && g.IsFocused() { fgColor = g.SelFgColor bgColor = g.SelBgColor frameColor = g.SelFrameColor @@ -1983,7 +1993,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error { matchingParentViewKb = nil break } - if v != nil && g.matchView(v.ParentView, kb) { + if matchingParentViewKb == nil && v != nil && g.matchView(v.ParentView, kb) { matchingParentViewKb = kb } if globalKb == nil && kb.viewName == "" { @@ -2095,13 +2105,15 @@ func (g *Gui) isSuspended() bool { return g.suspended } -// matchView returns if the keybinding matches the current view (and the view's context) +// matchView returns if the keybinding matches the given view (and the view's context) func (g *Gui) matchView(v *View, kb *keybinding) bool { - // if the user is typing in a field, ignore char keys if v == nil { return false } - if v.Editable && kb.key.Str() != "" && kb.key.Mod() == 0 { + // If the user is typing in a field, printable keys are theirs to type, so no + // keybinding gets a look at them: not the field's own, and not those of the + // view it is embedded in either. + if field := g.currentView; field != nil && field.Editable && !field.KeybindOnEdit && kb.key.IsPrintable() { return false } if kb.viewName != v.name { diff --git a/pkg/gocui/key.go b/pkg/gocui/key.go index dd0a912a0..0eaa29b38 100644 --- a/pkg/gocui/key.go +++ b/pkg/gocui/key.go @@ -61,6 +61,12 @@ func (k Key) IsSet() bool { return k.keyName != 0 } +// IsPrintable reports whether the key stands for a character that can be typed +// into a text field. +func (k Key) IsPrintable() bool { + return k.keyName == KeyName(tcell.KeyRune) && k.str != "" && k.mod == ModNone +} + func (k Key) Equals(otherKey Key) bool { return k.keyName == otherKey.keyName && k.str == otherKey.str && k.mod == otherKey.mod } diff --git a/pkg/gocui/key_test.go b/pkg/gocui/key_test.go new file mode 100644 index 000000000..8fce3fe53 --- /dev/null +++ b/pkg/gocui/key_test.go @@ -0,0 +1,16 @@ +package gocui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKeyIsPrintable(t *testing.T) { + assert.True(t, NewKeyRune('x').IsPrintable()) + assert.True(t, NewKeyRune('界').IsPrintable()) + assert.True(t, NewKeyRune(' ').IsPrintable()) + assert.False(t, NewKeyStrMod("x", ModCtrl).IsPrintable()) + assert.False(t, NewKeyName(KeyEnter).IsPrintable()) + assert.False(t, Key{}.IsPrintable()) +} diff --git a/pkg/gocui/mouse_capture_test.go b/pkg/gocui/mouse_capture_test.go index eea1e3f9f..f335e65c4 100644 --- a/pkg/gocui/mouse_capture_test.go +++ b/pkg/gocui/mouse_capture_test.go @@ -36,7 +36,7 @@ func TestMouseCaptureRoutesMotionAndReleaseOutsideView(t *testing.T) { }, }, } { - assert.NoError(t, g.SetViewClickBinding(binding)) + g.SetViewClickBinding(binding) } g.captureMouse(view) @@ -69,7 +69,7 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) { receivedBy := "" for _, viewName := range []string{"left", "right"} { - assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{ + g.SetViewClickBinding(&ViewMouseBinding{ ViewName: viewName, Key: MouseLeft, Modifier: ModMotion, @@ -77,7 +77,7 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) { receivedBy = viewName return nil }, - })) + }) } assert.NoError(t, g.onKey(&GocuiEvent{ @@ -102,10 +102,10 @@ func TestPrimaryMouseDragDoesNotActivateTabs(t *testing.T) { view.Tabs = []string{"first", "second"} clickedTabs := []int{} - assert.NoError(t, g.SetTabClickBinding("tabs", func(tabIndex int) error { + g.SetTabClickBinding("tabs", func(tabIndex int) error { clickedTabs = append(clickedTabs, tabIndex) return nil - })) + }) assert.NoError(t, g.onKey(&GocuiEvent{ Type: eventMouse, @@ -172,7 +172,7 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) { _, _ = g.SetView("right", 21, 0, 41, 10, 0) receivedBy := "" for _, viewName := range []string{"left", "right"} { - assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{ + g.SetViewClickBinding(&ViewMouseBinding{ ViewName: viewName, Key: MouseLeft, Modifier: ModMotion, @@ -180,7 +180,7 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) { receivedBy = viewName return nil }, - })) + }) } g.captureMouse(left) diff --git a/pkg/gocui/parent_view_test.go b/pkg/gocui/parent_view_test.go new file mode 100644 index 000000000..9d56a1c46 --- /dev/null +++ b/pkg/gocui/parent_view_test.go @@ -0,0 +1,142 @@ +package gocui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// A view and its parent view, with the child holding the focus. +func setupParentAndChildView(t *testing.T, g *Gui) (*View, *View) { + t.Helper() + + parent, _ := g.SetView("parent", 0, 0, 20, 10, 0) + child, _ := g.SetView("child", 0, 10, 20, 12, 0) + child.ParentView = parent + _, err := g.SetCurrentView(child.Name()) + assert.NoError(t, err) + + return parent, child +} + +func TestKeybindingOfParentViewIsUsedWhenChildHasNone(t *testing.T) { + g := newTestGui(t) + parent, child := setupParentAndChildView(t, g) + + pressed := []string{} + g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error { + pressed = append(pressed, "parent") + return nil + }) + g.SetKeybinding(child.Name(), NewKeyName(KeyEnter), func(*Gui, *View) error { + pressed = append(pressed, "child") + return nil + }) + + assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)})) + assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyEnter)})) + + assert.Equal(t, []string{"parent", "child"}, pressed) +} + +func TestFirstMatchingKeybindingOfParentViewWins(t *testing.T) { + g := newTestGui(t) + parent, _ := setupParentAndChildView(t, g) + + pressed := []string{} + for _, name := range []string{"first", "second"} { + g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error { + pressed = append(pressed, name) + return nil + }) + } + + assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)})) + + assert.Equal(t, []string{"first"}, pressed) +} + +func TestEmbeddedViewsAreFocusedTogether(t *testing.T) { + g := newTestGui(t) + parent, child := setupParentAndChildView(t, g) + sibling, _ := g.SetView("sibling", 0, 12, 20, 14, 0) + sibling.ParentView = parent + unrelated, _ := g.SetView("unrelated", 30, 0, 50, 10, 0) + + assert.True(t, g.hasFocus(child)) + assert.True(t, g.hasFocus(parent)) + assert.True(t, g.hasFocus(sibling)) + assert.False(t, g.hasFocus(unrelated)) + + _, err := g.SetCurrentView(unrelated.Name()) + assert.NoError(t, err) + + assert.True(t, g.hasFocus(unrelated)) + assert.False(t, g.hasFocus(parent)) + assert.False(t, g.hasFocus(child)) +} + +func TestPrintableKeysGoToTheFieldBeingTypedIn(t *testing.T) { + for _, test := range []struct { + name string + keybindOnEdit bool + declineKeybinding bool + expectedPresses int + expectedEdits int + }{ + {name: "the field gets the key", expectedEdits: 1}, + {name: "the parent view gets the key", keybindOnEdit: true, expectedPresses: 1}, + { + name: "the field gets the key the parent view declined", + keybindOnEdit: true, + declineKeybinding: true, + expectedPresses: 1, + expectedEdits: 1, + }, + } { + t.Run(test.name, func(t *testing.T) { + g := newTestGui(t) + parent, child := setupParentAndChildView(t, g) + child.Editable = true + child.KeybindOnEdit = test.keybindOnEdit + + edits := 0 + child.Editor = EditorFunc(func(*View, Key) bool { + edits++ + return true + }) + presses := 0 + g.SetKeybinding(parent.Name(), NewKeyRune('j'), func(*Gui, *View) error { + presses++ + if test.declineKeybinding { + return ErrKeybindingNotHandled + } + return nil + }) + + assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyRune('j')})) + + assert.Equal(t, test.expectedPresses, presses) + assert.Equal(t, test.expectedEdits, edits) + }) + } +} + +func TestUnhandledKeybindingOfParentViewFallsThroughToEditor(t *testing.T) { + g := newTestGui(t) + parent, child := setupParentAndChildView(t, g) + + edited := []Key{} + child.Editable = true + child.Editor = EditorFunc(func(_ *View, key Key) bool { + edited = append(edited, key) + return true + }) + g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error { + return ErrKeybindingNotHandled + }) + + assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)})) + + assert.Equal(t, []Key{NewKeyName(KeyArrowDown)}, edited) +} diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index dba71ab52..6e7b35520 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -210,7 +210,9 @@ type View struct { // Overlaps describes which edges are overlapping with another view's edges Overlaps byte - // ParentView is the view which catches events bubbled up from the given view if there's no matching handler + // ParentView is the view which catches events bubbled up from the given view if there's no matching handler. + // Views related this way are also drawn as a single focused unit: while one of + // them is the current view, they all get the focused frame and title colors. ParentView *View searcher *searcher 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/filtered_list_view_model.go b/pkg/gui/context/filtered_list_view_model.go index ce2f8ac36..2c2841964 100644 --- a/pkg/gui/context/filtered_list_view_model.go +++ b/pkg/gui/context/filtered_list_view_model.go @@ -1,7 +1,5 @@ package context -import "github.com/jesseduffield/lazygit/pkg/i18n" - type FilteredListViewModel[T HasID] struct { *FilteredList[T] *ListViewModel[T] @@ -35,8 +33,3 @@ func (self *FilteredListViewModel[T]) ClearFilter() { self.SetSelection(unfilteredIndex) } - -// Default implementation of most filterable contexts. Can be overridden if needed. -func (self *FilteredListViewModel[T]) FilterPrefix(tr *i18n.TranslationSet) string { - return tr.FilterPrefix -} diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go index 9ab24cf9b..77e071991 100644 --- a/pkg/gui/context/list_context_trait.go +++ b/pkg/gui/context/list_context_trait.go @@ -28,6 +28,11 @@ type ListContextTrait struct { // true if we're inside the OnSearchSelect call; in that case we don't want to update the search // result index. inOnSearchSelect bool + + // If set, this renders the "x of y" footer instead of the default, which puts + // it on the bottom border of the list's own view. A list that is part of a + // composite panel can use this to put it somewhere else; see MenuContext. + renderFooter func(footer string) } func (self *ListContextTrait) IsListContext() {} @@ -81,7 +86,13 @@ func (self *ListContextTrait) refreshViewport() { } func (self *ListContextTrait) setFooter() { - self.GetViewTrait().SetFooter(formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len())) + footer := formatListFooter(self.list.GetSelectedLineIdx(), self.list.Len()) + if self.renderFooter != nil { + self.renderFooter(footer) + return + } + + self.GetViewTrait().SetFooter(footer) } func formatListFooter(selectedLineIdx int, length int) string { diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 8129aa420..55a3e5bfa 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -8,7 +8,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -45,6 +44,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 +64,8 @@ type MenuViewModel struct { columnAlignment []utils.Alignment allowFilteringKeybindings bool keybindingsTakePrecedence bool + filterAsYouType bool + filterStarted bool onCancel func() error *FilteredListViewModel[*types.MenuItem] } @@ -128,10 +136,37 @@ 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 + // 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 { + return self.filterStarted +} + // TODO: move into presentation package func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string { menuItems := self.FilteredListViewModel.GetItems() @@ -209,6 +244,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 }) @@ -267,10 +312,13 @@ func (self *MenuContext) RangeSelectEnabled() bool { return false } -func (self *MenuContext) FilterPrefix(tr *i18n.TranslationSet) string { - if self.allowFilteringKeybindings { - return tr.FilterPrefixMenu +// 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.FilteredListViewModel.FilterPrefix(tr) + return self.GetViewName() } diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index a7eb35919..f21fb607f 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -138,6 +138,9 @@ func (gui *Gui) resetHelpersAndControllers() { common := controllers.NewControllerCommon(helperCommon, gui) + listControllerFactory := controllers.NewListControllerFactory(common) + menuListController := listControllerFactory.Create(gui.State.Contexts.Menu) + syncController := controllers.NewSyncController( common, ) @@ -156,7 +159,7 @@ func (gui *Gui) resetHelpersAndControllers() { remoteBranchesController := controllers.NewRemoteBranchesController(common) - menuController := controllers.NewMenuController(common) + menuController := controllers.NewMenuController(common, menuListController) localCommitsController := controllers.NewLocalCommitsController(common, syncController.HandlePull) tagsController := controllers.NewTagsController(common) filesController := controllers.NewFilesController( @@ -359,6 +362,7 @@ func (gui *Gui) resetHelpersAndControllers() { controllers.AttachControllers(gui.State.Contexts.Menu, menuController, + menuListController, ) controllers.AttachControllers(gui.State.Contexts.CommitMessage, @@ -412,8 +416,11 @@ func (gui *Gui) resetHelpersAndControllers() { ) // this must come last so that we've got our click handlers defined against the context - listControllerFactory := controllers.NewListControllerFactory(common) for _, context := range gui.c.Context().AllList() { + if context == gui.State.Contexts.Menu { + // already attached above, next to the menu controller that delegates to it + continue + } controllers.AttachControllers(context, listControllerFactory.Create(context)) } } diff --git a/pkg/gui/controllers/filter_controller.go b/pkg/gui/controllers/filter_controller.go index 358fb8ed5..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), @@ -44,5 +54,6 @@ func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*type } func (self *FilterController) OpenFilterPrompt() error { - return self.c.Helpers().Search.OpenFilterPrompt(self.context) + self.c.Helpers().Search.OpenFilterPrompt(self.context) + return nil } 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/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index d3b6bfe29..c8a33bcbe 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -153,7 +153,11 @@ func (self *ReposHelper) CreateRecentReposMenu() error { } }) - return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems}) + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.RecentRepos, + Items: menuItems, + FilterAsYouType: true, + }) } // SwitchToParentRepo switches back to the repo the current submodule was diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index 51f510792..96c4c35ba 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -29,14 +29,14 @@ func NewSearchHelper( } } -func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error { +func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) { state := self.searchState() state.PrevSearchIndex = -1 state.Context = context - self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr)) + self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) promptView := self.promptView() promptView.ClearTextArea() self.OnPromptContentChanged("") @@ -44,10 +44,10 @@ func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) err self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{}) - return self.c.ResetKeybindings() + self.c.ResetKeybindings() } -func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) error { +func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) { state := self.searchState() state.PrevSearchIndex = -1 @@ -61,7 +61,7 @@ func (self *SearchHelper) OpenSearchPrompt(context types.ISearchableContext) err self.c.Context().Push(self.c.Contexts().Search, types.OnFocusOpts{}) - return self.c.ResetKeybindings() + self.c.ResetKeybindings() } func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) { @@ -70,7 +70,7 @@ func (self *SearchHelper) DisplayFilterStatus(context types.IFilterableContext) state.Context = context searchString := context.GetFilter() - self.searchPrefixView().SetContent(context.FilterPrefix(self.c.Tr)) + self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix) promptView := self.promptView() keybindingConfig := self.c.UserConfig().Keybinding @@ -103,10 +103,11 @@ func (self *SearchHelper) promptContent() string { return self.c.Contexts().Search.GetView().TextArea.GetContent() } -func (self *SearchHelper) Confirm() error { +func (self *SearchHelper) Confirm() { state := self.searchState() if self.promptContent() == "" { - return self.CancelPrompt() + self.CancelPrompt() + return } switch state.SearchType() { @@ -118,7 +119,7 @@ func (self *SearchHelper) Confirm() error { self.c.Context().Pop() } - return self.c.ResetKeybindings() + self.c.ResetKeybindings() } func (self *SearchHelper) ConfirmFilter() { @@ -175,12 +176,12 @@ func modelSearchResults(context types.ISearchableContext) []gocui.SearchPosition return context.ModelSearchResults(normalizedSearchStr, caseSensitive) } -func (self *SearchHelper) CancelPrompt() error { +func (self *SearchHelper) CancelPrompt() { self.Cancel() self.c.Context().Pop() - return self.c.ResetKeybindings() + self.c.ResetKeybindings() } func (self *SearchHelper) ScrollHistory(scrollIncrement int) { @@ -224,9 +225,7 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) { state := self.searchState() switch context := state.Context.(type) { case types.IFilterableContext: - context.SetSelection(0) - context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch()) - self.c.PostRefreshUpdate(context) + self.ApplyFilter(context, searchString) case types.ISearchableContext: // do nothing default: @@ -234,6 +233,12 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) { } } +func (self *SearchHelper) ApplyFilter(context types.IFilterableContext, filter string) { + context.SetSelection(0) + context.SetFilter(filter, self.c.UserConfig().Gui.UseFuzzySearch()) + self.c.PostRefreshUpdate(context) +} + func (self *SearchHelper) ReApplyFilter(context types.Context) { filterableContext, ok := context.(types.IFilterableContext) if ok { diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 90a651809..5379e9c09 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -87,8 +87,8 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, repoState := self.c.State().GetRepoState() var searchPrefix string - if filterableContext, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok { - searchPrefix = filterableContext.FilterPrefix(self.c.Tr) + if _, ok := repoState.GetSearchState().Context.(types.IFilterableContext); ok { + searchPrefix = self.c.Tr.FilterPrefix } else { searchPrefix = self.c.Tr.SearchPrefix } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 884cbc941..1e1a01427 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -1684,7 +1684,8 @@ func (self *LocalCommitsController) openSearch() error { self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}}) } - return self.c.Helpers().Search.OpenSearchPrompt(self.context()) + self.c.Helpers().Search.OpenSearchPrompt(self.context()) + return nil } func (self *LocalCommitsController) handleOpenLogMenu() error { diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 6eb6c86e3..5bde8c5ff 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -109,7 +109,8 @@ func (self *MainViewController) openSearch() error { if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { manager.ReadToEnd(func() { self.c.OnUIThread(func() error { - return self.c.Helpers().Search.OpenSearchPrompt(self.context) + self.c.Helpers().Search.OpenSearchPrompt(self.context) + return nil }) }) } diff --git a/pkg/gui/controllers/menu_controller.go b/pkg/gui/controllers/menu_controller.go index 283c2bbdf..1bbb7b27f 100644 --- a/pkg/gui/controllers/menu_controller.go +++ b/pkg/gui/controllers/menu_controller.go @@ -1,20 +1,26 @@ package controllers import ( + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) type MenuController struct { baseController *ListControllerTrait[*types.MenuItem] c *ControllerCommon + // for delegating navigation to, see physicalKeyBindings + listController *ListController } var _ types.IController = &MenuController{} func NewMenuController( c *ControllerCommon, + listController *ListController, ) *MenuController { return &MenuController{ baseController: baseController{}, @@ -24,12 +30,13 @@ func NewMenuController( c.Contexts().Menu.GetSelected, c.Contexts().Menu.GetSelectedItems, ), - c: c, + c: c, + listController: listController, } } // NOTE: if you add a new keybinding here, you'll also need to add it to -// `reservedKeys` in `pkg/gui/context/menu_context.go` +// `essentialKeys` in `pkg/gui/menu_panel.go`, so that menu items can't shadow it func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { @@ -52,6 +59,51 @@ func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types. }, } + if self.context().FilterAsYouType() { + bindings = append(bindings, self.physicalKeyBindings(opts)...) + } + + return bindings +} + +// In a menu that filters as you type, the keys configured for driving the menu +// may all be printable, and printable keys become filter text once the user +// starts typing. These keys can't, so binding them on top guarantees that the +// menu stays usable no matter how the keybindings are configured. +func (self *MenuController) physicalKeyBindings(opts types.KeybindingsOpts) []*types.Binding { + candidates := []struct { + key gocui.Key + configured config.Keybinding + binding *types.Binding + }{ + { + key: gocui.NewKeyName(gocui.KeyEnter), + configured: opts.Config.Universal.ConfirmMenu, + binding: &types.Binding{ + Handler: self.withItem(self.press), + GetDisabledReason: self.require(self.singleItemSelected()), + }, + }, + {gocui.NewKeyName(gocui.KeyEsc), opts.Config.Universal.Return, &types.Binding{Handler: self.close}}, + {gocui.NewKeyName(gocui.KeyArrowUp), opts.Config.Universal.PrevItem, &types.Binding{Handler: self.listController.HandlePrevLine}}, + {gocui.NewKeyName(gocui.KeyArrowDown), opts.Config.Universal.NextItem, &types.Binding{Handler: self.listController.HandleNextLine}}, + {gocui.NewKeyName(gocui.KeyPgup), opts.Config.Universal.PrevPage, &types.Binding{Handler: self.listController.HandlePrevPage}}, + {gocui.NewKeyName(gocui.KeyPgdn), opts.Config.Universal.NextPage, &types.Binding{Handler: self.listController.HandleNextPage}}, + {gocui.NewKeyName(gocui.KeyHome), opts.Config.Universal.GotoTop, &types.Binding{Handler: self.listController.HandleGotoTop}}, + {gocui.NewKeyName(gocui.KeyEnd), opts.Config.Universal.GotoBottom, &types.Binding{Handler: self.listController.HandleGotoBottom}}, + } + + bindings := []*types.Binding{} + for _, candidate := range candidates { + if lo.Contains(opts.GetKeys(candidate.configured), candidate.key) { + // this key is the configured one, so it drives the menu already + continue + } + + candidate.binding.Keys = []gocui.Key{candidate.key} + bindings = append(bindings, candidate.binding) + } + return bindings } @@ -73,6 +125,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 +138,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/controllers/options_menu_action.go b/pkg/gui/controllers/options_menu_action.go index c92fdd589..be2899632 100644 --- a/pkg/gui/controllers/options_menu_action.go +++ b/pkg/gui/controllers/options_menu_action.go @@ -64,6 +64,7 @@ func (self *OptionsMenuAction) Call() error { ColumnAlignment: []utils.Alignment{utils.AlignRight, utils.AlignLeft}, AllowFilteringKeybindings: true, KeepConflictingKeybindings: true, + FilterAsYouType: true, }) } diff --git a/pkg/gui/controllers/search_controller.go b/pkg/gui/controllers/search_controller.go index f1d5efe2a..f84539646 100644 --- a/pkg/gui/controllers/search_controller.go +++ b/pkg/gui/controllers/search_controller.go @@ -37,12 +37,13 @@ func (self *SearchController) GetKeybindings(opts types.KeybindingsOpts) []*type return []*types.Binding{ { Keys: opts.GetKeys(opts.Config.Universal.StartSearch), - Handler: self.OpenSearchPrompt, + Handler: self.openSearchPrompt, Description: self.c.Tr.StartSearch, }, } } -func (self *SearchController) OpenSearchPrompt() error { - return self.c.Helpers().Search.OpenSearchPrompt(self.context) +func (self *SearchController) openSearchPrompt() error { + self.c.Helpers().Search.OpenSearchPrompt(self.context) + return nil } diff --git a/pkg/gui/controllers/search_prompt_controller.go b/pkg/gui/controllers/search_prompt_controller.go index 1ce02abf0..4e6de85dc 100644 --- a/pkg/gui/controllers/search_prompt_controller.go +++ b/pkg/gui/controllers/search_prompt_controller.go @@ -51,11 +51,13 @@ func (self *SearchPromptController) context() types.Context { } func (self *SearchPromptController) confirm() error { - return self.c.Helpers().Search.Confirm() + self.c.Helpers().Search.Confirm() + return nil } func (self *SearchPromptController) cancel() error { - return self.c.Helpers().Search.CancelPrompt() + self.c.Helpers().Search.CancelPrompt() + return nil } func (self *SearchPromptController) prevHistory() error { 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/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index a58f7d93e..a59bcb01a 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -7,7 +7,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -263,10 +262,6 @@ func (self *CommitFileTreeViewModel) IsFiltering() bool { // used for type switch func (self *CommitFileTreeViewModel) IsFilterableContext() {} -func (self *CommitFileTreeViewModel) FilterPrefix(tr *i18n.TranslationSet) string { - return tr.FilterPrefix -} - func (self *CommitFileTreeViewModel) GetSearchHistory() *utils.HistoryBuffer[string] { return self.searchHistory } diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index a5971f592..68829b444 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -7,7 +7,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -274,10 +273,6 @@ func (self *FileTreeViewModel) IsFiltering() bool { // used for type switch func (self *FileTreeViewModel) IsFilterableContext() {} -func (self *FileTreeViewModel) FilterPrefix(tr *i18n.TranslationSet) string { - return tr.FilterPrefix -} - func (self *FileTreeViewModel) GetSearchHistory() *utils.HistoryBuffer[string] { return self.searchHistory } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index bde383caf..4de37fec2 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -368,10 +368,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context contextToPush := gui.resetState(startArgs) gui.resetHelpersAndControllers() - - if err := gui.resetKeybindings(); err != nil { - return err - } + gui.resetKeybindings() gui.g.SetFocusHandler(func(Focused bool) error { if Focused { @@ -383,9 +380,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context gui.c.Log.Info("User config changed - reloading") reloadErr = gui.onUserConfigLoaded() gui.reloadSidePanels() - if err := gui.resetKeybindings(); err != nil { - return err - } + gui.resetKeybindings() if err := gui.checkForChangedConfigsThatDontAutoReload(oldConfig, gui.Config.GetUserConfig()); err != nil { return err @@ -927,6 +922,21 @@ func (gui *Gui) viewTabMap() map[string][]context.TabView { return result } +// The views that each popup panel is made up of. A panel's views share the +// keyboard focus, so clicking from one of them to another stays within the +// panel. +var popupPanelViewGroups = [][]string{ + {"commitMessage", "commitDescription"}, + {"prompt", "suggestions"}, + {"menu", "menuFilterFrame", "menuFilter"}, +} + +func viewsBelongToSamePopupPanel(viewName string, otherViewName string) bool { + return lo.SomeBy(popupPanelViewGroups, func(group []string) bool { + return lo.Contains(group, viewName) && lo.Contains(group, otherViewName) + }) +} + // Run: setup the gui with keybindings and start the mainloop func (gui *Gui) Run(startArgs appTypes.StartArgs) error { g, err := gui.initGocui(Headless(), startArgs.IntegrationTest) @@ -942,15 +952,11 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error { gui.g.ShouldHandleMouseEvent = func(view *gocui.View, key gocui.KeyName) bool { if gui.helpers.Confirmation.IsPopupPanelFocused() && gui.currentViewName() != view.Name() && !gocui.IsMouseScrollKey(key) { - // we ignore click events on views that aren't popup panels, when a popup panel is focused. - // Unless both the current view and the clicked-on view are either commit message or commit - // description, or a prompt and the suggestions view, because we want to allow switching - // between those two views by clicking. - isCommitMessageOrSuggestionsView := func(viewName string) bool { - return viewName == "commitMessage" || viewName == "commitDescription" || - viewName == "prompt" || viewName == "suggestions" - } - if !isCommitMessageOrSuggestionsView(gui.currentViewName()) || !isCommitMessageOrSuggestionsView(view.Name()) { + // we ignore click events on views that aren't popup panels, when a popup + // panel is focused. Unless the clicked-on view is part of the same popup + // panel as the current one, because we want to allow switching between the + // views of a panel by clicking. + if !viewsBelongToSamePopupPanel(gui.currentViewName(), view.Name()) { return false } } diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index 692df5142..d693fd77f 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -201,8 +201,8 @@ func (self *guiCommon) CallKeybindingHandler(binding *types.Binding) error { return self.gui.callKeybindingHandler(binding) } -func (self *guiCommon) ResetKeybindings() error { - return self.gui.resetKeybindings() +func (self *guiCommon) ResetKeybindings() { + self.gui.resetKeybindings() } func (self *guiCommon) IsAnyModeActive() bool { diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index b922f0705..07a0b7d4c 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -181,6 +181,10 @@ func (self *GuiDriver) CurrentContext() types.Context { return self.gui.State.ContextMgr.Current() } +func (self *GuiDriver) CursorVisible() bool { + return self.gui.g.Cursor +} + func (self *GuiDriver) ContextForView(viewName string) types.Context { context, ok := self.gui.helpers.View.ContextForView(viewName) if !ok { diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index c6ac2533e..5d03f6ba5 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -351,7 +351,7 @@ func (gui *Gui) GetInitialKeybindingsWithCustomCommands() ([]*types.Binding, []* return bindings, mouseBindings } -func (gui *Gui) resetKeybindings() error { +func (gui *Gui) resetKeybindings() { gui.g.DeleteAllKeybindings() bindings, mouseBindings := gui.GetInitialKeybindingsWithCustomCommands() @@ -361,9 +361,7 @@ func (gui *Gui) resetKeybindings() error { } for _, binding := range mouseBindings { - if err := gui.SetMouseKeybinding(binding); err != nil { - return err - } + gui.SetMouseKeybinding(binding) } for _, values := range gui.viewTabMap() { @@ -373,13 +371,9 @@ func (gui *Gui) resetKeybindings() error { return gui.onViewTabClick(gui.helpers.Window.WindowForView(viewName), tabIndex) } - if err := gui.g.SetTabClickBinding(viewName, tabClickCallback); err != nil { - return err - } + gui.g.SetTabClickBinding(viewName, tabClickCallback) } } - - return nil } func (gui *Gui) SetKeybinding(binding *types.Binding) { @@ -392,8 +386,8 @@ func (gui *Gui) SetKeybinding(binding *types.Binding) { } } -func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) error { - return gui.g.SetViewClickBinding(binding) +func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) { + gui.g.SetViewClickBinding(binding) } func (gui *Gui) callKeybindingHandler(binding *types.Binding) error { diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 67e695f2b..ccc83b1e9 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -144,15 +144,25 @@ 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 + 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() { @@ -229,6 +239,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 0ddefdbee..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 @@ -80,9 +84,7 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { gui.Views.Tooltip.Visible = true // resetting keybindings so that the menu-specific keybindings are registered - if err := gui.resetKeybindings(); err != nil { - return err - } + gui.resetKeybindings() gui.c.PostRefreshUpdate(gui.State.Contexts.Menu) diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 92cc141cf..58ccf6d60 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -145,7 +145,7 @@ type IGuiCommon interface { KeybindingsOpts() KeybindingsOpts CallKeybindingHandler(binding *Binding) error - ResetKeybindings() error + ResetKeybindings() // hopefully we can remove this once we've moved all our keybinding stuff out of the gui god struct. GetInitialKeybindingsWithCustomCommands() ([]*Binding, []*gocui.ViewMouseBinding) @@ -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 { diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 128c4f08f..2ef798a34 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -4,7 +4,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" - "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sasha-s/go-deadlock" ) @@ -57,6 +56,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 @@ -136,7 +139,6 @@ type IFilterableContext interface { ReApplyFilter(bool) IsFiltering() bool IsFilterableContext() - FilterPrefix(tr *i18n.TranslationSet) string } type ISearchableContext interface { diff --git a/pkg/gui/types/views.go b/pkg/gui/types/views.go index c740ccb2e..1a48d170a 100644 --- a/pkg/gui/types/views.go +++ b/pkg/gui/types/views.go @@ -27,6 +27,8 @@ type Views struct { Confirmation *gocui.View Prompt *gocui.View Menu *gocui.View + MenuFilterFrame *gocui.View + MenuFilter *gocui.View CommitMessage *gocui.View CommitDescription *gocui.View CommitFiles *gocui.View 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 b47e76c5a..7b6fa93eb 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -66,6 +66,12 @@ func (gui *Gui) orderedViewNameMappings() []viewNameMapping { {viewPtr: &gui.Views.CommitMessage, name: "commitMessage"}, {viewPtr: &gui.Views.CommitDescription, name: "commitDescription"}, {viewPtr: &gui.Views.Menu, name: "menu"}, + // the filter row of a menu that filters as you type: a frame that hangs off + // the bottom of the menu and shows the "Filter:" prompt, plus the input + // field that sits inside it. Both must come after the menu so that the row's + // top border is drawn over the menu's bottom border. + {viewPtr: &gui.Views.MenuFilterFrame, name: "menuFilterFrame"}, + {viewPtr: &gui.Views.MenuFilter, name: "menuFilter"}, {viewPtr: &gui.Views.Suggestions, name: "suggestions"}, {viewPtr: &gui.Views.Confirmation, name: "confirmation"}, {viewPtr: &gui.Views.Prompt, name: "prompt"}, @@ -139,6 +145,16 @@ func (gui *Gui) createAllViews() error { gui.Views.Menu.Visible = false + 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 + gui.Views.MenuFilter.ParentView = gui.Views.Menu + gui.Views.Tooltip.Visible = false gui.Views.Tooltip.AutoRenderHyperLinks = true @@ -155,17 +171,30 @@ func (gui *Gui) createAllViews() error { return nil } +// gocui expects a view's frame runes in this order: the horizontal and the +// vertical edge, then the top left, top right, bottom left and bottom right +// corner. +func frameRunesWithTopCorners(frameRunes []rune, topLeft rune, topRight rune) []rune { + return []rune{frameRunes[0], frameRunes[1], topLeft, topRight, frameRunes[4], frameRunes[5]} +} + func (gui *Gui) configureViewProperties() { frameRunes := []rune{'─', '│', '┌', '┐', '└', '┘'} + // The corners for a view that hangs off the bottom of another one, so that the + // border they share reads as a divider rather than as two frames touching. + teeLeft, teeRight := '├', '┤' switch gui.c.UserConfig().Gui.Border { case "double": frameRunes = []rune{'═', '║', '╔', '╗', '╚', '╝'} + teeLeft, teeRight = '╠', '╣' case "rounded": frameRunes = []rune{'─', '│', '╭', '╮', '╰', '╯'} case "hidden": frameRunes = []rune{' ', ' ', ' ', ' ', ' ', ' '} + teeLeft, teeRight = ' ', ' ' case "bold": frameRunes = []rune{'━', '┃', '┏', '┓', '┗', '┛'} + teeLeft, teeRight = '┣', '┫' } for _, mapping := range gui.orderedViewNameMappings() { @@ -177,6 +206,8 @@ func (gui *Gui) configureViewProperties() { (*mapping.viewPtr).InactiveViewSelBgColor = theme.GocuiInactiveViewSelectedLineBgColor } + gui.Views.MenuFilterFrame.FrameRunes = frameRunesWithTopCorners(frameRunes, teeLeft, teeRight) + gui.c.SetViewContent(gui.Views.SearchPrefix, gui.c.Tr.SearchPrefix) gui.Views.Stash.Title = gui.c.Tr.StashTitle diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 6d41d31d6..9c72b53df 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 @@ -2039,7 +2040,8 @@ func EnglishTranslationSet() *TranslationSet { SearchKeybindings: "%s: Next match, %s: Previous match, %s: Exit search mode", SearchPrefix: "Search: ", FilterPrefix: "Filter: ", - FilterPrefixMenu: "Filter (prepend '@' to filter keybindings): ", + FilterPrefixMenu: "Filter ('@' for keybindings): ", + MenuFilterHint: "(Type to filter)", WorktreesTitle: "Worktrees", WorktreeTitle: "Worktree", Switch: "Switch", diff --git a/pkg/integration/components/menu_driver.go b/pkg/integration/components/menu_driver.go index 95f29dcd3..e0d6133e6 100644 --- a/pkg/integration/components/menu_driver.go +++ b/pkg/integration/components/menu_driver.go @@ -56,8 +56,12 @@ func (self *MenuDriver) ContainsLines(matchers ...*TextMatcher) *MenuDriver { return self } +// types the text into the menu's filter row. Only for menus that filter as you +// type; other menus are filtered through the search prompt. func (self *MenuDriver) Filter(text string) *MenuDriver { - self.getViewDriver().FilterOrSearch(text) + self.getViewDriver().IsFocused() + self.t.typeContent(text) + self.t.Views().MenuFilter().IsVisible() return self } diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index afd55a845..bd5bbfc24 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -97,6 +97,24 @@ func (self *TestDriver) GlobalPress(key config.Keybinding) { self.press(key[0]) } +// asserts that the terminal's text cursor is shown, i.e. that there is a text +// field to type into +func (self *TestDriver) CursorIsVisible() *TestDriver { + self.assertWithRetries(func() (bool, string) { + return self.gui.CursorVisible(), "Expected the cursor to be visible" + }) + + return self +} + +func (self *TestDriver) CursorIsHidden() *TestDriver { + self.assertWithRetries(func() (bool, string) { + return !self.gui.CursorVisible(), "Expected the cursor to be hidden" + }) + + return self +} + // FocusIn simulates the terminal window regaining focus, which causes lazygit // to reload any config files that changed while it was in the background. func (self *TestDriver) FocusIn() { diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 2495a07c5..cce96105a 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -88,6 +88,10 @@ func (self *fakeGuiDriver) CurrentContext() types.Context { return nil } +func (self *fakeGuiDriver) CursorVisible() bool { + return false +} + func (self *fakeGuiDriver) ContextForView(viewName string) types.Context { return nil } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index b743afde7..102a8562a 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -41,6 +41,53 @@ func (self *ViewDriver) Title(expected *TextMatcher) *ViewDriver { return self } +// asserts that the view has the expected footer, i.e. the "x of y" text on its +// bottom border +func (self *ViewDriver) Footer(expected *TextMatcher) *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + actual := self.getView().Footer + return expected.context(fmt.Sprintf("%s footer", self.context)).test(actual) + }) + + return self +} + +// asserts that the view has the expected subtitle +func (self *ViewDriver) Subtitle(expected *TextMatcher) *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + actual := self.getView().Subtitle + return expected.context(fmt.Sprintf("%s subtitle", self.context)).test(actual) + }) + + return self +} + +// asserts that the view hangs off the bottom of the given one, sharing a border +// with it +func (self *ViewDriver) SharesTopBorderWithBottomOf(upper *ViewDriver) *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + _, _, _, upperY1 := upper.getView().Dimensions() + _, y0, _, _ := self.getView().Dimensions() + return y0 == upperY1, fmt.Sprintf( + "%s: Expected view to start on row %d, where the view above it ends, but it starts on row %d", + self.context, upperY1, y0) + }) + + return self +} + +// asserts that the view starts on the row below the given one +func (self *ViewDriver) IsImmediatelyBelow(upper *ViewDriver) *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + _, _, _, upperY1 := upper.getView().Dimensions() + _, y0, _, _ := self.getView().Dimensions() + return y0 == upperY1+1, fmt.Sprintf( + "%s: Expected view to start on row %d, but it starts on row %d", self.context, upperY1+1, y0) + }) + + return self +} + func (self *ViewDriver) Clear() *ViewDriver { // clearing multiple times in case there's multiple lines // (the clear button only clears a single line at a time) diff --git a/pkg/integration/components/views.go b/pkg/integration/components/views.go index 90795d942..5c91b6937 100644 --- a/pkg/integration/components/views.go +++ b/pkg/integration/components/views.go @@ -124,6 +124,14 @@ func (self *Views) Menu() *ViewDriver { return self.regularView("menu") } +func (self *Views) MenuFilter() *ViewDriver { + return self.regularView("menuFilter") +} + +func (self *Views) MenuFilterFrame() *ViewDriver { + return self.regularView("menuFilterFrame") +} + func (self *Views) Confirmation() *ViewDriver { return self.regularView("confirmation") } diff --git a/pkg/integration/tests/filter_and_search/filter_menu_as_you_type.go b/pkg/integration/tests/filter_and_search/filter_menu_as_you_type.go new file mode 100644 index 000000000..13b39e5eb --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_menu_as_you_type.go @@ -0,0 +1,94 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterMenuAsYouType = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Filtering a menu by typing into the filter row that appears as you type", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) {}, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().IsFocused().Press(keys.Universal.OptionMenu) + + // The menu offers the filter, but stays as it is until we take it up on it + t.Views().Menu(). + IsFocused(). + Subtitle(Equals("(Type to filter)")). + Footer(Contains(" of ")) + t.Views().MenuFilter().IsInvisible() + t.Views().MenuFilterFrame().IsInvisible() + t.CursorIsHidden() + + t.ExpectPopup().Menu().Filter("whitespace") + + t.Views().Menu(). + Lines( + Contains("─── Global"), + Contains("Toggle whitespace").IsSelected(), + ). + // the row covers the border the footer was on, so it moves there + Subtitle(Equals("")). + Footer(Equals("")) + t.Views().MenuFilterFrame(). + IsVisible(). + Content(Equals("Filter ('@' for keybindings): ")). + Footer(Equals("1 of 1")). + SharesTopBorderWithBottomOf(t.Views().Menu()) + t.Views().MenuFilter().IsVisible().Content(Equals("whitespace")) + t.Views().Tooltip(). + IsVisible(). + Content(Contains("Toggle whether or not whitespace changes are shown")). + IsImmediatelyBelow(t.Views().MenuFilterFrame()) + t.CursorIsVisible() + + // Emptying the filter shows all the items again, and keeps the row + t.GlobalPress(config.Keybinding{""}) + t.Views().MenuFilter().IsVisible().Content(Equals("")) + t.Views().Menu().LineCount(GreaterThan(2)) + t.CursorIsVisible() + + // Moving the text cursor within the filter leaves the menu's selection alone + t.ExpectPopup().Menu().Filter("co") + t.Views().Menu().LineCount(GreaterThan(2)) + t.GlobalPress(config.Keybinding{""}) + t.Views().Menu().SelectedLineIdxAtLeast(2) + t.GlobalPress(config.Keybinding{""}) + t.Views().Menu().SelectedLineIdxAtLeast(2) + t.GlobalPress(config.Keybinding{""}) + + // Clicking an item selects it and leaves the filter where it is + t.Views().Menu().Click(0, 1).SelectedLineIdx(1) + t.GlobalPress(config.Keybinding{"m"}) + t.Views().MenuFilter().Content(Equals("com")) + + t.GlobalPress(config.Keybinding{""}) + + // Escape gives up the filter, keeping the item that was selected + t.ExpectPopup().Menu().Filter("whitespace") + t.Views().Menu().SelectedLine(Contains("Toggle whitespace")) + t.GlobalPress(keys.Universal.Return) + t.Views().Menu(). + IsFocused(). + SelectedLine(Contains("Toggle whitespace")). + Subtitle(Equals("(Type to filter)")). + Footer(Contains(" of ")) + t.Views().MenuFilter().IsInvisible() + t.Views().MenuFilterFrame().IsInvisible() + t.CursorIsHidden() + + // The next escape closes the menu + t.GlobalPress(keys.Universal.Return) + t.Views().Files().IsFocused() + + // A menu opened afterwards starts with no filter + t.Views().Files().Press(keys.Universal.OptionMenu) + t.ExpectPopup().Menu(). + Title(Equals("Keybindings")). + LineCount(GreaterThan(2)). + Cancel() + }, +}) diff --git a/pkg/integration/tests/filter_and_search/filter_menu_key_handling.go b/pkg/integration/tests/filter_and_search/filter_menu_key_handling.go new file mode 100644 index 000000000..7ab25cbb8 --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_menu_key_handling.go @@ -0,0 +1,71 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterMenuKeyHandling = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Which keys drive a menu that filters as you type, and which ones are filter text", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // so that quitting is observable instead of ending the test + cfg.GetUserConfig().ConfirmOnQuit = true + }, + SetupRepo: func(shell *Shell) {}, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // presses a key that is expected to move the selection away from the first + // item, and one that is expected to bring it back + navigates := func(forward string, back string) { + t.GlobalPress(config.Keybinding{forward}) + t.Views().Menu().SelectedLineIdxAtLeast(2) + t.GlobalPress(config.Keybinding{back}) + t.Views().Menu().SelectedLineIdx(1) + } + + t.Views().Files().IsFocused().Press(keys.Universal.OptionMenu) + t.Views().Menu().IsFocused().SelectedLineIdx(1) + + // Until there is a filter, the configured navigation keys drive the menu, + // printable or not + navigates("", "") + navigates("j", "k") + navigates(".", ",") + navigates(">", "<") + t.Views().MenuFilter().IsInvisible() + + // A menu item's own key is filter text; it doesn't execute the item. 'c' + // commits when the files view has the focus. + t.ExpectPopup().Menu().Filter("c") + t.Views().Menu().IsFocused() + t.Views().MenuFilter().Content(Equals("c")) + + // So is the key that filters other lists + t.GlobalPress(keys.Universal.StartSearch) + t.Views().MenuFilter().Content(Equals("c/")) + t.Views().Search().IsInvisible() + + // And so are the printable navigation keys, now that there is somewhere for + // them to go + t.GlobalPress(config.Keybinding{""}) + t.GlobalPress(config.Keybinding{"j"}) + t.GlobalPress(config.Keybinding{"."}) + t.GlobalPress(config.Keybinding{">"}) + t.Views().MenuFilter().Content(Equals("j.>")) + + // The keys that can't be typed keep driving the menu + t.GlobalPress(config.Keybinding{""}) + navigates("", "") + navigates("", "") + navigates("", "") + + // Keys that the filter doesn't take and the menu doesn't handle reach the + // global keybindings + t.GlobalPress(config.Keybinding{""}) + t.ExpectPopup().Confirmation(). + Title(Equals("")). + Content(Contains("Are you sure you want to quit?")). + Confirm() + }, +}) diff --git a/pkg/integration/tests/filter_and_search/filter_menu_with_printable_keybindings.go b/pkg/integration/tests/filter_and_search/filter_menu_with_printable_keybindings.go new file mode 100644 index 000000000..991aa12e2 --- /dev/null +++ b/pkg/integration/tests/filter_and_search/filter_menu_with_printable_keybindings.go @@ -0,0 +1,66 @@ +package filter_and_search + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterMenuWithPrintableKeybindings = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Driving a menu that filters as you type when every key configured for it is printable", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Keybinding.Universal.ConfirmMenu = config.Keybinding{"x"} + cfg.GetUserConfig().Keybinding.Universal.Return = config.Keybinding{"q"} + cfg.GetUserConfig().Keybinding.Universal.PrevItem = config.Keybinding{"k"} + cfg.GetUserConfig().Keybinding.Universal.NextItem = config.Keybinding{"j"} + cfg.GetUserConfig().Keybinding.Universal.PrevPage = config.Keybinding{"u"} + cfg.GetUserConfig().Keybinding.Universal.NextPage = config.Keybinding{"d"} + cfg.GetUserConfig().Keybinding.Universal.GotoTop = config.Keybinding{"g"} + cfg.GetUserConfig().Keybinding.Universal.GotoBottom = config.Keybinding{"G"} + }, + SetupRepo: func(shell *Shell) {}, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + navigates := func(forward string, back string) { + t.GlobalPress(config.Keybinding{forward}) + t.Views().Menu().SelectedLineIdxAtLeast(2) + t.GlobalPress(config.Keybinding{back}) + t.Views().Menu().SelectedLineIdx(1) + } + + t.Views().Files().IsFocused().Press(keys.Universal.OptionMenu) + t.Views().Menu().IsFocused().SelectedLineIdx(1) + + // Until there is a filter, the configured keys drive the menu + navigates("j", "k") + navigates("d", "u") + navigates("G", "g") + + // Once there is one, they are all filter text. It takes a key that isn't a + // navigation key to get there. + t.ExpectPopup().Menu().Filter("a") + t.GlobalPress(config.Keybinding{"j"}) + t.GlobalPress(config.Keybinding{"k"}) + t.GlobalPress(config.Keybinding{"d"}) + t.GlobalPress(config.Keybinding{"u"}) + t.Views().MenuFilter().Content(Equals("ajkdu")) + t.GlobalPress(config.Keybinding{""}) + + // The menu is still navigable, because the physical keys drive it whatever + // the configuration says + navigates("", "") + navigates("", "") + navigates("", "") + + // And so are confirming and cancelling. Escape gives up the filter first. + t.ExpectPopup().Menu().Filter("Toggle whitespace") + t.GlobalPress(config.Keybinding{""}) + t.Views().MenuFilter().IsInvisible() + t.Views().Menu().IsFocused().SelectedLine(Contains("Toggle whitespace")) + + t.ExpectPopup().Menu().Filter("Toggle whitespace") + t.Views().Menu().SelectedLine(Contains("Toggle whitespace")) + t.GlobalPress(config.Keybinding{""}) + t.Views().Files().IsFocused() + }, +}) diff --git a/pkg/integration/tests/misc/filter_recent_repos.go b/pkg/integration/tests/misc/filter_recent_repos.go new file mode 100644 index 000000000..a54ca51db --- /dev/null +++ b/pkg/integration/tests/misc/filter_recent_repos.go @@ -0,0 +1,37 @@ +package misc + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilterRecentRepos = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Switching to a recent repository by typing part of its name", + ExtraCmdArgs: []string{}, + ExtraEnvVars: map[string]string{ + "SHOW_RECENT_REPOS": "true", + }, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // the first entry is the repo we're in, so it isn't offered + current, _ := filepath.Abs(".") + other, _ := filepath.Abs("../other") + target, _ := filepath.Abs("../target") + cfg.GetAppState().RecentRepos = []string{current, other, target} + }, + SetupRepo: func(shell *Shell) { + shell.CloneNonBare("other") + shell.CloneNonBare("target") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.ExpectPopup().Menu(). + Title(Equals("Recent repositories")). + Filter("target"). + Lines(Contains("target").IsSelected()). + Confirm() + + t.Views().Status().Content(Contains("target → master")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index bf4cfd5df..5ac4bdcba 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -262,9 +262,12 @@ var tests = []*components.IntegrationTest{ filter_and_search.FilterFilesStageDirectory, filter_and_search.FilterFuzzy, filter_and_search.FilterMenu, + filter_and_search.FilterMenuAsYouType, filter_and_search.FilterMenuByKeybinding, filter_and_search.FilterMenuCancelFilterWithEscape, + filter_and_search.FilterMenuKeyHandling, filter_and_search.FilterMenuWithNoKeybindings, + filter_and_search.FilterMenuWithPrintableKeybindings, filter_and_search.FilterPreservesSelectionOnModelChange, filter_and_search.FilterRemoteBranches, filter_and_search.FilterRemotes, @@ -357,6 +360,7 @@ var tests = []*components.IntegrationTest{ misc.DirenvApprovesEnvrc, misc.DirenvLoadedOnRepoSwitch, misc.DirenvUnloadsOnBlockedEnvrc, + misc.FilterRecentRepos, misc.InitialOpen, misc.RecentReposOnLaunch, misc.StartInGitDir, diff --git a/pkg/integration/tests/ui/empty_menu.go b/pkg/integration/tests/ui/empty_menu.go index 35c3d4560..971bcb3c8 100644 --- a/pkg/integration/tests/ui/empty_menu.go +++ b/pkg/integration/tests/ui/empty_menu.go @@ -17,16 +17,19 @@ var EmptyMenu = NewIntegrationTest(NewIntegrationTestArgs{ IsFocused(). Press(keys.Universal.OptionMenu) + t.ExpectPopup().Menu(). + // a string that filters everything out + Filter("ljasldkjaslkdjalskdjalsdjaslkd") + t.Views().Menu(). IsFocused(). - // a string that filters everything out - FilterOrSearch("ljasldkjaslkdjalskdjalsdjaslkd"). IsEmpty(). - Press(keys.Universal.Select). + // space is filter text in this menu, so we confirm with enter + Press(keys.Universal.ConfirmMenu). Tap(func() { t.ExpectToast(Equals("Disabled: No item selected")) }). - // escape the search + // escape the filter PressEscape(). // escape the view PressEscape() diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index db9068ad9..325f4ea38 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -45,6 +45,8 @@ type GuiDriver interface { FocusInAndClick(int, int) Keys() config.KeybindingConfig CurrentContext() types.Context + // Whether the terminal's text cursor is currently shown + CursorVisible() bool ContextForView(viewName string) types.Context Fail(message string) // These two log methods are for the sake of debugging while testing. There's no need to actually