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