feat: use new ui bubbletea
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type listScreen struct {
|
||||
state *wizardState
|
||||
title string
|
||||
desc []string
|
||||
list list.Model
|
||||
footer string
|
||||
errText string
|
||||
onPick func(optionItem) screen
|
||||
onBack func() screen
|
||||
}
|
||||
|
||||
func newListScreen(state *wizardState, title string, desc []string, items []optionItem, onPick func(optionItem) screen, onBack func() screen) *listScreen {
|
||||
delegate := list.NewDefaultDelegate()
|
||||
l := list.New(itemsToList(items), delegate, 0, 0)
|
||||
l.DisableQuitKeybindings()
|
||||
l.SetShowHelp(false)
|
||||
l.SetFilteringEnabled(true)
|
||||
l.SetShowStatusBar(false)
|
||||
l.Title = title
|
||||
return &listScreen{
|
||||
state: state,
|
||||
title: title,
|
||||
desc: desc,
|
||||
list: l,
|
||||
footer: "enter select • esc back • / filter",
|
||||
onPick: onPick,
|
||||
onBack: onBack,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *listScreen) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *listScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
s.list.SetSize(msg.Width-4, msg.Height-8)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if s.list.FilterState() != list.Filtering {
|
||||
if item, ok := s.list.SelectedItem().(optionItem); ok {
|
||||
if item.disabled {
|
||||
s.errText = "That option isn't available in this environment."
|
||||
break
|
||||
}
|
||||
if s.onPick != nil {
|
||||
return s.onPick(item), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
case "esc", "q":
|
||||
if s.list.FilterState() == list.Unfiltered {
|
||||
if s.onBack != nil {
|
||||
return s.onBack(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
s.list, cmd = s.list.Update(msg)
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
func (s *listScreen) View() string {
|
||||
header := renderHeader(s.title, s.desc, s.errText)
|
||||
footer := renderFooter(s.footer, s.list.FilterState() != list.Unfiltered)
|
||||
return header + s.list.View() + "\n" + footer
|
||||
}
|
||||
|
||||
type confirmScreen struct {
|
||||
state *wizardState
|
||||
title string
|
||||
desc []string
|
||||
list list.Model
|
||||
footer string
|
||||
onYes func() screen
|
||||
onNo func() screen
|
||||
errText string
|
||||
}
|
||||
|
||||
func newConfirmScreen(state *wizardState, title string, desc []string, yesLabel, noLabel string, onYes func() screen, onNo func() screen) *confirmScreen {
|
||||
items := []optionItem{
|
||||
{title: yesLabel, value: "yes"},
|
||||
{title: noLabel, value: "no"},
|
||||
}
|
||||
delegate := list.NewDefaultDelegate()
|
||||
l := list.New(itemsToList(items), delegate, 0, 0)
|
||||
l.DisableQuitKeybindings()
|
||||
l.SetShowHelp(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.Title = title
|
||||
return &confirmScreen{state: state, title: title, desc: desc, list: l, footer: "enter select • esc back", onYes: onYes, onNo: onNo}
|
||||
}
|
||||
|
||||
func (s *confirmScreen) Init() tea.Cmd { return nil }
|
||||
|
||||
func (s *confirmScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
s.list.SetSize(msg.Width-4, msg.Height-8)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if item, ok := s.list.SelectedItem().(optionItem); ok {
|
||||
if item.value == "yes" && s.onYes != nil {
|
||||
return s.onYes(), nil
|
||||
}
|
||||
if item.value == "no" && s.onNo != nil {
|
||||
return s.onNo(), nil
|
||||
}
|
||||
}
|
||||
case "esc", "q":
|
||||
if s.onNo != nil {
|
||||
return s.onNo(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
s.list, cmd = s.list.Update(msg)
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
func (s *confirmScreen) View() string {
|
||||
header := renderHeader(s.title, s.desc, s.errText)
|
||||
footer := renderFooter(s.footer, false)
|
||||
return header + s.list.View() + "\n" + footer
|
||||
}
|
||||
|
||||
type inputScreen struct {
|
||||
state *wizardState
|
||||
title string
|
||||
desc []string
|
||||
input textinput.Model
|
||||
footer string
|
||||
errText string
|
||||
onSubmit func(string) screen
|
||||
onCancel func() screen
|
||||
validateFn func(string) error
|
||||
}
|
||||
|
||||
func newInputScreen(state *wizardState, title string, desc []string, value string, placeholder string, password bool, validateFn func(string) error, onSubmit func(string) screen, onCancel func() screen) *inputScreen {
|
||||
input := textinput.New()
|
||||
input.SetValue(value)
|
||||
input.Placeholder = placeholder
|
||||
if password {
|
||||
input.EchoMode = textinput.EchoPassword
|
||||
input.EchoCharacter = '*'
|
||||
}
|
||||
input.Focus()
|
||||
input.CharLimit = 0
|
||||
return &inputScreen{
|
||||
state: state,
|
||||
title: title,
|
||||
desc: desc,
|
||||
input: input,
|
||||
footer: "enter save • esc back",
|
||||
onSubmit: onSubmit,
|
||||
onCancel: onCancel,
|
||||
validateFn: validateFn,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inputScreen) Init() tea.Cmd { return textinput.Blink }
|
||||
|
||||
func (s *inputScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
value := strings.TrimSpace(s.input.Value())
|
||||
if s.validateFn != nil {
|
||||
if err := s.validateFn(value); err != nil {
|
||||
s.errText = err.Error()
|
||||
break
|
||||
}
|
||||
}
|
||||
if s.onSubmit != nil {
|
||||
return s.onSubmit(value), nil
|
||||
}
|
||||
case "esc", "q":
|
||||
if s.onCancel != nil {
|
||||
return s.onCancel(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
s.input, cmd = s.input.Update(msg)
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
func (s *inputScreen) View() string {
|
||||
header := renderHeader(s.title, s.desc, s.errText)
|
||||
footer := renderFooter(s.footer, false)
|
||||
return header + s.input.View() + "\n\n" + footer
|
||||
}
|
||||
|
||||
type multiSelectScreen struct {
|
||||
state *wizardState
|
||||
title string
|
||||
desc []string
|
||||
list list.Model
|
||||
footer string
|
||||
errText string
|
||||
onSubmit func([]toggleItem) screen
|
||||
onCancel func() screen
|
||||
requireOne bool
|
||||
}
|
||||
|
||||
func newMultiSelectScreen(state *wizardState, title string, desc []string, items []toggleItem, requireOne bool, onSubmit func([]toggleItem) screen, onCancel func() screen) *multiSelectScreen {
|
||||
delegate := list.NewDefaultDelegate()
|
||||
l := list.New(toggleItemsToList(items), delegate, 0, 0)
|
||||
l.DisableQuitKeybindings()
|
||||
l.SetShowHelp(false)
|
||||
l.SetFilteringEnabled(true)
|
||||
l.SetShowStatusBar(false)
|
||||
l.Title = title
|
||||
return &multiSelectScreen{
|
||||
state: state,
|
||||
title: title,
|
||||
desc: desc,
|
||||
list: l,
|
||||
footer: "space toggle • enter save • esc back • / filter",
|
||||
onSubmit: onSubmit,
|
||||
onCancel: onCancel,
|
||||
requireOne: requireOne,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *multiSelectScreen) Init() tea.Cmd { return nil }
|
||||
|
||||
func (s *multiSelectScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
s.list.SetSize(msg.Width-4, msg.Height-8)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case " ":
|
||||
idx := s.list.Index()
|
||||
items := s.list.Items()
|
||||
if idx >= 0 && idx < len(items) {
|
||||
if item, ok := items[idx].(toggleItem); ok {
|
||||
item.selected = !item.selected
|
||||
items[idx] = item
|
||||
s.list.SetItems(items)
|
||||
}
|
||||
}
|
||||
case "enter":
|
||||
items := listToToggleItems(s.list.Items())
|
||||
if s.requireOne {
|
||||
has := false
|
||||
for _, item := range items {
|
||||
if item.selected {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
s.errText = "Select at least one option to continue."
|
||||
break
|
||||
}
|
||||
}
|
||||
if s.onSubmit != nil {
|
||||
return s.onSubmit(items), nil
|
||||
}
|
||||
case "esc", "q":
|
||||
if s.list.FilterState() == list.Unfiltered {
|
||||
if s.onCancel != nil {
|
||||
return s.onCancel(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
s.list, cmd = s.list.Update(msg)
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
func (s *multiSelectScreen) View() string {
|
||||
header := renderHeader(s.title, s.desc, s.errText)
|
||||
footer := renderFooter(s.footer, s.list.FilterState() != list.Unfiltered)
|
||||
return header + s.list.View() + "\n" + footer
|
||||
}
|
||||
|
||||
type infoScreen struct {
|
||||
state *wizardState
|
||||
title string
|
||||
desc []string
|
||||
footer string
|
||||
next func() screen
|
||||
back func() screen
|
||||
}
|
||||
|
||||
func newInfoScreen(state *wizardState, title string, desc []string, next func() screen, back func() screen) *infoScreen {
|
||||
return &infoScreen{state: state, title: title, desc: desc, footer: "enter continue • esc back", next: next, back: back}
|
||||
}
|
||||
|
||||
func (s *infoScreen) Init() tea.Cmd { return nil }
|
||||
|
||||
func (s *infoScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if s.next != nil {
|
||||
return s.next(), nil
|
||||
}
|
||||
case "esc", "q":
|
||||
if s.back != nil {
|
||||
return s.back(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *infoScreen) View() string {
|
||||
header := renderHeader(s.title, s.desc, "")
|
||||
footer := renderFooter(s.footer, false)
|
||||
return header + "\n" + footer
|
||||
}
|
||||
|
||||
type formField struct {
|
||||
key string
|
||||
label string
|
||||
desc string
|
||||
input textinput.Model
|
||||
validate func(string) error
|
||||
required bool
|
||||
sensitive bool
|
||||
}
|
||||
|
||||
type formScreen struct {
|
||||
state *wizardState
|
||||
title string
|
||||
desc []string
|
||||
fields []formField
|
||||
focused int
|
||||
footer string
|
||||
errText string
|
||||
onSubmit func(map[string]string) screen
|
||||
onCancel func() screen
|
||||
}
|
||||
|
||||
func newFormScreen(state *wizardState, title string, desc []string, fields []formField, onSubmit func(map[string]string) screen, onCancel func() screen) *formScreen {
|
||||
if len(fields) > 0 {
|
||||
fields[0].input.Focus()
|
||||
}
|
||||
return &formScreen{state: state, title: title, desc: desc, fields: fields, onSubmit: onSubmit, onCancel: onCancel}
|
||||
}
|
||||
|
||||
func (s *formScreen) Init() tea.Cmd { return textinput.Blink }
|
||||
|
||||
func (s *formScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
if s.onCancel != nil {
|
||||
return s.onCancel(), nil
|
||||
}
|
||||
case "tab", "down":
|
||||
s.moveFocus(1)
|
||||
case "shift+tab", "up":
|
||||
s.moveFocus(-1)
|
||||
case "enter":
|
||||
if s.focused == len(s.fields)-1 {
|
||||
values, err := s.validateAll()
|
||||
if err != nil {
|
||||
s.errText = err.Error()
|
||||
break
|
||||
}
|
||||
if s.onSubmit != nil {
|
||||
return s.onSubmit(values), nil
|
||||
}
|
||||
} else {
|
||||
s.moveFocus(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
if s.focused >= 0 && s.focused < len(s.fields) {
|
||||
s.fields[s.focused].input, cmd = s.fields[s.focused].input.Update(msg)
|
||||
}
|
||||
return s, cmd
|
||||
}
|
||||
|
||||
func (s *formScreen) View() string {
|
||||
header := renderHeader(s.title, s.desc, s.errText)
|
||||
body := strings.Builder{}
|
||||
for i, field := range s.fields {
|
||||
label := StyleLabel.Render(field.label)
|
||||
if i == s.focused {
|
||||
label = StyleHighlight.Render(field.label)
|
||||
}
|
||||
body.WriteString(label)
|
||||
if field.desc != "" {
|
||||
body.WriteString("\n")
|
||||
body.WriteString(StyleSubtle.Render(field.desc))
|
||||
}
|
||||
body.WriteString("\n")
|
||||
body.WriteString(field.input.View())
|
||||
body.WriteString("\n\n")
|
||||
}
|
||||
footer := renderFooter(s.footer, false)
|
||||
return header + body.String() + footer
|
||||
}
|
||||
|
||||
func (s *formScreen) moveFocus(delta int) {
|
||||
if len(s.fields) == 0 {
|
||||
return
|
||||
}
|
||||
s.fields[s.focused].input.Blur()
|
||||
s.focused = (s.focused + delta + len(s.fields)) % len(s.fields)
|
||||
s.fields[s.focused].input.Focus()
|
||||
}
|
||||
|
||||
func (s *formScreen) validateAll() (map[string]string, error) {
|
||||
values := make(map[string]string, len(s.fields))
|
||||
for _, field := range s.fields {
|
||||
value := strings.TrimSpace(field.input.Value())
|
||||
if field.required && value == "" {
|
||||
return nil, fmt.Errorf("%s is required", field.label)
|
||||
}
|
||||
if field.validate != nil {
|
||||
if err := field.validate(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
values[field.key] = value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
type downloadProgressMsg struct {
|
||||
downloaded int64
|
||||
total int64
|
||||
}
|
||||
|
||||
type downloadDoneMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type downloadScreen struct {
|
||||
state *wizardState
|
||||
title string
|
||||
desc []string
|
||||
modelID string
|
||||
progress int
|
||||
total int64
|
||||
footer string
|
||||
errText string
|
||||
onSuccess func() screen
|
||||
onCancel func() screen
|
||||
updates chan tea.Msg
|
||||
started bool
|
||||
}
|
||||
|
||||
func newDownloadScreen(state *wizardState, title string, desc []string, modelID string, onSuccess func() screen, onCancel func() screen) *downloadScreen {
|
||||
return &downloadScreen{
|
||||
state: state,
|
||||
title: title,
|
||||
desc: desc,
|
||||
modelID: modelID,
|
||||
onSuccess: onSuccess,
|
||||
onCancel: onCancel,
|
||||
updates: make(chan tea.Msg),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *downloadScreen) Init() tea.Cmd {
|
||||
if s.started {
|
||||
return listenForDownload(s.updates)
|
||||
}
|
||||
s.started = true
|
||||
return tea.Batch(s.startDownloadCmd(), listenForDownload(s.updates))
|
||||
}
|
||||
|
||||
func (s *downloadScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case downloadProgressMsg:
|
||||
s.total = msg.total
|
||||
if msg.total > 0 {
|
||||
s.progress = int(msg.downloaded * 100 / msg.total)
|
||||
}
|
||||
return s, listenForDownload(s.updates)
|
||||
case downloadDoneMsg:
|
||||
if msg.err != nil {
|
||||
s.errText = msg.err.Error()
|
||||
return s, nil
|
||||
}
|
||||
if s.onSuccess != nil {
|
||||
return s.onSuccess(), nil
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
if s.onCancel != nil {
|
||||
return s.onCancel(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *downloadScreen) View() string {
|
||||
header := renderHeader(s.title, s.desc, s.errText)
|
||||
progressLine := "Downloading"
|
||||
if s.total > 0 {
|
||||
progressLine = fmt.Sprintf("Downloading... %d%%", s.progress)
|
||||
}
|
||||
body := StyleMuted.Render(progressLine) + "\n\n"
|
||||
footer := renderFooter(s.footer, false)
|
||||
return header + body + footer
|
||||
}
|
||||
|
||||
func (s *downloadScreen) startDownloadCmd() tea.Cmd {
|
||||
modelID := s.modelID
|
||||
ch := s.updates
|
||||
return func() tea.Msg {
|
||||
err := downloadWhisperModel(modelID, func(downloaded, total int64) {
|
||||
ch <- downloadProgressMsg{downloaded: downloaded, total: total}
|
||||
})
|
||||
ch <- downloadDoneMsg{err: err}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func listenForDownload(ch <-chan tea.Msg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
msg, ok := <-ch
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func itemsToList(items []optionItem) []list.Item {
|
||||
result := make([]list.Item, len(items))
|
||||
for i, item := range items {
|
||||
result[i] = item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toggleItemsToList(items []toggleItem) []list.Item {
|
||||
result := make([]list.Item, len(items))
|
||||
for i, item := range items {
|
||||
result[i] = item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func listToToggleItems(items []list.Item) []toggleItem {
|
||||
result := make([]toggleItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if t, ok := item.(toggleItem); ok {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func renderHeader(title string, desc []string, errText string) string {
|
||||
var b strings.Builder
|
||||
if title != "" {
|
||||
b.WriteString(StyleHeader.Render(title))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
for _, line := range desc {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(StyleMuted.Render(line))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if errText != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(StyleError.Render(errText))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderFooter(extra string, filtering bool) string {
|
||||
if extra != "" {
|
||||
return StyleSubtle.Render(extra)
|
||||
}
|
||||
if filtering {
|
||||
return StyleSubtle.Render("enter apply • esc clear")
|
||||
}
|
||||
return StyleSubtle.Render("enter select • esc back")
|
||||
}
|
||||
|
||||
func makeInputField(key, label, desc, value, placeholder string, validate func(string) error) formField {
|
||||
input := textinput.New()
|
||||
input.SetValue(value)
|
||||
input.Placeholder = placeholder
|
||||
input.Prompt = ""
|
||||
input.Cursor.Style = lipgloss.NewStyle().Foreground(ColorPrimary)
|
||||
return formField{key: key, label: label, desc: desc, input: input, validate: validate}
|
||||
}
|
||||
|
||||
func parseDurationOrEmpty(value string) (time.Duration, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return 0, fmt.Errorf("duration is required")
|
||||
}
|
||||
return time.ParseDuration(value)
|
||||
}
|
||||
Reference in New Issue
Block a user