Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c840013
Remove error return value from functions that always return nil
stefanhaller Aug 24, 2026
b2a684b
Add a helper for recognizing printable keys
stefanhaller Aug 25, 2026
28c5f57
Give a parent view's keybindings the same precedence as a view's own
stefanhaller Aug 25, 2026
9e98b3d
Let an editable view opt into receiving printable keys as keybindings
stefanhaller Aug 25, 2026
f9ec7ad
Draw embedded views as one focused unit
stefanhaller Aug 25, 2026
45d68bc
Treat the views of a popup panel as a group when clicking
stefanhaller Aug 25, 2026
a9fe055
Extract applying a filter to a context
stefanhaller Aug 25, 2026
f1b0347
Allow a list to render its footer elsewhere
stefanhaller Aug 25, 2026
c80035d
Add the views for a menu's filter row
stefanhaller Aug 25, 2026
1fb5f87
Lay out the filter row of a menu that filters as you type
stefanhaller Aug 25, 2026
fe9b990
Filter a menu by typing into it
stefanhaller Aug 25, 2026
2048c7f
Keep a filtering menu navigable whatever the keybindings are
stefanhaller Aug 25, 2026
6835586
Add test helpers for a menu's filter row
stefanhaller Aug 25, 2026
fb76189
Filter the keybindings menu as you type
stefanhaller Aug 25, 2026
bb7e749
Drop the menu-specific wording for the filter prompt
stefanhaller Aug 25, 2026
4116a15
Filter the recent repositories menu as you type
stefanhaller Aug 25, 2026
8aa5726
Point the note about the menu's essential keys at what it means
stefanhaller Aug 25, 2026
4f78a55
Reword stale comment about filtering not being available in the files…
stefanhaller Aug 31, 2026
90f5348
Add a hint about filtering menus to the docs
stefanhaller Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs-master/Searching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<c-b>` in the files view.
Expand Down
4 changes: 2 additions & 2 deletions pkg/gocui/double_click_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
2 changes: 1 addition & 1 deletion pkg/gocui/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 23 additions & 11 deletions pkg/gocui/gui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions pkg/gocui/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
16 changes: 16 additions & 0 deletions pkg/gocui/key_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
14 changes: 7 additions & 7 deletions pkg/gocui/mouse_capture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func TestMouseCaptureRoutesMotionAndReleaseOutsideView(t *testing.T) {
},
},
} {
assert.NoError(t, g.SetViewClickBinding(binding))
g.SetViewClickBinding(binding)
}

g.captureMouse(view)
Expand Down Expand Up @@ -69,15 +69,15 @@ 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,
Handler: func(ViewMouseBindingOpts) error {
receivedBy = viewName
return nil
},
}))
})
}

assert.NoError(t, g.onKey(&GocuiEvent{
Expand All @@ -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,
Expand Down Expand Up @@ -172,15 +172,15 @@ 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,
Handler: func(ViewMouseBindingOpts) error {
receivedBy = viewName
return nil
},
}))
})
}

g.captureMouse(left)
Expand Down
142 changes: 142 additions & 0 deletions pkg/gocui/parent_view_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 3 additions & 1 deletion pkg/gocui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions pkg/gui/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
4 changes: 4 additions & 0 deletions pkg/gui/context/base_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading