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  )