diff --git a/picker/intstate.go b/picker/intstate.go new file mode 100644 index 00000000..e1679a96 --- /dev/null +++ b/picker/intstate.go @@ -0,0 +1,82 @@ +package picker + +type IntState struct { + min int + max int + selection int + ignoreMin bool + ignoreMax bool +} + +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: selection, + } +} + +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.NextExists(): + s.selection++ + + case canCycle: + s.selection = s.min + } +} + +func (s *IntState) Prev(canCycle bool) { + switch { + case s.PrevExists(): + s.selection-- + + case canCycle: + s.selection = s.max + } +} + +func (s *IntState) StepForward(size int) { + s.selection += size + if s.selection > s.max && !s.ignoreMax { + s.selection = s.max + } +} + +func (s *IntState) StepBackward(size int) { + s.selection -= size + if s.selection < s.min && !s.ignoreMin { + s.selection = s.min + } +} + +func (s *IntState) JumpForward() { + s.selection = s.max +} + +func (s *IntState) JumpBackward() { + s.selection = s.min +} diff --git a/picker/intstate_test.go b/picker/intstate_test.go new file mode 100644 index 00000000..549ba25f --- /dev/null +++ b/picker/intstate_test.go @@ -0,0 +1,606 @@ +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) + } + }) + } +} + +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) + } +} + +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_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 + 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) + } + }) + } +} + +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) + } + }) + } +} + +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 + 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) + } + }) + } +} diff --git a/picker/keys.go b/picker/keys.go new file mode 100644 index 00000000..0ca157bc --- /dev/null +++ b/picker/keys.go @@ -0,0 +1,43 @@ +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 + 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{ + Next: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "next"), + ), + Prev: key.NewBinding( + 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"), + ), + JumpBackward: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "jump backward"), + ), + } +} diff --git a/picker/liststate.go b/picker/liststate.go new file mode 100644 index 00000000..db24ef05 --- /dev/null +++ b/picker/liststate.go @@ -0,0 +1,67 @@ +package picker + +type ListState[T any] struct { + state []T + selection int +} + +func NewListState[T any](state []T, selection int) *ListState[T] { + return &ListState[T]{ + state: state, + selection: selection, + } +} + +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.NextExists(): + s.selection++ + + case canCycle: + s.selection = 0 + } +} + +func (s *ListState[T]) Prev(canCycle bool) { + switch { + case s.PrevExists(): + s.selection-- + + case canCycle: + s.selection = len(s.state) - 1 + } +} + +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(size int) { + s.selection -= size + if s.selection < 0 { + s.selection = 0 + } +} + +func (s *ListState[T]) JumpForward() { + s.selection = len(s.state) - 1 +} + +func (s *ListState[T]) JumpBackward() { + s.selection = 0 +} diff --git a/picker/liststate_test.go b/picker/liststate_test.go new file mode 100644 index 00000000..feabae14 --- /dev/null +++ b/picker/liststate_test.go @@ -0,0 +1,347 @@ +package picker + +import "testing" + +func TestNewListState(t *testing.T) { + want := ListState[string]{ + state: []string{"One", "Two", "Three"}, + } + + got := NewListState([]string{"One", "Two", "Three"}, 2) + + 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 != 2 { + 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_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] + 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) + } + }) + } +} + +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] + 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) + } + }) + } +} diff --git a/picker/picker.go b/picker/picker.go new file mode 100644 index 00000000..0eaddcb6 --- /dev/null +++ b/picker/picker.go @@ -0,0 +1,182 @@ +package picker + +import ( + "fmt" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "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 + CanCycle bool + CanJump bool + StepSize int + DisplayFunc DisplayFunc + Keys *KeyMap + Styles Styles +} + +type State interface { + GetValue() interface{} + + NextExists() bool + PrevExists() bool + + Next(canCycle bool) + Prev(canCycle bool) + StepForward(size int) + StepBackward(size int) + JumpForward() + JumpBackward() +} + +type DisplayFunc func(stateValue interface{}) string + +func New(state State, opts ...func(*Model)) Model { + defaultDisplayFunc := func(v interface{}) string { + return fmt.Sprintf("%v", v) + } + + m := Model{ + State: state, + ShowIndicators: true, + CanCycle: false, + CanJump: false, + StepSize: 10, + DisplayFunc: defaultDisplayFunc, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + + for _, opt := range opts { + opt(&m) + } + + return m +} + +func (m Model) Init() tea.Cmd { + return nil +} + +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.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() + } + + case key.Matches(msg, m.Keys.JumpBackward): + if m.CanJump { + m.State.JumpBackward() + } + } + } + + return m, nil +} + +func (m Model) View() string { + var prevInd, nextInd string + if m.ShowIndicators { + prevInd = m.GetPrevIndicator() + nextInd = m.GetNextIndicator() + } + + value := m.Styles.Selection.Render( + m.GetDisplayValue(), + ) + + return lipgloss.JoinHorizontal(lipgloss.Center, + prevInd, + value, + nextInd, + ) +} + +func (m Model) GetValue() interface{} { + return m.State.GetValue() +} + +func (m Model) GetDisplayValue() string { + return m.DisplayFunc(m.State.GetValue()) +} + +func (m Model) GetPrevIndicator() string { + return getIndicator(m.Styles.Previous, m.State.PrevExists()) +} + +func (m Model) GetNextIndicator() string { + return getIndicator(m.Styles.Next, m.State.NextExists()) +} + +func getIndicator(styles IndicatorStyles, enabled bool) string { + switch enabled { + case false: + return styles.Disabled.Render(styles.Value) + default: + return styles.Enabled.Render(styles.Value) + } +} + +// Model Options -------------------- + +func WithKeys(keys *KeyMap) func(*Model) { + return func(m *Model) { + m.Keys = keys + } +} + +func WithoutIndicators() func(*Model) { + return func(m *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 + } +} + +func WithStyles(s Styles) func(*Model) { + return func(m *Model) { + m.Styles = s + } +} + +func WithJumping() func(*Model) { + return func(m *Model) { + m.CanJump = true + } +} + +func WithStepSize(size int) func(*Model) { + return func(m *Model) { + m.StepSize = size + } +} diff --git a/picker/picker_test.go b/picker/picker_test.go new file mode 100644 index 00000000..c9589bae --- /dev/null +++ b/picker/picker_test.go @@ -0,0 +1,335 @@ +package picker + +import ( + "fmt" + "github.com/MakeNowJust/heredoc" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/lipgloss" + "reflect" + "testing" +) + +func TestNew(t *testing.T) { + tt := map[string]struct { + state State + opts []func(*Model) + wantFunc func() Model + }{ + "default": { + state: NewListState([]string{"One", "Two", "Three"}, 0), + opts: nil, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}, 0), + ShowIndicators: true, + CanCycle: false, + CanJump: false, + StepSize: 10, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + "WithKeys": { + state: NewListState([]string{"One", "Two", "Three"}, 0), + opts: []func(*Model){ + WithKeys(&KeyMap{ + Next: key.NewBinding(key.WithKeys("test", "key")), + }), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}, 0), + ShowIndicators: true, + CanCycle: false, + CanJump: false, + StepSize: 10, + 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"}, 0), + opts: []func(*Model){ + WithoutIndicators(), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}, 0), + ShowIndicators: false, + CanCycle: false, + CanJump: false, + StepSize: 10, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + "WithCycles": { + state: NewListState([]string{"One", "Two", "Three"}, 0), + opts: []func(*Model){ + WithCycles(), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}, 0), + ShowIndicators: true, + CanCycle: true, + CanJump: false, + StepSize: 10, + DisplayFunc: func(v interface{}) string { + return fmt.Sprintf("%v", v) + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + "WithDisplayFunc": { + state: NewListState([]string{"One", "Two", "Three"}, 0), + opts: []func(*Model){ + WithDisplayFunc(func(_ interface{}) string { + return fmt.Sprint("test") + }), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}, 0), + ShowIndicators: true, + CanCycle: false, + CanJump: false, + StepSize: 10, + DisplayFunc: func(_ interface{}) string { + return fmt.Sprint("test") + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + "WithStyles": { + state: NewListState([]string{"One", "Two", "Three"}, 0), + opts: []func(*Model){ + WithStyles(Styles{ + Selection: lipgloss.NewStyle().Width(555).Height(-555), + }), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}, 0), + ShowIndicators: true, + CanCycle: false, + CanJump: false, + StepSize: 10, + 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"}, 0), + opts: []func(*Model){ + WithJumping(), + }, + wantFunc: func() Model { + return Model{ + State: NewListState([]string{"One", "Two", "Three"}, 0), + 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) + }, + Keys: DefaultKeyMap(), + Styles: DefaultStyles(), + } + }, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + want := tc.wantFunc() + got := New(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.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() { + 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) + } + }) + } +} + +func TestModel_View(t *testing.T) { + model := New( + &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 + 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) + } + }) + } +} + +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) + } + }) + } +} 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, + }, + } +}