1package survey
2
3import (
4	"errors"
5	"fmt"
6
7	"github.com/AlecAivazis/survey/v2/core"
8	"github.com/AlecAivazis/survey/v2/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)
21*/
22type MultiSelect struct {
23	Renderer
24	Message       string
25	Options       []string
26	Default       interface{}
27	Help          string
28	PageSize      int
29	VimMode       bool
30	FilterMessage string
31	Filter        func(filter string, value string, index int) bool
32	filter        string
33	selectedIndex int
34	checked       map[int]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[int]bool
44	SelectedIndex int
45	ShowHelp      bool
46	PageEntries   []core.OptionAnswer
47	Config        *PromptConfig
48}
49
50var MultiSelectQuestionTemplate = `
51{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
52{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
53{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}}
54{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}}
55{{- else }}
56	{{- "  "}}{{- color "cyan"}}[Use arrows to move, space to select, <right> to all, <left> to none, type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}}
57  {{- "\n"}}
58  {{- range $ix, $option := .PageEntries}}
59    {{- if eq $ix $.SelectedIndex }}{{color $.Config.Icons.SelectFocus.Format }}{{ $.Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
60    {{- if index $.Checked $option.Index }}{{color $.Config.Icons.MarkedOption.Format }} {{ $.Config.Icons.MarkedOption.Text }} {{else}}{{color $.Config.Icons.UnmarkedOption.Format }} {{ $.Config.Icons.UnmarkedOption.Text }} {{end}}
61    {{- color "reset"}}
62    {{- " "}}{{$option.Value}}{{"\n"}}
63  {{- end}}
64{{- end}}`
65
66// OnChange is called on every keypress.
67func (m *MultiSelect) OnChange(key rune, config *PromptConfig) {
68	options := m.filterOptions(config)
69	oldFilter := m.filter
70
71	if key == terminal.KeyArrowUp || (m.VimMode && key == 'k') {
72		// if we are at the top of the list
73		if m.selectedIndex == 0 {
74			// go to the bottom
75			m.selectedIndex = len(options) - 1
76		} else {
77			// decrement the selected index
78			m.selectedIndex--
79		}
80	} else if key == terminal.KeyTab || key == terminal.KeyArrowDown || (m.VimMode && key == 'j') {
81		// if we are at the bottom of the list
82		if m.selectedIndex == len(options)-1 {
83			// start at the top
84			m.selectedIndex = 0
85		} else {
86			// increment the selected index
87			m.selectedIndex++
88		}
89		// if the user pressed down and there is room to move
90	} else if key == terminal.KeySpace {
91		// the option they have selected
92		if m.selectedIndex < len(options) {
93			selectedOpt := options[m.selectedIndex]
94
95			// if we haven't seen this index before
96			if old, ok := m.checked[selectedOpt.Index]; !ok {
97				// set the value to true
98				m.checked[selectedOpt.Index] = true
99			} else {
100				// otherwise just invert the current value
101				m.checked[selectedOpt.Index] = !old
102			}
103			if !config.KeepFilter {
104				m.filter = ""
105			}
106		}
107		// only show the help message if we have one to show
108	} else if string(key) == config.HelpInput && m.Help != "" {
109		m.showingHelp = true
110	} else if key == terminal.KeyEscape {
111		m.VimMode = !m.VimMode
112	} else if key == terminal.KeyDeleteWord || key == terminal.KeyDeleteLine {
113		m.filter = ""
114	} else if key == terminal.KeyDelete || key == terminal.KeyBackspace {
115		if m.filter != "" {
116			m.filter = m.filter[0 : len(m.filter)-1]
117		}
118	} else if key >= terminal.KeySpace {
119		m.filter += string(key)
120		m.VimMode = false
121	} else if key == terminal.KeyArrowRight {
122		for _, v := range options {
123			m.checked[v.Index] = true
124		}
125		if !config.KeepFilter {
126			m.filter = ""
127		}
128	} else if key == terminal.KeyArrowLeft {
129		for _, v := range options {
130			m.checked[v.Index] = false
131		}
132		if !config.KeepFilter {
133			m.filter = ""
134		}
135	}
136
137	m.FilterMessage = ""
138	if m.filter != "" {
139		m.FilterMessage = " " + m.filter
140	}
141	if oldFilter != m.filter {
142		// filter changed
143		options = m.filterOptions(config)
144		if len(options) > 0 && len(options) <= m.selectedIndex {
145			m.selectedIndex = len(options) - 1
146		}
147	}
148	// paginate the options
149	// figure out the page size
150	pageSize := m.PageSize
151	// if we dont have a specific one
152	if pageSize == 0 {
153		// grab the global value
154		pageSize = config.PageSize
155	}
156
157	// TODO if we have started filtering and were looking at the end of a list
158	// and we have modified the filter then we should move the page back!
159	opts, idx := paginate(pageSize, options, m.selectedIndex)
160
161	// render the options
162	m.Render(
163		MultiSelectQuestionTemplate,
164		MultiSelectTemplateData{
165			MultiSelect:   *m,
166			SelectedIndex: idx,
167			Checked:       m.checked,
168			ShowHelp:      m.showingHelp,
169			PageEntries:   opts,
170			Config:        config,
171		},
172	)
173}
174
175func (m *MultiSelect) filterOptions(config *PromptConfig) []core.OptionAnswer {
176	// the filtered list
177	answers := []core.OptionAnswer{}
178
179	// if there is no filter applied
180	if m.filter == "" {
181		// return all of the options
182		return core.OptionAnswerList(m.Options)
183	}
184
185	// the filter to apply
186	filter := m.Filter
187	if filter == nil {
188		filter = config.Filter
189	}
190
191	// apply the filter to each option
192	for i, opt := range m.Options {
193		// i the filter says to include the option
194		if filter(m.filter, opt, i) {
195			answers = append(answers, core.OptionAnswer{
196				Index: i,
197				Value: opt,
198			})
199		}
200	}
201
202	// we're done here
203	return answers
204}
205
206func (m *MultiSelect) Prompt(config *PromptConfig) (interface{}, error) {
207	// compute the default state
208	m.checked = make(map[int]bool)
209	// if there is a default
210	if m.Default != nil {
211		// if the default is string values
212		if defaultValues, ok := m.Default.([]string); ok {
213			for _, dflt := range defaultValues {
214				for i, opt := range m.Options {
215					// if the option corresponds to the default
216					if opt == dflt {
217						// we found our initial value
218						m.checked[i] = true
219						// stop looking
220						break
221					}
222				}
223			}
224			// if the default value is index values
225		} else if defaultIndices, ok := m.Default.([]int); ok {
226			// go over every index we need to enable by default
227			for _, idx := range defaultIndices {
228				// and enable it
229				m.checked[idx] = true
230			}
231		}
232	}
233
234	// if there are no options to render
235	if len(m.Options) == 0 {
236		// we failed
237		return "", errors.New("please provide options to select from")
238	}
239
240	// figure out the page size
241	pageSize := m.PageSize
242	// if we dont have a specific one
243	if pageSize == 0 {
244		// grab the global value
245		pageSize = config.PageSize
246	}
247	// paginate the options
248	// build up a list of option answers
249	opts, idx := paginate(pageSize, core.OptionAnswerList(m.Options), m.selectedIndex)
250
251	cursor := m.NewCursor()
252	cursor.Hide()       // hide the cursor
253	defer cursor.Show() // show the cursor when we're done
254
255	// ask the question
256	err := m.Render(
257		MultiSelectQuestionTemplate,
258		MultiSelectTemplateData{
259			MultiSelect:   *m,
260			SelectedIndex: idx,
261			Checked:       m.checked,
262			PageEntries:   opts,
263			Config:        config,
264		},
265	)
266	if err != nil {
267		return "", err
268	}
269
270	rr := m.NewRuneReader()
271	rr.SetTermMode()
272	defer rr.RestoreTermMode()
273
274	// start waiting for input
275	for {
276		r, _, _ := rr.ReadRune()
277		if r == '\r' || r == '\n' {
278			break
279		}
280		if r == terminal.KeyInterrupt {
281			return "", terminal.InterruptErr
282		}
283		if r == terminal.KeyEndTransmission {
284			break
285		}
286		m.OnChange(r, config)
287	}
288	m.filter = ""
289	m.FilterMessage = ""
290
291	answers := []core.OptionAnswer{}
292	for i, option := range m.Options {
293		if val, ok := m.checked[i]; ok && val {
294			answers = append(answers, core.OptionAnswer{Value: option, Index: i})
295		}
296	}
297
298	return answers, nil
299}
300
301// Cleanup removes the options section, and renders the ask like a normal question.
302func (m *MultiSelect) Cleanup(config *PromptConfig, val interface{}) error {
303	// the answer to show
304	answer := ""
305	for _, ans := range val.([]core.OptionAnswer) {
306		answer = fmt.Sprintf("%s, %s", answer, ans.Value)
307	}
308
309	// if we answered anything
310	if len(answer) > 2 {
311		// remove the precending commas
312		answer = answer[2:]
313	}
314
315	// execute the output summary template with the answer
316	return m.Render(
317		MultiSelectQuestionTemplate,
318		MultiSelectTemplateData{
319			MultiSelect:   *m,
320			SelectedIndex: m.selectedIndex,
321			Checked:       m.checked,
322			Answer:        answer,
323			ShowAnswer:    true,
324			Config:        config,
325		},
326	)
327}
328