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