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 }