github.com/wolfi-dev/wolfictl@v0.16.11/pkg/cli/components/interview/interview.go (about)

     1  package interview
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"strings"
     7  
     8  	tea "github.com/charmbracelet/bubbletea"
     9  	"github.com/wolfi-dev/wolfictl/pkg/cli/components/accept"
    10  	"github.com/wolfi-dev/wolfictl/pkg/cli/components/picker"
    11  	"github.com/wolfi-dev/wolfictl/pkg/cli/components/textinput"
    12  	"github.com/wolfi-dev/wolfictl/pkg/cli/internal/wrapped"
    13  	"github.com/wolfi-dev/wolfictl/pkg/question"
    14  )
    15  
    16  type Model[T any] struct {
    17  	root                 question.Question[T]
    18  	stack                []question.Question[T]
    19  	answerComponentStack []tea.Model
    20  	state                T
    21  	done                 bool
    22  	err                  error
    23  }
    24  
    25  // New returns a new interview model.
    26  func New[T any](root question.Question[T], initialState T) (Model[T], error) {
    27  	m := Model[T]{
    28  		root:  root,
    29  		state: initialState,
    30  	}
    31  
    32  	m.stack = append(m.stack, root)
    33  
    34  	ac, err := newAnswerComponent(root)
    35  	if err != nil {
    36  		return m, fmt.Errorf("creating answer component: %w", err)
    37  	}
    38  	m.answerComponentStack = append(m.answerComponentStack, ac)
    39  
    40  	return m, nil
    41  }
    42  
    43  func newAnswerComponent[T any](q question.Question[T]) (tea.Model, error) {
    44  	switch a := q.Answer.(type) {
    45  	case question.MultipleChoice[T]:
    46  		opts := picker.Options[question.Choice[T]]{
    47  			Items:          a,
    48  			ItemRenderFunc: renderChoice[T],
    49  		}
    50  		return picker.New(opts), nil
    51  
    52  	case question.AcceptText[T]:
    53  		return textinput.New(), nil
    54  
    55  	case question.MessageOnly[T]:
    56  		return accept.Model{}, nil
    57  	}
    58  
    59  	// This should never happen.
    60  	return nil, fmt.Errorf("unsupported question answer type %T for question %q", q.Answer, q.Text)
    61  }
    62  
    63  func (m Model[T]) stackTopIndex() int {
    64  	return len(m.stack) - 1
    65  }
    66  
    67  func (m Model[T]) answerComponentStackTop() tea.Model {
    68  	return m.answerComponentStack[m.stackTopIndex()]
    69  }
    70  
    71  func (m Model[T]) Init() tea.Cmd {
    72  	return m.answerComponentStackTop().Init()
    73  }
    74  
    75  func (m Model[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    76  	// Most events should be routed to the component for the current question.
    77  
    78  	switch m.answerComponentStackTop().(type) {
    79  	case picker.Model[question.Choice[T]]:
    80  		return m.updateForPicker(msg)
    81  
    82  	case textinput.Model:
    83  		return m.updateForTextInput(msg)
    84  
    85  	case accept.Model:
    86  		return m.updateForMessage(msg)
    87  	}
    88  
    89  	return m, nil
    90  }
    91  
    92  func (m Model[T]) updateForMessage(msg tea.Msg) (tea.Model, tea.Cmd) {
    93  	messageTea, cmd := m.answerComponentStackTop().Update(msg)
    94  	messageModel, ok := messageTea.(accept.Model)
    95  	if !ok {
    96  		// Nothing we can do here, but this shouldn't ever happen.
    97  		return m, nil
    98  	}
    99  	m.answerComponentStack[m.stackTopIndex()] = messageModel
   100  
   101  	if !messageModel.Accepted {
   102  		// The user hasn't acknowledged the message yet.
   103  		return m, cmd
   104  	}
   105  
   106  	// The user has acknowledged the message.
   107  
   108  	// Update the state.
   109  	var nextQuestion *question.Question[T]
   110  	var err error
   111  	m.state, nextQuestion, err = m.stack[m.stackTopIndex()].Answer.(question.MessageOnly[T])(m.state)
   112  	if err != nil {
   113  		if errors.Is(err, question.ErrTerminate) {
   114  			// Exit the interview without a resulting state.
   115  			m.done = true
   116  			m.err = err
   117  			return m, tea.Quit
   118  		}
   119  
   120  		// An error occurred.
   121  		m.err = err
   122  		return m, tea.Quit
   123  	}
   124  
   125  	if nextQuestion == nil {
   126  		// The line of questioning is concluded.
   127  		m.done = true
   128  		return m, tea.Quit
   129  	}
   130  
   131  	// The line of questioning continues.
   132  	// Update the stack.
   133  	m.stack = append(m.stack, *nextQuestion)
   134  
   135  	// Create a new answer component for the next question.
   136  	ac, err := newAnswerComponent(*nextQuestion)
   137  	if err != nil {
   138  		// An error occurred.
   139  		m.err = err
   140  		return m, tea.Quit
   141  	}
   142  	m.answerComponentStack = append(m.answerComponentStack, ac)
   143  
   144  	return m, ac.Init()
   145  }
   146  
   147  func (m Model[T]) updateForTextInput(msg tea.Msg) (tea.Model, tea.Cmd) {
   148  	switch msg := msg.(type) {
   149  	case tea.KeyMsg:
   150  		switch msg.String() {
   151  		case "enter":
   152  			// The user has submitted a text answer.
   153  			// Update the state.
   154  			val := m.answerComponentStackTop().(textinput.Model).Inner.Value()
   155  
   156  			// Update the state.
   157  			var nextQuestion *question.Question[T]
   158  			var err error
   159  			m.state, nextQuestion, err = m.stack[m.stackTopIndex()].Answer.(question.AcceptText[T])(m.state, val)
   160  			if err != nil {
   161  				if errors.Is(err, question.ErrTerminate) {
   162  					// Exit the interview without a resulting state.
   163  					m.done = true
   164  					m.err = err
   165  					return m, tea.Quit
   166  				}
   167  
   168  				// An error occurred.
   169  				m.err = err
   170  				return m, tea.Quit
   171  			}
   172  			if nextQuestion == nil {
   173  				// The line of questioning is concluded.
   174  				m.done = true
   175  				return m, tea.Quit
   176  			}
   177  
   178  			// The line of questioning continues.
   179  			// Update the stack.
   180  			m.stack = append(m.stack, *nextQuestion)
   181  
   182  			// Create a new answer component for the next question.
   183  			ac, err := newAnswerComponent(*nextQuestion)
   184  			if err != nil {
   185  				// An error occurred.
   186  				m.err = err
   187  				return m, tea.Quit
   188  			}
   189  			m.answerComponentStack = append(m.answerComponentStack, ac)
   190  
   191  			return m, ac.Init()
   192  
   193  		default:
   194  			return m.routeMsgToTextInputAtTopOfStack(msg)
   195  		}
   196  
   197  	default:
   198  		return m.routeMsgToTextInputAtTopOfStack(msg)
   199  	}
   200  }
   201  
   202  func (m Model[T]) routeMsgToTextInputAtTopOfStack(msg tea.Msg) (tea.Model, tea.Cmd) {
   203  	// Pass the message to the text input.
   204  	tiTea, cmd := m.answerComponentStackTop().Update(msg)
   205  	ti, ok := tiTea.(textinput.Model)
   206  	if !ok {
   207  		// Nothing we can do here, but this shouldn't ever happen.
   208  		return m, nil
   209  	}
   210  
   211  	m.answerComponentStack[m.stackTopIndex()] = ti
   212  	return m, cmd
   213  }
   214  
   215  func (m Model[T]) updateForPicker(msg tea.Msg) (tea.Model, tea.Cmd) {
   216  	switch msg := msg.(type) {
   217  	case tea.KeyMsg:
   218  		switch msg.String() {
   219  		case "enter", "up", "down":
   220  			pTea, cmd := m.answerComponentStackTop().Update(msg)
   221  			p, ok := pTea.(picker.Model[question.Choice[T]])
   222  			if !ok {
   223  				// Nothing we can do here, but this shouldn't ever happen.
   224  				return m, nil
   225  			}
   226  			m.answerComponentStack[m.stackTopIndex()] = p
   227  
   228  			// Check if the user has submitted an answer.
   229  
   230  			c := p.Picked()
   231  			if c == nil {
   232  				// The user hasn't picked an answer yet.
   233  				return m, cmd
   234  			}
   235  
   236  			// The user has picked an answer.
   237  
   238  			if c.Choose == nil {
   239  				return m, nil
   240  			}
   241  
   242  			// Update the state.
   243  			var nextQuestion *question.Question[T]
   244  			var err error
   245  			m.state, nextQuestion, err = c.Choose(m.state)
   246  			if err != nil {
   247  				if errors.Is(err, question.ErrTerminate) {
   248  					// Exit the interview without a resulting state.
   249  					m.done = true
   250  					m.err = err
   251  					return m, tea.Quit
   252  				}
   253  
   254  				// An error occurred.
   255  				m.err = err
   256  				return m, tea.Quit
   257  			}
   258  			if nextQuestion == nil {
   259  				// The line of questioning is concluded.
   260  				m.done = true
   261  				return m, tea.Quit
   262  			}
   263  
   264  			// The line of questioning continues.
   265  			// Update the stack.
   266  			m.stack = append(m.stack, *nextQuestion)
   267  
   268  			// Create a new answer component for the next question.
   269  			ac, err := newAnswerComponent(*nextQuestion)
   270  			if err != nil {
   271  				// An error occurred.
   272  				m.err = err
   273  				return m, tea.Quit
   274  			}
   275  			m.answerComponentStack = append(m.answerComponentStack, ac)
   276  
   277  			return m, ac.Init()
   278  		}
   279  
   280  	default:
   281  		// Pass the message to the picker.
   282  		pTea, cmd := m.answerComponentStackTop().Update(msg)
   283  		p, ok := pTea.(picker.Model[question.Choice[T]])
   284  		if !ok {
   285  			// Nothing we can do here, but this shouldn't ever happen.
   286  			return m, nil
   287  		}
   288  		m.answerComponentStack[m.stackTopIndex()] = p
   289  		return m, cmd
   290  	}
   291  
   292  	return m, nil
   293  }
   294  
   295  func (m Model[T]) View() string {
   296  	// TODO: consider rendering differently if m.done is true.
   297  
   298  	sb := strings.Builder{}
   299  
   300  	for i, q := range m.stack {
   301  		qText := wrapped.Sprint(q.Text)
   302  		sb.WriteString(qText + "\n\n")
   303  
   304  		acView := m.answerComponentStack[i].View()
   305  		sb.WriteString(acView)
   306  	}
   307  
   308  	return sb.String()
   309  }
   310  
   311  func (m Model[T]) State() (T, error) {
   312  	return m.state, m.err
   313  }
   314  
   315  func renderChoice[T any](c question.Choice[T]) string {
   316  	return c.Text
   317  }