github.com/wolfi-dev/wolfictl@v0.16.11/pkg/cli/components/advisory/field/text_field.go (about) 1 package field 2 3 import ( 4 "errors" 5 "fmt" 6 "strings" 7 8 "github.com/charmbracelet/bubbles/textinput" 9 tea "github.com/charmbracelet/bubbletea" 10 "github.com/wolfi-dev/wolfictl/pkg/advisory" 11 "github.com/wolfi-dev/wolfictl/pkg/cli/styles" 12 ) 13 14 var ( 15 selectedSuggestionStyle = styles.Accented().Copy().Underline(true) 16 helpKeyStyle = styles.FaintAccent().Copy().Bold(true) 17 helpExplanationStyle = styles.Faint().Copy() 18 ) 19 20 const maxSuggestionsDisplayed = 4 21 22 var ErrValueNotInAllowedSet = errors.New("value not in allowed set") 23 24 type TextField struct { 25 id string 26 allowedValues []string 27 requestUpdater func(value string, req advisory.Request) advisory.Request 28 29 input textinput.Model 30 done bool 31 currentSuggestions []string 32 selectedSuggestion int 33 suggestionWindowStart int 34 defaultSuggestion string 35 validationRules []TextValidationRule 36 currentValidationErr error 37 38 emptyValueHelpMsg string 39 noMatchHelpMsg string 40 } 41 42 type TextFieldConfiguration struct { 43 // ID is a unique identifier for the field. 44 ID string 45 46 // Prompt is the text shown before the input field. (E.g. "Name: ") 47 Prompt string 48 49 // RequestUpdater is a function that updates the advisory request with the 50 // current field value. 51 RequestUpdater func(value string, req advisory.Request) advisory.Request 52 53 // AllowedValues is a list of values that are allowed to be entered and are used 54 // as suggestions when the user starts typing. 55 AllowedValues []string 56 57 // DefaultSuggestion is the value that is shown as a suggestion when the user 58 // hasn't entered anything. 59 DefaultSuggestion string 60 61 // EmptyValueHelpMsg is the help message shown when the user hasn't entered 62 // anything and there are no suggestions. 63 EmptyValueHelpMsg string 64 65 // NoMatchHelpMsg is the help message shown when the user has entered something 66 // but there are no matching suggestions. 67 NoMatchHelpMsg string 68 69 // ValidationRules is a list of validation rules that are run when the user 70 // submits the field. All rules must pass for the field to be valid. 71 ValidationRules []TextValidationRule 72 } 73 74 type TextValidationRule func(string) error 75 76 func NewTextField(cfg TextFieldConfiguration) TextField { 77 t := textinput.New() 78 t.Cursor.Style = styles.Default() 79 80 t.Prompt = cfg.Prompt 81 82 return TextField{ 83 id: cfg.ID, 84 input: t, 85 requestUpdater: cfg.RequestUpdater, 86 allowedValues: cfg.AllowedValues, 87 emptyValueHelpMsg: cfg.EmptyValueHelpMsg, 88 noMatchHelpMsg: cfg.NoMatchHelpMsg, 89 validationRules: cfg.ValidationRules, 90 defaultSuggestion: cfg.DefaultSuggestion, 91 } 92 } 93 94 func NotEmpty(value string) error { 95 if strings.TrimSpace(value) == "" { 96 return errors.New("cannot be empty") 97 } 98 99 return nil 100 } 101 102 func (f TextField) ID() string { 103 return f.id 104 } 105 106 func (f TextField) runAllValidationRules(value string) error { 107 for _, rule := range f.validationRules { 108 err := rule(value) 109 if err != nil { 110 return err 111 } 112 } 113 114 return nil 115 } 116 117 func (f TextField) UpdateRequest(req advisory.Request) advisory.Request { 118 value := f.Value() 119 return f.requestUpdater(value, req) 120 } 121 122 func (f TextField) SubmitValue() (Field, error) { 123 if f.usingSuggestions() && f.noSuggestions() { 124 return nil, ErrValueNotAccepted{ 125 Value: f.input.Value(), 126 Reason: ErrValueNotInAllowedSet, 127 } 128 } 129 130 value := f.Value() 131 132 err := f.runAllValidationRules(value) 133 if err != nil { 134 return nil, ErrValueNotAccepted{ 135 Value: value, 136 Reason: err, 137 } 138 } 139 140 f = f.setDone() 141 142 return f, nil 143 } 144 145 func (f TextField) Update(msg tea.Msg) (Field, tea.Cmd) { 146 if f.done { 147 return f, nil 148 } 149 150 m, cmd := f.input.Update(msg) 151 f.input = m 152 153 f.currentValidationErr = f.runAllValidationRules(f.input.Value()) 154 155 if !f.usingSuggestions() { 156 return f, cmd 157 } 158 159 if msg, ok := msg.(tea.KeyMsg); ok { 160 switch msg.String() { 161 case "tab": 162 f = f.nextSuggestion() 163 164 case "shift+tab": 165 f = f.previousSuggestion() 166 167 default: 168 f = f.updateSuggestions() 169 } 170 } 171 172 return f, cmd 173 } 174 175 func (f TextField) usingSuggestions() bool { 176 return f.allowedValues != nil 177 } 178 179 func (f TextField) updateSuggestions() TextField { 180 f.currentSuggestions = f.computeSuggestions() 181 f.selectedSuggestion = 0 182 f.suggestionWindowStart = 0 183 184 return f 185 } 186 187 func (f TextField) nextSuggestion() TextField { 188 if f.selectedSuggestion == len(f.currentSuggestions)-1 { 189 return f 190 } 191 192 if f.selectedSuggestion == f.suggestionWindowEnd()-1 { 193 f.suggestionWindowStart++ 194 } 195 196 f.selectedSuggestion++ 197 return f 198 } 199 200 func (f TextField) previousSuggestion() TextField { 201 if f.selectedSuggestion == 0 { 202 return f 203 } 204 205 if f.selectedSuggestion == f.suggestionWindowStart { 206 f.suggestionWindowStart-- 207 } 208 209 f.selectedSuggestion-- 210 return f 211 } 212 213 func (f TextField) computeSuggestions() []string { 214 v := f.input.Value() 215 216 if v == "" { 217 if f.defaultSuggestion != "" { 218 return []string{f.defaultSuggestion} 219 } 220 221 return nil 222 } 223 224 var suggestions []string 225 226 for _, allowedValue := range f.allowedValues { 227 if strings.HasPrefix(allowedValue, v) { 228 suggestions = append(suggestions, allowedValue) 229 } 230 } 231 232 return suggestions 233 } 234 235 func (f TextField) suggestionWindowEnd() int { 236 if len(f.currentSuggestions) <= maxSuggestionsDisplayed { 237 return len(f.currentSuggestions) 238 } 239 240 return f.suggestionWindowStart + maxSuggestionsDisplayed 241 } 242 243 func (f TextField) setDone() TextField { 244 f.done = true 245 return f 246 } 247 248 func (f TextField) SetBlur() Field { 249 f.input.Blur() 250 251 f.input.PromptStyle = styles.Default() 252 f.input.TextStyle = styles.Default() 253 254 return f 255 } 256 257 func (f TextField) SetFocus() (Field, tea.Cmd) { 258 cmd := f.input.Focus() 259 260 f = f.updateSuggestions() 261 f.input.PromptStyle = styles.Default() 262 f.input.TextStyle = styles.Default() 263 264 return f, cmd 265 } 266 267 func (f TextField) IsDone() bool { 268 return f.done 269 } 270 271 func (f TextField) Value() string { 272 if !f.usingSuggestions() { 273 return f.input.Value() 274 } 275 276 selectedValue := f.currentSuggestions[f.selectedSuggestion] 277 return selectedValue 278 } 279 280 func (f TextField) View() string { 281 var lines []string 282 283 if f.done && f.usingSuggestions() { 284 f.input.SetValue(f.Value()) 285 } 286 287 inputLine := f.input.View() 288 289 if !f.done && f.usingSuggestions() { 290 inputLine = fmt.Sprintf("%s %s", inputLine, f.renderSuggestions()) 291 } 292 293 lines = append(lines, inputLine) 294 295 if !f.done { 296 helpText := f.renderHelp() 297 lines = append(lines, helpText) 298 } 299 300 return strings.Join(lines, "\n") 301 } 302 303 func (f TextField) noSuggestions() bool { 304 return len(f.currentSuggestions) == 0 305 } 306 307 func (f TextField) onlyOneSuggestion() bool { 308 return len(f.currentSuggestions) == 1 309 } 310 311 func (f TextField) multipleSuggestions() bool { 312 return len(f.currentSuggestions) > 1 313 } 314 315 func (f TextField) userHasEnteredText() bool { 316 return f.input.Value() != "" 317 } 318 319 func (f TextField) enteredTextIsSoleSuggestion() bool { 320 return f.input.Value() == f.currentSuggestions[0] 321 } 322 323 func (f TextField) enteredValueIsValid() bool { 324 return f.currentValidationErr == nil 325 } 326 327 func (f TextField) renderSuggestions() string { 328 if !f.userHasEnteredText() && f.defaultSuggestion != "" { 329 return selectedSuggestionStyle.Render(f.defaultSuggestion) 330 } 331 332 if f.noSuggestions() || (f.onlyOneSuggestion() && f.enteredTextIsSoleSuggestion()) { 333 return "" 334 } 335 336 renderedSuggestions := make([]string, 0, maxSuggestionsDisplayed) 337 338 for i := f.suggestionWindowStart; i < f.suggestionWindowEnd(); i++ { 339 suggestion := f.currentSuggestions[i] 340 if i == f.selectedSuggestion { 341 suggestion = selectedSuggestionStyle.Render(suggestion) 342 } else { 343 suggestion = styles.Secondary().Render(suggestion) 344 } 345 renderedSuggestions = append(renderedSuggestions, suggestion) 346 } 347 348 ellipses := styles.Secondary().Render("…") 349 350 if f.suggestionWindowStart > 0 { 351 renderedSuggestions = append([]string{ellipses}, renderedSuggestions...) 352 } 353 354 if f.suggestionWindowEnd() < len(f.currentSuggestions) { 355 renderedSuggestions = append(renderedSuggestions, ellipses) 356 } 357 358 return strings.Join(renderedSuggestions, " ") 359 } 360 361 func (f TextField) renderHelp() string { 362 var helpMsgs []string 363 364 if msg := f.renderHelpMsgEmptyValue(); !f.userHasEnteredText() && msg != "" { 365 helpMsgs = append(helpMsgs, msg) 366 } else if !f.enteredValueIsValid() { 367 msg := styles.Faint().Render( 368 fmt.Sprintf("Invalid value: %s.", f.currentValidationErr), 369 ) 370 helpMsgs = append(helpMsgs, msg) 371 } 372 373 if f.usingSuggestions() { 374 switch { 375 case f.noSuggestions() && f.userHasEnteredText(): 376 helpMsgs = append(helpMsgs, f.renderHelpMsgNoMatch()) 377 378 case f.onlyOneSuggestion() && f.enteredTextIsSoleSuggestion(): 379 helpMsgs = append(helpMsgs, helpMsgEnterOnInput) 380 381 case f.onlyOneSuggestion() && !f.enteredTextIsSoleSuggestion(): 382 helpMsgs = append(helpMsgs, helpMsgEnterOnSuggestion) 383 384 case f.multipleSuggestions(): 385 helpMsgs = append(helpMsgs, helpMsgEnterOnSuggestion, helpMsgTab) 386 } 387 } else if f.userHasEnteredText() && f.enteredValueIsValid() { 388 helpMsgs = append(helpMsgs, helpMsgEnterOnInput) 389 } 390 391 helpMsgs = append(helpMsgs, helpMsgQuit) 392 return strings.Join(helpMsgs, " ") 393 } 394 395 func (f TextField) renderHelpMsgEmptyValue() string { 396 msg := f.emptyValueHelpMsg 397 if msg == "" { 398 return "" 399 } 400 401 return styles.Faint().Render(msg) 402 } 403 404 func (f TextField) renderHelpMsgNoMatch() string { 405 return styles.Faint().Render(f.noMatchHelpMsg) 406 } 407 408 var ( 409 helpMsgEnterOnInput = helpKeyStyle.Render("Enter") + " " + helpExplanationStyle.Render("to confirm.") 410 helpMsgEnterOnSuggestion = helpKeyStyle.Render("Enter") + " " + helpExplanationStyle.Render("to accept suggestion.") 411 helpMsgTab = helpKeyStyle.Render("Tab") + " " + helpExplanationStyle.Render("for next suggestion.") 412 helpMsgQuit = helpKeyStyle.Render("Ctrl+C") + " " + helpExplanationStyle.Render("to quit.") 413 )