package cli import ( "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) // A form in a dialog: text to type, one of a few choices, a thing on or off, // and headings between them. The setup's settings for each part are one of // these, opened over the list of parts. type fieldKind int const ( fkText fieldKind = iota // fkChoice is one of Options, changed with ← →. fkChoice // fkToggle is on or off, changed with Space. fkToggle // fkHeading is a title between fields; it takes no focus. fkHeading ) type setupField struct { Label string // Hint is said under the field; Notes, for a choice, under each option // as it is chosen. Hint string Notes []string Kind fieldKind Text textField Options []string Choice int On bool // Show, when set, says whether the field is shown, and so whether it // counts: a directory for a mirror kept elsewhere is neither. Show func() bool } func textSetupField(label, value, placeholder, hint string) *setupField { t := newTextField() t.SetValue(value) t.Placeholder, t.CharLimit, t.Width = placeholder, 800, 40 return &setupField{Label: label, Hint: hint, Kind: fkText, Text: t} } func choiceField(label string, options []string, choice int, hint string) *setupField { return &setupField{Label: label, Hint: hint, Kind: fkChoice, Options: options, Choice: choice} } func toggleField(label string, on bool, hint string) *setupField { return &setupField{Label: label, Hint: hint, Kind: fkToggle, On: on} } func headingField(label string) *setupField { return &setupField{Label: label, Kind: fkHeading} } func (f *setupField) shown() bool { return f.Show == nil || f.Show() } func (f *setupField) Value() string { return strings.TrimSpace(f.Text.Value()) } // setupDialog is a form over the screen, with what it is for above it. type setupDialog struct { Title string About string Fields []*setupField focus int } // focusable is whether a field can have the focus now. func (d *setupDialog) focusable(i int) bool { f := d.Fields[i] return f.Kind != fkHeading && f.shown() } // first puts the focus on the first field that takes it. func (d *setupDialog) first() { d.focus = -1 d.move(1) } // focusOn puts the focus on a field, as when it is what is wrong. func (d *setupDialog) focusOn(f *setupField) { for i, x := range d.Fields { if x == f { d.focus = i } } d.blurAll() } func (d *setupDialog) move(dir int) { n := len(d.Fields) for range n { d.focus = (d.focus + dir + n) % n if d.focusable(d.focus) { break } } d.blurAll() } // blurAll leaves only the focused text field with a cursor. func (d *setupDialog) blurAll() { for i, f := range d.Fields { if f.Kind != fkText { continue } if i == d.focus { f.Text.Focus() } else { f.Text.Blur() } } } // hasFields is whether there is anything to set, rather than only to read. func (d *setupDialog) hasFields() bool { for i := range d.Fields { if d.focusable(i) { return true } } return false } // lastFocusable is the last field that takes the focus now. func (d *setupDialog) lastFocusable() int { for i := len(d.Fields) - 1; i >= 0; i-- { if d.focusable(i) { return i } } return -1 } // key takes a key and says whether the dialog is done: Esc, or Enter on // the last field. func (d *setupDialog) key(k tea.KeyMsg) (done bool) { switch k.Type { case tea.KeyEsc: return true case tea.KeyEnter: if !d.hasFields() || d.focus == d.lastFocusable() { return true } d.move(1) return false case tea.KeyTab, tea.KeyDown: if d.hasFields() { d.move(1) } return false case tea.KeyShiftTab, tea.KeyUp: if d.hasFields() { d.move(-1) } return false } if d.focus < 0 || d.focus >= len(d.Fields) || !d.focusable(d.focus) { return false } f := d.Fields[d.focus] switch f.Kind { case fkText: f.Text, _ = f.Text.Update(k) case fkChoice: switch k.String() { case "left", "h": f.Choice = (f.Choice + len(f.Options) - 1) % len(f.Options) case "right", "l", " ": f.Choice = (f.Choice + 1) % len(f.Options) } case fkToggle: if k.String() == " " || k.String() == "x" { f.On = !f.On } } return false } // dialogWidth is how wide the text in a dialog is wrapped. const dialogWidth = 66 // view draws the form in at most maxLines lines, the part around the focus // when it is longer: a long form scrolls as the focus moves. func (d *setupDialog) view(maxLines int) string { var lines []string add := func(s string) { lines = append(lines, strings.Split(s, "\n")...) } if d.About != "" { add(paragraphs(d.About, dialogWidth, "")) } if !d.hasFields() { add("") add(sDim.Render("Nothing to set for this part. Esc or Enter to go back.")) return clipLines(lines, 0, 0, maxLines) } add("") focusStart, focusEnd := 0, 0 for i, f := range d.Fields { if !f.shown() { continue } focused := i == d.focus if focused { focusStart = len(lines) } label := func(s string) string { if focused { return fSelected.Render("› " + s) } return " " + s } switch f.Kind { case fkHeading: add("") add(" " + lipgloss.NewStyle().Bold(true).Render(f.Label)) continue case fkText: add(label(f.Label)) add(" " + f.Text.View()) case fkChoice: var opts []string for j, o := range f.Options { if j == f.Choice { opts = append(opts, "("+sOK.Render("•")+") "+o) } else { opts = append(opts, "( ) "+sDim.Render(o)) } } add(label(f.Label)) // Side by side while they fit, else one a line. if row := strings.Join(opts, " "); ansi.StringWidth(row) <= dialogWidth-4 { add(" " + row) } else { for _, o := range opts { add(" " + o) } } if f.Choice < len(f.Notes) && f.Notes[f.Choice] != "" { add(" " + sDim.Render(wrap(f.Notes[f.Choice], dialogWidth-4, " "))) } case fkToggle: box := "[ ]" if f.On { box = "[" + sOK.Render("x") + "]" } add(label(box) + " " + f.Label) } if f.Hint != "" { add(" " + sDim.Render(wrap(f.Hint, dialogWidth-4, " "))) } if focused { focusEnd = len(lines) - 1 } if f.Kind != fkToggle { add("") } } for len(lines) > 0 && lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } return clipLines(lines, focusStart, focusEnd, maxLines) } // clipLines is at most n of lines, keeping lines from to through in sight // as far as they fit, with a mark at an edge that has more beyond it. func clipLines(lines []string, from, through, n int) string { if n <= 2 || len(lines) <= n { return strings.Join(lines, "\n") } top := 0 // The last line shown is a mark when there is more below: the focus // ends a line above it. if through > n-2 { top = through - (n - 2) } // Its first line wins over its last, under the mark above. if from > 0 && top > from-1 { top = from - 1 } top = max(0, min(top, len(lines)-n)) out := append([]string{}, lines[top:top+n]...) if top > 0 { out[0] = sDim.Render(" ↑ more") } if top+n < len(lines) { out[n-1] = sDim.Render(" ↓ more") } return strings.Join(out, "\n") } // paragraphs wraps text paragraph by paragraph, blank lines kept between. func paragraphs(s string, width int, indent string) string { var out []string for _, p := range strings.Split(s, "\n\n") { out = append(out, indent+wrap(p, width, indent)) } return strings.Join(out, "\n\n") } // setupDialogKeys is the key bar under a dialog. func setupDialogKeys(d *setupDialog) string { if !d.hasFields() { return "Esc or Enter back" } keys := "Tab/↑↓ move" if d.focus >= 0 && d.focus < len(d.Fields) { switch d.Fields[d.focus].Kind { case fkChoice: keys += " · ←→ choose" case fkToggle: keys += " · Space tick" } } return keys + " · Esc done" }