From 9b4f7bafbbdebea395240636d1ea5b5c63938612 Mon Sep 17 00:00:00 2001 From: Jarryd Tilbrook Date: Mon, 29 Jan 2024 13:09:35 +0800 Subject: [PATCH 1/5] Add package for keybinginds --- internal/keys/keys.go | 121 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 internal/keys/keys.go diff --git a/internal/keys/keys.go b/internal/keys/keys.go new file mode 100644 index 00000000..c6231914 --- /dev/null +++ b/internal/keys/keys.go @@ -0,0 +1,121 @@ +// Package keys provides types and function for defining user-configurable keymappings in bubbletea components. +// Example: +// +// var quit = NewBinding( +// WithKeys("q"), +// WithAction(func(m tea.Model) any { +// return tea.Quit() +// }), +// WithHelp("q", "quit"), +// ) +package keys + +import tea "github.com/charmbracelet/bubbletea" + +// Action is a callback function executed when a keybinding is pressed +type Action func(tea.Model) any + +// Help is help information for a given keybinding. +type Help struct { + Key string + Desc string +} + +// Binding describes a set of keybindings and a callback to execute when pressed, along with their associated help text. +type Binding struct { + action Action + disabled bool + help Help + keys []string +} + +// BindingOpt is an initialization option for a keybinding. It's used as an +// argument to NewBinding. +type BindingOpt func(*Binding) + +// NewBinding returns a new keybinding from a set of BindingOpt options. +func NewBinding(opts ...BindingOpt) Binding { + b := Binding{} + for _, opt := range opts { + opt(&b) + } + return b +} + +// WithAction initializes a keybinding with the given callback to execute when pressed. +func WithAction(act Action) BindingOpt { + return func(b *Binding) { + b.action = act + } +} + +// WithKeys initializes a keybinding with the given keystrokes. +func WithKeys(keys ...string) BindingOpt { + return func(b *Binding) { + b.keys = keys + } +} + +// WithHelp initializes a keybinding with the given help text. +func WithHelp(key, desc string) BindingOpt { + return func(b *Binding) { + b.help = Help{Key: key, Desc: desc} + } +} + +// WithDisabled initializes a disabled keybinding. +func WithDisabled() BindingOpt { + return func(b *Binding) { + b.disabled = true + } +} + +// ExecuteAction calls the action for the keybinding +func (b *Binding) ExecuteAction(model tea.Model) any { + return b.action(model) +} + +// SetKeys sets the keys for the keybinding. +func (b *Binding) SetKeys(keys ...string) { + b.keys = keys +} + +// Keys returns the keys for the keybinding. +func (b Binding) Keys() []string { + return b.keys +} + +// SetHelp sets the help text for the keybinding. +func (b *Binding) SetHelp(key, desc string) { + b.help = Help{Key: key, Desc: desc} +} + +// Help returns the Help information for the keybinding. +func (b Binding) Help() Help { + return b.help +} + +// Enabled returns whether or not the keybinding is enabled. Disabled +// keybindings won't be activated and won't show up in help. Keybindings are +// enabled by default. +func (b Binding) Enabled() bool { + return !b.disabled && b.keys != nil +} + +// SetEnabled enables or disables the keybinding. +func (b *Binding) SetEnabled(v bool) { + b.disabled = !v +} + +// Matches checks if the given KeyMsg matches the given bindings. +func Matches(k tea.KeyMsg, b ...Binding) bool { + keys := k.String() + for _, binding := range b { + for _, v := range binding.keys { + if keys == v && binding.Enabled() { + return true + } + } + } + return false +} From b9dec854642031dafd1c406dc58f2cb6f8f92675 Mon Sep 17 00:00:00 2001 From: Jarryd Tilbrook Date: Mon, 29 Jan 2024 13:49:02 +0800 Subject: [PATCH 2/5] Add new list package with default keybindings --- internal/list/keys.go | 67 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 internal/list/keys.go diff --git a/internal/list/keys.go b/internal/list/keys.go new file mode 100644 index 00000000..acd8ff69 --- /dev/null +++ b/internal/list/keys.go @@ -0,0 +1,67 @@ +package list + +import ( + "github.com/buildkite/cli/v3/internal/keys" + tea "github.com/charmbracelet/bubbletea" +) + +type KeyMap []keys.Binding + +type selectable interface { + MoveDown() + MoveTop() + MoveBottom() + MoveUp() +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + keys.NewBinding( + keys.WithKeys("q"), + keys.WithHelp("q", "quit"), + keys.WithAction(func(tea.Model) any { + return tea.Quit() + }), + ), + keys.NewBinding( + keys.WithKeys("k", "up"), + keys.WithHelp("↑/k", "up"), + keys.WithAction(func(m tea.Model) any { + if l, ok := m.(selectable); ok { + l.MoveUp() + } + return nil + }), + ), + keys.NewBinding( + keys.WithKeys("j", "down"), + keys.WithHelp("↓/j", "down"), + keys.WithAction(func(m tea.Model) any { + if l, ok := m.(selectable); ok { + l.MoveDown() + } + return nil + }), + ), + keys.NewBinding( + keys.WithKeys("home", "g"), + keys.WithHelp("g/home", "go to start"), + keys.WithAction(func(m tea.Model) any { + if l, ok := m.(selectable); ok { + l.MoveTop() + } + return nil + }), + ), + keys.NewBinding( + keys.WithKeys("end", "G"), + keys.WithHelp("G/end", "go to end"), + keys.WithAction(func(m tea.Model) any { + if l, ok := m.(selectable); ok { + l.MoveBottom() + } + return nil + }), + ), + } +} From 3612bcb5286002a47e3754271b6fbe460e630ee5 Mon Sep 17 00:00:00 2001 From: Jarryd Tilbrook Date: Mon, 29 Jan 2024 14:22:47 +0800 Subject: [PATCH 3/5] Embed pointer to bubbles key.Binding component instead --- internal/keys/keys.go | 64 +++++++------------------------------------ 1 file changed, 10 insertions(+), 54 deletions(-) diff --git a/internal/keys/keys.go b/internal/keys/keys.go index c6231914..7f831011 100644 --- a/internal/keys/keys.go +++ b/internal/keys/keys.go @@ -10,7 +10,10 @@ // ) package keys -import tea "github.com/charmbracelet/bubbletea" +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) // Action is a callback function executed when a keybinding is pressed type Action func(tea.Model) any @@ -23,10 +26,8 @@ type Help struct { // Binding describes a set of keybindings and a callback to execute when pressed, along with their associated help text. type Binding struct { - action Action - disabled bool - help Help - keys []string + *key.Binding + action Action } // BindingOpt is an initialization option for a keybinding. It's used as an @@ -52,21 +53,21 @@ func WithAction(act Action) BindingOpt { // WithKeys initializes a keybinding with the given keystrokes. func WithKeys(keys ...string) BindingOpt { return func(b *Binding) { - b.keys = keys + key.WithKeys(keys...)(b.Binding) } } // WithHelp initializes a keybinding with the given help text. -func WithHelp(key, desc string) BindingOpt { +func WithHelp(k, desc string) BindingOpt { return func(b *Binding) { - b.help = Help{Key: key, Desc: desc} + key.WithHelp(k, desc)(b.Binding) } } // WithDisabled initializes a disabled keybinding. func WithDisabled() BindingOpt { return func(b *Binding) { - b.disabled = true + key.WithDisabled()(b.Binding) } } @@ -74,48 +75,3 @@ func WithDisabled() BindingOpt { func (b *Binding) ExecuteAction(model tea.Model) any { return b.action(model) } - -// SetKeys sets the keys for the keybinding. -func (b *Binding) SetKeys(keys ...string) { - b.keys = keys -} - -// Keys returns the keys for the keybinding. -func (b Binding) Keys() []string { - return b.keys -} - -// SetHelp sets the help text for the keybinding. -func (b *Binding) SetHelp(key, desc string) { - b.help = Help{Key: key, Desc: desc} -} - -// Help returns the Help information for the keybinding. -func (b Binding) Help() Help { - return b.help -} - -// Enabled returns whether or not the keybinding is enabled. Disabled -// keybindings won't be activated and won't show up in help. Keybindings are -// enabled by default. -func (b Binding) Enabled() bool { - return !b.disabled && b.keys != nil -} - -// SetEnabled enables or disables the keybinding. -func (b *Binding) SetEnabled(v bool) { - b.disabled = !v -} - -// Matches checks if the given KeyMsg matches the given bindings. -func Matches(k tea.KeyMsg, b ...Binding) bool { - keys := k.String() - for _, binding := range b { - for _, v := range binding.keys { - if keys == v && binding.Enabled() { - return true - } - } - } - return false -} From 7720e9b28f6067e3e9dc0650dee580be85ce722f Mon Sep 17 00:00:00 2001 From: Jarryd Tilbrook Date: Mon, 29 Jan 2024 14:50:30 +0800 Subject: [PATCH 4/5] Add tests for key bindings --- internal/keys/keys.go | 5 +- internal/list/keys.go | 23 +++++++++ internal/list/keys_test.go | 50 +++++++++++++++++++ .../it_adds_more_menu_items.golden | 1 + .../it_doesnt_show_disabled_bindings.golden | 1 + .../it_renders_a_help_menu.golden | 1 + 6 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 internal/list/keys_test.go create mode 100644 internal/list/testdata/TestKeyMapHelpMenu/it_adds_more_menu_items.golden create mode 100644 internal/list/testdata/TestKeyMapHelpMenu/it_doesnt_show_disabled_bindings.golden create mode 100644 internal/list/testdata/TestKeyMapHelpMenu/it_renders_a_help_menu.golden diff --git a/internal/keys/keys.go b/internal/keys/keys.go index 7f831011..693a24a5 100644 --- a/internal/keys/keys.go +++ b/internal/keys/keys.go @@ -36,7 +36,10 @@ type BindingOpt func(*Binding) // NewBinding returns a new keybinding from a set of BindingOpt options. func NewBinding(opts ...BindingOpt) Binding { - b := Binding{} + innerBind := key.NewBinding() + b := Binding{ + Binding: &innerBind, + } for _, opt := range opts { opt(&b) } diff --git a/internal/list/keys.go b/internal/list/keys.go index acd8ff69..ec96a76d 100644 --- a/internal/list/keys.go +++ b/internal/list/keys.go @@ -2,11 +2,24 @@ package list import ( "github.com/buildkite/cli/v3/internal/keys" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" ) type KeyMap []keys.Binding +// FullHelp implements help.KeyMap. +func (km KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + km.AsBindings(), + } +} + +// ShortHelp implements help.KeyMap. +func (km KeyMap) ShortHelp() []key.Binding { + return km.AsBindings() +} + type selectable interface { MoveDown() MoveTop() @@ -14,6 +27,16 @@ type selectable interface { MoveUp() } +func (km KeyMap) AsBindings() []key.Binding { + bindings := make([]key.Binding, len(km)) + + for i, b := range km { + bindings[i] = *b.Binding + } + + return bindings +} + func DefaultKeyMap() KeyMap { return KeyMap{ keys.NewBinding( diff --git a/internal/list/keys_test.go b/internal/list/keys_test.go new file mode 100644 index 00000000..5c4142e8 --- /dev/null +++ b/internal/list/keys_test.go @@ -0,0 +1,50 @@ +package list_test + +import ( + "testing" + + "github.com/buildkite/cli/v3/internal/keys" + "github.com/buildkite/cli/v3/internal/list" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/x/exp/teatest" +) + +func TestKeyMapHelpMenu(t *testing.T) { + t.Parallel() + + t.Run("it renders a help menu", func(t *testing.T) { + t.Parallel() + + keymap := list.DefaultKeyMap() + help := help.New() + + out := help.View(keymap) + + teatest.RequireEqualOutput(t, []byte(out)) + }) + + t.Run("it adds more menu items", func(t *testing.T) { + t.Parallel() + + keymap := list.KeyMap([]keys.Binding{keys.NewBinding(keys.WithKeys("v"), keys.WithHelp("v", "view"))}) + help := help.New() + + out := help.View(keymap) + + teatest.RequireEqualOutput(t, []byte(out)) + }) + + t.Run("it doesnt show disabled bindings", func(t *testing.T) { + t.Parallel() + + keymap := list.KeyMap([]keys.Binding{ + keys.NewBinding(keys.WithKeys("v"), keys.WithHelp("v", "view")), + keys.NewBinding(keys.WithKeys("b"), keys.WithHelp("w", "open in browser"), keys.WithDisabled()), + }) + help := help.New() + + out := help.View(keymap) + + teatest.RequireEqualOutput(t, []byte(out)) + }) +} diff --git a/internal/list/testdata/TestKeyMapHelpMenu/it_adds_more_menu_items.golden b/internal/list/testdata/TestKeyMapHelpMenu/it_adds_more_menu_items.golden new file mode 100644 index 00000000..e3e37216 --- /dev/null +++ b/internal/list/testdata/TestKeyMapHelpMenu/it_adds_more_menu_items.golden @@ -0,0 +1 @@ +v view \ No newline at end of file diff --git a/internal/list/testdata/TestKeyMapHelpMenu/it_doesnt_show_disabled_bindings.golden b/internal/list/testdata/TestKeyMapHelpMenu/it_doesnt_show_disabled_bindings.golden new file mode 100644 index 00000000..e3e37216 --- /dev/null +++ b/internal/list/testdata/TestKeyMapHelpMenu/it_doesnt_show_disabled_bindings.golden @@ -0,0 +1 @@ +v view \ No newline at end of file diff --git a/internal/list/testdata/TestKeyMapHelpMenu/it_renders_a_help_menu.golden b/internal/list/testdata/TestKeyMapHelpMenu/it_renders_a_help_menu.golden new file mode 100644 index 00000000..481ffdd1 --- /dev/null +++ b/internal/list/testdata/TestKeyMapHelpMenu/it_renders_a_help_menu.golden @@ -0,0 +1 @@ +q quit • ↑/k up • ↓/j down • g/home go to start • G/end go to end \ No newline at end of file From ba6c60037d8bf0a74cc977ce82719cb986f0cd8c Mon Sep 17 00:00:00 2001 From: Jarryd Tilbrook Date: Tue, 30 Jan 2024 11:01:13 +0800 Subject: [PATCH 5/5] Simplify test expectations --- internal/list/keys_test.go | 22 +++++++++++++------ .../it_adds_more_menu_items.golden | 1 - .../it_doesnt_show_disabled_bindings.golden | 1 - .../it_renders_a_help_menu.golden | 1 - 4 files changed, 15 insertions(+), 10 deletions(-) delete mode 100644 internal/list/testdata/TestKeyMapHelpMenu/it_adds_more_menu_items.golden delete mode 100644 internal/list/testdata/TestKeyMapHelpMenu/it_doesnt_show_disabled_bindings.golden delete mode 100644 internal/list/testdata/TestKeyMapHelpMenu/it_renders_a_help_menu.golden diff --git a/internal/list/keys_test.go b/internal/list/keys_test.go index 5c4142e8..8f65cdfe 100644 --- a/internal/list/keys_test.go +++ b/internal/list/keys_test.go @@ -6,7 +6,6 @@ import ( "github.com/buildkite/cli/v3/internal/keys" "github.com/buildkite/cli/v3/internal/list" "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/x/exp/teatest" ) func TestKeyMapHelpMenu(t *testing.T) { @@ -18,9 +17,12 @@ func TestKeyMapHelpMenu(t *testing.T) { keymap := list.DefaultKeyMap() help := help.New() - out := help.View(keymap) + got := help.View(keymap) + want := "q quit • ↑/k up • ↓/j down • g/home go to start • G/end go to end" - teatest.RequireEqualOutput(t, []byte(out)) + if got != want { + t.Fatalf("Output does not match expected. %s != %s", got, want) + } }) t.Run("it adds more menu items", func(t *testing.T) { @@ -29,9 +31,12 @@ func TestKeyMapHelpMenu(t *testing.T) { keymap := list.KeyMap([]keys.Binding{keys.NewBinding(keys.WithKeys("v"), keys.WithHelp("v", "view"))}) help := help.New() - out := help.View(keymap) + got := help.View(keymap) + want := "v view" - teatest.RequireEqualOutput(t, []byte(out)) + if got != want { + t.Fatalf("Output does not match expected. %s != %s", got, want) + } }) t.Run("it doesnt show disabled bindings", func(t *testing.T) { @@ -43,8 +48,11 @@ func TestKeyMapHelpMenu(t *testing.T) { }) help := help.New() - out := help.View(keymap) + got := help.View(keymap) + want := "v view" - teatest.RequireEqualOutput(t, []byte(out)) + if got != want { + t.Fatalf("Output does not match expected. %s != %s", got, want) + } }) } diff --git a/internal/list/testdata/TestKeyMapHelpMenu/it_adds_more_menu_items.golden b/internal/list/testdata/TestKeyMapHelpMenu/it_adds_more_menu_items.golden deleted file mode 100644 index e3e37216..00000000 --- a/internal/list/testdata/TestKeyMapHelpMenu/it_adds_more_menu_items.golden +++ /dev/null @@ -1 +0,0 @@ -v view \ No newline at end of file diff --git a/internal/list/testdata/TestKeyMapHelpMenu/it_doesnt_show_disabled_bindings.golden b/internal/list/testdata/TestKeyMapHelpMenu/it_doesnt_show_disabled_bindings.golden deleted file mode 100644 index e3e37216..00000000 --- a/internal/list/testdata/TestKeyMapHelpMenu/it_doesnt_show_disabled_bindings.golden +++ /dev/null @@ -1 +0,0 @@ -v view \ No newline at end of file diff --git a/internal/list/testdata/TestKeyMapHelpMenu/it_renders_a_help_menu.golden b/internal/list/testdata/TestKeyMapHelpMenu/it_renders_a_help_menu.golden deleted file mode 100644 index 481ffdd1..00000000 --- a/internal/list/testdata/TestKeyMapHelpMenu/it_renders_a_help_menu.golden +++ /dev/null @@ -1 +0,0 @@ -q quit • ↑/k up • ↓/j down • g/home go to start • G/end go to end \ No newline at end of file