github.com/GoogleCloudPlatform/deploystack@v1.12.8/tui/picker.go (about)

     1  // Copyright 2023 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package tui
    16  
    17  import (
    18  	"fmt"
    19  	"io"
    20  	"strings"
    21  
    22  	"github.com/charmbracelet/bubbles/list"
    23  	"github.com/charmbracelet/bubbles/spinner"
    24  	tea "github.com/charmbracelet/bubbletea"
    25  	"github.com/charmbracelet/lipgloss"
    26  )
    27  
    28  type itemDelegate struct{}
    29  
    30  func (d itemDelegate) Height() int                               { return 1 }
    31  func (d itemDelegate) Spacing() int                              { return 0 }
    32  func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
    33  func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
    34  	i, ok := listItem.(item)
    35  	if !ok {
    36  		return
    37  	}
    38  
    39  	str := fmt.Sprintf("%2d. %-50s", index+1, i.label)
    40  
    41  	fn := itemStyle.Render
    42  	if index == m.Index() {
    43  		color := selectedItemStyle.background.code()
    44  		fn = func(s string) string {
    45  			defaultItemStyle := lipgloss.NewStyle().Bold(true)
    46  			return selectedItemStyle.Render(color + "> " + defaultItemStyle.Render(s) + clear)
    47  		}
    48  	}
    49  
    50  	fmt.Fprint(w, fn(str))
    51  }
    52  
    53  type item struct {
    54  	label, value string
    55  }
    56  
    57  func (i item) FilterValue() string { return i.value }
    58  
    59  type picker struct {
    60  	dynamicPage
    61  
    62  	list         list.Model
    63  	target       string
    64  	defaultValue string
    65  }
    66  
    67  func newPicker(listLabel, spinnerLabel, key, defaultValue string, preProcessor tea.Cmd) picker {
    68  	p := picker{}
    69  
    70  	l := list.New([]list.Item{}, itemDelegate{}, 0, 19)
    71  	l.Title = listLabel
    72  	l.Styles.Title = titleStyle.style
    73  	l.Styles.PaginationStyle = paginationStyle
    74  	l.Styles.HelpStyle = helpStyle
    75  	p.list = l
    76  
    77  	p.showProgress = true
    78  	p.defaultValue = defaultValue
    79  	p.preProcessor = preProcessor
    80  	p.key = key
    81  	p.state = "idle"
    82  	if preProcessor != nil {
    83  		p.state = "querying"
    84  	}
    85  
    86  	p.spinnerLabel = spinnerLabel
    87  
    88  	s := spinner.New()
    89  	s.Spinner = spinnerType
    90  	p.spinner = s
    91  
    92  	return p
    93  }
    94  
    95  func positionDefault(items []list.Item, defaultValue string) ([]list.Item, int) {
    96  	selectedIndex := 0
    97  	if defaultValue == "" {
    98  		return items, selectedIndex
    99  	}
   100  
   101  	defaultIndex := 0
   102  	newItems := []list.Item{}
   103  	defaultItem := item{}
   104  	createItem := item{}
   105  	returnItems := []list.Item{}
   106  
   107  	for i, v := range items {
   108  		item := v.(item)
   109  		if item.value == defaultValue || item.label == defaultValue || defaultValue == item.value+"|"+item.label {
   110  			defaultItem = item
   111  			text := defaultItem.label + " (Default Value)"
   112  			defaultItem.label = text
   113  			items[i] = defaultItem
   114  			defaultIndex = i
   115  			continue
   116  		}
   117  		if strings.Contains(item.label, "Create New Project") {
   118  			createItem = item
   119  			continue
   120  		}
   121  		newItems = append(newItems, item)
   122  	}
   123  
   124  	if len(items) <= 10 {
   125  		return items, defaultIndex
   126  	}
   127  
   128  	createAdded := 0
   129  	if createItem.label != "" {
   130  		createAdded++
   131  		returnItems = append(returnItems, createItem)
   132  	}
   133  
   134  	defaultAdded := 0
   135  	if defaultItem.value != "" {
   136  		defaultAdded++
   137  
   138  		returnItems = append(returnItems, defaultItem)
   139  	}
   140  
   141  	selectedIndex = (createAdded + defaultAdded) - 1
   142  	if selectedIndex < 0 {
   143  		selectedIndex = 0
   144  	}
   145  
   146  	returnItems = append(returnItems, newItems...)
   147  
   148  	return returnItems, selectedIndex
   149  }
   150  
   151  func (p picker) Init() tea.Cmd {
   152  	return tea.Batch(p.spinner.Tick, p.preProcessor)
   153  }
   154  
   155  func (p picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   156  	switch msg := msg.(type) {
   157  	case []list.Item:
   158  		p.state = "displaying"
   159  		items := []list.Item(msg)
   160  
   161  		offset := len(p.list.Items())
   162  
   163  		for i, v := range items {
   164  			p.list.InsertItem(i+offset, v)
   165  		}
   166  
   167  		tmp, selectedIndex := positionDefault(p.list.Items(), p.defaultValue)
   168  		p.list.SetItems(tmp)
   169  
   170  		p.list.Select(selectedIndex)
   171  
   172  		return p, p.spinner.Tick
   173  	case errMsg:
   174  		p.state = "idle"
   175  		p.err = msg
   176  		p.target = msg.target
   177  		return p, nil
   178  	case successMsg:
   179  		p.state = "idle"
   180  		newValue := p.value
   181  		if msg.msg == "prependProject" {
   182  			currentProject := p.queue.Get("currentProject").(string)
   183  			newValue = fmt.Sprintf("%s-%s", currentProject, newValue)
   184  		}
   185  
   186  		if !msg.unset && !p.omitFromSettings {
   187  			p.queue.stack.AddSetting(p.key, newValue)
   188  		}
   189  
   190  		return p.queue.next()
   191  	case tea.KeyMsg:
   192  		if p.list.FilterState() == list.Filtering {
   193  			break
   194  		}
   195  		switch keypress := msg.String(); keypress {
   196  		case "alt+b", "ctrl+b":
   197  			return p.queue.prev()
   198  		case "ctrl+c":
   199  			return p.queue.exitPage()
   200  		case "enter":
   201  			if p.state == "displaying" {
   202  				i, ok := p.list.SelectedItem().(item)
   203  				if ok {
   204  					p.value = string(i.value)
   205  				}
   206  				if !p.omitFromSettings {
   207  					p.queue.stack.AddSetting(p.key, p.value)
   208  				}
   209  
   210  				if p.postProcessor != nil {
   211  					if p.state != "querying" {
   212  						p.state = "querying"
   213  						p.err = nil
   214  
   215  						var cmd tea.Cmd
   216  						var cmdSpin tea.Cmd
   217  						cmd = p.postProcessor(p.value, p.queue)
   218  						p.spinner, cmdSpin = p.spinner.Update(msg)
   219  
   220  						return p, tea.Batch(cmd, cmdSpin)
   221  					}
   222  
   223  					return p, nil
   224  				}
   225  
   226  				return p.queue.next()
   227  			}
   228  			if p.err != nil && p.target != "" {
   229  				p.queue.clear(p.target)
   230  				return p.queue.goToModel(p.target)
   231  			}
   232  		}
   233  
   234  	default:
   235  		var cmdList tea.Cmd
   236  		var cmdSpin tea.Cmd
   237  		p.list, cmdList = p.list.Update(msg)
   238  		p.spinner, cmdSpin = p.spinner.Update(msg)
   239  		return p, tea.Batch(cmdSpin, cmdList)
   240  	}
   241  
   242  	// If this isn't here, then keyPress events do not get responded to by
   243  	// the list ¯\(°_o)/¯
   244  	if p.state == "displaying" {
   245  		var cmd tea.Cmd
   246  		p.list, cmd = p.list.Update(msg)
   247  		return p, cmd
   248  	}
   249  
   250  	return p, nil
   251  }
   252  
   253  func (p picker) View() string {
   254  	if p.preViewFunc != nil {
   255  		p.preViewFunc(p.queue)
   256  	}
   257  	doc := strings.Builder{}
   258  	doc.WriteString(p.queue.header.render())
   259  
   260  	if p.showProgress && p.err == nil {
   261  		doc.WriteString(drawProgress(p.queue.calcPercent()))
   262  		doc.WriteString("\n\n")
   263  	}
   264  
   265  	if p.err != nil {
   266  		doc.WriteString(errorAlert{p.err.(errMsg)}.Render())
   267  		return docStyle.Render(doc.String())
   268  	}
   269  
   270  	if len(p.content) > 0 {
   271  		inst := strings.Builder{}
   272  		for _, v := range p.content {
   273  			content := v.render()
   274  
   275  			inst.WriteString(content)
   276  		}
   277  		doc.WriteString(instructionStyle.Width(width).Render(inst.String()))
   278  		doc.WriteString("\n")
   279  		doc.WriteString("\n")
   280  	}
   281  
   282  	if p.state != "waiting" && p.state != "idle" && p.state != "querying" {
   283  		selectedItemStyle.Width(hardWidthLimit)
   284  		doc.WriteString(componentStyle.Render(p.list.View()))
   285  	}
   286  
   287  	if p.state == "querying" {
   288  		spinnerSB := strings.Builder{}
   289  		spinnerSB.WriteString(textStyle.Render(fmt.Sprintf("%s ", p.spinnerLabel)))
   290  		spinnerSB.WriteString(spinnerStyle.Render(fmt.Sprintf("%s", p.spinner.View())))
   291  		if p.querySlowText != "" {
   292  			spinnerSB.WriteString(textStyle.Render(fmt.Sprintf("\n%s ", p.querySlowText)))
   293  		}
   294  
   295  		doc.WriteString(bodyStyle.Render(spinnerSB.String()))
   296  	}
   297  
   298  	return docStyle.Render(doc.String())
   299  }