From 62a667222c8612ba2d02a15835818f484440439f Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 09:00:31 +1000 Subject: [PATCH 01/30] chore: scaffold empty picker component --- picker/picker.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 picker/picker.go diff --git a/picker/picker.go b/picker/picker.go new file mode 100644 index 00000000..f0e9504a --- /dev/null +++ b/picker/picker.go @@ -0,0 +1,17 @@ +package picker + +import tea "github.com/charmbracelet/bubbletea" + +type Model struct{} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(_ tea.Msg) (tea.Model, tea.Cmd) { + return m, nil +} + +func (m Model) View() string { + return "" +} From e6b33cbf3008a2fd41822c3d9948f77192e6bbe1 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 09:13:44 +1000 Subject: [PATCH 02/30] feat: define keymap --- picker/keys.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 picker/keys.go diff --git a/picker/keys.go b/picker/keys.go new file mode 100644 index 00000000..b904c163 --- /dev/null +++ b/picker/keys.go @@ -0,0 +1,23 @@ +package picker + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines the structure of this component's key bindings. +type KeyMap struct { + Next key.Binding + Prev key.Binding +} + +// DefaultKeyMap returns a default set of key bindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Next: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "next"), + ), + Prev: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "previous"), + ), + } +} From 02cf2c41bed7b2e6f6651ff9d49f42f1977912ac Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 09:47:17 +1000 Subject: [PATCH 03/30] feat: add ListState; implement Model --- picker/liststate.go | 57 +++++++++++++++++++++++++++++++++++++++++++++ picker/picker.go | 57 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 picker/liststate.go diff --git a/picker/liststate.go b/picker/liststate.go new file mode 100644 index 00000000..858c61e7 --- /dev/null +++ b/picker/liststate.go @@ -0,0 +1,57 @@ +package picker + +import "fmt" + +type ListState[T any] struct { + state []T + selection int + canCycle bool +} + +func NewListState[T any](state []T, opts ...func(listState *ListState[T])) *ListState[T] { + s := &ListState[T]{ + state: state, + } + + for _, opt := range opts { + opt(s) + } + + return s +} + +func (s *ListState[T]) GetValue() interface{} { + return s.state[s.selection] +} + +func (s *ListState[T]) GetDisplayValue() string { + return fmt.Sprintf("%v", s.GetValue()) +} + +func (s *ListState[T]) Next() { + switch { + case s.selection < len(s.state)-1: + s.selection++ + + case s.canCycle: + s.selection = 0 + } +} + +func (s *ListState[T]) Prev() { + switch { + case s.selection > 0: + s.selection-- + + case s.canCycle: + s.selection = len(s.state) - 1 + } +} + +// ListState Options -------------------- + +func WithCycles[T any]() func(state *ListState[T]) { + return func(s *ListState[T]) { + s.canCycle = true + } +} diff --git a/picker/picker.go b/picker/picker.go index f0e9504a..65d8e23c 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -1,17 +1,66 @@ package picker -import tea "github.com/charmbracelet/bubbletea" +import ( + "fmt" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) -type Model struct{} +type Model struct { + state State + + keys KeyMap +} + +type State interface { + GetValue() interface{} + GetDisplayValue() string + Next() + Prev() +} + +func NewModel(state State, opts ...func(*Model)) Model { + m := Model{ + state: state, + keys: DefaultKeyMap(), + } + + for _, opt := range opts { + opt(&m) + } + + return m +} func (m Model) Init() tea.Cmd { return nil } -func (m Model) Update(_ tea.Msg) (tea.Model, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if msg, ok := msg.(tea.KeyMsg); ok { + switch { + case key.Matches(msg, m.keys.Next): + m.state.Next() + case key.Matches(msg, m.keys.Prev): + m.state.Prev() + } + } + return m, nil } func (m Model) View() string { - return "" + var output string + + output += fmt.Sprintf("< %v >", m.state.GetDisplayValue()) + + return output +} + +// Model Options -------------------- + +func WithKeys(keys KeyMap) func(*Model) { + return func(m *Model) { + m.keys = keys + } } From 5c4ad9db42b56d0d2f5cc99e95244d421dfe62a8 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 09:53:34 +1000 Subject: [PATCH 04/30] feat: add Model opt to hide indicators --- picker/picker.go | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/picker/picker.go b/picker/picker.go index 65d8e23c..97de53a4 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -7,9 +7,9 @@ import ( ) type Model struct { - state State - - keys KeyMap + state State + showIndicators bool + keys KeyMap } type State interface { @@ -21,8 +21,9 @@ type State interface { func NewModel(state State, opts ...func(*Model)) Model { m := Model{ - state: state, - keys: DefaultKeyMap(), + state: state, + showIndicators: true, + keys: DefaultKeyMap(), } for _, opt := range opts { @@ -50,9 +51,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) View() string { + var leftIndicator, rightIndicator string + if m.showIndicators { + leftIndicator = "<" + rightIndicator = ">" + } + var output string - output += fmt.Sprintf("< %v >", m.state.GetDisplayValue()) + output += fmt.Sprintf("%s%s%s", leftIndicator, m.state.GetDisplayValue(), rightIndicator) return output } @@ -64,3 +71,9 @@ func WithKeys(keys KeyMap) func(*Model) { m.keys = keys } } + +func WithoutIndicators() func(*Model) { + return func(m *Model) { + m.showIndicators = false + } +} From 533274ca7d6178934b36dc724fb89b062ce39e33 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 10:20:39 +1000 Subject: [PATCH 05/30] feat: add IntState; allow cycles; allow custom DisplayFunc --- picker/intstate.go | 39 +++++++++++++++++++++++++++++++++++++++ picker/liststate.go | 33 ++++++--------------------------- picker/picker.go | 41 +++++++++++++++++++++++++++++++++++------ 3 files changed, 80 insertions(+), 33 deletions(-) create mode 100644 picker/intstate.go diff --git a/picker/intstate.go b/picker/intstate.go new file mode 100644 index 00000000..d0a3428b --- /dev/null +++ b/picker/intstate.go @@ -0,0 +1,39 @@ +package picker + +type IntState struct { + min int + max int + selection int +} + +func NewIntState(min, max int) *IntState { + return &IntState{ + min: min, + max: max, + selection: min, + } +} + +func (s *IntState) GetValue() interface{} { + return s.selection +} + +func (s *IntState) Next(canCycle bool) { + switch { + case s.selection < s.max: + s.selection++ + + case canCycle: + s.selection = s.min + } +} + +func (s *IntState) Prev(canCycle bool) { + switch { + case s.selection > s.min: + s.selection-- + + case canCycle: + s.selection = s.max + } +} diff --git a/picker/liststate.go b/picker/liststate.go index 858c61e7..2e7f6c7c 100644 --- a/picker/liststate.go +++ b/picker/liststate.go @@ -1,57 +1,36 @@ package picker -import "fmt" - type ListState[T any] struct { state []T selection int - canCycle bool } -func NewListState[T any](state []T, opts ...func(listState *ListState[T])) *ListState[T] { - s := &ListState[T]{ +func NewListState[T any](state []T) *ListState[T] { + return &ListState[T]{ state: state, } - - for _, opt := range opts { - opt(s) - } - - return s } func (s *ListState[T]) GetValue() interface{} { return s.state[s.selection] } -func (s *ListState[T]) GetDisplayValue() string { - return fmt.Sprintf("%v", s.GetValue()) -} - -func (s *ListState[T]) Next() { +func (s *ListState[T]) Next(canCycle bool) { switch { case s.selection < len(s.state)-1: s.selection++ - case s.canCycle: + case canCycle: s.selection = 0 } } -func (s *ListState[T]) Prev() { +func (s *ListState[T]) Prev(canCycle bool) { switch { case s.selection > 0: s.selection-- - case s.canCycle: + case canCycle: s.selection = len(s.state) - 1 } } - -// ListState Options -------------------- - -func WithCycles[T any]() func(state *ListState[T]) { - return func(s *ListState[T]) { - s.canCycle = true - } -} diff --git a/picker/picker.go b/picker/picker.go index 97de53a4..2da4db9c 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -9,20 +9,29 @@ import ( type Model struct { state State showIndicators bool + canCycle bool + displayFunc DisplayFunc keys KeyMap } type State interface { GetValue() interface{} - GetDisplayValue() string - Next() - Prev() + Next(canCycle bool) + Prev(canCycle bool) } +type DisplayFunc func(interface{}) string + func NewModel(state State, opts ...func(*Model)) Model { + defaultDisplayFunc := func(v interface{}) string { + return fmt.Sprintf("%v", v) + } + m := Model{ state: state, showIndicators: true, + canCycle: false, + displayFunc: defaultDisplayFunc, keys: DefaultKeyMap(), } @@ -41,9 +50,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { switch { case key.Matches(msg, m.keys.Next): - m.state.Next() + m.state.Next(m.canCycle) case key.Matches(msg, m.keys.Prev): - m.state.Prev() + m.state.Prev(m.canCycle) } } @@ -59,11 +68,19 @@ func (m Model) View() string { var output string - output += fmt.Sprintf("%s%s%s", leftIndicator, m.state.GetDisplayValue(), rightIndicator) + output += fmt.Sprintf("%s%s%s", leftIndicator, m.GetDisplayValue(), rightIndicator) return output } +func (m Model) GetValue() interface{} { + return m.state.GetValue() +} + +func (m Model) GetDisplayValue() string { + return m.displayFunc(m.state.GetValue()) +} + // Model Options -------------------- func WithKeys(keys KeyMap) func(*Model) { @@ -77,3 +94,15 @@ func WithoutIndicators() func(*Model) { m.showIndicators = false } } + +func WithCycles() func(*Model) { + return func(m *Model) { + m.canCycle = true + } +} + +func WithDisplayFunc(df DisplayFunc) func(*Model) { + return func(m *Model) { + m.displayFunc = df + } +} From d565b952245251f4afc13cddf5f2e910c7430faf Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 10:27:20 +1000 Subject: [PATCH 06/30] feat: add ignore max & min to IntState --- picker/intstate.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/picker/intstate.go b/picker/intstate.go index d0a3428b..0e8179d6 100644 --- a/picker/intstate.go +++ b/picker/intstate.go @@ -3,13 +3,17 @@ package picker type IntState struct { min int max int + ignoreMin bool + ignoreMax bool selection int } -func NewIntState(min, max int) *IntState { +func NewIntState(min, max int, ignoreMin, ignoreMax bool) *IntState { return &IntState{ min: min, max: max, + ignoreMin: ignoreMin, + ignoreMax: ignoreMax, selection: min, } } @@ -20,6 +24,9 @@ func (s *IntState) GetValue() interface{} { func (s *IntState) Next(canCycle bool) { switch { + case s.ignoreMax: + s.selection++ + case s.selection < s.max: s.selection++ @@ -30,6 +37,9 @@ func (s *IntState) Next(canCycle bool) { func (s *IntState) Prev(canCycle bool) { switch { + case s.ignoreMin: + s.selection-- + case s.selection > s.min: s.selection-- From 725f3f5229aa8a3d715b228574c377059d8ef747 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 10:33:41 +1000 Subject: [PATCH 07/30] fix: clamp IntState selection between min & max --- picker/intstate.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/picker/intstate.go b/picker/intstate.go index 0e8179d6..09f0f285 100644 --- a/picker/intstate.go +++ b/picker/intstate.go @@ -3,18 +3,25 @@ package picker type IntState struct { min int max int + selection int ignoreMin bool ignoreMax bool - selection int } -func NewIntState(min, max int, ignoreMin, ignoreMax bool) *IntState { +func NewIntState(min, max, selection int, ignoreMin, ignoreMax bool) *IntState { + switch { + case selection < min && !ignoreMin: + selection = min + case selection > max && !ignoreMax: + selection = max + } + return &IntState{ min: min, max: max, ignoreMin: ignoreMin, ignoreMax: ignoreMax, - selection: min, + selection: selection, } } From 9c1984bd194f71eb8251c71592219b92425bab56 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 10:42:47 +1000 Subject: [PATCH 08/30] feat: add methods for getting indicators --- picker/picker.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/picker/picker.go b/picker/picker.go index 2da4db9c..acf39921 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -20,7 +20,7 @@ type State interface { Prev(canCycle bool) } -type DisplayFunc func(interface{}) string +type DisplayFunc func(stateValue interface{}) string func NewModel(state State, opts ...func(*Model)) Model { defaultDisplayFunc := func(v interface{}) string { @@ -60,17 +60,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) View() string { - var leftIndicator, rightIndicator string + var prevInd, nextInd string if m.showIndicators { - leftIndicator = "<" - rightIndicator = ">" + prevInd = m.GetPrevIndicator() + nextInd = m.GetNextIndicator() } - var output string - - output += fmt.Sprintf("%s%s%s", leftIndicator, m.GetDisplayValue(), rightIndicator) - - return output + return fmt.Sprintf("%s%s%s", prevInd, m.GetDisplayValue(), nextInd) } func (m Model) GetValue() interface{} { @@ -81,6 +77,14 @@ func (m Model) GetDisplayValue() string { return m.displayFunc(m.state.GetValue()) } +func (m Model) GetPrevIndicator() string { + return "<" +} + +func (m Model) GetNextIndicator() string { + return ">" +} + // Model Options -------------------- func WithKeys(keys KeyMap) func(*Model) { From 05e0d0725aba7dacc0f11b6bc5ef3b677285ae70 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 10:44:17 +1000 Subject: [PATCH 09/30] feat: make Model fields exported --- picker/picker.go | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/picker/picker.go b/picker/picker.go index acf39921..bf6f3234 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -7,11 +7,11 @@ import ( ) type Model struct { - state State - showIndicators bool - canCycle bool - displayFunc DisplayFunc - keys KeyMap + State State + ShowIndicators bool + CanCycle bool + DisplayFunc DisplayFunc + Keys KeyMap } type State interface { @@ -28,11 +28,11 @@ func NewModel(state State, opts ...func(*Model)) Model { } m := Model{ - state: state, - showIndicators: true, - canCycle: false, - displayFunc: defaultDisplayFunc, - keys: DefaultKeyMap(), + State: state, + ShowIndicators: true, + CanCycle: false, + DisplayFunc: defaultDisplayFunc, + Keys: DefaultKeyMap(), } for _, opt := range opts { @@ -49,10 +49,10 @@ func (m Model) Init() tea.Cmd { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { switch { - case key.Matches(msg, m.keys.Next): - m.state.Next(m.canCycle) - case key.Matches(msg, m.keys.Prev): - m.state.Prev(m.canCycle) + case key.Matches(msg, m.Keys.Next): + m.State.Next(m.CanCycle) + case key.Matches(msg, m.Keys.Prev): + m.State.Prev(m.CanCycle) } } @@ -61,20 +61,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) View() string { var prevInd, nextInd string - if m.showIndicators { + if m.ShowIndicators { prevInd = m.GetPrevIndicator() nextInd = m.GetNextIndicator() } - return fmt.Sprintf("%s%s%s", prevInd, m.GetDisplayValue(), nextInd) + return prevInd + m.GetDisplayValue() + nextInd } func (m Model) GetValue() interface{} { - return m.state.GetValue() + return m.State.GetValue() } func (m Model) GetDisplayValue() string { - return m.displayFunc(m.state.GetValue()) + return m.DisplayFunc(m.State.GetValue()) } func (m Model) GetPrevIndicator() string { @@ -89,24 +89,24 @@ func (m Model) GetNextIndicator() string { func WithKeys(keys KeyMap) func(*Model) { return func(m *Model) { - m.keys = keys + m.Keys = keys } } func WithoutIndicators() func(*Model) { return func(m *Model) { - m.showIndicators = false + m.ShowIndicators = false } } func WithCycles() func(*Model) { return func(m *Model) { - m.canCycle = true + m.CanCycle = true } } func WithDisplayFunc(df DisplayFunc) func(*Model) { return func(m *Model) { - m.displayFunc = df + m.DisplayFunc = df } } From f33ff4f8eed5dce623ba9b28caf2fb03d5fccb88 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 11:14:18 +1000 Subject: [PATCH 10/30] test: add ListState unit tests --- picker/liststate_test.go | 137 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 picker/liststate_test.go diff --git a/picker/liststate_test.go b/picker/liststate_test.go new file mode 100644 index 00000000..41705b85 --- /dev/null +++ b/picker/liststate_test.go @@ -0,0 +1,137 @@ +package picker + +import "testing" + +func TestNewListState(t *testing.T) { + want := ListState[string]{ + state: []string{"One", "Two", "Three"}, + } + + got := NewListState([]string{"One", "Two", "Three"}) + + for i := range got.state { + if got.state[i] != want.state[i] { + t.Errorf("state[%d]: want %v, got %v", i, want.state[i], got.state[i]) + } + } + + if got.selection != 0 { + t.Errorf("selection: want 0, got %v", got.selection) + } +} + +func TestListState_GetValue(t *testing.T) { + state := ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + } + want := "Two" + + got := state.GetValue() + + if want != got { + t.Errorf("want %v, got %v", want, got) + } +} + +func TestListState_Next(t *testing.T) { + tt := map[string]struct { + state ListState[string] + canCycle bool + wantSelection int + }{ + "can increment; can cycle": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + }, + canCycle: true, + wantSelection: 2, + }, + "can increment; cannot cycle": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + }, + canCycle: false, + wantSelection: 2, + }, + "cannot increment; can cycle": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 2, + }, + canCycle: true, + wantSelection: 0, + }, + "cannot increment; cannot cycle": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 2, + }, + canCycle: false, + wantSelection: 2, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.Next(tc.canCycle) + + if tc.wantSelection != tc.state.selection { + t.Errorf("want %v, got %v", tc.wantSelection, tc.state.selection) + } + }) + } +} + +func TestListState_Prev(t *testing.T) { + tt := map[string]struct { + state ListState[string] + canCycle bool + wantSelection int + }{ + "can decrement; can cycle": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + }, + canCycle: true, + wantSelection: 0, + }, + "can decrement; cannot cycle": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + }, + canCycle: false, + wantSelection: 0, + }, + "cannot decrement; can cycle": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 0, + }, + canCycle: true, + wantSelection: 2, + }, + "cannot decrement; cannot cycle": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 0, + }, + canCycle: false, + wantSelection: 0, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.Prev(tc.canCycle) + + if tc.wantSelection != tc.state.selection { + t.Errorf("want %v, got %v", tc.wantSelection, tc.state.selection) + } + }) + } +} From 095aa3f72c6ad9289bf2d11910ff9e58c3f5e6fa Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 11:33:09 +1000 Subject: [PATCH 11/30] test: add TestNewIntState --- picker/intstate_test.go | 103 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 picker/intstate_test.go diff --git a/picker/intstate_test.go b/picker/intstate_test.go new file mode 100644 index 00000000..1c64c537 --- /dev/null +++ b/picker/intstate_test.go @@ -0,0 +1,103 @@ +package picker + +import "testing" + +func TestNewIntState(t *testing.T) { + tt := map[string]struct { + min int + max int + selection int + ignoreMin bool + ignoreMax bool + wantSelection int + }{ + "select min": { + min: 0, + max: 2, + selection: 0, + ignoreMin: false, + ignoreMax: false, + wantSelection: 0, + }, + "select max": { + min: 0, + max: 2, + selection: 2, + ignoreMin: false, + ignoreMax: false, + wantSelection: 2, + }, + + "select less than min": { + min: 0, + max: 2, + selection: -10, + ignoreMin: false, + ignoreMax: false, + wantSelection: 0, + }, + "select less than min; ignore min": { + min: 0, + max: 2, + selection: -10, + ignoreMin: true, + ignoreMax: false, + wantSelection: -10, + }, + "select less than min; ignore max": { + min: 0, + max: 2, + selection: -10, + ignoreMin: false, + ignoreMax: true, + wantSelection: 0, + }, + + "select greater than max": { + min: 0, + max: 2, + selection: 10, + ignoreMin: false, + ignoreMax: false, + wantSelection: 2, + }, + "select greater than max; ignore max": { + min: 0, + max: 2, + selection: 10, + ignoreMin: false, + ignoreMax: true, + wantSelection: 10, + }, + "select greater than max; ignore min": { + min: 0, + max: 2, + selection: 10, + ignoreMin: true, + ignoreMax: false, + wantSelection: 2, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + got := NewIntState(tc.min, tc.max, tc.selection, tc.ignoreMin, tc.ignoreMax) + + if got.min != tc.min { + t.Errorf("min: got %v, want %v", got.min, tc.min) + } + if got.max != tc.max { + t.Errorf("max: got %v, want %v", got.max, tc.max) + } + if got.selection != tc.wantSelection { + t.Errorf("selection: got %v, want %v", got.selection, tc.wantSelection) + } + if got.ignoreMin != tc.ignoreMin { + t.Errorf("ignoreMin: got %v, want %v", got.ignoreMin, tc.ignoreMin) + } + if got.ignoreMax != tc.ignoreMax { + t.Errorf("ignoreMax: got %v, want %v", got.ignoreMax, tc.ignoreMax) + } + }) + } +} From 31894e9410ee982bd1a02d6287c1754790e77bf7 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 11:37:01 +1000 Subject: [PATCH 12/30] test: add TestIntState_GetValue --- picker/intstate_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/picker/intstate_test.go b/picker/intstate_test.go index 1c64c537..2a54b234 100644 --- a/picker/intstate_test.go +++ b/picker/intstate_test.go @@ -101,3 +101,20 @@ func TestNewIntState(t *testing.T) { }) } } + +func TestIntState_GetValue(t *testing.T) { + state := IntState{ + min: 0, + max: 10, + selection: 5, + ignoreMin: false, + ignoreMax: false, + } + want := 5 + + got := state.GetValue() + + if want != got { + t.Errorf("want %v, got %v", want, got) + } +} From f4009e353428099ffff7479a6495cbc655125259 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 11:51:55 +1000 Subject: [PATCH 13/30] test: add TestIntState_Next --- picker/intstate_test.go | 108 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/picker/intstate_test.go b/picker/intstate_test.go index 2a54b234..1ab68148 100644 --- a/picker/intstate_test.go +++ b/picker/intstate_test.go @@ -118,3 +118,111 @@ func TestIntState_GetValue(t *testing.T) { t.Errorf("want %v, got %v", want, got) } } + +func TestIntState_Next(t *testing.T) { + tt := map[string]struct { + state IntState + canCycle bool + wantSelection int + }{ + "ignore max; cannot increment; cannot cycle": { + state: IntState{ + min: 0, + max: 10, + selection: 10, + ignoreMin: false, + ignoreMax: true, + }, + canCycle: false, + wantSelection: 11, + }, + "ignore max; cannot increment; can cycle": { + state: IntState{ + min: 0, + max: 10, + selection: 10, + ignoreMin: false, + ignoreMax: true, + }, + canCycle: true, + wantSelection: 11, + }, + "ignore max; can increment; cannot cycle": { + state: IntState{ + min: 0, + max: 11, + selection: 10, + ignoreMin: false, + ignoreMax: true, + }, + canCycle: false, + wantSelection: 11, + }, + "ignore max; can increment; can cycle": { + state: IntState{ + min: 0, + max: 11, + selection: 10, + ignoreMin: false, + ignoreMax: true, + }, + canCycle: true, + wantSelection: 11, + }, + + "enforce max; cannot increment; cannot cycle": { + state: IntState{ + min: 0, + max: 10, + selection: 10, + ignoreMin: false, + ignoreMax: false, + }, + canCycle: false, + wantSelection: 10, + }, + "enforce max; cannot increment; can cycle": { + state: IntState{ + min: 0, + max: 10, + selection: 10, + ignoreMin: false, + ignoreMax: false, + }, + canCycle: true, + wantSelection: 0, + }, + "enforce max; can increment; cannot cycle": { + state: IntState{ + min: 0, + max: 11, + selection: 10, + ignoreMin: false, + ignoreMax: false, + }, + canCycle: false, + wantSelection: 11, + }, + "enforce max; can increment; can cycle": { + state: IntState{ + min: 0, + max: 11, + selection: 10, + ignoreMin: false, + ignoreMax: false, + }, + canCycle: true, + wantSelection: 11, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.Next(tc.canCycle) + + if tc.wantSelection != tc.state.selection { + t.Errorf("want %v, got %v", tc.wantSelection, tc.state.selection) + } + }) + } +} From 703c5dc4433472c7098abcb13c9b04c14a93ea8d Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 12:07:55 +1000 Subject: [PATCH 14/30] test: add TestIntState_Prev --- picker/intstate_test.go | 108 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/picker/intstate_test.go b/picker/intstate_test.go index 1ab68148..f0c3396c 100644 --- a/picker/intstate_test.go +++ b/picker/intstate_test.go @@ -226,3 +226,111 @@ func TestIntState_Next(t *testing.T) { }) } } + +func TestIntState_Prev(t *testing.T) { + tt := map[string]struct { + state IntState + canCycle bool + wantSelection int + }{ + "ignore min; cannot decrement; cannot cycle": { + state: IntState{ + min: -10, + max: 0, + selection: -10, + ignoreMin: true, + ignoreMax: false, + }, + canCycle: false, + wantSelection: -11, + }, + "ignore min; cannot decrement; can cycle": { + state: IntState{ + min: -10, + max: 0, + selection: -10, + ignoreMin: true, + ignoreMax: false, + }, + canCycle: true, + wantSelection: -11, + }, + "ignore min; can decrement; cannot cycle": { + state: IntState{ + min: -11, + max: 0, + selection: -10, + ignoreMin: true, + ignoreMax: false, + }, + canCycle: false, + wantSelection: -11, + }, + "ignore min; can decrement; can cycle": { + state: IntState{ + min: -11, + max: 0, + selection: -10, + ignoreMin: true, + ignoreMax: false, + }, + canCycle: true, + wantSelection: -11, + }, + + "enforce min; cannot decrement; cannot cycle": { + state: IntState{ + min: -10, + max: 0, + selection: -10, + ignoreMin: false, + ignoreMax: false, + }, + canCycle: false, + wantSelection: -10, + }, + "enforce min; cannot decrement; can cycle": { + state: IntState{ + min: -10, + max: 0, + selection: -10, + ignoreMin: false, + ignoreMax: false, + }, + canCycle: true, + wantSelection: 0, + }, + "enforce min; can decrement; cannot cycle": { + state: IntState{ + min: -11, + max: 0, + selection: -10, + ignoreMin: false, + ignoreMax: false, + }, + canCycle: false, + wantSelection: -11, + }, + "enforce min; can decrement; can cycle": { + state: IntState{ + min: -11, + max: 0, + selection: -10, + ignoreMin: false, + ignoreMax: false, + }, + canCycle: true, + wantSelection: -11, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.Prev(tc.canCycle) + + if tc.wantSelection != tc.state.selection { + t.Errorf("want %v, got %v", tc.wantSelection, tc.state.selection) + } + }) + } +} From b829c18dfb720e3ecbf3dbe6541ce5762bd36946 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 14:19:34 +1000 Subject: [PATCH 15/30] feat: add styles --- picker/intstate.go | 22 +++++++++++++--------- picker/liststate.go | 12 ++++++++++-- picker/picker.go | 36 +++++++++++++++++++++++++++++++++--- picker/styles.go | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 picker/styles.go diff --git a/picker/intstate.go b/picker/intstate.go index 09f0f285..34ee916b 100644 --- a/picker/intstate.go +++ b/picker/intstate.go @@ -29,14 +29,21 @@ func (s *IntState) GetValue() interface{} { return s.selection } +func (s *IntState) NextExists() bool { + return s.ignoreMax || + s.selection < s.max +} + +func (s *IntState) PrevExists() bool { + return s.ignoreMin || + s.selection > s.min +} + func (s *IntState) Next(canCycle bool) { switch { - case s.ignoreMax: + case s.NextExists(): s.selection++ - - case s.selection < s.max: - s.selection++ - + case canCycle: s.selection = s.min } @@ -44,10 +51,7 @@ func (s *IntState) Next(canCycle bool) { func (s *IntState) Prev(canCycle bool) { switch { - case s.ignoreMin: - s.selection-- - - case s.selection > s.min: + case s.PrevExists(): s.selection-- case canCycle: diff --git a/picker/liststate.go b/picker/liststate.go index 2e7f6c7c..5fea480c 100644 --- a/picker/liststate.go +++ b/picker/liststate.go @@ -15,9 +15,17 @@ func (s *ListState[T]) GetValue() interface{} { return s.state[s.selection] } +func (s *ListState[T]) NextExists() bool { + return s.selection < len(s.state)-1 +} + +func (s *ListState[T]) PrevExists() bool { + return s.selection > 0 +} + func (s *ListState[T]) Next(canCycle bool) { switch { - case s.selection < len(s.state)-1: + case s.NextExists(): s.selection++ case canCycle: @@ -27,7 +35,7 @@ func (s *ListState[T]) Next(canCycle bool) { func (s *ListState[T]) Prev(canCycle bool) { switch { - case s.selection > 0: + case s.PrevExists(): s.selection-- case canCycle: diff --git a/picker/picker.go b/picker/picker.go index bf6f3234..ac87574a 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type Model struct { @@ -12,10 +13,15 @@ type Model struct { CanCycle bool DisplayFunc DisplayFunc Keys KeyMap + Styles Styles } type State interface { GetValue() interface{} + + NextExists() bool + PrevExists() bool + Next(canCycle bool) Prev(canCycle bool) } @@ -33,6 +39,7 @@ func NewModel(state State, opts ...func(*Model)) Model { CanCycle: false, DisplayFunc: defaultDisplayFunc, Keys: DefaultKeyMap(), + Styles: DefaultStyles(), } for _, opt := range opts { @@ -66,7 +73,15 @@ func (m Model) View() string { nextInd = m.GetNextIndicator() } - return prevInd + m.GetDisplayValue() + nextInd + value := m.Styles.Selection.Render( + m.GetDisplayValue(), + ) + + return lipgloss.JoinHorizontal(lipgloss.Center, + prevInd, + value, + nextInd, + ) } func (m Model) GetValue() interface{} { @@ -78,11 +93,20 @@ func (m Model) GetDisplayValue() string { } func (m Model) GetPrevIndicator() string { - return "<" + return getIndicator(m.Styles.Previous, m.State.PrevExists()) } func (m Model) GetNextIndicator() string { - return ">" + return getIndicator(m.Styles.Next, m.State.NextExists()) +} + +func getIndicator(styles IndicatorStyles, exists bool) string { + switch exists { + case false: + return styles.Disabled.Render(styles.Value) + default: + return styles.Enabled.Render(styles.Value) + } } // Model Options -------------------- @@ -110,3 +134,9 @@ func WithDisplayFunc(df DisplayFunc) func(*Model) { m.DisplayFunc = df } } + +func WithStyles(s Styles) func(*Model) { + return func(m *Model) { + m.Styles = s + } +} diff --git a/picker/styles.go b/picker/styles.go new file mode 100644 index 00000000..1d4a9f45 --- /dev/null +++ b/picker/styles.go @@ -0,0 +1,36 @@ +package picker + +import ( + "github.com/charmbracelet/lipgloss" +) + +type Styles struct { + Selection lipgloss.Style + Next IndicatorStyles + Previous IndicatorStyles +} + +type IndicatorStyles struct { + Value string + Enabled lipgloss.Style + Disabled lipgloss.Style +} + +func DefaultStyles() Styles { + indEnabled := lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(7)) + indDisabled := lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(8)) + + return Styles{ + Selection: lipgloss.NewStyle().Padding(0, 1), + Next: IndicatorStyles{ + Value: ">", + Enabled: indEnabled, + Disabled: indDisabled, + }, + Previous: IndicatorStyles{ + Value: "<", + Enabled: indEnabled, + Disabled: indDisabled, + }, + } +} From 08508eb869a7df3f80ae9b84fc4ecd01f433edf9 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 14:26:54 +1000 Subject: [PATCH 16/30] feat: add jumping --- picker/intstate.go | 10 +++++++++- picker/keys.go | 14 ++++++++++++-- picker/liststate.go | 8 ++++++++ picker/picker.go | 20 ++++++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/picker/intstate.go b/picker/intstate.go index 34ee916b..b63ea15d 100644 --- a/picker/intstate.go +++ b/picker/intstate.go @@ -43,7 +43,7 @@ func (s *IntState) Next(canCycle bool) { switch { case s.NextExists(): s.selection++ - + case canCycle: s.selection = s.min } @@ -58,3 +58,11 @@ func (s *IntState) Prev(canCycle bool) { s.selection = s.max } } + +func (s *IntState) JumpForward() { + s.selection = s.max +} + +func (s *IntState) JumpBackward() { + s.selection = s.min +} diff --git a/picker/keys.go b/picker/keys.go index b904c163..55713e18 100644 --- a/picker/keys.go +++ b/picker/keys.go @@ -4,8 +4,10 @@ import "github.com/charmbracelet/bubbles/key" // KeyMap defines the structure of this component's key bindings. type KeyMap struct { - Next key.Binding - Prev key.Binding + Next key.Binding + Prev key.Binding + JumpForward key.Binding + JumpBackward key.Binding } // DefaultKeyMap returns a default set of key bindings. @@ -19,5 +21,13 @@ func DefaultKeyMap() KeyMap { key.WithKeys("left", "h"), key.WithHelp("←/h", "previous"), ), + JumpForward: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "jump forward"), + ), + JumpBackward: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "jump backward"), + ), } } diff --git a/picker/liststate.go b/picker/liststate.go index 5fea480c..1b664d17 100644 --- a/picker/liststate.go +++ b/picker/liststate.go @@ -42,3 +42,11 @@ func (s *ListState[T]) Prev(canCycle bool) { s.selection = len(s.state) - 1 } } + +func (s *ListState[T]) JumpForward() { + s.selection = len(s.state) - 1 +} + +func (s *ListState[T]) JumpBackward() { + s.selection = 0 +} diff --git a/picker/picker.go b/picker/picker.go index ac87574a..d74040a1 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -11,6 +11,7 @@ type Model struct { State State ShowIndicators bool CanCycle bool + CanJump bool DisplayFunc DisplayFunc Keys KeyMap Styles Styles @@ -24,6 +25,8 @@ type State interface { Next(canCycle bool) Prev(canCycle bool) + JumpForward() + JumpBackward() } type DisplayFunc func(stateValue interface{}) string @@ -58,8 +61,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.Keys.Next): m.State.Next(m.CanCycle) + case key.Matches(msg, m.Keys.Prev): m.State.Prev(m.CanCycle) + + case key.Matches(msg, m.Keys.JumpForward): + if m.CanJump { + m.State.JumpForward() + } + + case key.Matches(msg, m.Keys.JumpBackward): + if m.CanJump { + m.State.JumpBackward() + } } } @@ -140,3 +154,9 @@ func WithStyles(s Styles) func(*Model) { m.Styles = s } } + +func WithJumping() func(*Model) { + return func(m *Model) { + m.CanJump = true + } +} From 2327f735443a129d34afb454c909bb9f7aa6b416 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 14:30:59 +1000 Subject: [PATCH 17/30] chore: add doc comment for Model --- picker/picker.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/picker/picker.go b/picker/picker.go index d74040a1..1ee18297 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -7,6 +7,9 @@ import ( "github.com/charmbracelet/lipgloss" ) +// Model is a picker component. +// By default the View method will render it as a horizontal picker. +// Methods are exposed to get the value and indicators separately, allowing you to build your own UI (vertical, 4-dimensional, etc). type Model struct { State State ShowIndicators bool From 446bc18c8402d5c10116039c45705183c2c1e22a Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 16:07:25 +1000 Subject: [PATCH 18/30] test: add TestIntState_JumpForward & TestIntState_JumpBackward --- picker/intstate_test.go | 84 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/picker/intstate_test.go b/picker/intstate_test.go index f0c3396c..15be587c 100644 --- a/picker/intstate_test.go +++ b/picker/intstate_test.go @@ -334,3 +334,87 @@ func TestIntState_Prev(t *testing.T) { }) } } + +func TestIntState_JumpForward(t *testing.T) { + tt := map[string]struct { + state IntState + want int + }{ + "from min": { + state: IntState{ + min: 0, + max: 10, + selection: 0, + }, + want: 10, + }, + "from middle": { + state: IntState{ + min: 0, + max: 10, + selection: 5, + }, + want: 10, + }, + "from max": { + state: IntState{ + min: 0, + max: 10, + selection: 10, + }, + want: 10, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.JumpForward() + + if tc.want != tc.state.selection { + t.Errorf("want %v, got %v", tc.want, tc.state.selection) + } + }) + } +} + +func TestIntState_JumpBackward(t *testing.T) { + tt := map[string]struct { + state IntState + want int + }{ + "from min": { + state: IntState{ + min: 0, + max: 10, + selection: 0, + }, + want: 0, + }, + "from middle": { + state: IntState{ + min: 0, + max: 10, + selection: 5, + }, + want: 0, + }, + "from max": { + state: IntState{ + min: 0, + max: 10, + selection: 10, + }, + want: 0, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.JumpBackward() + + if tc.want != tc.state.selection { + t.Errorf("want %v, got %v", tc.want, tc.state.selection) + } + }) + } +} From fa8a6584f5aff9825edea8132346000a8c9a096f Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 16:14:22 +1000 Subject: [PATCH 19/30] test: add TestIntState_NextExists --- picker/intstate_test.go | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/picker/intstate_test.go b/picker/intstate_test.go index 15be587c..33b68c35 100644 --- a/picker/intstate_test.go +++ b/picker/intstate_test.go @@ -119,6 +119,65 @@ func TestIntState_GetValue(t *testing.T) { } } +func TestIntState_NextExists(t *testing.T) { + tt := map[string]struct { + state IntState + want bool + }{ + "enforce max; can increment": { + state: IntState{ + min: 0, + max: 10, + selection: 9, + ignoreMin: false, + ignoreMax: false, + }, + want: true, + }, + "enforce max; cannot increment": { + state: IntState{ + min: 0, + max: 10, + selection: 10, + ignoreMin: false, + ignoreMax: false, + }, + want: false, + }, + + "ignore max; can increment": { + state: IntState{ + min: 0, + max: 10, + selection: 9, + ignoreMin: false, + ignoreMax: true, + }, + want: true, + }, + "ignore max; cannot increment": { + state: IntState{ + min: 0, + max: 10, + selection: 10, + ignoreMin: false, + ignoreMax: true, + }, + want: true, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + got := tc.state.NextExists() + + if tc.want != got { + t.Errorf("want %v, got %v", tc.want, got) + } + }) + } +} + func TestIntState_Next(t *testing.T) { tt := map[string]struct { state IntState From 12da11b22ec2f2bbe88f4c96df50ff74bb9e148d Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 16:19:31 +1000 Subject: [PATCH 20/30] test: add TestIntState_PrevExists --- picker/intstate_test.go | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/picker/intstate_test.go b/picker/intstate_test.go index 33b68c35..44892fe4 100644 --- a/picker/intstate_test.go +++ b/picker/intstate_test.go @@ -178,6 +178,65 @@ func TestIntState_NextExists(t *testing.T) { } } +func TestIntState_PrevExists(t *testing.T) { + tt := map[string]struct { + state IntState + want bool + }{ + "enforce min; can decrement": { + state: IntState{ + min: 0, + max: 10, + selection: 1, + ignoreMin: false, + ignoreMax: false, + }, + want: true, + }, + "enforce min; cannot decrement": { + state: IntState{ + min: 0, + max: 10, + selection: 0, + ignoreMin: false, + ignoreMax: false, + }, + want: false, + }, + + "ignore min; can decrement": { + state: IntState{ + min: 0, + max: 10, + selection: 1, + ignoreMin: true, + ignoreMax: false, + }, + want: true, + }, + "ignore min; cannot decrement": { + state: IntState{ + min: 0, + max: 10, + selection: 0, + ignoreMin: true, + ignoreMax: false, + }, + want: true, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + got := tc.state.PrevExists() + + if tc.want != got { + t.Errorf("want %v, got %v", tc.want, got) + } + }) + } +} + func TestIntState_Next(t *testing.T) { tt := map[string]struct { state IntState From 196a021f8a8a79d405505958bbbdfd43faaa1b08 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 16:24:22 +1000 Subject: [PATCH 21/30] test: add TestListState_NextExists & TestListState_PrevExists --- picker/liststate_test.go | 64 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/picker/liststate_test.go b/picker/liststate_test.go index 41705b85..19ab6b38 100644 --- a/picker/liststate_test.go +++ b/picker/liststate_test.go @@ -34,6 +34,70 @@ func TestListState_GetValue(t *testing.T) { } } +func TestListState_NextExists(t *testing.T) { + tt := map[string]struct { + state ListState[string] + want bool + }{ + "can increment": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + }, + want: true, + }, + "cannot increment": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 2, + }, + want: false, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + got := tc.state.NextExists() + + if tc.want != got { + t.Errorf("want %v, got %v", tc.want, got) + } + }) + } +} + +func TestListState_PrevExists(t *testing.T) { + tt := map[string]struct { + state ListState[string] + want bool + }{ + "can increment": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + }, + want: true, + }, + "cannot increment": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 0, + }, + want: false, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + got := tc.state.PrevExists() + + if tc.want != got { + t.Errorf("want %v, got %v", tc.want, got) + } + }) + } +} + func TestListState_Next(t *testing.T) { tt := map[string]struct { state ListState[string] From aea9f2f05bc05985325766a9ed77a8f8bbbf8965 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 16:27:27 +1000 Subject: [PATCH 22/30] test: add TestListState_JumpForward & TestListState_JumpBackward --- picker/liststate_test.go | 78 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/picker/liststate_test.go b/picker/liststate_test.go index 19ab6b38..6c606884 100644 --- a/picker/liststate_test.go +++ b/picker/liststate_test.go @@ -199,3 +199,81 @@ func TestListState_Prev(t *testing.T) { }) } } + +func TestListState_JumpForward(t *testing.T) { + tt := map[string]struct { + state ListState[string] + want int + }{ + "from min": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 0, + }, + want: 2, + }, + "from middle": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + }, + want: 2, + }, + "from max": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 2, + }, + want: 2, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.JumpForward() + + if tc.want != tc.state.selection { + t.Errorf("want %v, got %v", tc.want, tc.state.selection) + } + }) + } +} + +func TestListState_JumpBackward(t *testing.T) { + tt := map[string]struct { + state ListState[string] + want int + }{ + "from min": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 0, + }, + want: 0, + }, + "from middle": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + }, + want: 0, + }, + "from max": { + state: ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 2, + }, + want: 0, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.JumpBackward() + + if tc.want != tc.state.selection { + t.Errorf("want %v, got %v", tc.want, tc.state.selection) + } + }) + } +} From e3f4dbb79243e380de3b6afbf299f160cc7f42f3 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 16:47:35 +1000 Subject: [PATCH 23/30] test: add TestNewModel --- picker/picker.go | 1 + picker/picker_test.go | 196 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 picker/picker_test.go diff --git a/picker/picker.go b/picker/picker.go index 1ee18297..8aaeb2bc 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -43,6 +43,7 @@ func NewModel(state State, opts ...func(*Model)) Model { State: state, ShowIndicators: true, CanCycle: false, + CanJump: false, DisplayFunc: defaultDisplayFunc, Keys: DefaultKeyMap(), Styles: DefaultStyles(), diff --git a/picker/picker_test.go b/picker/picker_test.go new file mode 100644 index 00000000..cd25d42a --- /dev/null +++ b/picker/picker_test.go @@ -0,0 +1,196 @@ +package picker + +import ( + "fmt" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/lipgloss" + "reflect" + "testing" +) + +func TestNewModel(t *testing.T) { + tt := map[string]struct { + state State + opts []func(*Model) + wantFunc func() Model + }{ + "default": { + state: NewListState([]string{"One", "Two", "Three"}), + opts: nil, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}), + ShowIndicators: true, + CanCycle: false, + CanJump: false, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + "WithKeys": { + state: NewListState([]string{"One", "Two", "Three"}), + opts: []func(*Model){ + WithKeys(KeyMap{ + Next: key.NewBinding(key.WithKeys("test", "key")), + }), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}), + ShowIndicators: true, + CanCycle: false, + CanJump: false, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + Keys: KeyMap{ + Next: key.NewBinding(key.WithKeys("test", "key")), + }, + Styles: DefaultStyles(), + } + }, + }, + "WithoutIndicators": { + state: NewListState([]string{"One", "Two", "Three"}), + opts: []func(*Model){ + WithoutIndicators(), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}), + ShowIndicators: false, + CanCycle: false, + CanJump: false, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + "WithCycles": { + state: NewListState([]string{"One", "Two", "Three"}), + opts: []func(*Model){ + WithCycles(), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}), + ShowIndicators: true, + CanCycle: true, + CanJump: false, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + "WithDisplayFunc": { + state: NewListState([]string{"One", "Two", "Three"}), + opts: []func(*Model){ + WithDisplayFunc(func(_ interface{}) string { + return fmt.Sprint("test") + }), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}), + ShowIndicators: true, + CanCycle: false, + CanJump: false, + DisplayFunc: func(_ interface{}) string { + return fmt.Sprint("test") + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + "WithStyles": { + state: NewListState([]string{"One", "Two", "Three"}), + opts: []func(*Model){ + WithStyles(Styles{ + Selection: lipgloss.NewStyle().Width(555).Height(-555), + }), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}), + ShowIndicators: true, + CanCycle: false, + CanJump: false, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + Keys: DefaultKeyMap(), + Styles: Styles{ + Selection: lipgloss.NewStyle().Width(555).Height(-555), + }, + } + }, + }, + "WithJumping": { + state: NewListState([]string{"One", "Two", "Three"}), + opts: []func(*Model){ + WithJumping(), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}), + ShowIndicators: true, + CanCycle: false, + CanJump: true, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + want := tc.wantFunc() + got := NewModel(tc.state, tc.opts...) + + if !reflect.DeepEqual(got.State, want.State) { + t.Errorf("State: \ngot: \n%v \nwant: \n%v", got, want) + } + + if got.ShowIndicators != want.ShowIndicators { + t.Errorf("ShowIndicators: \ngot: \n%v \nwant: \n%v", got, want) + } + + if got.CanCycle != want.CanCycle { + t.Errorf("CanCycle: \ngot: \n%v \nwant: \n%v", got, want) + } + + if got.CanJump != want.CanJump { + t.Errorf("CanJump: \ngot: \n%v \nwant: \n%v", got, want) + } + + if got.DisplayFunc == nil { + t.Errorf("DisplayFunc: \ngot: \n%v \nwant: \n%v", got, want) + } else if got.GetDisplayValue() != want.GetDisplayValue() { + t.Errorf("GetDisplayValue: \ngot: \n%v \nwant: \n%v", got, want) + } + + if !reflect.DeepEqual(got.Keys, want.Keys) { + t.Errorf("Keys: \ngot: \n%v \nwant: \n%v", got, want) + } + + if !reflect.DeepEqual(got.Styles, want.Styles) { + t.Errorf("Styles: \ngot: \n%v \nwant: \n%v", got, want) + } + }) + } +} From f3207c3b46ff6eede49644b84dde4b1627e5ae6b Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 16:55:28 +1000 Subject: [PATCH 24/30] test: add TestModel_GetValue --- picker/picker_test.go | 46 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/picker/picker_test.go b/picker/picker_test.go index cd25d42a..91392836 100644 --- a/picker/picker_test.go +++ b/picker/picker_test.go @@ -194,3 +194,49 @@ func TestNewModel(t *testing.T) { }) } } + +func TestModel_GetValue(t *testing.T) { + tt := map[string]struct { + state State + want interface{} + }{ + "min": { + state: &ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 0, + }, + want: "One", + }, + "middle": { + state: &ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + }, + want: "Two", + }, + "end": { + state: &ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 2, + }, + want: "Three", + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + model := Model{ + State: tc.state, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + } + + got := model.GetDisplayValue() + + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("\ngot: \n%v \nwant: \n%v", got, tc.want) + } + }) + } +} From 2451ced41adffa7fe3e2fb556e47dba57243000c Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 17:05:47 +1000 Subject: [PATCH 25/30] test: add TestGetIndicator --- picker/picker.go | 4 ++-- picker/picker_test.go | 44 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/picker/picker.go b/picker/picker.go index 8aaeb2bc..3e7f5501 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -118,8 +118,8 @@ func (m Model) GetNextIndicator() string { return getIndicator(m.Styles.Next, m.State.NextExists()) } -func getIndicator(styles IndicatorStyles, exists bool) string { - switch exists { +func getIndicator(styles IndicatorStyles, enabled bool) string { + switch enabled { case false: return styles.Disabled.Render(styles.Value) default: diff --git a/picker/picker_test.go b/picker/picker_test.go index 91392836..031d5566 100644 --- a/picker/picker_test.go +++ b/picker/picker_test.go @@ -2,6 +2,7 @@ package picker import ( "fmt" + "github.com/MakeNowJust/heredoc" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/lipgloss" "reflect" @@ -240,3 +241,46 @@ func TestModel_GetValue(t *testing.T) { }) } } + +func TestGetIndicator(t *testing.T) { + tt := map[string]struct { + styles IndicatorStyles + enabled bool + want string + }{ + "enabled": { + styles: IndicatorStyles{ + Value: "test", + Enabled: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderTop(true), + Disabled: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true), + }, + enabled: true, + want: heredoc.Doc(` + ──── + test`, + ), + }, + "disabled": { + styles: IndicatorStyles{ + Value: "test", + Enabled: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderTop(true), + Disabled: lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true), + }, + enabled: false, + want: heredoc.Doc(` + test + ────`, + ), + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + got := getIndicator(tc.styles, tc.enabled) + + if got != tc.want { + t.Errorf("\ngot: \n%q \nwant: \n%q", got, tc.want) + } + }) + } +} From 2bf8639d0ebfe2e97cdcfef9bab7fce078e66881 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Fri, 20 Sep 2024 17:11:19 +1000 Subject: [PATCH 26/30] test: add TestModel_View --- picker/picker_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/picker/picker_test.go b/picker/picker_test.go index 031d5566..8ed2f1ea 100644 --- a/picker/picker_test.go +++ b/picker/picker_test.go @@ -196,6 +196,24 @@ func TestNewModel(t *testing.T) { } } +func TestModel_View(t *testing.T) { + model := NewModel( + &ListState[string]{ + state: []string{"One", "Two", "Three"}, + selection: 1, + }, + ) + want := heredoc.Doc(` + < Two >`, + ) + + got := model.View() + + if want != got { + t.Errorf("View: \ngot: \n%q\nwant: \n%q", got, want) + } +} + func TestModel_GetValue(t *testing.T) { tt := map[string]struct { state State From ac0266274f8a9d888b2e9a8ebd3a117ed710ede5 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Sat, 21 Sep 2024 07:24:05 +1000 Subject: [PATCH 27/30] refactor: rename Model constructor to New --- picker/picker.go | 2 +- picker/picker_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/picker/picker.go b/picker/picker.go index 3e7f5501..427c9471 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -34,7 +34,7 @@ type State interface { type DisplayFunc func(stateValue interface{}) string -func NewModel(state State, opts ...func(*Model)) Model { +func New(state State, opts ...func(*Model)) Model { defaultDisplayFunc := func(v interface{}) string { return fmt.Sprintf("%v", v) } diff --git a/picker/picker_test.go b/picker/picker_test.go index 8ed2f1ea..83e14e7e 100644 --- a/picker/picker_test.go +++ b/picker/picker_test.go @@ -9,7 +9,7 @@ import ( "testing" ) -func TestNewModel(t *testing.T) { +func TestNew(t *testing.T) { tt := map[string]struct { state State opts []func(*Model) @@ -161,7 +161,7 @@ func TestNewModel(t *testing.T) { for name, tc := range tt { t.Run(name, func(t *testing.T) { want := tc.wantFunc() - got := NewModel(tc.state, tc.opts...) + got := New(tc.state, tc.opts...) if !reflect.DeepEqual(got.State, want.State) { t.Errorf("State: \ngot: \n%v \nwant: \n%v", got, want) @@ -197,7 +197,7 @@ func TestNewModel(t *testing.T) { } func TestModel_View(t *testing.T) { - model := NewModel( + model := New( &ListState[string]{ state: []string{"One", "Two", "Three"}, selection: 1, From a3de379d65b4e159f5bd9744d5154982f2d9d4d0 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Sat, 21 Sep 2024 07:39:55 +1000 Subject: [PATCH 28/30] feat: add stepping --- picker/intstate.go | 14 ++++++++++++++ picker/keys.go | 14 ++++++++++++-- picker/liststate.go | 14 ++++++++++++++ picker/picker.go | 20 ++++++++++++++++++-- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/picker/intstate.go b/picker/intstate.go index b63ea15d..d66def54 100644 --- a/picker/intstate.go +++ b/picker/intstate.go @@ -59,6 +59,20 @@ func (s *IntState) Prev(canCycle bool) { } } +func (s *IntState) StepForward(count int) { + s.selection += count + if s.selection > s.max || s.ignoreMax { + s.selection = s.max + } +} + +func (s *IntState) StepBackward(count int) { + s.selection -= count + if s.selection < s.min || s.ignoreMin { + s.selection = s.min + } +} + func (s *IntState) JumpForward() { s.selection = s.max } diff --git a/picker/keys.go b/picker/keys.go index 55713e18..0ca157bc 100644 --- a/picker/keys.go +++ b/picker/keys.go @@ -6,13 +6,15 @@ import "github.com/charmbracelet/bubbles/key" type KeyMap struct { Next key.Binding Prev key.Binding + StepForward key.Binding + StepBackward key.Binding JumpForward key.Binding JumpBackward key.Binding } // DefaultKeyMap returns a default set of key bindings. -func DefaultKeyMap() KeyMap { - return KeyMap{ +func DefaultKeyMap() *KeyMap { + return &KeyMap{ Next: key.NewBinding( key.WithKeys("right", "l"), key.WithHelp("→/l", "next"), @@ -21,6 +23,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("left", "h"), key.WithHelp("←/h", "previous"), ), + StepForward: key.NewBinding( + key.WithKeys("shift+right", "shift+l"), + key.WithHelp("shift + →/l", "step forward"), + ), + StepBackward: key.NewBinding( + key.WithKeys("shift+left", "shift+h"), + key.WithHelp("shift + ←/h", "step backward"), + ), JumpForward: key.NewBinding( key.WithKeys("up", "k"), key.WithHelp("↑/k", "jump forward"), diff --git a/picker/liststate.go b/picker/liststate.go index 1b664d17..d7e0f764 100644 --- a/picker/liststate.go +++ b/picker/liststate.go @@ -43,6 +43,20 @@ func (s *ListState[T]) Prev(canCycle bool) { } } +func (s *ListState[T]) StepForward(count int) { + s.selection += count + if s.selection > len(s.state)-1 { + s.selection = len(s.state) - 1 + } +} + +func (s *ListState[T]) StepBackward(count int) { + s.selection -= count + if s.selection < 0 { + s.selection = 0 + } +} + func (s *ListState[T]) JumpForward() { s.selection = len(s.state) - 1 } diff --git a/picker/picker.go b/picker/picker.go index 427c9471..a504ef74 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -15,8 +15,9 @@ type Model struct { ShowIndicators bool CanCycle bool CanJump bool + StepSize int DisplayFunc DisplayFunc - Keys KeyMap + Keys *KeyMap Styles Styles } @@ -28,6 +29,8 @@ type State interface { Next(canCycle bool) Prev(canCycle bool) + StepForward(count int) + StepBackward(count int) JumpForward() JumpBackward() } @@ -44,6 +47,7 @@ func New(state State, opts ...func(*Model)) Model { ShowIndicators: true, CanCycle: false, CanJump: false, + StepSize: 10, DisplayFunc: defaultDisplayFunc, Keys: DefaultKeyMap(), Styles: DefaultStyles(), @@ -69,6 +73,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.Keys.Prev): m.State.Prev(m.CanCycle) + case key.Matches(msg, m.Keys.StepForward): + m.State.StepForward(m.StepSize) + + case key.Matches(msg, m.Keys.StepBackward): + m.State.StepBackward(m.StepSize) + case key.Matches(msg, m.Keys.JumpForward): if m.CanJump { m.State.JumpForward() @@ -129,7 +139,7 @@ func getIndicator(styles IndicatorStyles, enabled bool) string { // Model Options -------------------- -func WithKeys(keys KeyMap) func(*Model) { +func WithKeys(keys *KeyMap) func(*Model) { return func(m *Model) { m.Keys = keys } @@ -164,3 +174,9 @@ func WithJumping() func(*Model) { m.CanJump = true } } + +func WithStepSize(size int) func(*Model) { + return func(m *Model) { + m.StepSize = size + } +} From f5e19c0deac8a69451692fcf51a9188a1fe07bca Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Sat, 21 Sep 2024 20:22:45 +1000 Subject: [PATCH 29/30] feat: finalise State StepForward & StepBackward methods --- picker/intstate.go | 12 +++---- picker/intstate_test.go | 68 +++++++++++++++++++++++++++++++++++++ picker/liststate.go | 13 ++++---- picker/liststate_test.go | 72 ++++++++++++++++++++++++++++++++++++++-- picker/picker.go | 4 +-- picker/picker_test.go | 32 +++++++++--------- 6 files changed, 169 insertions(+), 32 deletions(-) diff --git a/picker/intstate.go b/picker/intstate.go index d66def54..e1679a96 100644 --- a/picker/intstate.go +++ b/picker/intstate.go @@ -59,16 +59,16 @@ func (s *IntState) Prev(canCycle bool) { } } -func (s *IntState) StepForward(count int) { - s.selection += count - if s.selection > s.max || s.ignoreMax { +func (s *IntState) StepForward(size int) { + s.selection += size + if s.selection > s.max && !s.ignoreMax { s.selection = s.max } } -func (s *IntState) StepBackward(count int) { - s.selection -= count - if s.selection < s.min || s.ignoreMin { +func (s *IntState) StepBackward(size int) { + s.selection -= size + if s.selection < s.min && !s.ignoreMin { s.selection = s.min } } diff --git a/picker/intstate_test.go b/picker/intstate_test.go index 44892fe4..549ba25f 100644 --- a/picker/intstate_test.go +++ b/picker/intstate_test.go @@ -453,6 +453,74 @@ func TestIntState_Prev(t *testing.T) { } } +func TestIntState_StepForward(t *testing.T) { + tt := map[string]struct { + state *IntState + size int + wantSelection int + }{ + "size 0": { + state: NewIntState(0, 100, 50, false, false), + size: 0, + wantSelection: 50, + }, + "normal": { + state: NewIntState(0, 100, 50, false, false), + size: 13, + wantSelection: 63, + }, + "beyond max": { + state: NewIntState(0, 100, 50, false, false), + size: 100, + wantSelection: 100, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.StepForward(tc.size) + + if tc.wantSelection != tc.state.selection { + t.Errorf("want %v, got %v", tc.wantSelection, tc.state.selection) + } + }) + } +} + +func TestIntState_StepBackward(t *testing.T) { + tt := map[string]struct { + state *IntState + size int + wantSelection int + }{ + "size 0": { + state: NewIntState(0, 100, 50, false, false), + size: 0, + wantSelection: 50, + }, + "normal": { + state: NewIntState(0, 100, 50, false, false), + size: 13, + wantSelection: 37, + }, + "beyond max": { + state: NewIntState(0, 100, 50, false, false), + size: 100, + wantSelection: 0, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.StepBackward(tc.size) + + if tc.wantSelection != tc.state.selection { + t.Errorf("want %v, got %v", tc.wantSelection, tc.state.selection) + } + }) + } +} + func TestIntState_JumpForward(t *testing.T) { tt := map[string]struct { state IntState diff --git a/picker/liststate.go b/picker/liststate.go index d7e0f764..db24ef05 100644 --- a/picker/liststate.go +++ b/picker/liststate.go @@ -5,9 +5,10 @@ type ListState[T any] struct { selection int } -func NewListState[T any](state []T) *ListState[T] { +func NewListState[T any](state []T, selection int) *ListState[T] { return &ListState[T]{ - state: state, + state: state, + selection: selection, } } @@ -43,15 +44,15 @@ func (s *ListState[T]) Prev(canCycle bool) { } } -func (s *ListState[T]) StepForward(count int) { - s.selection += count +func (s *ListState[T]) StepForward(size int) { + s.selection += size if s.selection > len(s.state)-1 { s.selection = len(s.state) - 1 } } -func (s *ListState[T]) StepBackward(count int) { - s.selection -= count +func (s *ListState[T]) StepBackward(size int) { + s.selection -= size if s.selection < 0 { s.selection = 0 } diff --git a/picker/liststate_test.go b/picker/liststate_test.go index 6c606884..feabae14 100644 --- a/picker/liststate_test.go +++ b/picker/liststate_test.go @@ -7,7 +7,7 @@ func TestNewListState(t *testing.T) { state: []string{"One", "Two", "Three"}, } - got := NewListState([]string{"One", "Two", "Three"}) + got := NewListState([]string{"One", "Two", "Three"}, 2) for i := range got.state { if got.state[i] != want.state[i] { @@ -15,7 +15,7 @@ func TestNewListState(t *testing.T) { } } - if got.selection != 0 { + if got.selection != 2 { t.Errorf("selection: want 0, got %v", got.selection) } } @@ -200,6 +200,74 @@ func TestListState_Prev(t *testing.T) { } } +func TestListState_StepForward(t *testing.T) { + tt := map[string]struct { + state *ListState[string] + size int + wantSelection int + }{ + "size 0": { + state: NewListState([]string{"One", "Two", "Three", "Four", "Five", "Six"}, 0), + size: 0, + wantSelection: 0, + }, + "normal": { + state: NewListState([]string{"One", "Two", "Three", "Four", "Five", "Six"}, 0), + size: 2, + wantSelection: 2, + }, + "beyond max": { + state: NewListState([]string{"One", "Two", "Three", "Four", "Five", "Six"}, 0), + size: 10, + wantSelection: 5, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.StepForward(tc.size) + + if tc.wantSelection != tc.state.selection { + t.Errorf("want %v, got %v", tc.wantSelection, tc.state.selection) + } + }) + } +} + +func TestListState_StepBackward(t *testing.T) { + tt := map[string]struct { + state *ListState[string] + size int + wantSelection int + }{ + "size 0": { + state: NewListState([]string{"One", "Two", "Three", "Four", "Five", "Six"}, 5), + size: 0, + wantSelection: 5, + }, + "normal": { + state: NewListState([]string{"One", "Two", "Three", "Four", "Five", "Six"}, 5), + size: 2, + wantSelection: 3, + }, + "beyond max": { + state: NewListState([]string{"One", "Two", "Three", "Four", "Five", "Six"}, 5), + size: 10, + wantSelection: 0, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + tc.state.StepBackward(tc.size) + + if tc.wantSelection != tc.state.selection { + t.Errorf("want %v, got %v", tc.wantSelection, tc.state.selection) + } + }) + } +} + func TestListState_JumpForward(t *testing.T) { tt := map[string]struct { state ListState[string] diff --git a/picker/picker.go b/picker/picker.go index a504ef74..0eaddcb6 100644 --- a/picker/picker.go +++ b/picker/picker.go @@ -29,8 +29,8 @@ type State interface { Next(canCycle bool) Prev(canCycle bool) - StepForward(count int) - StepBackward(count int) + StepForward(size int) + StepBackward(size int) JumpForward() JumpBackward() } diff --git a/picker/picker_test.go b/picker/picker_test.go index 83e14e7e..f22095de 100644 --- a/picker/picker_test.go +++ b/picker/picker_test.go @@ -16,11 +16,11 @@ func TestNew(t *testing.T) { wantFunc func() Model }{ "default": { - state: NewListState([]string{"One", "Two", "Three"}), + state: NewListState([]string{"One", "Two", "Three"}, 0), opts: nil, wantFunc: func() Model { return Model{ - State: NewListState([]string{"One", "Two", "Three"}), + State: NewListState([]string{"One", "Two", "Three"}, 0), ShowIndicators: true, CanCycle: false, CanJump: false, @@ -33,22 +33,22 @@ func TestNew(t *testing.T) { }, }, "WithKeys": { - state: NewListState([]string{"One", "Two", "Three"}), + state: NewListState([]string{"One", "Two", "Three"}, 0), opts: []func(*Model){ - WithKeys(KeyMap{ + WithKeys(&KeyMap{ Next: key.NewBinding(key.WithKeys("test", "key")), }), }, wantFunc: func() Model { return Model{ - State: NewListState([]string{"One", "Two", "Three"}), + State: NewListState([]string{"One", "Two", "Three"}, 0), ShowIndicators: true, CanCycle: false, CanJump: false, DisplayFunc: func(v interface{}) string { return fmt.Sprintf("%v", v) }, - Keys: KeyMap{ + Keys: &KeyMap{ Next: key.NewBinding(key.WithKeys("test", "key")), }, Styles: DefaultStyles(), @@ -56,13 +56,13 @@ func TestNew(t *testing.T) { }, }, "WithoutIndicators": { - state: NewListState([]string{"One", "Two", "Three"}), + state: NewListState([]string{"One", "Two", "Three"}, 0), opts: []func(*Model){ WithoutIndicators(), }, wantFunc: func() Model { return Model{ - State: NewListState([]string{"One", "Two", "Three"}), + State: NewListState([]string{"One", "Two", "Three"}, 0), ShowIndicators: false, CanCycle: false, CanJump: false, @@ -75,13 +75,13 @@ func TestNew(t *testing.T) { }, }, "WithCycles": { - state: NewListState([]string{"One", "Two", "Three"}), + state: NewListState([]string{"One", "Two", "Three"}, 0), opts: []func(*Model){ WithCycles(), }, wantFunc: func() Model { return Model{ - State: NewListState([]string{"One", "Two", "Three"}), + State: NewListState([]string{"One", "Two", "Three"}, 0), ShowIndicators: true, CanCycle: true, CanJump: false, @@ -94,7 +94,7 @@ func TestNew(t *testing.T) { }, }, "WithDisplayFunc": { - state: NewListState([]string{"One", "Two", "Three"}), + state: NewListState([]string{"One", "Two", "Three"}, 0), opts: []func(*Model){ WithDisplayFunc(func(_ interface{}) string { return fmt.Sprint("test") @@ -102,7 +102,7 @@ func TestNew(t *testing.T) { }, wantFunc: func() Model { return Model{ - State: NewListState([]string{"One", "Two", "Three"}), + State: NewListState([]string{"One", "Two", "Three"}, 0), ShowIndicators: true, CanCycle: false, CanJump: false, @@ -115,7 +115,7 @@ func TestNew(t *testing.T) { }, }, "WithStyles": { - state: NewListState([]string{"One", "Two", "Three"}), + state: NewListState([]string{"One", "Two", "Three"}, 0), opts: []func(*Model){ WithStyles(Styles{ Selection: lipgloss.NewStyle().Width(555).Height(-555), @@ -123,7 +123,7 @@ func TestNew(t *testing.T) { }, wantFunc: func() Model { return Model{ - State: NewListState([]string{"One", "Two", "Three"}), + State: NewListState([]string{"One", "Two", "Three"}, 0), ShowIndicators: true, CanCycle: false, CanJump: false, @@ -138,13 +138,13 @@ func TestNew(t *testing.T) { }, }, "WithJumping": { - state: NewListState([]string{"One", "Two", "Three"}), + state: NewListState([]string{"One", "Two", "Three"}, 0), opts: []func(*Model){ WithJumping(), }, wantFunc: func() Model { return Model{ - State: NewListState([]string{"One", "Two", "Three"}), + State: NewListState([]string{"One", "Two", "Three"}, 0), ShowIndicators: true, CanCycle: false, CanJump: true, From 967912f7b076d16127d1badea0c07f566d24e458 Mon Sep 17 00:00:00 2001 From: Broderick Westrope Date: Sat, 21 Sep 2024 20:30:22 +1000 Subject: [PATCH 30/30] test: add StepSize in TestNew --- picker/picker_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/picker/picker_test.go b/picker/picker_test.go index f22095de..c9589bae 100644 --- a/picker/picker_test.go +++ b/picker/picker_test.go @@ -24,6 +24,7 @@ func TestNew(t *testing.T) { ShowIndicators: true, CanCycle: false, CanJump: false, + StepSize: 10, DisplayFunc: func(v interface{}) string { return fmt.Sprintf("%v", v) }, @@ -45,6 +46,7 @@ func TestNew(t *testing.T) { ShowIndicators: true, CanCycle: false, CanJump: false, + StepSize: 10, DisplayFunc: func(v interface{}) string { return fmt.Sprintf("%v", v) }, @@ -66,6 +68,7 @@ func TestNew(t *testing.T) { ShowIndicators: false, CanCycle: false, CanJump: false, + StepSize: 10, DisplayFunc: func(v interface{}) string { return fmt.Sprintf("%v", v) }, @@ -85,6 +88,7 @@ func TestNew(t *testing.T) { ShowIndicators: true, CanCycle: true, CanJump: false, + StepSize: 10, DisplayFunc: func(v interface{}) string { return fmt.Sprintf("%v", v) }, @@ -106,6 +110,7 @@ func TestNew(t *testing.T) { ShowIndicators: true, CanCycle: false, CanJump: false, + StepSize: 10, DisplayFunc: func(_ interface{}) string { return fmt.Sprint("test") }, @@ -127,6 +132,7 @@ func TestNew(t *testing.T) { ShowIndicators: true, CanCycle: false, CanJump: false, + StepSize: 10, DisplayFunc: func(v interface{}) string { return fmt.Sprintf("%v", v) }, @@ -148,6 +154,27 @@ func TestNew(t *testing.T) { ShowIndicators: true, CanCycle: false, CanJump: true, + StepSize: 10, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + "WithStepping": { + state: NewListState([]string{"One", "Two", "Three"}, 0), + opts: []func(*Model){ + WithStepSize(2), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}, 0), + ShowIndicators: true, + CanCycle: false, + CanJump: false, + StepSize: 2, DisplayFunc: func(v interface{}) string { return fmt.Sprintf("%v", v) }, @@ -179,6 +206,10 @@ func TestNew(t *testing.T) { t.Errorf("CanJump: \ngot: \n%v \nwant: \n%v", got, want) } + if got.StepSize != want.StepSize { + t.Errorf("StepSize: \ngot: \n%v \nwant: \n%v", got, want) + } + if got.DisplayFunc == nil { t.Errorf("DisplayFunc: \ngot: \n%v \nwant: \n%v", got, want) } else if got.GetDisplayValue() != want.GetDisplayValue() {