Skip to content

Commit 3f6be3b

Browse files
authored
Allow filtering the keybindings and recent repos menus more directly (simply by typing) (#5985)
All menus in lazygit can be filtered by pressing the `/` key; for most menus which only show a handful of choices this is not really needed, but with the two cases where it's useful, it was unnecessarily inconvenient: you first have to press `/` to open the filter prompt, and then press enter to confirm the filter before you could press enter again to trigger the chosen item. It's much easier to simply type to filter, and still use the arrow keys to select one of the filtered items, or press enter to trigger it while the filter prompt is showing. The consequence of this is that while the keybindings menu is open you can no longer use the displayed key bindings to trigger the commands; I think that's fine, that menu is more for looking up those keybindings rather than for using them from within the menu. Also: since `j`/`k` are bound to move the list selection by default, it is not possible to filter for something that begins with `j`/`k`. I didn't want to change this because I'm concerned that die-hard vim users would perceive it as a regression if they can no longer type `j` to select the next menu item. The workaround is to type some other letter and backspace; this keeps the filter prompt open, so you can now type `j` or `k`.
2 parents 8924bca + 90f5348 commit 3f6be3b

55 files changed

Lines changed: 1017 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs-master/Searching.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44

55
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`.
66

7-
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.
7+
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.
88

99
If you would like both filtering and searching to be enabled on a given view, please raise an issue for this.
1010

11+
## Menu filtering
12+
13+
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.
14+
1115
## Filtering files by status
1216

1317
You can filter the files view to only show staged/unstaged files by pressing `<c-b>` in the files view.

pkg/gocui/double_click_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) {
1313
g := newTestGui(t)
1414
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
1515
doubleClicks := []bool{}
16-
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
16+
g.SetViewClickBinding(&ViewMouseBinding{
1717
ViewName: "list",
1818
Key: MouseLeft,
1919
Handler: func(opts ViewMouseBindingOpts) error {
2020
doubleClicks = append(doubleClicks, opts.IsDoubleClick)
2121
return nil
2222
},
23-
}))
23+
})
2424

2525
for _, event := range []GocuiEvent{
2626
gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)),

pkg/gocui/edit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func SimpleEditor(v *View, key Key) bool {
7878
v.TextArea.GoToEndOfLine()
7979
case key.Equals(NewKeyStrMod("y", ModCtrl)):
8080
v.TextArea.Yank()
81-
case key.Str() != "" && key.Mod() == 0:
81+
case key.IsPrintable():
8282
v.TextArea.TypeCharacter(key.Str())
8383
default:
8484
return false

pkg/gocui/gui.go

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -675,19 +675,15 @@ func (g *Gui) DeleteViewKeybindings(viewname string) {
675675
}
676676

677677
// SetTabClickBinding sets a binding for a tab click event
678-
func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) error {
678+
func (g *Gui) SetTabClickBinding(viewName string, handler tabClickHandler) {
679679
g.tabClickBindings = append(g.tabClickBindings, &tabClickBinding{
680680
viewName: viewName,
681681
handler: handler,
682682
})
683-
684-
return nil
685683
}
686684

687-
func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error {
685+
func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) {
688686
g.viewMouseBindings = append(g.viewMouseBindings, binding)
689-
690-
return nil
691687
}
692688

693689
// captureMouse routes subsequent mouse events to view until the mouse button is
@@ -1619,6 +1615,20 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
16191615
return g.flushContentOnly(views)
16201616
}
16211617

1618+
// hasFocus reports whether a view is drawn as focused. Views that are embedded
1619+
// in one another (see View.ParentView) form a single unit, so they are all drawn
1620+
// as focused while any one of them is the current view.
1621+
func (g *Gui) hasFocus(v *View) bool {
1622+
return g.currentView != nil && outermostView(v) == outermostView(g.currentView)
1623+
}
1624+
1625+
func outermostView(v *View) *View {
1626+
for v.ParentView != nil {
1627+
v = v.ParentView
1628+
}
1629+
return v
1630+
}
1631+
16221632
// draw manages the cursor and calls the draw function of a view.
16231633
func (g *Gui) draw(v *View) error {
16241634
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
@@ -1643,7 +1653,7 @@ func (g *Gui) draw(v *View) error {
16431653

16441654
if v.Frame {
16451655
var fgColor, bgColor, frameColor Attribute
1646-
if g.Highlight && v == g.currentView && g.IsFocused() {
1656+
if g.Highlight && g.hasFocus(v) && g.IsFocused() {
16471657
fgColor = g.SelFgColor
16481658
bgColor = g.SelBgColor
16491659
frameColor = g.SelFrameColor
@@ -1983,7 +1993,7 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
19831993
matchingParentViewKb = nil
19841994
break
19851995
}
1986-
if v != nil && g.matchView(v.ParentView, kb) {
1996+
if matchingParentViewKb == nil && v != nil && g.matchView(v.ParentView, kb) {
19871997
matchingParentViewKb = kb
19881998
}
19891999
if globalKb == nil && kb.viewName == "" {
@@ -2095,13 +2105,15 @@ func (g *Gui) isSuspended() bool {
20952105
return g.suspended
20962106
}
20972107

2098-
// matchView returns if the keybinding matches the current view (and the view's context)
2108+
// matchView returns if the keybinding matches the given view (and the view's context)
20992109
func (g *Gui) matchView(v *View, kb *keybinding) bool {
2100-
// if the user is typing in a field, ignore char keys
21012110
if v == nil {
21022111
return false
21032112
}
2104-
if v.Editable && kb.key.Str() != "" && kb.key.Mod() == 0 {
2113+
// If the user is typing in a field, printable keys are theirs to type, so no
2114+
// keybinding gets a look at them: not the field's own, and not those of the
2115+
// view it is embedded in either.
2116+
if field := g.currentView; field != nil && field.Editable && !field.KeybindOnEdit && kb.key.IsPrintable() {
21052117
return false
21062118
}
21072119
if kb.viewName != v.name {

pkg/gocui/key.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ func (k Key) IsSet() bool {
6161
return k.keyName != 0
6262
}
6363

64+
// IsPrintable reports whether the key stands for a character that can be typed
65+
// into a text field.
66+
func (k Key) IsPrintable() bool {
67+
return k.keyName == KeyName(tcell.KeyRune) && k.str != "" && k.mod == ModNone
68+
}
69+
6470
func (k Key) Equals(otherKey Key) bool {
6571
return k.keyName == otherKey.keyName && k.str == otherKey.str && k.mod == otherKey.mod
6672
}

pkg/gocui/key_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package gocui
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestKeyIsPrintable(t *testing.T) {
10+
assert.True(t, NewKeyRune('x').IsPrintable())
11+
assert.True(t, NewKeyRune('界').IsPrintable())
12+
assert.True(t, NewKeyRune(' ').IsPrintable())
13+
assert.False(t, NewKeyStrMod("x", ModCtrl).IsPrintable())
14+
assert.False(t, NewKeyName(KeyEnter).IsPrintable())
15+
assert.False(t, Key{}.IsPrintable())
16+
}

pkg/gocui/mouse_capture_test.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func TestMouseCaptureRoutesMotionAndReleaseOutsideView(t *testing.T) {
3636
},
3737
},
3838
} {
39-
assert.NoError(t, g.SetViewClickBinding(binding))
39+
g.SetViewClickBinding(binding)
4040
}
4141

4242
g.captureMouse(view)
@@ -69,15 +69,15 @@ func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
6969

7070
receivedBy := ""
7171
for _, viewName := range []string{"left", "right"} {
72-
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
72+
g.SetViewClickBinding(&ViewMouseBinding{
7373
ViewName: viewName,
7474
Key: MouseLeft,
7575
Modifier: ModMotion,
7676
Handler: func(ViewMouseBindingOpts) error {
7777
receivedBy = viewName
7878
return nil
7979
},
80-
}))
80+
})
8181
}
8282

8383
assert.NoError(t, g.onKey(&GocuiEvent{
@@ -102,10 +102,10 @@ func TestPrimaryMouseDragDoesNotActivateTabs(t *testing.T) {
102102
view.Tabs = []string{"first", "second"}
103103

104104
clickedTabs := []int{}
105-
assert.NoError(t, g.SetTabClickBinding("tabs", func(tabIndex int) error {
105+
g.SetTabClickBinding("tabs", func(tabIndex int) error {
106106
clickedTabs = append(clickedTabs, tabIndex)
107107
return nil
108-
}))
108+
})
109109

110110
assert.NoError(t, g.onKey(&GocuiEvent{
111111
Type: eventMouse,
@@ -172,15 +172,15 @@ func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
172172
_, _ = g.SetView("right", 21, 0, 41, 10, 0)
173173
receivedBy := ""
174174
for _, viewName := range []string{"left", "right"} {
175-
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
175+
g.SetViewClickBinding(&ViewMouseBinding{
176176
ViewName: viewName,
177177
Key: MouseLeft,
178178
Modifier: ModMotion,
179179
Handler: func(ViewMouseBindingOpts) error {
180180
receivedBy = viewName
181181
return nil
182182
},
183-
}))
183+
})
184184
}
185185

186186
g.captureMouse(left)

pkg/gocui/parent_view_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package gocui
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
// A view and its parent view, with the child holding the focus.
10+
func setupParentAndChildView(t *testing.T, g *Gui) (*View, *View) {
11+
t.Helper()
12+
13+
parent, _ := g.SetView("parent", 0, 0, 20, 10, 0)
14+
child, _ := g.SetView("child", 0, 10, 20, 12, 0)
15+
child.ParentView = parent
16+
_, err := g.SetCurrentView(child.Name())
17+
assert.NoError(t, err)
18+
19+
return parent, child
20+
}
21+
22+
func TestKeybindingOfParentViewIsUsedWhenChildHasNone(t *testing.T) {
23+
g := newTestGui(t)
24+
parent, child := setupParentAndChildView(t, g)
25+
26+
pressed := []string{}
27+
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
28+
pressed = append(pressed, "parent")
29+
return nil
30+
})
31+
g.SetKeybinding(child.Name(), NewKeyName(KeyEnter), func(*Gui, *View) error {
32+
pressed = append(pressed, "child")
33+
return nil
34+
})
35+
36+
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
37+
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyEnter)}))
38+
39+
assert.Equal(t, []string{"parent", "child"}, pressed)
40+
}
41+
42+
func TestFirstMatchingKeybindingOfParentViewWins(t *testing.T) {
43+
g := newTestGui(t)
44+
parent, _ := setupParentAndChildView(t, g)
45+
46+
pressed := []string{}
47+
for _, name := range []string{"first", "second"} {
48+
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
49+
pressed = append(pressed, name)
50+
return nil
51+
})
52+
}
53+
54+
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
55+
56+
assert.Equal(t, []string{"first"}, pressed)
57+
}
58+
59+
func TestEmbeddedViewsAreFocusedTogether(t *testing.T) {
60+
g := newTestGui(t)
61+
parent, child := setupParentAndChildView(t, g)
62+
sibling, _ := g.SetView("sibling", 0, 12, 20, 14, 0)
63+
sibling.ParentView = parent
64+
unrelated, _ := g.SetView("unrelated", 30, 0, 50, 10, 0)
65+
66+
assert.True(t, g.hasFocus(child))
67+
assert.True(t, g.hasFocus(parent))
68+
assert.True(t, g.hasFocus(sibling))
69+
assert.False(t, g.hasFocus(unrelated))
70+
71+
_, err := g.SetCurrentView(unrelated.Name())
72+
assert.NoError(t, err)
73+
74+
assert.True(t, g.hasFocus(unrelated))
75+
assert.False(t, g.hasFocus(parent))
76+
assert.False(t, g.hasFocus(child))
77+
}
78+
79+
func TestPrintableKeysGoToTheFieldBeingTypedIn(t *testing.T) {
80+
for _, test := range []struct {
81+
name string
82+
keybindOnEdit bool
83+
declineKeybinding bool
84+
expectedPresses int
85+
expectedEdits int
86+
}{
87+
{name: "the field gets the key", expectedEdits: 1},
88+
{name: "the parent view gets the key", keybindOnEdit: true, expectedPresses: 1},
89+
{
90+
name: "the field gets the key the parent view declined",
91+
keybindOnEdit: true,
92+
declineKeybinding: true,
93+
expectedPresses: 1,
94+
expectedEdits: 1,
95+
},
96+
} {
97+
t.Run(test.name, func(t *testing.T) {
98+
g := newTestGui(t)
99+
parent, child := setupParentAndChildView(t, g)
100+
child.Editable = true
101+
child.KeybindOnEdit = test.keybindOnEdit
102+
103+
edits := 0
104+
child.Editor = EditorFunc(func(*View, Key) bool {
105+
edits++
106+
return true
107+
})
108+
presses := 0
109+
g.SetKeybinding(parent.Name(), NewKeyRune('j'), func(*Gui, *View) error {
110+
presses++
111+
if test.declineKeybinding {
112+
return ErrKeybindingNotHandled
113+
}
114+
return nil
115+
})
116+
117+
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyRune('j')}))
118+
119+
assert.Equal(t, test.expectedPresses, presses)
120+
assert.Equal(t, test.expectedEdits, edits)
121+
})
122+
}
123+
}
124+
125+
func TestUnhandledKeybindingOfParentViewFallsThroughToEditor(t *testing.T) {
126+
g := newTestGui(t)
127+
parent, child := setupParentAndChildView(t, g)
128+
129+
edited := []Key{}
130+
child.Editable = true
131+
child.Editor = EditorFunc(func(_ *View, key Key) bool {
132+
edited = append(edited, key)
133+
return true
134+
})
135+
g.SetKeybinding(parent.Name(), NewKeyName(KeyArrowDown), func(*Gui, *View) error {
136+
return ErrKeybindingNotHandled
137+
})
138+
139+
assert.NoError(t, g.onKey(&GocuiEvent{Type: eventKey, Key: NewKeyName(KeyArrowDown)}))
140+
141+
assert.Equal(t, []Key{NewKeyName(KeyArrowDown)}, edited)
142+
}

pkg/gocui/view.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,9 @@ type View struct {
210210
// Overlaps describes which edges are overlapping with another view's edges
211211
Overlaps byte
212212

213-
// ParentView is the view which catches events bubbled up from the given view if there's no matching handler
213+
// ParentView is the view which catches events bubbled up from the given view if there's no matching handler.
214+
// Views related this way are also drawn as a single focused unit: while one of
215+
// them is the current view, they all get the focused frame and title colors.
214216
ParentView *View
215217

216218
searcher *searcher

pkg/gui/context.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,12 @@ func (self *ContextMgr) Activate(c types.Context, opts types.OnFocusOpts) {
179179
self.gui.helpers.Window.SetWindowContext(c)
180180

181181
self.gui.helpers.Window.MoveToTopOfWindow(c)
182+
inputViewName := c.GetInputViewName()
182183
oldView := self.gui.c.GocuiGui().CurrentView()
183-
if oldView != nil && oldView.Name() != viewName {
184+
if oldView != nil && oldView.Name() != inputViewName {
184185
oldView.HighlightInactive = true
185186
}
186-
if _, err := self.gui.c.GocuiGui().SetCurrentView(viewName); err != nil {
187+
if _, err := self.gui.c.GocuiGui().SetCurrentView(inputViewName); err != nil {
187188
panic(err)
188189
}
189190

0 commit comments

Comments
 (0)