Skip to content

Commit 3934cb6

Browse files
chore: replace survery library with bubbletea
1 parent 2ed88d5 commit 3934cb6

File tree

4 files changed

+313
-64
lines changed

4 files changed

+313
-64
lines changed

cli/cliui/select.go

Lines changed: 260 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
package cliui
22

33
import (
4-
"errors"
54
"flag"
6-
"io"
7-
"os"
5+
"fmt"
6+
"strings"
87

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"
1110
"golang.org/x/xerrors"
1211

1312
"github.com/coder/coder/v2/codersdk"
@@ -66,6 +65,7 @@ func RichSelect(inv *serpent.Invocation, richOptions RichSelectOptions) (*coders
6665

6766
// Select displays a list of user options.
6867
func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
68+
// TODO: Check if this is still true for Bubbletea.
6969
// The survey library used *always* fails when testing on Windows,
7070
// as it requires a live TTY (can't be a conpty). We should fork
7171
// this library to add a dummy fallback, that simply reads/writes
@@ -75,33 +75,154 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
7575
return opts.Options[0], nil
7676
}
7777

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,
8183
}
8284

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+
8398
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
93102
}
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
101104
}
102105
return value, err
103106
}
104107

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+
105226
type MultiSelectOptions struct {
106227
Message string
107228
Options []string
@@ -114,35 +235,129 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er
114235
return opts.Defaults, nil
115236
}
116237

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,
121246
}
122247

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+
}
131268
}
132269
return values, err
133270
}
134271

135-
type fileReadWriter struct {
136-
io.Reader
137-
io.Writer
272+
type multiSelectOption struct {
273+
option string
274+
chosen bool
138275
}
139276

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+
}
143340
}
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)
146360
}
147-
return 0
361+
362+
return s
148363
}

cmd/cliui/main.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,26 @@ func main() {
7979
Use: "select",
8080
Handler: func(inv *serpent.Invocation) error {
8181
value, err := cliui.Select(inv, cliui.SelectOptions{
82-
Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"},
83-
Size: 3,
82+
Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"},
83+
Size: 3,
84+
HideSearch: true,
8485
})
8586
_, _ = fmt.Printf("Selected: %q\n", value)
8687
return err
8788
},
8889
})
8990

91+
root.Children = append(root.Children, &serpent.Command{
92+
Use: "multi-select",
93+
Handler: func(inv *serpent.Invocation) error {
94+
values, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
95+
Options: []string{"Tomato", "Banana", "Onion", "Grape", "Lemon"},
96+
})
97+
_, _ = fmt.Printf("Selected: %q\n", values)
98+
return err
99+
},
100+
})
101+
90102
root.Children = append(root.Children, &serpent.Command{
91103
Use: "job",
92104
Handler: func(inv *serpent.Invocation) error {

0 commit comments

Comments
 (0)