-
Notifications
You must be signed in to change notification settings - Fork 875
feat: allow entering non-default values in multi-select #15935
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -195,7 +195,7 @@ func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
if m.cursor > 0 { | ||
m.cursor-- | ||
} else { | ||
m.cursor = len(options) - 1 | ||
m.cursor = len(options) | ||
} | ||
|
||
case tea.KeyDown: | ||
|
@@ -300,9 +300,10 @@ func (m selectModel) filteredOptions() []string { | |
} | ||
|
||
type MultiSelectOptions struct { | ||
Message string | ||
Options []string | ||
Defaults []string | ||
Message string | ||
Options []string | ||
Defaults []string | ||
EnableCustomInput bool | ||
} | ||
|
||
func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) { | ||
|
@@ -328,9 +329,10 @@ func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, er | |
} | ||
|
||
initialModel := multiSelectModel{ | ||
search: textinput.New(), | ||
options: options, | ||
message: opts.Message, | ||
search: textinput.New(), | ||
options: options, | ||
message: opts.Message, | ||
enableCustomInput: opts.EnableCustomInput, | ||
} | ||
|
||
initialModel.search.Prompt = "" | ||
|
@@ -370,12 +372,15 @@ type multiSelectOption struct { | |
} | ||
|
||
type multiSelectModel struct { | ||
search textinput.Model | ||
options []*multiSelectOption | ||
cursor int | ||
message string | ||
canceled bool | ||
selected bool | ||
search textinput.Model | ||
options []*multiSelectOption | ||
cursor int | ||
message string | ||
canceled bool | ||
selected bool | ||
isInputMode bool // New field to track if we're adding a custom option | ||
customInput string // New field to store custom input | ||
enableCustomInput bool // New field to control whether custom input is allowed | ||
} | ||
|
||
func (multiSelectModel) Init() tea.Cmd { | ||
|
@@ -386,6 +391,10 @@ func (multiSelectModel) Init() tea.Cmd { | |
func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
var cmd tea.Cmd | ||
|
||
if m.isInputMode { | ||
return m.handleCustomInputMode(msg) | ||
} | ||
|
||
switch msg := msg.(type) { | ||
case terminateMsg: | ||
m.canceled = true | ||
|
@@ -398,6 +407,11 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
return m, tea.Quit | ||
|
||
case tea.KeyEnter: | ||
// Switch to custom input mode if we're on the "+ Add custom value:" option | ||
if m.enableCustomInput && m.cursor == len(m.filteredOptions()) { | ||
m.isInputMode = true | ||
return m, nil | ||
} | ||
if len(m.options) != 0 { | ||
m.selected = true | ||
return m, tea.Quit | ||
|
@@ -414,15 +428,17 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
|
||
case tea.KeyUp: | ||
options := m.filteredOptions() | ||
maxIndex := len(options) | ||
if m.cursor > 0 { | ||
m.cursor-- | ||
} else { | ||
m.cursor = len(options) - 1 | ||
m.cursor = maxIndex | ||
Comment on lines
-420
to
+434
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this logic needs to handle when |
||
} | ||
|
||
case tea.KeyDown: | ||
options := m.filteredOptions() | ||
if m.cursor < len(options)-1 { | ||
maxIndex := len(options) | ||
if m.cursor < maxIndex { | ||
m.cursor++ | ||
} else { | ||
m.cursor = 0 | ||
|
@@ -457,6 +473,52 @@ func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
return m, cmd | ||
} | ||
|
||
// handleCustomInputMode manages keyboard interactions when in custom input mode | ||
func (m *multiSelectModel) handleCustomInputMode(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
keyMsg, ok := msg.(tea.KeyMsg) | ||
if !ok { | ||
return m, nil | ||
} | ||
|
||
switch keyMsg.Type { | ||
case tea.KeyEnter: | ||
return m.handleCustomInputSubmission() | ||
|
||
case tea.KeyCtrlC: | ||
m.canceled = true | ||
return m, tea.Quit | ||
|
||
case tea.KeyBackspace: | ||
return m.handleCustomInputBackspace() | ||
|
||
default: | ||
m.customInput += keyMsg.String() | ||
return m, nil | ||
} | ||
} | ||
|
||
// handleCustomInputSubmission processes the submission of custom input | ||
func (m *multiSelectModel) handleCustomInputSubmission() (tea.Model, tea.Cmd) { | ||
if m.customInput != "" { | ||
m.options = append(m.options, &multiSelectOption{ | ||
option: m.customInput, | ||
chosen: true, | ||
}) | ||
} | ||
// Reset input state regardless of whether input was empty | ||
m.customInput = "" | ||
m.isInputMode = false | ||
return m, nil | ||
} | ||
|
||
// handleCustomInputBackspace handles backspace in custom input mode | ||
func (m *multiSelectModel) handleCustomInputBackspace() (tea.Model, tea.Cmd) { | ||
if len(m.customInput) > 0 { | ||
m.customInput = m.customInput[:len(m.customInput)-1] | ||
} | ||
return m, nil | ||
} | ||
|
||
func (m multiSelectModel) View() string { | ||
var s strings.Builder | ||
|
||
|
@@ -469,13 +531,19 @@ func (m multiSelectModel) View() string { | |
return s.String() | ||
} | ||
|
||
if m.isInputMode { | ||
_, _ = s.WriteString(fmt.Sprintf("%s\nEnter custom value: %s\n", msg, m.customInput)) | ||
return s.String() | ||
} | ||
|
||
_, _ = s.WriteString(fmt.Sprintf( | ||
"%s %s[Use arrows to move, space to select, <right> to all, <left> to none, type to filter]\n", | ||
msg, | ||
m.search.View(), | ||
)) | ||
|
||
for i, option := range m.filteredOptions() { | ||
options := m.filteredOptions() | ||
for i, option := range options { | ||
cursor := " " | ||
chosen := "[ ]" | ||
o := option.option | ||
|
@@ -498,6 +566,16 @@ func (m multiSelectModel) View() string { | |
)) | ||
} | ||
|
||
if m.enableCustomInput { | ||
// Add the "+ Add custom value" option at the bottom | ||
cursor := " " | ||
text := " + Add custom value" | ||
if m.cursor == len(options) { | ||
cursor = pretty.Sprint(DefaultStyles.Keyword, "> ") | ||
text = pretty.Sprint(DefaultStyles.Keyword, text) | ||
} | ||
_, _ = s.WriteString(fmt.Sprintf("%s%s\n", cursor, text)) | ||
} | ||
return s.String() | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -101,6 +101,39 @@ func TestMultiSelect(t *testing.T) { | |||
}() | ||||
require.Equal(t, items, <-msgChan) | ||||
}) | ||||
|
||||
t.Run("MultiSelectWithCustomInput", func(t *testing.T) { | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. couldn't add tests to check the custom input flow, we had this check here and not sure if its safe to remove and add interactive tests, please do let me know if there's a better way to incorporate those tests Line 310 in 63572d9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think historically we've manually tested this using |
||||
t.Parallel() | ||||
items := []string{"Code", "Chairs", "Whale", "Diamond", "Carrot"} | ||||
ptty := ptytest.New(t) | ||||
msgChan := make(chan []string) | ||||
go func() { | ||||
resp, err := newMultiSelectWithCustomInput(ptty, items) | ||||
assert.NoError(t, err) | ||||
msgChan <- resp | ||||
}() | ||||
require.Equal(t, items, <-msgChan) | ||||
}) | ||||
} | ||||
|
||||
func newMultiSelectWithCustomInput(ptty *ptytest.PTY, items []string) ([]string, error) { | ||||
var values []string | ||||
cmd := &serpent.Command{ | ||||
Handler: func(inv *serpent.Invocation) error { | ||||
selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{ | ||||
Options: items, | ||||
Defaults: items, | ||||
EnableCustomInput: true, | ||||
}) | ||||
if err == nil { | ||||
values = selectedItems | ||||
} | ||||
return err | ||||
}, | ||||
} | ||||
inv := cmd.Invoke() | ||||
ptty.Attach(inv) | ||||
return values, inv.Run() | ||||
} | ||||
|
||||
func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) { | ||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -156,9 +156,10 @@ func (RootCmd) promptExample() *serpent.Command { | |
multiSelectValues, multiSelectError = cliui.MultiSelect(inv, cliui.MultiSelectOptions{ | ||
Message: "Select some things:", | ||
Options: []string{ | ||
"Code", "Chair", "Whale", "Diamond", "Carrot", | ||
"Code", "Chairs", "Whale", "Diamond", "Carrot", | ||
}, | ||
Defaults: []string{"Code"}, | ||
Defaults: []string{"Code"}, | ||
EnableCustomInput: true, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we expose this as a flag so we can easily test both with and without custom input? |
||
}) | ||
} | ||
_, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(multiSelectValues, ", ")) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion:
isCustomInputMode
for consistency