1package survey
2
3import (
4	"errors"
5	"strings"
6
7	"gopkg.in/AlecAivazis/survey.v1/core"
8	"gopkg.in/AlecAivazis/survey.v1/terminal"
9)
10
11/*
12MultiSelect is a prompt that presents a list of various options to the user
13for them to select using the arrow keys and enter. Response type is a slice of strings.
14
15	days := []string{}
16	prompt := &survey.MultiSelect{
17		Message: "What days do you prefer:",
18		Options: []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"},
19	}
20	survey.AskOne(prompt, &days, nil)
21*/
22type MultiSelect struct {
23	core.Renderer
24	Message       string
25	Options       []string
26	Default       []string
27	Help          string
28	PageSize      int
29	VimMode       bool
30	FilterMessage string
31	FilterFn      func(string, []string) []string
32	filter        string
33	selectedIndex int
34	checked       map[string]bool
35	showingHelp   bool
36}
37
38// data available to the templates when processing
39type MultiSelectTemplateData struct {
40	MultiSelect
41	Answer        string
42	ShowAnswer    bool
43	Checked       map[string]bool
44	SelectedIndex int
45	ShowHelp      bool
46	PageEntries   []string
47}
48
49var MultiSelectQuestionTemplate = `
50{{- if .ShowHelp }}{{- color "cyan"}}{{ HelpIcon }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
51{{- color "green+hb"}}{{ QuestionIcon }} {{color "reset"}}
52{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}
53{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}}
54{{- else }}
55	{{- "  "}}{{- color "cyan"}}[Use arrows to move, enter to select, type to filter{{- if and .Help (not .ShowHelp)}}, {{ HelpInputRune }} for more help{{end}}]{{color "reset"}}
56  {{- "\n"}}
57  {{- range $ix, $option := .PageEntries}}
58    {{- if eq $ix $.SelectedIndex}}{{color "cyan"}}{{ SelectFocusIcon }}{{color "reset"}}{{else}} {{end}}
59    {{- if index $.Checked $option}}{{color "green"}} {{ MarkedOptionIcon }} {{else}}{{color "default+hb"}} {{ UnmarkedOptionIcon }} {{end}}
60    {{- color "reset"}}
61    {{- " "}}{{$option}}{{"\n"}}
62  {{- end}}
63{{- end}}`
64
65// OnChange is called on every keypress.
66func (m *MultiSelect) OnChange(line []rune, pos int, key rune) (newLine []rune, newPos int, ok bool) {
67	options := m.filterOptions()
68	oldFilter := m.filter
69
70	if key == terminal.KeyArrowUp || (m.VimMode && key == 'k') {
71		// if we are at the top of the list
72		if m.selectedIndex == 0 {
73			// go to the bottom
74			m.selectedIndex = len(options) - 1
75		} else {
76			// decrement the selected index
77			m.selectedIndex--
78		}
79	} else if key == terminal.KeyArrowDown || (m.VimMode && key == 'j') {
80		// if we are at the bottom of the list
81		if m.selectedIndex == len(options)-1 {
82			// start at the top
83			m.selectedIndex = 0
84		} else {
85			// increment the selected index
86			m.selectedIndex++
87		}
88		// if the user pressed down and there is room to move
89	} else if key == terminal.KeySpace {
90		if m.selectedIndex < len(options) {
91			if old, ok := m.checked[options[m.selectedIndex]]; !ok {
92				// otherwise just invert the current value
93				m.checked[options[m.selectedIndex]] = true
94			} else {
95				// otherwise just invert the current value
96				m.checked[options[m.selectedIndex]] = !old
97			}
98			m.filter = ""
99		}
100		// only show the help message if we have one to show
101	} else if key == core.HelpInputRune && m.Help != "" {
102		m.showingHelp = true
103	} else if key == terminal.KeyEscape {
104		m.VimMode = !m.VimMode
105	} else if key == terminal.KeyDeleteWord || key == terminal.KeyDeleteLine {
106		m.filter = ""
107	} else if key == terminal.KeyDelete || key == terminal.KeyBackspace {
108		if m.filter != "" {
109			m.filter = m.filter[0 : len(m.filter)-1]
110		}
111	} else if key >= terminal.KeySpace {
112		m.filter += string(key)
113		m.VimMode = false
114	}
115
116	m.FilterMessage = ""
117	if m.filter != "" {
118		m.FilterMessage = " " + m.filter
119	}
120	if oldFilter != m.filter {
121		// filter changed
122		options = m.filterOptions()
123		if len(options) > 0 && len(options) <= m.selectedIndex {
124			m.selectedIndex = len(options) - 1
125		}
126	}
127	// paginate the options
128
129	// TODO if we have started filtering and were looking at the end of a list
130	// and we have modified the filter then we should move the page back!
131	opts, idx := paginate(m.PageSize, options, m.selectedIndex)
132
133	// render the options
134	m.Render(
135		MultiSelectQuestionTemplate,
136		MultiSelectTemplateData{
137			MultiSelect:   *m,
138			SelectedIndex: idx,
139			Checked:       m.checked,
140			ShowHelp:      m.showingHelp,
141			PageEntries:   opts,
142		},
143	)
144
145	// if we are not pressing ent
146	return line, 0, true
147}
148
149func (m *MultiSelect) filterOptions() []string {
150	if m.filter == "" {
151		return m.Options
152	}
153	if m.FilterFn != nil {
154		return m.FilterFn(m.filter, m.Options)
155	}
156	return DefaultFilterFn(m.filter, m.Options)
157}
158
159func (m *MultiSelect) Prompt() (interface{}, error) {
160	// compute the default state
161	m.checked = make(map[string]bool)
162	// if there is a default
163	if len(m.Default) > 0 {
164		for _, dflt := range m.Default {
165			for _, opt := range m.Options {
166				// if the option corresponds to the default
167				if opt == dflt {
168					// we found our initial value
169					m.checked[opt] = true
170					// stop looking
171					break
172				}
173			}
174		}
175	}
176
177	// if there are no options to render
178	if len(m.Options) == 0 {
179		// we failed
180		return "", errors.New("please provide options to select from")
181	}
182
183	// paginate the options
184	opts, idx := paginate(m.PageSize, m.Options, m.selectedIndex)
185
186	cursor := m.NewCursor()
187	cursor.Hide()       // hide the cursor
188	defer cursor.Show() // show the cursor when we're done
189
190	// ask the question
191	err := m.Render(
192		MultiSelectQuestionTemplate,
193		MultiSelectTemplateData{
194			MultiSelect:   *m,
195			SelectedIndex: idx,
196			Checked:       m.checked,
197			PageEntries:   opts,
198		},
199	)
200	if err != nil {
201		return "", err
202	}
203
204	rr := m.NewRuneReader()
205	rr.SetTermMode()
206	defer rr.RestoreTermMode()
207
208	// start waiting for input
209	for {
210		r, _, _ := rr.ReadRune()
211		if r == '\r' || r == '\n' {
212			break
213		}
214		if r == terminal.KeyInterrupt {
215			return "", terminal.InterruptErr
216		}
217		if r == terminal.KeyEndTransmission {
218			break
219		}
220		m.OnChange(nil, 0, r)
221	}
222	m.filter = ""
223	m.FilterMessage = ""
224
225	answers := []string{}
226	for _, option := range m.Options {
227		if val, ok := m.checked[option]; ok && val {
228			answers = append(answers, option)
229		}
230	}
231
232	return answers, nil
233}
234
235// Cleanup removes the options section, and renders the ask like a normal question.
236func (m *MultiSelect) Cleanup(val interface{}) error {
237	// execute the output summary template with the answer
238	return m.Render(
239		MultiSelectQuestionTemplate,
240		MultiSelectTemplateData{
241			MultiSelect:   *m,
242			SelectedIndex: m.selectedIndex,
243			Checked:       m.checked,
244			Answer:        strings.Join(val.([]string), ", "),
245			ShowAnswer:    true,
246		},
247	)
248}
249