The : line completes from what its host lists and hands the line back to it to carry out (cmdline.Host: theme, size, edit line, close, the commands, run and fail), so vim's file commands, going to a cell and running a command by name stay in package ui, through runCommand.
- commit
- f3818a864cbb62d225a5e343c9a26607e27c13df
- parent
- 4e8f94e5360d1b780ae63f3f4488ab7fc8162b1a
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 242 | +-------------------------- |
| A | internal/ | 275 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 158 | +++++++++++++++++ |
| M | internal/ | 32 | +++- |
@@ -7,12 +7,10 @@ import (
77 "strings"88 99 tea "charm.land/bubbletea/v2"10- "github.com/charmbracelet/x/ansi"1110 1211 "github.com/FelineStateMachine/012/internal/fileio"1312 "github.com/FelineStateMachine/012/internal/sheet"14- "github.com/FelineStateMachine/012/internal/ui/overlay"15- "github.com/FelineStateMachine/012/internal/ui/theme"13+ "github.com/FelineStateMachine/012/internal/ui/cmdline"1614 )1715 1816 // The command line (: with vim keys, or from the palette) takes vim's@@ -22,86 +20,15 @@ import (
2220 // the command registry as you type, in a box under the context line, so2321 // there's no second list of commands to keep up.2422 25-type cmdLine struct {26- m *Model // the model it acts on27- overlay.List28- shown []cmdItem29- tabbed bool // the text is a completion Tab put there; Tab again moves on30-}31-32-// cmdItem is a completion: a file command or a registered command.33-type cmdItem struct {34- word string // what Tab puts on the line: "w", or a command's ID35- title string36- desc string37- key string // the command's shortcut38- off bool // unavailable right now39-}40-41-// fileWords are vim's file commands.42-var fileWords = []cmdItem{43- {word: "w", title: "Write", desc: "Save the sheet; :w name saves it as name, :w name.csv downloads it"},44- {word: "q", title: "Quit", desc: "Close 012, asking about unsaved changes"},45- {word: "q!", title: "Quit without saving", desc: "Close 012, discarding unsaved changes"},46- {word: "wq", title: "Write and quit", desc: "Save the sheet, then close 012"},47- {word: "x", title: "Write if changed and quit", desc: "Save the sheet if it changed, then close 012"},48- {word: "e", title: "Edit a file", desc: "Open a sheet or import a file: :e name"},49-}50-51-const cmdLineID = "cmdline"52-5323 func init() {5424 register(&command{id: "vim.command", macro: macroNever, title: "Command line",5525 desc: "Type a command: a cell to go to (B12), w, q, wq, e file, or any command by name",5626 run: func(m *Model) tea.Cmd {57- c := &cmdLine{m: m}58- m.line.Clear()59- m.openOverlay(c)60- c.Changed()27+ m.openOverlay(cmdline.New(m.host()))6128 return nil6229 }})6330 }6431 65-func (c *cmdLine) Indicator() string { return "COMMAND" }66-67-// Changed completes what's typed: file commands, then commands whose ID68-// or title starts with it, then those that contain it. Nothing is69-// completed once a file command's argument follows.70-func (c *cmdLine) Changed() {71- m := c.m72- c.Sel, c.Top, c.tabbed = 0, 0, false73- c.shown = c.shown[:0]74- q := strings.ToLower(strings.TrimPrefix(strings.TrimLeft(m.line.Text(), " "), ":"))75- if word, _, arg := strings.Cut(q, " "); arg && isFileWord(word) {76- return77- }78- for _, w := range fileWords {79- if strings.HasPrefix(w.word, q) {80- c.shown = append(c.shown, w)81- }82- }83- var starts, contains []cmdItem84- for _, id := range commandIDs() {85- cm := commands[id]86- title := strings.ToLower(cm.title)87- it := cmdItem{word: id, title: cm.title, desc: cm.desc, key: m.shortcut(id), off: !cm.available(m)}88- switch {89- case id == "vim.command":90- case strings.HasPrefix(id, q) || strings.HasPrefix(title, q):91- starts = append(starts, it)92- case strings.Contains(id, q) || strings.Contains(title, q):93- contains = append(contains, it)94- }95- }96- c.shown = append(append(c.shown, starts...), contains...)97-}98-99-// isFileWord reports whether word is one of vim's file commands, which100-// take an argument.101-func isFileWord(word string) bool {102- return slices.ContainsFunc(fileWords, func(it cmdItem) bool { return it.word == strings.TrimSuffix(word, "!") })103-}104-10532 // commandIDs are the registered commands' IDs, by title.10633 func commandIDs() []string {10734 ids := make([]string, 0, len(commands))@@ -114,69 +41,6 @@ func commandIDs() []string {
11441 return ids11542 }11643 117-func (c *cmdLine) Key(k tea.KeyPressMsg) tea.Cmd {118- m := c.m119- switch key := k.String(); {120- case key == "esc", key == "backspace" && len(m.line.Buf) == 0:121- m.closeOverlay()122- case key == "enter":123- return c.run(m)124- case key == "tab":125- c.complete(m, 1)126- case key == "shift+tab":127- c.complete(m, -1)128- case key == "up", key == "ctrl+p":129- c.Move(-1, len(c.shown))130- case key == "down", key == "ctrl+n":131- c.Move(1, len(c.shown))132- default:133- before := m.line.Text()134- m.line.Key(k)135- if m.line.Text() != before {136- c.Changed()137- }138- }139- return nil140-}141-142-// complete puts the highlighted completion on the line; pressed again,143-// it moves on to the next one (d = 1) or the previous one (d = -1).144-func (c *cmdLine) complete(m *Model, d int) {145- if len(c.shown) == 0 {146- return147- }148- if c.tabbed {149- c.Move(d, len(c.shown))150- }151- m.line.Set(c.shown[c.Sel].word)152- c.tabbed = true153-}154-155-// run closes the line and carries it out. Text that isn't a command,156-// cell or range runs the highlighted completion, so ":fill d" Enter157-// fills down.158-func (c *cmdLine) run(m *Model) tea.Cmd {159- text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(m.line.Text()), ":"))160- fallback := ""161- if c.Sel < len(c.shown) {162- fallback = c.shown[c.Sel].word163- }164- m.closeOverlay()165- if text == "" {166- return nil167- }168- if cmd, ok := m.runCmdLine(text); ok {169- return cmd170- }171- if fallback != "" {172- if cmd, ok := m.runCmdLine(fallback); ok {173- return cmd174- }175- }176- m.fail("Not a command, cell or range: " + text)177- return nil178-}179-18044 // runCmdLine carries out a command line and reports whether it was one.18145 func (m *Model) runCmdLine(text string) (tea.Cmd, bool) {18246 word, arg, _ := strings.Cut(text, " ")@@ -267,105 +131,3 @@ func (m *Model) editFile(name string, force bool) tea.Cmd {
267131 }268132 return m.openFile(name)269133 }270-271-func (c *cmdLine) ContextLine() (string, string) {272- m := c.m273- return m.th.Title.Render(":") + m.line.Text(), ""274-}275-276-func (c *cmdLine) Cursor() (int, int) {277- m := c.m278- return 1 + ansi.StringWidth(m.line.Head()), contextLine279-}280-281-func (c *cmdLine) Status() (string, string) {282- m := c.m283- keys := m.th.KeyHints("Tab", "complete", "Enter", "run", "Esc", "cancel")284- if c.Sel >= len(c.shown) {285- return "", keys286- }287- it := c.shown[c.Sel]288- if it.off {289- return it.desc + " (not available now)", keys290- }291- return it.desc, keys292-}293-294-// rows is how many completions show.295-func (c *cmdLine) rows(m *Model) int {296- return max(min(len(c.shown), 8, m.height-contextLine-4), 0)297-}298-299-// Layout draws the completions in a box under the context line: title,300-// what Tab types, and the shortcut.301-func (c *cmdLine) Layout() []overlay.Box {302- m := c.m303- rows := c.rows(m)304- if rows == 0 {305- return nil306- }307- c.Show(rows)308- tw, ww, kw := 0, 0, 0309- for _, it := range c.shown {310- tw = max(tw, ansi.StringWidth(it.title))311- ww = max(ww, ansi.StringWidth(it.word))312- if it.key != "" {313- kw = max(kw, ansi.StringWidth(it.key)+2)314- }315- }316- // As wide as the completions, within the screen and a line's reach.317- inner := min(max(1+tw+2+ww+2+kw+1, 40), 72, m.width-2)318- tw = min(tw, inner/2)319- ww = max(min(ww, inner-1-tw-2-kw-2), 0)320- lines := make([]string, rows)321- for r := range rows {322- i := c.Top + r323- it := c.shown[i]324- base, dim := m.th.MenuBar, m.th.Muted325- switch {326- case i == c.Sel:327- base, dim = m.th.MenuSelected, m.th.MenuSelected328- case it.off:329- base, dim = m.th.Disabled, m.th.Disabled330- }331- row := base.Render(" "+theme.PadRight(ansi.Truncate(it.title, tw, "…"), tw)+" ") +332- dim.Render(theme.PadRight(ansi.Truncate(it.word, ww, "…"), ww))333- k := ""334- switch {335- case it.key == "":336- case i == c.Sel || it.off:337- k = base.Render(" " + it.key + " ")338- default:339- k = m.th.Chip(it.key)340- }341- row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")342- lines[r] = ansi.Truncate(row, inner, "")343- }344- footer := strconv.Itoa(c.Sel+1) + " of " + strconv.Itoa(len(c.shown))345- return []overlay.Box{{ID: cmdLineID, X: 0, Y: contextLine + 1, Lines: m.th.Frame(inner, "Commands", footer, lines)}}346-}347-348-func (c *cmdLine) Mouse(e overlay.MouseEvent) tea.Cmd {349- m := c.m350- if e.Box != cmdLineID {351- if e.Kind == overlay.MousePress {352- m.closeOverlay()353- }354- return nil355- }356- i := c.Top + e.Row - 1 // under the top border357- switch {358- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:359- c.Move(-1, len(c.shown))360- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:361- c.Move(1, len(c.shown))362- case e.Row < 1 || i >= len(c.shown) || i >= c.Top+c.rows(m):363- case e.Kind == overlay.MouseMotion:364- c.Sel = i365- case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:366- m.line.Set(c.shown[i].word)367- c.Sel = i368- return c.run(m)369- }370- return nil371-}@@ -0,0 +1,275 @@
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.6+package cmdline7+8+import (9+ "slices"10+ "strconv"11+ "strings"12+13+ tea "charm.land/bubbletea/v2"14+ "github.com/charmbracelet/x/ansi"15+16+ "github.com/FelineStateMachine/012/internal/ui/lineedit"17+ "github.com/FelineStateMachine/012/internal/ui/overlay"18+ "github.com/FelineStateMachine/012/internal/ui/theme"19+)20+21+// Host is what the command line needs of the UI.22+type 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+}36+37+// Item is a completion: a file command or a registered command.38+type Item struct {39+ Word string // what Tab puts on the line: "w", or a command's ID40+ Title string41+ Desc string42+ Key string // the command's shortcut43+ Off bool // unavailable right now44+}45+46+// FileWords are vim's file commands.47+var FileWords = []Item{48+ {Word: "w", Title: "Write", Desc: "Save the sheet; :w name saves it as name, :w name.csv downloads it"},49+ {Word: "q", Title: "Quit", Desc: "Close 012, asking about unsaved changes"},50+ {Word: "q!", Title: "Quit without saving", Desc: "Close 012, discarding unsaved changes"},51+ {Word: "wq", Title: "Write and quit", Desc: "Save the sheet, then close 012"},52+ {Word: "x", Title: "Write if changed and quit", Desc: "Save the sheet if it changed, then close 012"},53+ {Word: "e", Title: "Edit a file", Desc: "Open a sheet or import a file: :e name"},54+}55+56+// ID identifies the completions' box in mouse events.57+const ID = "cmdline"58+59+// Line is the open command line.60+type Line struct {61+ h Host62+ overlay.List63+ shown []Item64+ tabbed bool // the text is a completion Tab put there; Tab again moves on65+}66+67+// New returns a command line with the edit line cleared.68+func New(h Host) *Line {69+ c := &Line{h: h}70+ h.Line().Clear()71+ c.Changed()72+ return c73+}74+75+// Shown are the completions of what's typed.76+func (c *Line) Shown() []Item { return c.shown }77+78+func (c *Line) Indicator() string { return "COMMAND" }79+80+// Changed completes what's typed: file commands, then commands whose ID81+// or title starts with it, then those that contain it. Nothing is82+// completed once a file command's argument follows.83+func (c *Line) Changed() {84+ c.Sel, c.Top, c.tabbed = 0, 0, false85+ c.shown = c.shown[:0]86+ q := strings.ToLower(strings.TrimPrefix(strings.TrimLeft(c.h.Line().Text(), " "), ":"))87+ if word, _, arg := strings.Cut(q, " "); arg && isFileWord(word) {88+ return89+ }90+ for _, w := range FileWords {91+ if strings.HasPrefix(w.Word, q) {92+ c.shown = append(c.shown, w)93+ }94+ }95+ var starts, contains []Item96+ for _, it := range c.h.Commands() {97+ title := strings.ToLower(it.Title)98+ switch {99+ case strings.HasPrefix(it.Word, q) || strings.HasPrefix(title, q):100+ starts = append(starts, it)101+ case strings.Contains(it.Word, q) || strings.Contains(title, q):102+ contains = append(contains, it)103+ }104+ }105+ c.shown = append(append(c.shown, starts...), contains...)106+}107+108+// isFileWord reports whether word is one of vim's file commands, which109+// take an argument.110+func isFileWord(word string) bool {111+ return slices.ContainsFunc(FileWords, func(it Item) bool { return it.Word == strings.TrimSuffix(word, "!") })112+}113+114+func (c *Line) Key(k tea.KeyPressMsg) tea.Cmd {115+ line := c.h.Line()116+ switch key := k.String(); {117+ case key == "esc", key == "backspace" && len(line.Buf) == 0:118+ c.h.Close()119+ case key == "enter":120+ return c.run()121+ case key == "tab":122+ c.complete(1)123+ case key == "shift+tab":124+ c.complete(-1)125+ case key == "up", key == "ctrl+p":126+ c.Move(-1, len(c.shown))127+ case key == "down", key == "ctrl+n":128+ c.Move(1, len(c.shown))129+ default:130+ before := line.Text()131+ line.Key(k)132+ if line.Text() != before {133+ c.Changed()134+ }135+ }136+ return nil137+}138+139+// complete puts the highlighted completion on the line; pressed again,140+// it moves on to the next one (d = 1) or the previous one (d = -1).141+func (c *Line) complete(d int) {142+ if len(c.shown) == 0 {143+ return144+ }145+ if c.tabbed {146+ c.Move(d, len(c.shown))147+ }148+ c.h.Line().Set(c.shown[c.Sel].Word)149+ c.tabbed = true150+}151+152+// run closes the line and carries it out. Text that isn't a command,153+// cell or range runs the highlighted completion, so ":fill d" Enter154+// fills down.155+func (c *Line) run() tea.Cmd {156+ text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(c.h.Line().Text()), ":"))157+ fallback := ""158+ if c.Sel < len(c.shown) {159+ fallback = c.shown[c.Sel].Word160+ }161+ c.h.Close()162+ if text == "" {163+ return nil164+ }165+ if cmd, ok := c.h.Run(text); ok {166+ return cmd167+ }168+ if fallback != "" {169+ if cmd, ok := c.h.Run(fallback); ok {170+ return cmd171+ }172+ }173+ c.h.Fail("Not a command, cell or range: " + text)174+ return nil175+}176+177+func (c *Line) ContextLine() (string, string) {178+ return c.h.Theme().Title.Render(":") + c.h.Line().Text(), ""179+}180+181+func (c *Line) Cursor() (int, int) {182+ return 1 + ansi.StringWidth(c.h.Line().Head()), overlay.ContextLine183+}184+185+func (c *Line) Status() (string, string) {186+ keys := c.h.Theme().KeyHints("Tab", "complete", "Enter", "run", "Esc", "cancel")187+ if c.Sel >= len(c.shown) {188+ return "", keys189+ }190+ it := c.shown[c.Sel]191+ if it.Off {192+ return it.Desc + " (not available now)", keys193+ }194+ return it.Desc, keys195+}196+197+// rows is how many completions show.198+func (c *Line) rows() int {199+ _, height := c.h.Size()200+ return max(min(len(c.shown), 8, height-overlay.ContextLine-4), 0)201+}202+203+// Layout draws the completions in a box under the context line: title,204+// what Tab types, and the shortcut.205+func (c *Line) Layout() []overlay.Box {206+ rows := c.rows()207+ if rows == 0 {208+ return nil209+ }210+ c.Show(rows)211+ th := c.h.Theme()212+ width, _ := c.h.Size()213+ tw, ww, kw := 0, 0, 0214+ for _, it := range c.shown {215+ tw = max(tw, ansi.StringWidth(it.Title))216+ ww = max(ww, ansi.StringWidth(it.Word))217+ if it.Key != "" {218+ kw = max(kw, ansi.StringWidth(it.Key)+2)219+ }220+ }221+ // As wide as the completions, within the screen and a line's reach.222+ inner := min(max(1+tw+2+ww+2+kw+1, 40), 72, width-2)223+ tw = min(tw, inner/2)224+ ww = max(min(ww, inner-1-tw-2-kw-2), 0)225+ lines := make([]string, rows)226+ for r := range rows {227+ i := c.Top + r228+ it := c.shown[i]229+ base, dim := th.MenuBar, th.Muted230+ switch {231+ case i == c.Sel:232+ base, dim = th.MenuSelected, th.MenuSelected233+ case it.Off:234+ base, dim = th.Disabled, th.Disabled235+ }236+ row := base.Render(" "+theme.PadRight(ansi.Truncate(it.Title, tw, "…"), tw)+" ") +237+ dim.Render(theme.PadRight(ansi.Truncate(it.Word, ww, "…"), ww))238+ k := ""239+ switch {240+ case it.Key == "":241+ case i == c.Sel || it.Off:242+ k = base.Render(" " + it.Key + " ")243+ default:244+ k = th.Chip(it.Key)245+ }246+ row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")247+ lines[r] = ansi.Truncate(row, inner, "")248+ }249+ footer := strconv.Itoa(c.Sel+1) + " of " + strconv.Itoa(len(c.shown))250+ return []overlay.Box{{ID: ID, X: 0, Y: overlay.ContextLine + 1, Lines: th.Frame(inner, "Commands", footer, lines)}}251+}252+253+func (c *Line) Mouse(e overlay.MouseEvent) tea.Cmd {254+ if e.Box != ID {255+ if e.Kind == overlay.MousePress {256+ c.h.Close()257+ }258+ return nil259+ }260+ i := c.Top + e.Row - 1 // under the top border261+ switch {262+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:263+ c.Move(-1, len(c.shown))264+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:265+ c.Move(1, len(c.shown))266+ case e.Row < 1 || i >= len(c.shown) || i >= c.Top+c.rows():267+ case e.Kind == overlay.MouseMotion:268+ c.Sel = i269+ case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:270+ c.h.Line().Set(c.shown[i].Word)271+ c.Sel = i272+ return c.run()273+ }274+ return nil275+}@@ -0,0 +1,158 @@
1+package cmdline2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/lineedit"11+ "github.com/FelineStateMachine/012/internal/ui/overlay"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+// fakeHost knows a few commands and runs lines it recognises.16+type fakeHost struct {17+ th theme.Theme18+ line lineedit.Line19+ closed int20+ ran []string21+ failed string22+}23+24+func newHost() *fakeHost { return &fakeHost{th: theme.New(true)} }25+26+func (f *fakeHost) Theme() *theme.Theme { return &f.th }27+func (f *fakeHost) Size() (width, height int) { return 80, 24 }28+func (f *fakeHost) Line() *lineedit.Line { return &f.line }29+func (f *fakeHost) Close() { f.closed++ }30+func (f *fakeHost) Fail(msg string) { f.failed = msg }31+32+func (f *fakeHost) Commands() []Item {33+ return []Item{34+ {Word: "edit.fill_down", Title: "Fill down", Desc: "Copy the top cell down", Key: "Ctrl+D"},35+ {Word: "format.bold", Title: "Bold", Desc: "Bold the selection", Key: "Ctrl+B"},36+ {Word: "edit.undo", Title: "Undo", Desc: "Undo", Off: true},37+ }38+}39+40+func (f *fakeHost) Run(text string) (tea.Cmd, bool) {41+ if text == "edit.fill_down" || text == "w" || text == "B12" {42+ f.ran = append(f.ran, text)43+ return nil, true44+ }45+ return nil, false46+}47+48+func typeText(c *Line, s string) {49+ for _, r := range s {50+ c.Key(tea.KeyPressMsg{Code: r, Text: string(r)})51+ }52+}53+54+func words(c *Line) string {55+ var out []string56+ for _, it := range c.Shown() {57+ out = append(out, it.Word)58+ }59+ return strings.Join(out, ",")60+}61+62+func TestCompletes(t *testing.T) {63+ h := newHost()64+ c := New(h)65+ if n := len(c.Shown()); n != len(FileWords)+3 {66+ t.Fatalf("empty line completes %d", n)67+ }68+ typeText(c, "fill")69+ if got := words(c); got != "edit.fill_down" {70+ t.Errorf("fill completes %s", got)71+ }72+ typeText(c, " d")73+ if got := words(c); got != "edit.fill_down" {74+ t.Errorf("fill d completes %s", got)75+ }76+ h.line.Clear()77+ typeText(c, "w")78+ if got := words(c); !strings.HasPrefix(got, "w,wq,") {79+ t.Errorf("w completes %s", got)80+ }81+ typeText(c, " out.csv")82+ if got := words(c); got != "" {83+ t.Errorf("a file command's argument completes %s", got)84+ }85+}86+87+func TestTabCompletesThenCycles(t *testing.T) {88+ h := newHost()89+ c := New(h)90+ typeText(c, "o")91+ c.Key(tea.KeyPressMsg{Code: tea.KeyTab})92+ first := h.line.Text()93+ c.Key(tea.KeyPressMsg{Code: tea.KeyTab})94+ if first != c.Shown()[0].Word || h.line.Text() != c.Shown()[1].Word {95+ t.Errorf("Tab put %q then %q", first, h.line.Text())96+ }97+}98+99+func TestEnterRunsOrFallsBack(t *testing.T) {100+ h := newHost()101+ c := New(h)102+ typeText(c, ":B12")103+ c.Key(tea.KeyPressMsg{Code: tea.KeyEnter})104+ if h.closed != 1 || strings.Join(h.ran, ",") != "B12" {105+ t.Errorf("closed %d, ran %v", h.closed, h.ran)106+ }107+ // Text that isn't a command runs the highlighted completion.108+ c = New(h)109+ typeText(c, "fill d")110+ c.Key(tea.KeyPressMsg{Code: tea.KeyEnter})111+ if h.failed != "" || strings.Join(h.ran, ",") != "B12,edit.fill_down" {112+ t.Errorf("ran %v, failed %q", h.ran, h.failed)113+ }114+ c = New(h)115+ typeText(c, "nonsense")116+ c.Key(tea.KeyPressMsg{Code: tea.KeyEnter})117+ if h.failed != "Not a command, cell or range: nonsense" {118+ t.Errorf("failed %q", h.failed)119+ }120+}121+122+func TestBackspaceOnEmptyCloses(t *testing.T) {123+ h := newHost()124+ c := New(h)125+ c.Key(tea.KeyPressMsg{Code: tea.KeyBackspace})126+ if h.closed != 1 {127+ t.Error("Backspace on an empty line didn't close it")128+ }129+}130+131+func TestLayoutStatusAndMouse(t *testing.T) {132+ h := newHost()133+ c := New(h)134+ typeText(c, "o")135+ left, _ := c.ContextLine()136+ if ansi.Strip(left) != ":o" {137+ t.Errorf("context line %q", left)138+ }139+ if x, y := c.Cursor(); x != 2 || y != overlay.ContextLine {140+ t.Errorf("cursor %d,%d", x, y)141+ }142+ b := c.Layout()[0]143+ if b.ID != ID || b.Y != overlay.ContextLine+1 || !strings.Contains(ansi.Strip(strings.Join(b.Lines, "\n")), "Commands") {144+ t.Fatalf("box %+v", b)145+ }146+ for i, it := range c.Shown() {147+ if it.Word == "edit.undo" {148+ c.Mouse(overlay.MouseEvent{Kind: overlay.MouseMotion, Box: ID, Row: 1 + i})149+ }150+ }151+ if desc, _ := c.Status(); desc != "Undo (not available now)" {152+ t.Errorf("status %q", desc)153+ }154+ c.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft})155+ if h.closed != 1 {156+ t.Error("a click outside didn't close the line")157+ }158+}@@ -1,6 +1,9 @@
11 package ui22 33 import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/ui/cmdline"47 "github.com/FelineStateMachine/012/internal/ui/lineedit"58 "github.com/FelineStateMachine/012/internal/ui/picker"69 "github.com/FelineStateMachine/012/internal/ui/theme"@@ -14,16 +17,43 @@ type host struct{ m *Model }
1417 1518 func (m *Model) host() host { return host{m} }1619 17-var _ picker.Host = host{}20+var (21+ _ picker.Host = host{}22+ _ cmdline.Host = host{}23+)24+25+// What every component with a text field needs.1826 1927 func (h host) Theme() *theme.Theme { return &h.m.th }2028 func (h host) Size() (width, height int) { return h.m.width, h.m.height }2129 func (h host) Line() *lineedit.Line { return &h.m.line }2230 func (h host) Close() { h.m.closeOverlay() }2331 32+// The picker.33+2434 func (h host) RecordAnswer(answer string, cancelled bool) { h.m.recordAnswer(answer, cancelled) }2535 2636 // newPicker returns a picker of items over the model; the caller opens it.2737 func newPicker(m *Model, title, placeholder string, maxW int, items []picker.Item) *picker.Picker {2838 return picker.New(m.host(), title, placeholder, maxW, items)2939 }40+41+// The command line.42+43+// Commands are the command line's completions: every registered command44+// but the command line itself, by title.45+func (h host) Commands() []cmdline.Item {46+ ids := commandIDs()47+ items := make([]cmdline.Item, 0, len(ids))48+ for _, id := range ids {49+ if id == "vim.command" {50+ continue51+ }52+ c := commands[id]53+ items = append(items, cmdline.Item{Word: id, Title: c.title, Desc: c.desc, Key: h.m.shortcut(id), Off: !c.available(h.m)})54+ }55+ return items56+}57+58+func (h host) Run(text string) (tea.Cmd, bool) { return h.m.runCmdLine(text) }59+func (h host) Fail(msg string) { h.m.fail(msg) }