1package ui23import (4 "strconv"5 "strings"6 "unicode"78 tea "charm.land/bubbletea/v2"910 "src.rest/cat/012/internal/sheet"11 "src.rest/cat/012/internal/ui/lineedit"12 "src.rest/cat/012/internal/ui/theme"13)1415// Prompts are questions on the context line, answered by typing (a file16// name, a width) or by pointing at a range with the arrows, as in 1-2-3.17// The mode indicator says what's being asked, e.g. EDIT, POINT or WIDTH.18// The text is edited in Model.line; a range is pointed at with19// Model.point.2021type promptKind int2223const (24 promptText promptKind = iota25 promptRange26 promptWidth // a size: digits only, and Left/Right preview one less or more27)2829// prompt is a question on the context line, such as a file name or a30// range to act on.31type prompt struct {32 kind promptKind33 label string34 indicator string35 fresh bool // the default is still showing; typing replaces it36 secret bool // the answer is shown masked, e.g. an API key37 breaks bool // Shift+Enter and Alt+Enter type a line break, shown as noteBreak38 typing bool // range prompts: the user is typing instead of pointing39 onText func(text string) tea.Cmd40 onRange func(r sheet.Rect) tea.Cmd41 onCancel func()42 // resize previews a size prompt's answer, from 1 to maxSize.43 resize func(n int)44 maxSize int45 files []string // the file list shown by File Open46}4748// promptHost is what a prompt acts on. The model implements it. Prompts49// stay in package ui because a prompt is one of the model's modes rather50// than an overlay: a range is pointed at with the grid's own movement51// and drawn by the grid, and the answer goes to a callback over the52// model.53type promptHost interface {54 styles() *theme.Theme55 // editLine is the shared edit line the answer is typed in.56 editLine() *lineedit.Line57 // pointer is the range being pointed at, which the grid draws.58 pointer() *pointer59 // pointMoveKey moves or stretches the pointer as the grid's keys60 // do, and reports whether key was one of them.61 pointMoveKey(key string) bool62 closePrompt()63 fail(msg string)64 // recordAnswer tells a macro recording the answer to the question65 // of the command that asked.66 recordAnswer(answer string, cancelled bool)67 // cancelQuit stops Quit waiting on the save a cancelled prompt was68 // part of: cancelling Save as cancels Save and quit.69 cancelQuit()70}7172func (m *Model) editLine() *lineedit.Line { return &m.line }73func (m *Model) pointer() *pointer { return &m.point }74func (m *Model) cancelQuit() { m.quitAfterSave = false }7576// pointing reports whether a range prompt is taking the arrows.77func (p *prompt) pointing() bool { return p.kind == promptRange && !p.typing }7879// pointing reports whether a range prompt is taking the arrows.80func (m *Model) pointing() bool {81 return m.mode == modePrompt && m.prompt.pointing()82}8384func (m *Model) openPrompt(p *prompt, initial string) {85 m.mode = modePrompt86 m.prompt = p87 m.line.Clear()88 m.entry.hint = ""89 m.line.Insert(initial)90}9192// openText asks for text, starting from initial, which typing replaces.93func (m *Model) openText(label, initial string, onText func(*Model, string) tea.Cmd) {94 done := func(text string) tea.Cmd { return onText(m, text) }95 m.openPrompt(&prompt{kind: promptText, label: label, indicator: "EDIT", fresh: true, onText: done}, initial)96}9798// openRange asks for a range, starting from the current selection.99func (m *Model) openRange(label string, onRange func(*Model, sheet.Rect) tea.Cmd) {100 r := m.selection()101 done := func(r sheet.Rect) tea.Cmd { return onRange(m, r) }102 m.openPrompt(&prompt{kind: promptRange, label: label, indicator: "POINT", onRange: done}, "")103 m.point = pointer{anchor: r.From, at: r.To, anchored: r.From != r.To}104}105106func (m *Model) closePrompt() {107 m.prompt = nil108 m.cancelEntry()109}110111// key handles a key while the prompt is open.112func (p *prompt) key(m promptHost, k tea.KeyPressMsg) tea.Cmd {113 switch key := k.String(); {114 case key == "esc":115 p.cancel(m)116 case key == "enter":117 return p.accept(m)118 case p.breaks && (key == "shift+enter" || key == "alt+enter"):119 p.fresh = false120 m.editLine().Insert(noteBreak)121 case p.pointing():122 p.pointKey(m, k)123 case p.kind == promptWidth && (key == "left" || key == "right"):124 p.stepWidth(m, key)125 default:126 p.typeKey(m, k)127 }128 return nil129}130131// cancel backs out: of a range being stretched, or of the prompt.132func (p *prompt) cancel(m promptHost) {133 if pt := m.pointer(); p.pointing() && pt.anchored {134 pt.anchored = false135 return136 }137 m.cancelQuit()138 // Closed first, so onCancel can return to what opened the prompt, as139 // the chart editor does.140 m.closePrompt()141 if p.onCancel != nil {142 p.onCancel()143 }144 m.recordAnswer("", true)145}146147// accept closes the prompt and acts on the answer.148func (p *prompt) accept(m promptHost) tea.Cmd {149 text := strings.TrimSpace(m.editLine().Text())150 m.closePrompt()151 if p.kind != promptRange {152 cmd := p.onText(text)153 m.recordAnswer(text, false)154 return cmd155 }156 r := m.pointer().rect()157 if p.typing {158 var ok bool159 if r, ok = sheet.ParseRange(text); !ok {160 m.fail("Invalid range: " + text)161 return nil162 }163 }164 cmd := p.onRange(r)165 m.recordAnswer(r.String(), false)166 return cmd167}168169// pointKey moves or stretches the range being pointed at; typing switches170// to typing the range instead.171func (p *prompt) pointKey(m promptHost, k tea.KeyPressMsg) {172 key := k.String()173 switch pt := m.pointer(); {174 case m.pointMoveKey(key):175 case key == ":":176 pt.anchor, pt.anchored = pt.at, true177 case typed(k) != "":178 p.typing = true179 m.editLine().Insert(typed(k))180 }181}182183// stepWidth changes a width by one, previewing it live.184func (p *prompt) stepWidth(m promptHost, key string) {185 line := m.editLine()186 w, _ := strconv.Atoi(line.Text())187 if key == "left" {188 w--189 } else {190 w++191 }192 w = clamp(w, 1, p.maxSize)193 line.Clear()194 p.fresh = false195 line.Insert(strconv.Itoa(w))196 p.resize(w)197}198199// typeKey edits the answer; the first key typed replaces the default.200func (p *prompt) typeKey(m promptHost, k tea.KeyPressMsg) {201 line := m.editLine()202 if p.fresh && typed(k) != "" {203 line.Clear()204 }205 p.fresh = false206 if p.kind == promptWidth && !isDigits(typed(k)) {207 return208 }209 line.Key(k)210}211212// paste types pasted text into the answer.213func (p *prompt) paste(m promptHost, text string) {214 line := m.editLine()215 if p.fresh {216 line.Clear()217 p.fresh = false218 }219 line.Insert(text)220}221222// line is the prompt as the context line shows it, e.g. "Save as:223// budget.012", and the keys or choices that go with it.224func (p *prompt) line(m promptHost) (left, right string) {225 th, text := m.styles(), m.editLine().Text()226 switch {227 case p.pointing():228 return p.prefix() + th.Selection.Render(m.pointer().text()),229 th.KeyHints("Arrows", "move", "Shift+arrows", "extend", "Enter", "apply", "Esc", "cancel")230 case len(p.files) > 0:231 return p.prefix() + text, th.Muted.Render(strings.Join(p.files, " "))232 case p.kind == promptWidth:233 return p.prefix() + text, th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")234 case p.secret:235 return p.prefix() + mask(len(m.editLine().Buf)), th.KeyHints("Enter", "save", "Esc", "cancel")236 case p.breaks:237 return p.prefix() + text, th.KeyHints("Enter", "save", "Shift+Enter", "new line", "Esc", "cancel")238 }239 return p.prefix() + text, th.KeyHints("Enter", "apply", "Esc", "cancel")240}241242func (p *prompt) prefix() string {243 return p.label + " "244}245246// mask stands for n typed characters of a secret.247func mask(n int) string { return strings.Repeat("•", n) }248249// head is the answer before the caret, as shown.250func (p *prompt) head(m promptHost) string {251 if p.secret {252 return mask(m.editLine().Pos)253 }254 return m.editLine().Head()255}256257func isDigits(s string) bool {258 return !strings.ContainsFunc(s, func(r rune) bool { return !unicode.IsDigit(r) })259}