1// Package cmdline is the command line (: with vim keys, or from the2// palette): what's typed on the context line, with completions from the3// command registry in a box under it. It knows the UI only through Host;4// what a line means (vim's file commands, a cell to go to, a command by5// name) is the host's to carry out.6package cmdline78import (9 "slices"10 "strconv"11 "strings"1213 tea "charm.land/bubbletea/v2"14 "github.com/charmbracelet/x/ansi"1516 "src.rest/cat/012/internal/ui/lineedit"17 "src.rest/cat/012/internal/ui/overlay"18 "src.rest/cat/012/internal/ui/theme"19)2021// Host is what the command line needs of the UI.22type Host interface {23 Theme() *theme.Theme24 Size() (width, height int)25 // Line is the shared edit line the command is typed in.26 Line() *lineedit.Line27 // Close closes the open overlay.28 Close()29 // Commands are the registered commands to complete, by title.30 Commands() []Item31 // Run carries out a command line and reports whether it was one.32 Run(text string) (tea.Cmd, bool)33 // Fail shows an error.34 Fail(msg string)35 // History is the lines run this session, for Up and Down.36 History() *History37}3839// Item is a completion: a file command or a registered command.40type Item struct {41 Word string // what Tab puts on the line: "w", or a command's ID42 Title string43 Desc string44 Key string // the command's shortcut45 Off bool // unavailable right now46}4748// FileWords are vim's file commands.49var FileWords = []Item{50 {Word: "w", Title: "Write", Desc: "Save the sheet; :w name saves it as name, :w name.csv downloads it"},51 {Word: "w!", Title: "Write anyway", Desc: "Save even if the file changed on disk since it was opened; :w! name replaces name"},52 {Word: "q", Title: "Quit", Desc: "Close 012, asking about unsaved changes"},53 {Word: "q!", Title: "Quit without saving", Desc: "Close 012, discarding unsaved changes"},54 {Word: "wq", Title: "Write and quit", Desc: "Save the sheet, then close 012"},55 {Word: "x", Title: "Write if changed and quit", Desc: "Save the sheet if it changed, then close 012"},56 {Word: "e", Title: "Edit a file", Desc: "Open a sheet or import a file: :e name"},57}5859// ID identifies the completions' box in mouse events.60const ID = "cmdline"6162// Line is the open command line.63type Line struct {64 h Host65 overlay.List66 shown []Item67 tabbed bool // the text is a completion Tab put there; Tab again moves on68 hist browse69}7071// New returns a command line with the edit line cleared.72func New(h Host) *Line {73 c := &Line{h: h}74 h.Line().Clear()75 c.Changed()76 return c77}7879// Shown are the completions of what's typed.80func (c *Line) Shown() []Item { return c.shown }8182func (c *Line) Indicator() string { return "COMMAND" }8384// Changed completes what's typed: file commands, then commands whose ID85// or title starts with it, then those that contain it. Nothing is86// completed once a file command's argument follows.87func (c *Line) Changed() {88 c.Sel, c.Top, c.tabbed = 0, 0, false89 c.shown = c.shown[:0]90 q := strings.ToLower(strings.TrimPrefix(strings.TrimLeft(c.h.Line().Text(), " "), ":"))91 if word, _, arg := strings.Cut(q, " "); arg && isFileWord(word) {92 return93 }94 for _, w := range FileWords {95 if strings.HasPrefix(w.Word, q) {96 c.shown = append(c.shown, w)97 }98 }99 var starts, contains []Item100 for _, it := range c.h.Commands() {101 title := strings.ToLower(it.Title)102 switch {103 case strings.HasPrefix(it.Word, q) || strings.HasPrefix(title, q):104 starts = append(starts, it)105 case strings.Contains(it.Word, q) || strings.Contains(title, q):106 contains = append(contains, it)107 }108 }109 c.shown = append(append(c.shown, starts...), contains...)110}111112// isFileWord reports whether word is one of vim's file commands, which113// take an argument.114func isFileWord(word string) bool {115 return slices.ContainsFunc(FileWords, func(it Item) bool { return it.Word == strings.TrimSuffix(word, "!") })116}117118func (c *Line) Key(k tea.KeyPressMsg) tea.Cmd {119 line := c.h.Line()120 switch key := k.String(); {121 case key == "esc", key == "backspace" && len(line.Buf) == 0:122 c.h.Close()123 case key == "enter":124 return c.run()125 case key == "tab":126 c.complete(1)127 case key == "shift+tab":128 c.complete(-1)129 case key == "up", key == "down":130 c.recall(key)131 case key == "ctrl+p":132 c.Move(-1, len(c.shown))133 case key == "ctrl+n":134 c.Move(1, len(c.shown))135 default:136 before := line.Text()137 line.Key(k)138 if line.Text() != before {139 c.hist = browse{}140 c.Changed()141 }142 }143 return nil144}145146// recall is Up and Down: an older or newer line from the history, among147// those starting with what was typed.148func (c *Line) recall(key string) {149 d := -1150 if key == "down" {151 d = 1152 }153 text, ok := c.hist.step(c.h.History(), c.h.Line().Text(), d)154 if !ok {155 return156 }157 c.h.Line().Set(text)158 c.Changed()159}160161// complete puts the highlighted completion on the line; pressed again,162// it moves on to the next one (d = 1) or the previous one (d = -1).163func (c *Line) complete(d int) {164 if len(c.shown) == 0 {165 return166 }167 if c.tabbed {168 c.Move(d, len(c.shown))169 }170 c.h.Line().Set(c.shown[c.Sel].Word)171 c.tabbed = true172}173174// run closes the line and carries it out. Text that isn't a command,175// cell or range runs the highlighted completion, so ":fill d" Enter176// fills down.177func (c *Line) run() tea.Cmd {178 text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(c.h.Line().Text()), ":"))179 fallback := ""180 if c.Sel < len(c.shown) {181 fallback = c.shown[c.Sel].Word182 }183 c.h.Close()184 if text == "" {185 return nil186 }187 c.h.History().Add(text)188 if cmd, ok := c.h.Run(text); ok {189 return cmd190 }191 if fallback != "" {192 if cmd, ok := c.h.Run(fallback); ok {193 return cmd194 }195 }196 c.h.Fail("Not a command, cell or range: " + text)197 return nil198}199200func (c *Line) ContextLine() (string, string) {201 return c.h.Theme().Title.Render(":") + c.h.Line().Text(), ""202}203204func (c *Line) Cursor() (int, int) {205 return 1 + ansi.StringWidth(c.h.Line().Head()), overlay.ContextLine206}207208func (c *Line) Status() (string, string) {209 keys := c.h.Theme().KeyHints("Tab", "complete", "Up", "history", "Enter", "run", "Esc", "cancel")210 if c.Sel >= len(c.shown) {211 return "", keys212 }213 it := c.shown[c.Sel]214 if it.Off {215 return it.Desc + " (not available now)", keys216 }217 return it.Desc, keys218}219220// rows is how many completions show.221func (c *Line) rows() int {222 _, height := c.h.Size()223 return max(min(len(c.shown), 8, height-overlay.ContextLine-4), 0)224}225226// Layout draws the completions in a box under the context line: title,227// what Tab types, and the shortcut.228func (c *Line) Layout() []overlay.Box {229 rows := c.rows()230 if rows == 0 {231 return nil232 }233 c.Show(rows)234 th := c.h.Theme()235 width, _ := c.h.Size()236 tw, ww, kw := 0, 0, 0237 for _, it := range c.shown {238 tw = max(tw, ansi.StringWidth(it.Title))239 ww = max(ww, ansi.StringWidth(it.Word))240 if it.Key != "" {241 kw = max(kw, ansi.StringWidth(it.Key)+2)242 }243 }244 // As wide as the completions, within the screen and a line's reach.245 inner := min(max(1+tw+2+ww+2+kw+1, 40), 72, width-2)246 tw = min(tw, inner/2)247 ww = max(min(ww, inner-1-tw-2-kw-2), 0)248 lines := make([]string, rows)249 for r := range rows {250 i := c.Top + r251 it := c.shown[i]252 base, dim := th.MenuBar, th.Muted253 switch {254 case i == c.Sel:255 base, dim = th.MenuSelected, th.MenuSelected256 case it.Off:257 base, dim = th.Disabled, th.Disabled258 }259 row := base.Render(" "+theme.PadRight(ansi.Truncate(it.Title, tw, "…"), tw)+" ") +260 dim.Render(theme.PadRight(ansi.Truncate(it.Word, ww, "…"), ww))261 k := ""262 switch {263 case it.Key == "":264 case i == c.Sel || it.Off:265 k = base.Render(" " + it.Key + " ")266 default:267 k = th.Chip(it.Key)268 }269 row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")270 lines[r] = ansi.Truncate(row, inner, "")271 }272 footer := strconv.Itoa(c.Sel+1) + " of " + strconv.Itoa(len(c.shown))273 return []overlay.Box{{ID: ID, X: 0, Y: overlay.ContextLine + 1, Lines: th.Frame(inner, "Commands", footer, lines)}}274}275276func (c *Line) Mouse(e overlay.MouseEvent) tea.Cmd {277 if e.Box != ID {278 if e.Kind == overlay.MousePress {279 c.h.Close()280 }281 return nil282 }283 i := c.Top + e.Row - 1 // under the top border284 switch {285 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:286 c.Move(-1, len(c.shown))287 case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:288 c.Move(1, len(c.shown))289 case e.Row < 1 || i >= len(c.shown) || i >= c.Top+c.rows():290 case e.Kind == overlay.MouseMotion:291 c.Sel = i292 case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:293 c.h.Line().Set(c.shown[i].Word)294 c.Sel = i295 return c.run()296 }297 return nil298}