cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Move the edit line into internal/ui/lineedit

Dami committed c0462c2
The one-line editor every text field shares depends on nothing in the
UI, so components outside package ui can edit it.
commit
c0462c2be7ed95450a11a8e8354c19169fc6a393
parent
fe630b6b7696d9e4122b8b215f6ab4e5b0f2734a
author
Dami <dami.etoile@gmail.com>
35 files changed, +334 -280
Minternal/ui/assist.go23+++---
Minternal/ui/assist_test.go18++--
Minternal/ui/assistsheets.go11++-
Minternal/ui/assistsheets_test.go16++--
Minternal/ui/charts_test.go2+-
Minternal/ui/cmdline.go22+++---
Minternal/ui/edit.go8+-
Minternal/ui/edit_test.go12++--
Minternal/ui/entry.go30++++----
Minternal/ui/filter.go22+++---
Minternal/ui/find.go14++--
Minternal/ui/find_test.go4+-
Minternal/ui/findview.go4+-
Minternal/ui/help_test.go4+-
Minternal/ui/hidden_test.go8+-
Dinternal/ui/line.go105---------------------------
Ainternal/ui/lineedit/lineedit.go116++++++++++++++++++++++++++++++
Ainternal/ui/lineedit/lineedit_test.go45++++++++++++
Minternal/ui/macro_test.go10+-
Minternal/ui/macrohostnav.go4+-
Minternal/ui/model.go10+--
Minternal/ui/model_test.go8+-
Minternal/ui/mouse.go12++--
Minternal/ui/mouse_test.go8+-
Minternal/ui/overlay.go2+-
Minternal/ui/palette.go18++--
Minternal/ui/palette_test.go4+-
Minternal/ui/panel.go10+-
Minternal/ui/prefs_test.go2+-
Minternal/ui/prompt.go34++++----
Minternal/ui/tabs.go6+-
Minternal/ui/transfer_test.go10+-
Minternal/ui/view.go2+-
Minternal/ui/vim_test.go8+-
Minternal/ui/vimkeys.go2+-
M internal/ui/assist.go +12 -11
@@ -10,6 +10,7 @@ import (
1010 1111 "github.com/FelineStateMachine/012/internal/sheet"1212 "github.com/FelineStateMachine/012/internal/ui/formula"13+ "github.com/FelineStateMachine/012/internal/ui/lineedit"1314 "github.com/FelineStateMachine/012/internal/ui/overlay"1415 "github.com/FelineStateMachine/012/internal/ui/theme"1516 )
@@ -84,10 +85,10 @@ func suggestions(sh *sheet.Sheet, word string) []suggestion {
8485 // EDIT, in plain text, after the caret moved, or when the word already8586 // names a range exactly and nothing else matches.8687 func (a *assist) shown(m *Model) ([]suggestion, int) {87- if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.isFormula() || !a.active || m.overlay != nil {88+ if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.IsFormula() || !a.active || m.overlay != nil {8889 return nil, 08990 }90- c := formula.ScanCaret(m.line.buf, m.line.pos)91+ c := formula.ScanCaret(m.line.Buf, m.line.Pos)9192 if c.Word == "" {9293 return nil, 09394 }
@@ -102,9 +103,9 @@ func (a *assist) shown(m *Model) ([]suggestion, int) {
102103 // typeKey applies a line-editing key to the entry. Typing or deleting103104 // shows suggestions; moving the caret hides them, as in Sheets.104105 func (m *Model) typeKey(k tea.KeyPressMsg) {105- before := m.line.text()106- m.line.key(k)107- m.entry.assist = assist{active: m.line.text() != before}106+ before := m.line.Text()107+ m.line.Key(k)108+ m.entry.assist = assist{active: m.line.Text() != before}108109 }109110 110111 // key handles the keys the suggestion list takes while it shows.
@@ -132,7 +133,7 @@ func (a *assist) key(m *Model, key string) bool {
132133 // a function unless one is already there.133134 func (a *assist) accept(m *Model, s suggestion, start int) {134135 text := []rune(s.name)135- rest := m.line.buf[m.line.pos:]136+ rest := m.line.Buf[m.line.Pos:]136137 if s.fn && (len(rest) == 0 || rest[0] != '(') {137138 text = append(text, '(')138139 }
@@ -143,8 +144,8 @@ func (a *assist) accept(m *Model, s suggestion, start int) {
143144 if s.fn && len(rest) > 0 && rest[0] == '(' {144145 pos++ // into the existing parentheses145146 }146- m.line.buf = slices.Concat(m.line.buf[:start], text, rest)147- m.line.pos = pos147+ m.line.Buf = slices.Concat(m.line.Buf[:start], text, rest)148+ m.line.Pos = pos148149 a.active = false149150 }150151
@@ -186,7 +187,7 @@ func (a *assist) box(m *Model) (overlay.Box, bool) {
186187 footer = strconv.Itoa(a.sel+1) + " of " + strconv.Itoa(len(list))187188 }188189 // The box's text starts one column in, under the word's first letter.189- x := formulaBarTextX() + ansi.StringWidth(string(m.line.buf[:start])) - 2190+ x := formulaBarTextX() + ansi.StringWidth(string(m.line.Buf[:start])) - 2190191 x = clamp(x, 0, max(m.width-inner-2, 0))191192 return overlay.Box{ID: assistID, X: x, Y: contextLine + 1, Lines: m.th.Frame(inner, "", footer, lines)}, true192193 }
@@ -236,8 +237,8 @@ func (a *assist) status(m *Model) (desc, keys string, ok bool) {
236237 237238 // inFunction reports whether the caret is inside a known function's238239 // parentheses.239-func (l *lineEdit) inFunction() bool {240- _, ok := sheet.LookupFunc(formula.ScanCaret(l.buf, l.pos).Fn)240+func inFunction(l *lineedit.Line) bool {241+ _, ok := sheet.LookupFunc(formula.ScanCaret(l.Buf, l.Pos).Fn)241242 return ok242243 }243244
M internal/ui/assist_test.go +9 -9
@@ -31,14 +31,14 @@ func TestAutocompleteFunctions(t *testing.T) {
3131 press(t, m, "<down>")3232 }3333 press(t, m, "<tab>")34- if m.mode != modeEnter || m.line.text() != "=SUM(" {35- t.Fatalf("mode %v buf %q", m.mode, m.line.text())34+ if m.mode != modeEnter || m.line.Text() != "=SUM(" {35+ t.Fatalf("mode %v buf %q", m.mode, m.line.Text())3636 }3737 if ctx := line(m, contextLine); !strings.HasPrefix(ctx, "SUM(value1, [value2, ...])") || !strings.Contains(ctx, "Sum of numbers") {3838 t.Errorf("signature %q", ctx)3939 }4040 press(t, m, "1,")41- sig, _ := signature(&m.th, m.line.buf, m.line.pos)41+ sig, _ := signature(&m.th, m.line.Buf, m.line.Pos)4242 if !strings.Contains(sig, m.th.Argument.Render("[value2, ...]")) {4343 t.Errorf("second argument not marked: %q", sig)4444 }
@@ -52,8 +52,8 @@ func TestAutocompleteKeys(t *testing.T) {
5252 m := newModel()5353 // Enter inserts while the list shows, then commits when it doesn't.5454 press(t, m, "=ab", "<enter>")55- if m.line.text() != "=ABS(" {56- t.Fatalf("enter: %q", m.line.text())55+ if m.line.Text() != "=ABS(" {56+ t.Fatalf("enter: %q", m.line.Text())5757 }5858 press(t, m, "-4)", "<enter>")5959 if m.sheet.Value(addr("A1")).Num != 4 {
@@ -102,15 +102,15 @@ func TestAutocompleteNamesAndMouse(t *testing.T) {
102102 // Clicking a suggestion inserts it.103103 b, _ := m.entry.assist.box(m)104104 send(m, tea.MouseClickMsg{X: b.X + 3, Y: b.Y + 1, Button: tea.MouseLeft})105- if m.line.text() != "=SUM(Sales" || m.line.pos != start+5 {106- t.Fatalf("click: %q", m.line.text())105+ if m.line.Text() != "=SUM(Sales" || m.line.Pos != start+5 {106+ t.Fatalf("click: %q", m.line.Text())107107 }108108 // Editing inside existing parentheses doesn't double them.109109 m = newModel()110110 press(t, m, "=AB(1)", "<f2>", "<left>", "<left>", "<left>", "<backspace>", "B")111111 press(t, m, "<tab>")112- if m.line.text() != "=ABS(1)" || m.line.pos != 5 {113- t.Errorf("into parens: %q at %d", m.line.text(), m.line.pos)112+ if m.line.Text() != "=ABS(1)" || m.line.Pos != 5 {113+ t.Errorf("into parens: %q at %d", m.line.Text(), m.line.Pos)114114 }115115 }116116
M internal/ui/assistsheets.go +6 -5
@@ -5,6 +5,7 @@ import (
55 "strings"66 77 "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/FelineStateMachine/012/internal/ui/lineedit"89 )910 1011 // Sheet names in formula suggestions: typing =Su offers Summary! with the
@@ -61,8 +62,8 @@ func trimSheetEnd(rest []rune) []rune {
6162 6263 // sheetBeforeCaret returns the sheet named just before the caret, as in6364 // "=SUM(Summary!" or "='Q3 plan'!", and where its name starts.64-func (l *lineEdit) sheetBeforeCaret(book *sheet.Workbook) (*sheet.Sheet, int, bool) {65- head := l.buf[:l.pos]65+func sheetBeforeCaret(l *lineedit.Line, book *sheet.Workbook) (*sheet.Sheet, int, bool) {66+ head := l.Buf[:l.Pos]6667 end := len(head) - 1 // the "!"6768 if end < 2 || head[end] != '!' {6869 return nil, 0, false
@@ -106,15 +107,15 @@ func (m *Model) pointAfterSheet(key string) bool {
106107 if _, extend := extendKey(key); !extend && (!isMoveKey(key) || key == "tab" || key == "shift+tab") {107108 return false108109 }109- s, start, ok := m.line.sheetBeforeCaret(m.book())110+ s, start, ok := sheetBeforeCaret(&m.line, m.book())110111 if !ok || s.Hidden() {111112 return false112113 }113114 if s == m.sheet && !m.away() {114115 return m.startPoint(key) // its own sheet: the name stays as typed115116 }116- m.line.buf = append(m.line.buf[:start:start], m.line.buf[m.line.pos:]...)117- m.line.pos = start117+ m.line.Buf = append(m.line.Buf[:start:start], m.line.Buf[m.line.Pos:]...)118+ m.line.Pos = start118119 if s == m.sheet {119120 return m.startPoint(key)120121 }
M internal/ui/assistsheets_test.go +8 -8
M internal/ui/charts_test.go +1 -1
M internal/ui/cmdline.go +11 -11
@@ -55,7 +55,7 @@ func init() {
5555 desc: "Type a command: a cell to go to (B12), w, q, wq, e file, or any command by name",5656 run: func(m *Model) tea.Cmd {5757 c := &cmdLine{m: m}58- m.line.clear()58+ m.line.Clear()5959 m.openOverlay(c)6060 c.Changed()6161 return nil
@@ -71,7 +71,7 @@ func (c *cmdLine) Changed() {
7171 m := c.m7272 c.Sel, c.Top, c.tabbed = 0, 0, false7373 c.shown = c.shown[:0]74- q := strings.ToLower(strings.TrimPrefix(strings.TrimLeft(m.line.text(), " "), ":"))74+ q := strings.ToLower(strings.TrimPrefix(strings.TrimLeft(m.line.Text(), " "), ":"))7575 if word, _, arg := strings.Cut(q, " "); arg && isFileWord(word) {7676 return7777 }
@@ -117,7 +117,7 @@ func commandIDs() []string {
117117 func (c *cmdLine) Key(k tea.KeyPressMsg) tea.Cmd {118118 m := c.m119119 switch key := k.String(); {120- case key == "esc", key == "backspace" && len(m.line.buf) == 0:120+ case key == "esc", key == "backspace" && len(m.line.Buf) == 0:121121 m.closeOverlay()122122 case key == "enter":123123 return c.run(m)
@@ -130,9 +130,9 @@ func (c *cmdLine) Key(k tea.KeyPressMsg) tea.Cmd {
130130 case key == "down", key == "ctrl+n":131131 c.Move(1, len(c.shown))132132 default:133- before := m.line.text()134- m.line.key(k)135- if m.line.text() != before {133+ before := m.line.Text()134+ m.line.Key(k)135+ if m.line.Text() != before {136136 c.Changed()137137 }138138 }
@@ -148,7 +148,7 @@ func (c *cmdLine) complete(m *Model, d int) {
148148 if c.tabbed {149149 c.Move(d, len(c.shown))150150 }151- m.line.set(c.shown[c.Sel].word)151+ m.line.Set(c.shown[c.Sel].word)152152 c.tabbed = true153153 }154154
@@ -156,7 +156,7 @@ func (c *cmdLine) complete(m *Model, d int) {
156156 // cell or range runs the highlighted completion, so ":fill d" Enter157157 // fills down.158158 func (c *cmdLine) run(m *Model) tea.Cmd {159- text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(m.line.text()), ":"))159+ text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(m.line.Text()), ":"))160160 fallback := ""161161 if c.Sel < len(c.shown) {162162 fallback = c.shown[c.Sel].word
@@ -270,12 +270,12 @@ func (m *Model) editFile(name string, force bool) tea.Cmd {
270270 271271 func (c *cmdLine) ContextLine() (string, string) {272272 m := c.m273- return m.th.Title.Render(":") + m.line.text(), ""273+ return m.th.Title.Render(":") + m.line.Text(), ""274274 }275275 276276 func (c *cmdLine) Cursor() (int, int) {277277 m := c.m278- return 1 + ansi.StringWidth(m.line.head()), contextLine278+ return 1 + ansi.StringWidth(m.line.Head()), contextLine279279 }280280 281281 func (c *cmdLine) Status() (string, string) {
@@ -363,7 +363,7 @@ func (c *cmdLine) Mouse(e overlay.MouseEvent) tea.Cmd {
363363 case e.Kind == overlay.MouseMotion:364364 c.Sel = i365365 case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:366- m.line.set(c.shown[i].word)366+ m.line.Set(c.shown[i].word)367367 c.Sel = i368368 return c.run(m)369369 }
M internal/ui/edit.go +4 -4
M internal/ui/edit_test.go +6 -6
M internal/ui/entry.go +15 -15
@@ -44,16 +44,16 @@ func (m *Model) startEntry(md mode, text string) {
4444 }4545 m.mode = md4646 m.entry.home = nil47- m.line.clear()47+ m.line.Clear()4848 m.entry.hint, m.entry.assist = "", assist{}49- m.line.insert(text)49+ m.line.Insert(text)5050 }5151 5252 // startEdit edits the active cell's contents with the caret at the end.5353 func (m *Model) startEdit() tea.Cmd {5454 m.startEntry(modeEdit, "")5555 if c := m.sheet.Cell(m.cur); c != nil && m.mode == modeEdit {56- m.line.set(c.Input)56+ m.line.Set(c.Input)5757 }5858 return nil5959 }
@@ -73,10 +73,10 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
7373 m.toggleAbsolute()7474 return nil7575 }76- if m.line.isFormula() && (m.pointAfterSheet(key) || m.line.canPoint() && m.startPoint(key)) {76+ if m.line.IsFormula() && (m.pointAfterSheet(key) || m.line.CanPoint() && m.startPoint(key)) {7777 return nil7878 }79- if m.line.isFormula() && (key == "left" || key == "right") {79+ if m.line.IsFormula() && (key == "left" || key == "right") {8080 m.typeKey(k) // move the caret within a formula8181 return nil8282 }
@@ -96,7 +96,7 @@ func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {
9696 if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {9797 return nil9898 }99- if m.line.isFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.line.canPoint() && m.startPoint(key)) {99+ if m.line.IsFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.line.CanPoint() && m.startPoint(key)) {100100 return nil101101 }102102 switch key {
@@ -171,8 +171,8 @@ func (m *Model) startPoint(key string) bool {
171171 } else {172172 return false173173 }174- m.entry.prefix = m.line.head()175- m.entry.suffix = m.line.tail()174+ m.entry.prefix = m.line.Head()175+ m.entry.suffix = m.line.Tail()176176 m.mode = modePoint177177 m.navigate(key, &m.point.at)178178 return true
@@ -206,7 +206,7 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
206206 }207207 if text := typed(k); text != "" {208208 m.resumeEntry(m.pointRef())209- m.line.insert(text)209+ m.line.Insert(text)210210 m.entry.assist = assist{active: true}211211 }212212 return nil
@@ -215,14 +215,14 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
215215 // resumeEntry leaves POINT mode, inserting ref at the caret.216216 func (m *Model) resumeEntry(ref string) {217217 m.mode = modeEnter218- m.line.buf = []rune(m.entry.prefix + ref + m.entry.suffix)219- m.line.pos = utf8.RuneCountInString(m.entry.prefix + ref)218+ m.line.Buf = []rune(m.entry.prefix + ref + m.entry.suffix)219+ m.line.Pos = utf8.RuneCountInString(m.entry.prefix + ref)220220 }221221 222222 // commit stores the edit line in the active cell. An invalid formula stays223223 // in EDIT mode with the caret at the problem and the reason on line 3.224224 func (m *Model) commit() bool {225- input := m.line.text()225+ input := m.line.Text()226226 if err := m.set(m.cur, input); err != nil {227227 m.entryError(err, input)228228 return false
@@ -246,7 +246,7 @@ func (m *Model) set(a sheet.Addr, input string) error {
246246 func (m *Model) cancelEntry() {247247 m.returnHome()248248 m.mode = modeReady249- m.line.clear()249+ m.line.Clear()250250 m.entry.hint, m.entry.assist = "", assist{}251251 }252252
@@ -262,14 +262,14 @@ func (m *Model) handlePaste(content string) {
262262 m.startEntry(modeEnter, content)263263 }264264 case modeEnter, modeEdit:265- m.line.insert(content)265+ m.line.Insert(content)266266 case modePrompt:267267 if m.prompt.kind != promptRange {268268 m.prompt.paste(m, content)269269 }270270 case modeMenu:271271 if o, ok := m.overlay.(overlay.Text); ok {272- m.line.insert(content)272+ m.line.Insert(content)273273 o.Changed()274274 }275275 }
M internal/ui/filter.go +11 -11
@@ -145,7 +145,7 @@ func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue
145145 }146146 p.fields[1] = p.cond.Arg147147 m.openOverlay(p)148- m.line.clear()148+ m.line.Clear()149149 p.search()150150 return p151151 }
@@ -162,14 +162,14 @@ func (p *filterPicker) Indicator() string { return "FILTER" }
162162 163163 // focus moves editing to field i, keeping the other field's text.164164 func (p *filterPicker) focus(m *Model, i int) {165- p.fields[p.field] = m.line.text()165+ p.fields[p.field] = m.line.Text()166166 p.field = i167- m.line.set(p.fields[i])167+ m.line.Set(p.fields[i])168168 }169169 170170 func (p *filterPicker) Changed() {171171 m := p.m172- p.fields[p.field] = m.line.text()172+ p.fields[p.field] = m.line.Text()173173 if p.field == 0 {174174 p.search()175175 }
@@ -234,7 +234,7 @@ func (p *filterPicker) cycle(d int) {
234234 235235 // apply sets the column's criteria and closes the picker.236236 func (p *filterPicker) apply(m *Model) {237- p.fields[p.field] = m.line.text()237+ p.fields[p.field] = m.line.Text()238238 var cr sheet.Criteria239239 for _, v := range p.values {240240 if !p.checked[v.Text] {
@@ -298,9 +298,9 @@ func (p *filterPicker) Key(k tea.KeyPressMsg) tea.Cmd {
298298 case p.field == 0 && key == "space":299299 p.toggle(p.Sel)300300 default:301- before := m.line.text()302- m.line.key(k)303- if m.line.text() != before {301+ before := m.line.Text()302+ m.line.Key(k)303+ if m.line.Text() != before {304304 p.Changed()305305 }306306 }
@@ -388,7 +388,7 @@ func (p *filterPicker) box(m *Model) (x, y, inner int) {
388388 func (p *filterPicker) Cursor() (int, int) {389389 m := p.m390390 x, y, _ := p.box(m)391- caret := ansi.StringWidth(m.line.head())391+ caret := ansi.StringWidth(m.line.Head())392392 if p.field == 1 {393393 return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1394394 }
@@ -414,7 +414,7 @@ func (p *filterPicker) Layout() []overlay.Box {
414414 if p.cond.Op.TakesArg() {415415 arg := p.fields[1]416416 if p.field == 1 {417- arg = m.line.text()417+ arg = m.line.Text()418418 }419419 if arg == "" && p.field != 1 {420420 arg = m.th.Muted.Render("value")
@@ -423,7 +423,7 @@ func (p *filterPicker) Layout() []overlay.Box {
423423 }424424 search := p.fields[0]425425 if p.field == 0 {426- search = m.line.text()426+ search = m.line.Text()427427 }428428 input := m.th.Title.Render(searchPrompt) + search429429 if search == "" {
M internal/ui/find.go +7 -7
M internal/ui/find_test.go +2 -2
M internal/ui/findview.go +2 -2
M internal/ui/help_test.go +2 -2
M internal/ui/hidden_test.go +4 -4
D internal/ui/line.go +0 -105
@@ -1,105 +0,0 @@
1-package ui2-3-import (4- "slices"5- "strings"6- "unicode"7-8- tea "charm.land/bubbletea/v2"9- "github.com/charmbracelet/x/ansi"10-)11-12-// lineEdit is the one-line text editor behind every text field: the13-// entry typed into a cell (ENTER, EDIT), prompts on the context line, and14-// the search fields of the find bar and the pickers. Only one of them is15-// edited at a time, so they share it; a component with several fields16-// keeps the others' text itself and loads a field here to edit it.17-type lineEdit struct {18- buf []rune19- pos int // the caret, an index into buf20-}21-22-// set replaces the text, with the caret at its end.23-func (l *lineEdit) set(text string) {24- l.buf = []rune(text)25- l.pos = len(l.buf)26-}27-28-// clear empties the line.29-func (l *lineEdit) clear() {30- l.buf, l.pos = nil, 031-}32-33-// text is the whole line.34-func (l *lineEdit) text() string { return string(l.buf) }35-36-// head and tail are the text before and after the caret.37-func (l *lineEdit) head() string { return string(l.buf[:l.pos]) }38-func (l *lineEdit) tail() string { return string(l.buf[l.pos:]) }39-40-// insert types text at the caret. Line breaks and tabs become spaces, and41-// other control characters are dropped.42-func (l *lineEdit) insert(text string) {43- for _, r := range text {44- if r == '\n' || r == '\r' || r == '\t' {45- r = ' '46- }47- if !unicode.IsPrint(r) {48- continue49- }50- l.buf = slices.Insert(l.buf, l.pos, r)51- l.pos++52- }53-}54-55-// key applies a line-editing key.56-func (l *lineEdit) key(k tea.KeyPressMsg) {57- switch k.String() {58- case "left":59- l.pos = max(l.pos-1, 0)60- case "right":61- l.pos = min(l.pos+1, len(l.buf))62- case "home", "ctrl+a":63- l.pos = 064- case "end", "ctrl+e":65- l.pos = len(l.buf)66- case "backspace":67- if l.pos > 0 {68- l.buf = slices.Delete(l.buf, l.pos-1, l.pos)69- l.pos--70- }71- case "delete":72- if l.pos < len(l.buf) {73- l.buf = slices.Delete(l.buf, l.pos, l.pos+1)74- }75- default:76- l.insert(typed(k))77- }78-}79-80-// setCaret puts the edit caret at display column x of the entry.81-func (l *lineEdit) setCaret(x int) {82- w := 083- for i, r := range l.buf {84- if w >= x {85- l.pos = i86- return87- }88- w += ansi.StringWidth(string(r))89- }90- l.pos = len(l.buf)91-}92-93-// isFormula reports whether the edit line holds a formula.94-func (l *lineEdit) isFormula() bool {95- return len(l.buf) > 0 && (l.buf[0] == '=' || l.buf[0] == '+' || l.buf[0] == '-')96-}97-98-// canPoint reports whether the text before the caret ends where a cell99-// reference may follow.100-func (l *lineEdit) canPoint() bool {101- if l.pos == 0 {102- return false103- }104- return strings.ContainsRune("=+-*/^(,;<>&:", l.buf[l.pos-1])105-}
A internal/ui/lineedit/lineedit.go +116 -0
@@ -0,0 +1,116 @@
1+// Package lineedit is the one-line text editor behind every text field of2+// the UI. It depends on nothing in package ui, so components in their own3+// packages can edit the shared line.4+package lineedit5+6+import (7+ "slices"8+ "strings"9+ "unicode"10+11+ tea "charm.land/bubbletea/v2"12+ "github.com/charmbracelet/x/ansi"13+)14+15+// Line is the one-line text editor behind every text field: the16+// entry typed into a cell (ENTER, EDIT), prompts on the context line, and17+// the search fields of the find bar and the pickers. Only one of them is18+// edited at a time, so they share it; a component with several fields19+// keeps the others' text itself and loads a field here to edit it.20+type Line struct {21+ Buf []rune22+ Pos int // the caret, an index into Buf23+}24+25+// Set replaces the text, with the caret at its end.26+func (l *Line) Set(text string) {27+ l.Buf = []rune(text)28+ l.Pos = len(l.Buf)29+}30+31+// Clear empties the line.32+func (l *Line) Clear() {33+ l.Buf, l.Pos = nil, 034+}35+36+// Text is the whole line.37+func (l *Line) Text() string { return string(l.Buf) }38+39+// Head and tail are the text before and after the caret.40+func (l *Line) Head() string { return string(l.Buf[:l.Pos]) }41+func (l *Line) Tail() string { return string(l.Buf[l.Pos:]) }42+43+// Insert types text at the caret. Line breaks and tabs become spaces, and44+// other control characters are dropped.45+func (l *Line) Insert(text string) {46+ for _, r := range text {47+ if r == '\n' || r == '\r' || r == '\t' {48+ r = ' '49+ }50+ if !unicode.IsPrint(r) {51+ continue52+ }53+ l.Buf = slices.Insert(l.Buf, l.Pos, r)54+ l.Pos++55+ }56+}57+58+// Key applies a line-editing Key.59+func (l *Line) Key(k tea.KeyPressMsg) {60+ switch k.String() {61+ case "left":62+ l.Pos = max(l.Pos-1, 0)63+ case "right":64+ l.Pos = min(l.Pos+1, len(l.Buf))65+ case "home", "ctrl+a":66+ l.Pos = 067+ case "end", "ctrl+e":68+ l.Pos = len(l.Buf)69+ case "backspace":70+ if l.Pos > 0 {71+ l.Buf = slices.Delete(l.Buf, l.Pos-1, l.Pos)72+ l.Pos--73+ }74+ case "delete":75+ if l.Pos < len(l.Buf) {76+ l.Buf = slices.Delete(l.Buf, l.Pos, l.Pos+1)77+ }78+ default:79+ l.Insert(Typed(k))80+ }81+}82+83+// SetCaret puts the edit caret at display column x of the entry.84+func (l *Line) SetCaret(x int) {85+ w := 086+ for i, r := range l.Buf {87+ if w >= x {88+ l.Pos = i89+ return90+ }91+ w += ansi.StringWidth(string(r))92+ }93+ l.Pos = len(l.Buf)94+}95+96+// IsFormula reports whether the edit line holds a formula.97+func (l *Line) IsFormula() bool {98+ return len(l.Buf) > 0 && (l.Buf[0] == '=' || l.Buf[0] == '+' || l.Buf[0] == '-')99+}100+101+// CanPoint reports whether the text before the caret ends where a cell102+// reference may follow.103+func (l *Line) CanPoint() bool {104+ if l.Pos == 0 {105+ return false106+ }107+ return strings.ContainsRune("=+-*/^(,;<>&:", l.Buf[l.Pos-1])108+}109+110+// Typed returns the printable text of a key press, if any.111+func Typed(k tea.KeyPressMsg) string {112+ if k.Mod.Contains(tea.ModCtrl) || k.Mod.Contains(tea.ModAlt) {113+ return ""114+ }115+ return k.Text116+}
A internal/ui/lineedit/lineedit_test.go +45 -0
M internal/ui/macro_test.go +5 -5
M internal/ui/macrohostnav.go +2 -2
M internal/ui/model.go +3 -7
M internal/ui/model_test.go +4 -4
M internal/ui/mouse.go +6 -6
M internal/ui/mouse_test.go +4 -4
M internal/ui/overlay.go +1 -1
M internal/ui/palette.go +9 -9
@@ -123,7 +123,7 @@ const (
123123 124124 func newPicker(m *Model, title, placeholder string, maxW int, items []pickItem) *picker {125125 p := &picker{m: m, title: title, placeholder: placeholder, maxW: maxW, items: items}126- m.line.clear()126+ m.line.Clear()127127 p.Changed()128128 return p129129 }
@@ -136,7 +136,7 @@ func (p *picker) Changed() {
136136 m := p.m137137 p.Sel, p.Top = 0, 0138138 p.shown = p.shown[:0]139- q := strings.TrimSpace(m.line.text())139+ q := strings.TrimSpace(m.line.Text())140140 if q == "" {141141 for i := range p.items {142142 p.shown = append(p.shown, pickMatch{item: &p.items[i]})
@@ -228,9 +228,9 @@ func (p *picker) Key(k tea.KeyPressMsg) tea.Cmd {
228228 case "esc":229229 p.close(m)230230 default:231- before := m.line.text()232- m.line.key(k)233- if m.line.text() != before {231+ before := m.line.Text()232+ m.line.Key(k)233+ if m.line.Text() != before {234234 p.Changed()235235 }236236 }
@@ -239,7 +239,7 @@ func (p *picker) Key(k tea.KeyPressMsg) tea.Cmd {
239239 240240 func (p *picker) pick(m *Model) tea.Cmd {241241 if p.enter != nil {242- if cmd, ok := p.enter(m, strings.TrimSpace(m.line.text())); ok {242+ if cmd, ok := p.enter(m, strings.TrimSpace(m.line.Text())); ok {243243 return cmd244244 }245245 }
@@ -337,7 +337,7 @@ func (p *picker) box(m *Model) (x, y, inner int) {
337337 func (p *picker) Cursor() (int, int) {338338 m := p.m339339 x, y, _ := p.box(m)340- return x + 1 + ansi.StringWidth(searchPrompt) + ansi.StringWidth(m.line.head()), y + 1340+ return x + 1 + ansi.StringWidth(searchPrompt) + ansi.StringWidth(m.line.Head()), y + 1341341 }342342 343343 func (p *picker) Layout() []overlay.Box {
@@ -347,8 +347,8 @@ func (p *picker) Layout() []overlay.Box {
347347 if len(p.shown) > 0 {348348 p.Show(rows)349349 }350- input := m.th.Title.Render(searchPrompt) + m.line.text()351- if len(m.line.buf) == 0 {350+ input := m.th.Title.Render(searchPrompt) + m.line.Text()351+ if len(m.line.Buf) == 0 {352352 input += m.th.Muted.Render(p.placeholder)353353 }354354 lines := []string{theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}
M internal/ui/palette_test.go +2 -2
M internal/ui/panel.go +5 -5
@@ -180,7 +180,7 @@ func (m *Model) formulaBar() string {
180180 box := m.th.Header.Render(theme.PadRight(" "+ansi.Truncate(name, nameBoxW-1, "…"), nameBoxW)) + " "181181 switch m.mode {182182 case modeEnter, modeEdit:183- return box + m.line.text()183+ return box + m.line.Text()184184 case modePoint:185185 return box + m.entry.prefix + m.th.Selection.Render(m.pointRef()) + m.entry.suffix186186 }
@@ -232,9 +232,9 @@ func (m *Model) contextLineText() string {
232232 if left, right, ok = signatureLine(&m.th, m.width, prefix, len(prefix), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {233233 left = m.th.KeyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")234234 }235- case (m.mode == modeEnter || m.mode == modeEdit) && m.line.isFormula() && m.line.inFunction():236- left, right, _ = signatureLine(&m.th, m.width, m.line.buf, m.line.pos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))237- case m.mode == modeEnter && m.line.isFormula():235+ case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && inFunction(&m.line):236+ left, right, _ = signatureLine(&m.th, m.width, m.line.Buf, m.line.Pos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))237+ case m.mode == modeEnter && m.line.IsFormula():238238 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")239239 case m.mode == modeEnter:240240 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "accept and move", "Esc", "cancel")
@@ -264,7 +264,7 @@ func (m *Model) cursorPos() (x, y int, ok bool) {
264264 }265265 switch {266266 case m.mode == modeEnter, m.mode == modeEdit:267- return formulaBarTextX() + ansi.StringWidth(m.line.head()), formulaLine, true267+ return formulaBarTextX() + ansi.StringWidth(m.line.Head()), formulaLine, true268268 case m.mode == modePrompt && !m.pointing():269269 return ansi.StringWidth(m.prompt.prefix() + m.prompt.head(m)), contextLine, true270270 }
M internal/ui/prefs_test.go +1 -1
M internal/ui/prompt.go +17 -17
@@ -47,9 +47,9 @@ func (m *Model) pointing() bool {
4747 func (m *Model) openPrompt(p *prompt, initial string) {4848 m.mode = modePrompt4949 m.prompt = p50- m.line.clear()50+ m.line.Clear()5151 m.entry.hint = ""52- m.line.insert(initial)52+ m.line.Insert(initial)5353 }5454 5555 // openText asks for text, starting from initial, which typing replaces.
@@ -102,7 +102,7 @@ func (p *prompt) cancel(m *Model) {
102102 103103 // accept closes the prompt and acts on the answer.104104 func (p *prompt) accept(m *Model) tea.Cmd {105- text := strings.TrimSpace(m.line.text())105+ text := strings.TrimSpace(m.line.Text())106106 m.closePrompt()107107 if p.kind != promptRange {108108 cmd := p.onText(m, text)
@@ -132,44 +132,44 @@ func (p *prompt) pointKey(m *Model, k tea.KeyPressMsg) {
132132 m.point.anchor, m.point.anchored = m.point.at, true133133 case typed(k) != "":134134 p.typing = true135- m.line.insert(typed(k))135+ m.line.Insert(typed(k))136136 }137137 }138138 139139 // stepWidth changes a width by one, previewing it live.140140 func (p *prompt) stepWidth(m *Model, key string) {141- w, _ := strconv.Atoi(m.line.text())141+ w, _ := strconv.Atoi(m.line.Text())142142 if key == "left" {143143 w--144144 } else {145145 w++146146 }147147 w = clamp(w, 1, 240)148- m.line.clear()148+ m.line.Clear()149149 p.fresh = false150- m.line.insert(strconv.Itoa(w))150+ m.line.Insert(strconv.Itoa(w))151151 m.setWidths(w) // live preview152152 }153153 154154 // typeKey edits the answer; the first key typed replaces the default.155155 func (p *prompt) typeKey(m *Model, k tea.KeyPressMsg) {156156 if p.fresh && typed(k) != "" {157- m.line.clear()157+ m.line.Clear()158158 }159159 p.fresh = false160160 if p.kind == promptWidth && !isDigits(typed(k)) {161161 return162162 }163- m.line.key(k)163+ m.line.Key(k)164164 }165165 166166 // paste types pasted text into the answer.167167 func (p *prompt) paste(m *Model, text string) {168168 if p.fresh {169- m.line.clear()169+ m.line.Clear()170170 p.fresh = false171171 }172- m.line.insert(text)172+ m.line.Insert(text)173173 }174174 175175 // line is the prompt as the context line shows it, e.g. "Save as:
@@ -180,13 +180,13 @@ func (p *prompt) line(m *Model) (left, right string) {
180180 return p.prefix() + m.th.Selection.Render(m.point.text()),181181 m.th.KeyHints("Arrows", "move", "Shift+arrows", "extend", "Enter", "apply", "Esc", "cancel")182182 case len(p.files) > 0:183- return p.prefix() + m.line.text(), m.th.Muted.Render(strings.Join(p.files, " "))183+ return p.prefix() + m.line.Text(), m.th.Muted.Render(strings.Join(p.files, " "))184184 case p.kind == promptWidth:185- return p.prefix() + m.line.text(), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")185+ return p.prefix() + m.line.Text(), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")186186 case p.secret:187- return p.prefix() + mask(len(m.line.buf)), m.th.KeyHints("Enter", "save", "Esc", "cancel")187+ return p.prefix() + mask(len(m.line.Buf)), m.th.KeyHints("Enter", "save", "Esc", "cancel")188188 }189- return p.prefix() + m.line.text(), m.th.KeyHints("Enter", "apply", "Esc", "cancel")189+ return p.prefix() + m.line.Text(), m.th.KeyHints("Enter", "apply", "Esc", "cancel")190190 }191191 192192 func (p *prompt) prefix() string {
@@ -199,9 +199,9 @@ func mask(n int) string { return strings.Repeat("•", n) }
199199 // head is the answer before the caret, as shown.200200 func (p *prompt) head(m *Model) string {201201 if p.secret {202- return mask(m.line.pos)202+ return mask(m.line.Pos)203203 }204- return m.line.head()204+ return m.line.Head()205205 }206206 207207 func isDigits(s string) bool {
M internal/ui/tabs.go +3 -3
M internal/ui/transfer_test.go +5 -5
M internal/ui/view.go +1 -1
M internal/ui/vim_test.go +4 -4
M internal/ui/vimkeys.go +1 -1