diff --git a/list/list.go b/list/list.go index c98a39c..572586f 100644 --- a/list/list.go +++ b/list/list.go @@ -19,12 +19,14 @@ const NotFound = -1 // visible items. The list can be moved up, down by one item of time or an // entire page (ie: visible size). It keeps track of the current selected item. type List struct { - items []*interface{} - scope []*interface{} - cursor int // cursor holds the index of the current selected item - size int // size is the number of visible options - start int - Searcher Searcher + items []*interface{} + scope []*interface{} + cursor int // cursor holds the index of the current selected item + size int // size is the number of visible options + start int + Searcher Searcher + multi []int // holds indexes of multiple selected items + allowMulti bool } // New creates and initializes a list of searchable items. The items attribute must be a slice type with a @@ -49,6 +51,37 @@ func New(items interface{}, size int) (*List, error) { return &List{size: size, items: values, scope: values}, nil } +// NewMulti does the same New() but allows multiple item selections +func NewMulti(items interface{}, size int) (*List, error) { + l, err := New(items, size) + if err != nil { + return l, err + } + l.allowMulti = true + l.multi = make([]int, 0) + return l, err +} + +// Select adds or removes the current item to a multi selection list. +// If toggle is false the item is added only. +// Otherwise it is added if not found and removed if found. +func (l *List) Select(toggle bool) { + if l.allowMulti { + j := l.Index() + if j >= 0 { + for i, val := range l.multi { + if val == j { + if toggle { + l.multi = append(l.multi[:i], l.multi[i+1:]...) + } + return + } + } + l.multi = append(l.multi, j) + } + } +} + // Prev moves the visible list back one item. If the selected item is out of // view, the new select item becomes the last visible item. If the list is // already at the top, nothing happens. @@ -201,7 +234,11 @@ func (l *List) CanPageUp() bool { // Index returns the index of the item currently selected inside the searched list. If no item is selected, // the NotFound (-1) index is returned. func (l *List) Index() int { - selected := l.scope[l.cursor] + return l.findIdx(l.cursor) +} + +func (l *List) findIdx(idx int) int { + selected := l.scope[idx] for i, item := range l.items { if item == selected { @@ -212,6 +249,38 @@ func (l *List) Index() int { return NotFound } +// Selected returns a list of selected items. +// The list will be empty if the struct is not a multi-select list. +func (l *List) Selected() []interface{} { + result := make([]interface{}, 0) + if !l.allowMulti || len(l.multi) == 0 { + return result + } + for _, i := range l.multi { + result = append(result, *l.items[i]) + } + return result +} + +// IsSelected return true if the given index is selected. +func (l *List) IsSelected(idx int) bool { + if !l.allowMulti { + return false + } + + sIdx := l.findIdx(idx) + if sIdx < 0 { + return false + } + for _, i := range l.multi { + if i == sIdx { + return true + } + } + + return false +} + // Items returns a slice equal to the size of the list with the current visible // items and the index of the active item in this list. func (l *List) Items() ([]interface{}, int) { diff --git a/list/list_test.go b/list/list_test.go index 4dd6fde..0a1c80b 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -29,6 +29,18 @@ func TestListNew(t *testing.T) { }) } +func TestListNewMulti(t *testing.T) { + t.Run("when items a slice nil", func(t *testing.T) { + l, err := NewMulti([]int{1, 2, 3}, 3) + if err != nil { + t.Errorf("Expected no errors, error %v", err) + } + if !l.allowMulti { + t.Error("List not set to multi-select") + } + }) +} + func TestListMovement(t *testing.T) { letters := []rune{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'} @@ -162,3 +174,85 @@ func castList(list []interface{}) []rune { } return result } + +func TestListMultSelectMovement(t *testing.T) { + letters := []rune{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'} + + l, err := New(letters, 4) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + m, err := NewMulti(letters, 4) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + tcs := []struct { + mexpect []rune + lexpect []rune + move string + selected rune + action string + }{ + {move: "next", action: "", selected: 'b', mexpect: []rune{}, lexpect: []rune{}}, + {move: "prev", action: "", selected: 'a', mexpect: []rune{}, lexpect: []rune{}}, + {move: "prev", action: "select", selected: 'a', mexpect: []rune{'a'}, lexpect: []rune{}}, + {move: "next", action: "", selected: 'b', mexpect: []rune{'a'}, lexpect: []rune{}}, + {move: "next", action: "select", selected: 'c', mexpect: []rune{'a', 'c'}, lexpect: []rune{}}, + {move: "next", action: "", selected: 'd', mexpect: []rune{'a', 'c'}, lexpect: []rune{}}, + {move: "next", action: "select", selected: 'e', mexpect: []rune{'a', 'c', 'e'}, lexpect: []rune{}}, + {move: "prev", action: "", selected: 'd', mexpect: []rune{'a', 'c', 'e'}, lexpect: []rune{}}, + {move: "up", action: "select", selected: 'a', mexpect: []rune{'c', 'e'}, lexpect: []rune{}}, + {move: "up", action: "", selected: 'a', mexpect: []rune{'c', 'e'}, lexpect: []rune{}}, + {move: "down", action: "", selected: 'e', mexpect: []rune{'c', 'e'}, lexpect: []rune{}}, + {move: "down", action: "select", selected: 'g', mexpect: []rune{'c', 'e', 'g'}, lexpect: []rune{}}, + {move: "down", action: "enter", selected: 'j', mexpect: []rune{'c', 'e', 'g', 'j'}, lexpect: []rune{}}, + {move: "down", action: "enter", selected: 'j', mexpect: []rune{'c', 'e', 'g', 'j'}, lexpect: []rune{}}, + } + + for _, tc := range tcs { + t.Run(fmt.Sprintf("list %s", tc.move), func(t *testing.T) { + switch tc.move { + case "next": + l.Next() + m.Next() + case "prev": + l.Prev() + m.Prev() + case "up": + l.PageUp() + m.PageUp() + case "down": + l.PageDown() + m.PageDown() + default: + t.Fatalf("unknown move %q", tc.move) + } + + switch tc.action { + case "select": + l.Select(true) + m.Select(true) + case "enter": + l.Select(false) + m.Select(false) + } + + list := l.Selected() + + got := castList(list) + + if !reflect.DeepEqual(tc.lexpect, got) { + t.Errorf("L expected %q, got %q", tc.lexpect, got) + } + + list = m.Selected() + + got = castList(list) + + if !reflect.DeepEqual(tc.mexpect, got) { + t.Errorf("M expected %q, got %q", tc.mexpect, got) + } + }) + } +} diff --git a/select.go b/select.go index b58ed97..a19dfa8 100644 --- a/select.go +++ b/select.go @@ -44,6 +44,9 @@ type Select struct { // CursorPos is the initial position of the cursor. CursorPos int + // Multi determines if multi select is allowed. Defaults to false. + Multi bool + // IsVimMode sets whether to use vim mode when using readline in the command prompt. Look at // https://godoc.org/github.com/chzyer/readline#Config for more information on readline. IsVimMode bool @@ -99,6 +102,9 @@ type SelectKeys struct { // Search is the key used to trigger the search mode for the list. Default to the "/" key. Search Key + + // Select is the key used to toggle an item as selected or not without ending selection. Defaults to space bar. + Select Key } // Key defines a keyboard code and a display representation for the help menu. @@ -116,24 +122,27 @@ type Key struct { // text/template syntax. Custom state, colors and background color are available for use inside // the templates and are documented inside the Variable section of the docs. // -// Examples +// # Examples // // text/templates use a special notation to display programmable content. Using the double bracket notation, // the value can be printed with specific helper functions. For example // // This displays the value given to the template as pure, unstylized text. Structs are transformed to string // with this notation. -// '{{ . }}' +// +// '{{ . }}' // // This displays the name property of the value colored in cyan -// '{{ .Name | cyan }}' +// +// '{{ .Name | cyan }}' // // This displays the label property of value colored in red with a cyan background-color -// '{{ .Label | red | cyan }}' +// +// '{{ .Label | red | cyan }}' // // See the doc of text/template for more info: https://golang.org/pkg/text/template/ // -// Notes +// # Notes // // Setting any of these templates will remove the icons from the default templates. They must // be added back in each of their specific templates. The styles.go constants contains the default icons. @@ -145,6 +154,9 @@ type SelectTemplates struct { // Active is a text/template for when an item is currently active within the list. Active string + // ActiveSelected is a text/template for when the currently active item is also multi selected . + ActiveSelected string + // Inactive is a text/template for when an item is not currently active inside the list. This // template is used for all items unless they are active or selected. Inactive string @@ -172,12 +184,13 @@ type SelectTemplates struct { // is overridden, the colors functions must be added in the override from promptui.FuncMap to work. FuncMap template.FuncMap - label *template.Template - active *template.Template - inactive *template.Template - selected *template.Template - details *template.Template - help *template.Template + label *template.Template + active *template.Template + activeSelected *template.Template + inactive *template.Template + selected *template.Template + details *template.Template + help *template.Template } // SearchPrompt is the prompt displayed in search mode. @@ -202,7 +215,15 @@ func (s *Select) RunCursorAt(cursorPos, scroll int) (int, string, error) { s.Size = 5 } - l, err := list.New(s.Items, s.Size) + var ( + l *list.List + err error + ) + if s.Multi { + l, err = list.NewMulti(s.Items, s.Size) + } else { + l, err = list.New(s.Items, s.Size) + } if err != nil { return 0, "", err } @@ -256,6 +277,7 @@ func (s *Select) innerRun(cursorPos, scroll int, top rune) (int, string, error) c.SetListener(func(line []rune, pos int, key rune) ([]rune, int, bool) { switch { case key == KeyEnter: + s.list.Select(false) return nil, 0, true case key == s.Keys.Next.Code || (key == 'j' && !searchMode): s.list.Next() @@ -288,6 +310,8 @@ func (s *Select) innerRun(cursorPos, scroll int, top rune) (int, string, error) s.list.PageUp() case key == s.Keys.PageDown.Code || (key == 'l' && !searchMode): s.list.PageDown() + case key == s.Keys.Select.Code && !searchMode: + s.list.Select(true) default: if canSearch && searchMode { cur.Update(string(line)) @@ -328,7 +352,13 @@ func (s *Select) innerRun(cursorPos, scroll int, top rune) (int, string, error) output := []byte(page + " ") if i == idx { - output = append(output, render(s.Templates.active, item)...) + if s.list.IsSelected(i) { + output = append(output, render(s.Templates.activeSelected, item)...) + } else { + output = append(output, render(s.Templates.active, item)...) + } + } else if s.list.IsSelected(i) { + output = append(output, render(s.Templates.selected, item)...) } else { output = append(output, render(s.Templates.inactive, item)...) } @@ -387,6 +417,14 @@ func (s *Select) innerRun(cursorPos, scroll int, top rune) (int, string, error) items, idx := s.list.Items() item := items[idx] + if s.Multi { + selected := `` + opts := s.GetSelected() + for _, o := range opts { + selected += o.(string) + `, ` + } + item = selected[:len(selected)-2] + } if s.HideSelected { clearScreen(sb) @@ -402,6 +440,15 @@ func (s *Select) innerRun(cursorPos, scroll int, top rune) (int, string, error) return s.list.Index(), fmt.Sprintf("%v", item), err } +// GetSelected returns the selected values. +// Returns nil if not multi select. +func (s *Select) GetSelected() []interface{} { + if s.Multi { + return s.list.Selected() + } + return nil +} + // ScrollPosition returns the current scroll position. func (s *Select) ScrollPosition() int { return s.list.Start() @@ -439,6 +486,17 @@ func (s *Select) prepareTemplates() error { tpls.active = tpl + if tpls.ActiveSelected == "" { + tpls.ActiveSelected = fmt.Sprintf("%s {{ . | underline }}", IconGood) + } + + tpl, err = template.New("").Funcs(tpls.FuncMap).Parse(tpls.ActiveSelected) + if err != nil { + return err + } + + tpls.activeSelected = tpl + if tpls.Inactive == "" { tpls.Inactive = " {{.}}" } @@ -470,8 +528,13 @@ func (s *Select) prepareTemplates() error { } if tpls.Help == "" { + multi := `` + if s.Multi { + multi = `{{ "space bar or enter to select:" | faint }} {{ .SelectKey | faint }} ` + } tpls.Help = fmt.Sprintf(`{{ "Use the arrow keys to navigate:" | faint }} {{ .NextKey | faint }} ` + `{{ .PrevKey | faint }} {{ .PageDownKey | faint }} {{ .PageUpKey | faint }} ` + + multi + `{{ if .Search }} {{ "and" | faint }} {{ .SearchKey | faint }} {{ "toggles search" | faint }}{{ end }}`) } @@ -578,6 +641,7 @@ func (s *Select) setKeys() { PageUp: Key{Code: KeyBackward, Display: KeyBackwardDisplay}, PageDown: Key{Code: KeyForward, Display: KeyForwardDisplay}, Search: Key{Code: '/', Display: "/"}, + Select: Key{Code: ' ', Display: ""}, } } @@ -610,6 +674,7 @@ func (s *Select) renderHelp(b bool) []byte { PageUpKey string Search bool SearchKey string + SelectKey string }{ NextKey: s.Keys.Next.Display, PrevKey: s.Keys.Prev.Display, @@ -617,6 +682,7 @@ func (s *Select) renderHelp(b bool) []byte { PageUpKey: s.Keys.PageUp.Display, SearchKey: s.Keys.Search.Display, Search: b, + SelectKey: s.Keys.Select.Display, } return render(s.Templates.help, keys)