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