1
1
package cliui
2
2
3
3
import (
4
- "errors"
5
4
"flag"
6
- "io "
7
- "os "
5
+ "fmt "
6
+ "strings "
8
7
9
- "github.com/AlecAivazis/survey/v2 "
10
- "github.com/AlecAivazis/survey/v2/terminal "
8
+ "github.com/charmbracelet/bubbles/textinput "
9
+ tea "github.com/charmbracelet/bubbletea "
11
10
"golang.org/x/xerrors"
12
11
13
12
"github.com/coder/coder/v2/codersdk"
@@ -66,6 +65,7 @@ func RichSelect(inv *serpent.Invocation, richOptions RichSelectOptions) (*coders
66
65
67
66
// Select displays a list of user options.
68
67
func Select (inv * serpent.Invocation , opts SelectOptions ) (string , error ) {
68
+ // TODO: Check if this is still true for Bubbletea.
69
69
// The survey library used *always* fails when testing on Windows,
70
70
// as it requires a live TTY (can't be a conpty). We should fork
71
71
// this library to add a dummy fallback, that simply reads/writes
@@ -75,33 +75,154 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
75
75
return opts .Options [0 ], nil
76
76
}
77
77
78
- var defaultOption interface {}
79
- if opts .Default != "" {
80
- defaultOption = opts .Default
78
+ initialModel := selectModel {
79
+ search : textinput .New (),
80
+ hideSearch : opts .HideSearch ,
81
+ options : opts .Options ,
82
+ height : opts .Size ,
81
83
}
82
84
85
+ if initialModel .height == 0 {
86
+ initialModel .height = 5 // TODO: Pick a default?
87
+ }
88
+
89
+ initialModel .search .Prompt = ""
90
+ initialModel .search .Focus ()
91
+
92
+ m , err := tea .NewProgram (
93
+ initialModel ,
94
+ tea .WithInput (inv .Stdin ),
95
+ tea .WithOutput (inv .Stdout ),
96
+ ).Run ()
97
+
83
98
var value string
84
- err := survey .AskOne (& survey.Select {
85
- Options : opts .Options ,
86
- Default : defaultOption ,
87
- PageSize : opts .Size ,
88
- Message : opts .Message ,
89
- }, & value , survey .WithIcons (func (is * survey.IconSet ) {
90
- is .Help .Text = "Type to search"
91
- if opts .HideSearch {
92
- is .Help .Text = ""
99
+ if m , ok := m .(selectModel ); ok {
100
+ if m .canceled {
101
+ return value , Canceled
93
102
}
94
- }), survey .WithStdio (fileReadWriter {
95
- Reader : inv .Stdin ,
96
- }, fileReadWriter {
97
- Writer : inv .Stdout ,
98
- }, inv .Stdout ))
99
- if errors .Is (err , terminal .InterruptErr ) {
100
- return value , Canceled
103
+ value = m .selected
101
104
}
102
105
return value , err
103
106
}
104
107
108
+ type selectModel struct {
109
+ search textinput.Model
110
+ options []string
111
+ cursor int
112
+ height int
113
+ selected string
114
+ canceled bool
115
+ hideSearch bool
116
+ }
117
+
118
+ func (selectModel ) Init () tea.Cmd {
119
+ return textinput .Blink
120
+ }
121
+
122
+ //nolint:revive The linter complains about modifying 'm' but this is typical practice for bubbletea
123
+ func (m selectModel ) Update (msg tea.Msg ) (tea.Model , tea.Cmd ) {
124
+ var cmd tea.Cmd
125
+
126
+ if msg , ok := msg .(tea.KeyMsg ); ok {
127
+ switch msg .Type {
128
+ case tea .KeyCtrlC :
129
+ m .canceled = true
130
+ return m , tea .Quit
131
+
132
+ case tea .KeyEnter :
133
+ options := m .filteredOptions ()
134
+ if len (options ) != 0 {
135
+ m .selected = options [m .cursor ]
136
+ return m , tea .Quit
137
+ }
138
+
139
+ case tea .KeyUp :
140
+ if m .cursor > 0 {
141
+ m .cursor --
142
+ }
143
+
144
+ case tea .KeyDown :
145
+ options := m .filteredOptions ()
146
+ if m .cursor < len (options )- 1 {
147
+ m .cursor ++
148
+ }
149
+ }
150
+ }
151
+
152
+ if ! m .hideSearch {
153
+ oldSearch := m .search .Value ()
154
+ m .search , cmd = m .search .Update (msg )
155
+
156
+ // If the search query has changed then we need to ensure
157
+ // the cursor is still pointing at a valid option.
158
+ if m .search .Value () != oldSearch {
159
+ options := m .filteredOptions ()
160
+
161
+ if m .cursor > len (options )- 1 {
162
+ m .cursor = max (0 , len (options )- 1 )
163
+ }
164
+ }
165
+ }
166
+
167
+ return m , cmd
168
+ }
169
+
170
+ func (m selectModel ) View () string {
171
+ var s string
172
+
173
+ if m .hideSearch {
174
+ s += "? [Use arrows to move]\n "
175
+ } else {
176
+ s += fmt .Sprintf ("? %s [Use arrows to move, type to filter]\n " , m .search .View ())
177
+ }
178
+
179
+ options , start := m .viewableOptions ()
180
+
181
+ for i , option := range options {
182
+ // Is this the currently selected option?
183
+ cursor := " "
184
+ if m .cursor == start + i {
185
+ cursor = ">"
186
+ }
187
+
188
+ s += fmt .Sprintf ("%s %s\n " , cursor , option )
189
+ }
190
+
191
+ return s
192
+ }
193
+
194
+ func (m selectModel ) viewableOptions () ([]string , int ) {
195
+ options := m .filteredOptions ()
196
+ halfHeight := m .height / 2
197
+ bottom := 0
198
+ top := len (options )
199
+
200
+ switch {
201
+ case m .cursor <= halfHeight :
202
+ top = min (top , m .height )
203
+ case m .cursor < top - halfHeight :
204
+ bottom = m .cursor - halfHeight
205
+ top = min (top , m .cursor + halfHeight + 1 )
206
+ default :
207
+ bottom = top - m .height
208
+ }
209
+
210
+ return options [bottom :top ], bottom
211
+ }
212
+
213
+ func (m selectModel ) filteredOptions () []string {
214
+ options := []string {}
215
+ for _ , o := range m .options {
216
+ prefix := strings .ToLower (m .search .Value ())
217
+ option := strings .ToLower (o )
218
+
219
+ if strings .HasPrefix (option , prefix ) {
220
+ options = append (options , o )
221
+ }
222
+ }
223
+ return options
224
+ }
225
+
105
226
type MultiSelectOptions struct {
106
227
Message string
107
228
Options []string
@@ -114,35 +235,129 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er
114
235
return opts .Defaults , nil
115
236
}
116
237
117
- prompt := & survey.MultiSelect {
118
- Options : opts .Options ,
119
- Default : opts .Defaults ,
120
- Message : opts .Message ,
238
+ options := make ([]multiSelectOption , len (opts .Options ))
239
+ for i , option := range opts .Options {
240
+ options [i ].option = option
241
+ }
242
+
243
+ initialModel := multiSelectModel {
244
+ search : textinput .New (),
245
+ options : options ,
121
246
}
122
247
123
- var values []string
124
- err := survey .AskOne (prompt , & values , survey .WithStdio (fileReadWriter {
125
- Reader : inv .Stdin ,
126
- }, fileReadWriter {
127
- Writer : inv .Stdout ,
128
- }, inv .Stdout ))
129
- if errors .Is (err , terminal .InterruptErr ) {
130
- return nil , Canceled
248
+ initialModel .search .Prompt = ""
249
+ initialModel .search .Focus ()
250
+
251
+ m , err := tea .NewProgram (
252
+ initialModel ,
253
+ tea .WithInput (inv .Stdin ),
254
+ tea .WithOutput (inv .Stdout ),
255
+ ).Run ()
256
+
257
+ values := []string {}
258
+ if m , ok := m .(multiSelectModel ); ok {
259
+ if m .canceled {
260
+ return values , Canceled
261
+ }
262
+
263
+ for _ , option := range m .options {
264
+ if option .chosen {
265
+ values = append (values , option .option )
266
+ }
267
+ }
131
268
}
132
269
return values , err
133
270
}
134
271
135
- type fileReadWriter struct {
136
- io. Reader
137
- io. Writer
272
+ type multiSelectOption struct {
273
+ option string
274
+ chosen bool
138
275
}
139
276
140
- func (f fileReadWriter ) Fd () uintptr {
141
- if file , ok := f .Reader .(* os.File ); ok {
142
- return file .Fd ()
277
+ type multiSelectModel struct {
278
+ search textinput.Model
279
+ options []multiSelectOption
280
+ cursor int
281
+ canceled bool
282
+ }
283
+
284
+ func (multiSelectModel ) Init () tea.Cmd {
285
+ return nil
286
+ }
287
+
288
+ //nolint:revive For same reason as previous Update definition
289
+ func (m multiSelectModel ) Update (msg tea.Msg ) (tea.Model , tea.Cmd ) {
290
+ var cmd tea.Cmd
291
+
292
+ if msg , ok := msg .(tea.KeyMsg ); ok {
293
+ switch msg .Type {
294
+ case tea .KeyCtrlC :
295
+ m .canceled = true
296
+ return m , tea .Quit
297
+
298
+ case tea .KeyEnter :
299
+ if len (m .options ) != 0 {
300
+ return m , tea .Quit
301
+ }
302
+
303
+ case tea .KeySpace :
304
+ if len (m .options ) != 0 {
305
+ m .options [m .cursor ].chosen = true
306
+ }
307
+
308
+ case tea .KeyUp :
309
+ if m .cursor > 0 {
310
+ m .cursor --
311
+ }
312
+
313
+ case tea .KeyDown :
314
+ if m .cursor < len (m .options )- 1 {
315
+ m .cursor ++
316
+ }
317
+
318
+ case tea .KeyRight :
319
+ for i := range m .options {
320
+ m .options [i ].chosen = true
321
+ }
322
+
323
+ case tea .KeyLeft :
324
+ for i := range m .options {
325
+ m .options [i ].chosen = false
326
+ }
327
+
328
+ default :
329
+ oldSearch := m .search .Value ()
330
+ m .search , cmd = m .search .Update (msg )
331
+
332
+ // If the search query has changed then we need to ensure
333
+ // the cursor is still pointing at a valid option.
334
+ if m .search .Value () != oldSearch {
335
+ if m .cursor > len (m .options )- 1 {
336
+ m .cursor = max (0 , len (m .options )- 1 )
337
+ }
338
+ }
339
+ }
143
340
}
144
- if file , ok := f .Writer .(* os.File ); ok {
145
- return file .Fd ()
341
+
342
+ return m , cmd
343
+ }
344
+
345
+ func (m multiSelectModel ) View () string {
346
+ s := fmt .Sprintf ("? %s [Use arrows to move, space to select, <right> to all, <left> to none, type to filter]\n " , m .search .View ())
347
+
348
+ for i , option := range m .options {
349
+ cursor := " "
350
+ if m .cursor == i {
351
+ cursor = ">"
352
+ }
353
+
354
+ chosen := "[ ]"
355
+ if option .chosen {
356
+ chosen = "[x]"
357
+ }
358
+
359
+ s += fmt .Sprintf ("%s %s %s\n " , cursor , chosen , option .option )
146
360
}
147
- return 0
361
+
362
+ return s
148
363
}
0 commit comments