The edit line (lineEdit), typing into a cell (entry), the mouse (mouseState), the sheet tabs (tabStrip) and chart commands (chartState) get types of their own, owned by Model, and the kept find bar and File Open's file list move into the find bar and the prompt. Model keeps the file, the mode and the note, and routes to its components. Line editing moves onto lineEdit.
- commit
- 69583be6abf28b39029853202156c1a4a18799ee
- parent
- cb0293743341eed08d8ef7d1c4da312afb85e4dc
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 46 | ++++++------ |
| M | internal/ | 20 | ++-- |
| M | internal/ | 21 | +++-- |
| M | internal/ | 2 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 10 | +- |
| M | internal/ | 12 | +- |
| M | internal/ | 122 | +++++++++++------------------- |
| M | internal/ | 26 | +++--- |
| M | internal/ | 23 | +++--- |
| M | internal/ | 25 | +++--- |
| M | internal/ | 4 | +- |
| M | internal/ | 4 | +- |
| A | internal/ | 105 | ++++++++++++++++++++++++++ |
| M | internal/ | 29 | +++---- |
| M | internal/ | 91 | ++++++++--------------- |
| M | internal/ | 14 | ++-- |
| M | internal/ | 115 | +++++++++++++++-------------- |
| M | internal/ | 22 | +++--- |
| M | internal/ | 4 | +- |
| M | internal/ | 20 | ++-- |
| M | internal/ | 4 | +- |
| M | internal/ | 38 | +++++----- |
| M | internal/ | 6 | +- |
| M | internal/ | 69 | +++++++++-------- |
| M | internal/ | 10 | +- |
| M | internal/ | 16 | ++-- |
@@ -73,10 +73,10 @@ func (m *Model) suggestions(word string) []suggestion {
7373 // EDIT, in plain text, after the caret moved, or when the word already7474 // names a range exactly and nothing else matches.7575 func (m *Model) shownSuggestions() ([]suggestion, int) {76- if (m.mode != modeEnter && m.mode != modeEdit) || !m.isFormula() || !m.assist.active || m.overlay != nil {76+ if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.isFormula() || !m.entry.assist.active || m.overlay != nil {7777 return nil, 07878 }79- c := scanCaret(m.buf, m.bufPos)79+ c := scanCaret(m.line.buf, m.line.pos)8080 if c.word == "" {8181 return nil, 08282 }@@ -84,16 +84,16 @@ func (m *Model) shownSuggestions() ([]suggestion, int) {
8484 if len(list) == 0 || len(list) == 1 && !list[0].fn && strings.EqualFold(list[0].name, c.word) {8585 return nil, 08686 }87- m.assist.sel = min(m.assist.sel, len(list)-1)87+ m.entry.assist.sel = min(m.entry.assist.sel, len(list)-1)8888 return list, c.wordStart8989 }9090 9191 // typeKey applies a line-editing key to the entry. Typing or deleting9292 // shows suggestions; moving the caret hides them, as in Sheets.9393 func (m *Model) typeKey(k tea.KeyPressMsg) {94- before := string(m.buf)95- m.lineKey(k)96- m.assist = assist{active: string(m.buf) != before}94+ before := m.line.text()95+ m.line.key(k)96+ m.entry.assist = assist{active: m.line.text() != before}9797 }9898 9999 // assistKey handles the keys the suggestion list takes while it shows.@@ -104,13 +104,13 @@ func (m *Model) assistKey(key string) bool {
104104 }105105 switch key {106106 case "up", "ctrl+p":107- m.assist.sel = (m.assist.sel - 1 + len(list)) % len(list)107+ m.entry.assist.sel = (m.entry.assist.sel - 1 + len(list)) % len(list)108108 case "down", "ctrl+n":109- m.assist.sel = (m.assist.sel + 1) % len(list)109+ m.entry.assist.sel = (m.entry.assist.sel + 1) % len(list)110110 case "tab", "enter":111- m.acceptSuggestion(list[m.assist.sel], start)111+ m.acceptSuggestion(list[m.entry.assist.sel], start)112112 case "esc":113- m.assist.active = false113+ m.entry.assist.active = false114114 default:115115 return false116116 }@@ -121,7 +121,7 @@ func (m *Model) assistKey(key string) bool {
121121 // a function unless one is already there.122122 func (m *Model) acceptSuggestion(s suggestion, start int) {123123 text := []rune(s.name)124- rest := m.buf[m.bufPos:]124+ rest := m.line.buf[m.line.pos:]125125 if s.fn && (len(rest) == 0 || rest[0] != '(') {126126 text = append(text, '(')127127 }@@ -129,9 +129,9 @@ func (m *Model) acceptSuggestion(s suggestion, start int) {
129129 if s.fn && len(rest) > 0 && rest[0] == '(' {130130 pos++ // into the existing parentheses131131 }132- m.buf = slices.Concat(m.buf[:start], text, rest)133- m.bufPos = pos134- m.assist.active = false132+ m.line.buf = slices.Concat(m.line.buf[:start], text, rest)133+ m.line.pos = pos134+ m.entry.assist.active = false135135 }136136 137137 // assistBox draws the list under the formula bar, its text aligned with@@ -145,7 +145,7 @@ func (m *Model) assistBox() (box, bool) {
145145 if rows < 1 {146146 return box{}, false147147 }148- a := &m.assist148+ a := &m.entry.assist149149 a.top = clamp(a.top, a.sel-rows+1, a.sel)150150 a.top = clamp(a.top, 0, len(list)-rows)151151 nw, dw := 0, 0@@ -173,7 +173,7 @@ func (m *Model) assistBox() (box, bool) {
173173 footer = strconv.Itoa(a.sel+1) + " of " + strconv.Itoa(len(list))174174 }175175 // The box's text starts one column in, under the word's first letter.176- x := formulaBarTextX() + ansi.StringWidth(string(m.buf[:start])) - 2176+ x := formulaBarTextX() + ansi.StringWidth(string(m.line.buf[:start])) - 2177177 x = clamp(x, 0, max(m.width-inner-2, 0))178178 return box{id: assistID, x: x, y: contextLine + 1, lines: m.th.Frame(inner, "", footer, lines)}, true179179 }@@ -191,20 +191,20 @@ func (m *Model) assistMouse(msg tea.MouseMsg) bool {
191191 return false192192 }193193 list, start := m.shownSuggestions()194- i := m.assist.top + mouse.Y - b.y - 1194+ i := m.entry.assist.top + mouse.Y - b.y - 1195195 switch msg.(type) {196196 case tea.MouseWheelMsg:197197 d := 1198198 if mouse.Button == tea.MouseWheelUp {199199 d = -1200200 }201- m.assist.sel = clamp(m.assist.sel+d, 0, len(list)-1)201+ m.entry.assist.sel = clamp(m.entry.assist.sel+d, 0, len(list)-1)202202 case tea.MouseMotionMsg:203- if i >= m.assist.top && i < min(len(list), m.assist.top+b.height()-2) {204- m.assist.sel = i203+ if i >= m.entry.assist.top && i < min(len(list), m.entry.assist.top+b.height()-2) {204+ m.entry.assist.sel = i205205 }206206 case tea.MouseClickMsg:207- if mouse.Button == tea.MouseLeft && i >= m.assist.top && i < min(len(list), m.assist.top+b.height()-2) {207+ if mouse.Button == tea.MouseLeft && i >= m.entry.assist.top && i < min(len(list), m.entry.assist.top+b.height()-2) {208208 m.acceptSuggestion(list[i], start)209209 }210210 }@@ -218,13 +218,13 @@ func (m *Model) assistStatus() (desc, keys string, ok bool) {
218218 if list == nil {219219 return "", "", false220220 }221- return list[m.assist.sel].desc, m.th.KeyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true221+ return list[m.entry.assist.sel].desc, m.th.KeyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true222222 }223223 224224 // inFunction reports whether the caret is inside a known function's225225 // parentheses.226226 func (m *Model) inFunction() bool {227- _, ok := sheet.LookupFunc(scanCaret(m.buf, m.bufPos).fn)227+ _, ok := sheet.LookupFunc(scanCaret(m.line.buf, m.line.pos).fn)228228 return ok229229 }230230 @@ -81,18 +81,18 @@ func TestAutocompleteFunctions(t *testing.T) {
8181 t.Errorf("status %q", st)8282 }8383 // Down moves the highlight rather than committing; Tab inserts.84- for list[m.assist.sel].name != "SUM" {84+ for list[m.entry.assist.sel].name != "SUM" {8585 press(t, m, "<down>")8686 }8787 press(t, m, "<tab>")88- if m.mode != modeEnter || string(m.buf) != "=SUM(" {89- t.Fatalf("mode %v buf %q", m.mode, string(m.buf))88+ if m.mode != modeEnter || m.line.text() != "=SUM(" {89+ t.Fatalf("mode %v buf %q", m.mode, m.line.text())9090 }9191 if ctx := line(m, contextLine); !strings.HasPrefix(ctx, "SUM(value1, [value2, ...])") || !strings.Contains(ctx, "Sum of numbers") {9292 t.Errorf("signature %q", ctx)9393 }9494 press(t, m, "1,")95- sig, _ := m.signature(m.buf, m.bufPos)95+ sig, _ := m.signature(m.line.buf, m.line.pos)9696 if !strings.Contains(sig, m.th.Argument.Render("[value2, ...]")) {9797 t.Errorf("second argument not marked: %q", sig)9898 }@@ -106,8 +106,8 @@ func TestAutocompleteKeys(t *testing.T) {
106106 m := newModel()107107 // Enter inserts while the list shows, then commits when it doesn't.108108 press(t, m, "=ab", "<enter>")109- if string(m.buf) != "=ABS(" {110- t.Fatalf("enter: %q", string(m.buf))109+ if m.line.text() != "=ABS(" {110+ t.Fatalf("enter: %q", m.line.text())111111 }112112 press(t, m, "-4)", "<enter>")113113 if m.sheet.Value(addr("A1")).Num != 4 {@@ -156,15 +156,15 @@ func TestAutocompleteNamesAndMouse(t *testing.T) {
156156 // Clicking a suggestion inserts it.157157 b, _ := m.assistBox()158158 send(m, tea.MouseClickMsg{X: b.x + 3, Y: b.y + 1, Button: tea.MouseLeft})159- if string(m.buf) != "=SUM(Sales" || m.bufPos != start+5 {160- t.Fatalf("click: %q", string(m.buf))159+ if m.line.text() != "=SUM(Sales" || m.line.pos != start+5 {160+ t.Fatalf("click: %q", m.line.text())161161 }162162 // Editing inside existing parentheses doesn't double them.163163 m = newModel()164164 press(t, m, "=AB(1)", "<f2>", "<left>", "<left>", "<left>", "<backspace>", "B")165165 press(t, m, "<tab>")166- if string(m.buf) != "=ABS(1)" || m.bufPos != 5 {167- t.Errorf("into parens: %q at %d", string(m.buf), m.bufPos)166+ if m.line.text() != "=ABS(1)" || m.line.pos != 5 {167+ t.Errorf("into parens: %q at %d", m.line.text(), m.line.pos)168168 }169169 }170170 @@ -53,6 +53,11 @@ func init() {
5353 )5454 }5555 56+// chartState is what chart commands remember between selections.57+type chartState struct {58+ last int // the chart last selected, for chart commands; -1 for none59+}60+5661 func hasCharts(m *Model) bool { return len(m.sheet.Charts()) > 0 }5762 5863 // targetChart is the chart a chart command acts on: the selected one, the@@ -65,8 +70,8 @@ func (m *Model) targetChart() int {
6570 case *chartEditor:6671 return o.i6772 }68- if m.lastChart >= 0 && m.lastChart < len(charts) {69- return m.lastChart73+ if m.charts.last >= 0 && m.charts.last < len(charts) {74+ return m.charts.last7075 }7176 for i, c := range charts {7277 if c.Data.Contains(m.cur) {@@ -129,7 +134,7 @@ func (m *Model) deleteChart(i int) {
129134 }130135 m.sheet.DeleteChart(i)131136 m.changed = true132- m.lastChart = -1137+ m.charts.last = -1133138 if _, ok := m.overlay.(*chartSel); ok {134139 m.closeOverlay()135140 }@@ -329,16 +334,16 @@ var chartMenu = []menuItem{{cmd: "chart.edit"}, {cmd: "chart.delete"}}
329334 // selectChart selects chart i.330335 func (m *Model) selectChart(i int) {331336 m.clearSelection()332- m.lastChart = i337+ m.charts.last = i333338 m.openOverlay(&chartSel{i: i})334339 }335340 336341 // setShape sets the terminal's pointer shape, if it changed.337342 func (m *Model) setShape(shape string) tea.Cmd {338- if shape == m.shape {343+ if shape == m.mouse.shape {339344 return nil340345 }341- m.shape = shape346+ m.mouse.shape = shape342347 return tea.Raw(ansi.SetPointerShape(shape))343348 }344349 @@ -565,7 +570,7 @@ func (m *Model) openChartEditor(i int, isNew bool, start int) {
565570 if i < 0 || i >= len(m.sheet.Charts()) {566571 return567572 }568- m.lastChart = i573+ m.charts.last = i569574 m.openOverlay(&chartEditor{i: i, start: start, isNew: isNew})570575 }571576 @@ -608,7 +613,7 @@ func (e *chartEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
608613 }609614 m.changed = m.sheet.StateID() != m.saved610615 m.closeOverlay()611- m.lastChart = -1616+ m.charts.last = -1612617 case "left", "right", "shift+tab", "tab":613618 d := 1614619 if key == "left" || key == "shift+tab" {@@ -89,7 +89,7 @@ func TestChartEditorPrompts(t *testing.T) {
8989 if m.mode != modePrompt {9090 t.Fatalf("title prompt not open, mode %v", m.mode)9191 }92- m.buf, m.bufPos = []rune("Spend"), 592+ m.line.buf, m.line.pos = []rune("Spend"), 59393 press(t, m, "<enter>")9494 if c := m.sheet.Charts()[0]; c.Title != "Spend" || m.indicator() != "CHART" {9595 t.Errorf("title %q, indicator %q", c.Title, m.indicator())@@ -74,8 +74,8 @@ func TestFillHandleEscCancels(t *testing.T) {
7474 send(m, tea.MouseMotionMsg(leftAt(cellX(0), gridTop+4)))7575 press(t, m, "<esc>")7676 send(m, tea.MouseReleaseMsg(leftAt(cellX(0), gridTop+4)))77- if input(m, "A2") != "" || m.drag != dragNone {78- t.Errorf("A2 %q drag %v", input(m, "A2"), m.drag)77+ if input(m, "A2") != "" || m.mouse.drag != dragNone {78+ t.Errorf("A2 %q drag %v", input(m, "A2"), m.mouse.drag)7979 }8080 }8181 @@ -150,7 +150,7 @@ func (m *Model) structural(err error) tea.Cmd {
150150 // fillEntry stores the entry being typed in every selected cell, adjusting151151 // references as if it were copied from the active cell (Ctrl+Enter).152152 func (m *Model) fillEntry() bool {153- input := string(m.buf)153+ input := m.line.text()154154 if err := m.entrySheet().FillEntry(m.selection(), m.cur, input); err != nil {155155 m.entryError(err, input)156156 return false@@ -164,17 +164,17 @@ func (m *Model) fillEntry() bool {
164164 func (m *Model) entryError(err error, input string) {165165 var pe *sheet.ParseError166166 m.mode = modeEdit167- m.hint = err.Error()167+ m.entry.hint = err.Error()168168 if errors.As(err, &pe) {169- m.bufPos = utf8.RuneCountInString(input[:min(pe.Pos, len(input))])169+ m.line.pos = utf8.RuneCountInString(input[:min(pe.Pos, len(input))])170170 }171171 }172172 173173 // toggleAbsolute cycles the reference at the caret through A1, $A$1, A$1174174 // and $A1, as F4 does in Sheets. A range cycles both corners.175175 func (m *Model) toggleAbsolute() {176- if buf, pos, ok := cycleRef(m.buf, m.bufPos); ok {177- m.buf, m.bufPos = buf, pos176+ if buf, pos, ok := cycleRef(m.line.buf, m.line.pos); ok {177+ m.line.buf, m.line.pos = buf, pos178178 }179179 }180180 @@ -171,8 +171,8 @@ func TestPasteTSVFromTerminal(t *testing.T) {
171171 }172172 // A single line still starts an entry.173173 send(m, tea.PasteMsg{Content: "hello\n"})174- if m.mode != modeEnter || string(m.buf) != "hello" {175- t.Errorf("mode %v buf %q", m.mode, string(m.buf))174+ if m.mode != modeEnter || m.line.text() != "hello" {175+ t.Errorf("mode %v buf %q", m.mode, m.line.text())176176 }177177 }178178 @@ -254,14 +254,14 @@ func TestF4CyclesReferences(t *testing.T) {
254254 press(t, m, "=A1+B2")255255 for _, want := range []string{"=A1+$B$2", "=A1+B$2", "=A1+$B2", "=A1+B2"} {256256 press(t, m, "<f4>")257- if string(m.buf) != want {258- t.Errorf("F4 gave %q, want %q", string(m.buf), want)257+ if m.line.text() != want {258+ t.Errorf("F4 gave %q, want %q", m.line.text(), want)259259 }260260 }261261 // F4 while pointing puts the reference in first.262262 press(t, m, "*", "<up>", "<f4>")263- if string(m.buf) != "=A1+B2*$A$1" || m.mode != modeEnter {264- t.Errorf("F4 in POINT: %q mode %v", string(m.buf), m.mode)263+ if m.line.text() != "=A1+B2*$A$1" || m.mode != modeEnter {264+ t.Errorf("F4 in POINT: %q mode %v", m.line.text(), m.mode)265265 }266266 }267267 @@ -1,9 +1,7 @@
11 package ui22 33 import (4- "slices"54 "strings"6- "unicode"75 "unicode/utf8"86 97 tea "charm.land/bubbletea/v2"@@ -17,28 +15,44 @@ import (
1715 // operator point at cells (POINT) and insert their references. The1816 // selection stays while typing, so Ctrl+Enter can fill it.1917 18+// entry is the state of typing into a cell, beyond the text itself (in19+// Model.line) and the cell or range pointed at (Model.point).20+type entry struct {21+ hint string // shown on the context line, e.g. a formula error22+23+ // While pointing, the entry's text before and after the reference24+ // being pointed at.25+ prefix, suffix string26+27+ // home is the entry's sheet while the pointer is on another one; see28+ // tabs.go.29+ home *sheet.Sheet30+31+ assist assist // function and name suggestions: assist.go32+33+ // tabStart remembers where a run of Tab-committed entries began, so34+ // Enter returns to that column on the next row, as in Sheets.35+ tabStart int36+ tabbing bool37+}38+2039 func (m *Model) startEntry(md mode, text string) {2140 m.mode = md22- m.home = nil23- m.buf, m.bufPos, m.hint, m.assist = nil, 0, "", assist{}24- m.insert(text)41+ m.entry.home = nil42+ m.line.clear()43+ m.entry.hint, m.entry.assist = "", assist{}44+ m.line.insert(text)2545 }2646 2747 // startEdit edits the active cell's contents with the caret at the end.2848 func (m *Model) startEdit() tea.Cmd {2949 m.startEntry(modeEdit, "")3050 if c := m.sheet.Cell(m.cur); c != nil {31- m.buf = []rune(c.Input)32- m.bufPos = len(m.buf)51+ m.line.set(c.Input)3352 }3453 return nil3554 }3655 37-// isFormula reports whether the edit line holds a formula.38-func (m *Model) isFormula() bool {39- return len(m.buf) > 0 && (m.buf[0] == '=' || m.buf[0] == '+' || m.buf[0] == '-')40-}41-4256 // enterKey handles ENTER mode. Arrow keys accept the entry and move, except4357 // in a formula where a reference may follow the caret: then they point.4458 func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {@@ -54,10 +68,10 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
5468 m.toggleAbsolute()5569 return nil5670 }57- if m.isFormula() && m.canPoint() && m.startPoint(key) {71+ if m.line.isFormula() && m.line.canPoint() && m.startPoint(key) {5872 return nil5973 }60- if m.isFormula() && (key == "left" || key == "right") {74+ if m.line.isFormula() && (key == "left" || key == "right") {6175 m.typeKey(k) // move the caret within a formula6276 return nil6377 }@@ -77,7 +91,7 @@ func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {
7791 if m.assistKey(key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {7892 return nil7993 }80- if m.isFormula() && m.canPoint() && strings.HasPrefix(key, "shift+") && m.startPoint(key) {94+ if m.line.isFormula() && m.line.canPoint() && strings.HasPrefix(key, "shift+") && m.startPoint(key) {8195 return nil8296 }8397 switch key {@@ -110,16 +124,16 @@ func (m *Model) commitKey(key string) bool {
110124 }111125 switch key {112126 case "enter":113- if m.tabbing {114- m.cur.Col = m.tabStart115- m.tabbing = false127+ if m.entry.tabbing {128+ m.cur.Col = m.entry.tabStart129+ m.entry.tabbing = false116130 }117131 m.cur.Row++118132 case "shift+enter":119133 m.cur.Row--120134 case "tab":121- if !m.tabbing {122- m.tabStart, m.tabbing = m.cur.Col, true135+ if !m.entry.tabbing {136+ m.entry.tabStart, m.entry.tabbing = m.cur.Col, true123137 }124138 m.cur.Col++125139 case "shift+tab":@@ -137,40 +151,6 @@ func (m *Model) cancelKey(key string) bool {
137151 return true138152 }139153 140-// lineKey applies line-editing keys to buf.141-func (m *Model) lineKey(k tea.KeyPressMsg) {142- switch k.String() {143- case "left":144- m.bufPos = max(m.bufPos-1, 0)145- case "right":146- m.bufPos = min(m.bufPos+1, len(m.buf))147- case "home", "ctrl+a":148- m.bufPos = 0149- case "end", "ctrl+e":150- m.bufPos = len(m.buf)151- case "backspace":152- if m.bufPos > 0 {153- m.buf = slices.Delete(m.buf, m.bufPos-1, m.bufPos)154- m.bufPos--155- }156- case "delete":157- if m.bufPos < len(m.buf) {158- m.buf = slices.Delete(m.buf, m.bufPos, m.bufPos+1)159- }160- default:161- m.insert(typed(k))162- }163-}164-165-// canPoint reports whether the text before the caret ends where a cell166-// reference may follow.167-func (m *Model) canPoint() bool {168- if m.bufPos == 0 {169- return false170- }171- return strings.ContainsRune("=+-*/^(,;<>&:", m.buf[m.bufPos-1])172-}173-174154 // startPoint enters POINT mode if key moves (Shift extends). The pointer175155 // starts at the active cell, or on another sheet where it last pointed.176156 func (m *Model) startPoint(key string) bool {@@ -186,8 +166,8 @@ func (m *Model) startPoint(key string) bool {
186166 } else {187167 return false188168 }189- m.pointPrefix = string(m.buf[:m.bufPos])190- m.pointSuffix = string(m.buf[m.bufPos:])169+ m.entry.prefix = m.line.head()170+ m.entry.suffix = m.line.tail()191171 m.mode = modePoint192172 m.navigate(key, &m.point.at)193173 return true@@ -221,8 +201,8 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
221201 }222202 if text := typed(k); text != "" {223203 m.resumeEntry(m.pointRef())224- m.insert(text)225- m.assist = assist{active: true}204+ m.line.insert(text)205+ m.entry.assist = assist{active: true}226206 }227207 return nil228208 }@@ -230,14 +210,14 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
230210 // resumeEntry leaves POINT mode, inserting ref at the caret.231211 func (m *Model) resumeEntry(ref string) {232212 m.mode = modeEnter233- m.buf = []rune(m.pointPrefix + ref + m.pointSuffix)234- m.bufPos = utf8.RuneCountInString(m.pointPrefix + ref)213+ m.line.buf = []rune(m.entry.prefix + ref + m.entry.suffix)214+ m.line.pos = utf8.RuneCountInString(m.entry.prefix + ref)235215 }236216 237217 // commit stores the edit line in the active cell. An invalid formula stays238218 // in EDIT mode with the caret at the problem and the reason on line 3.239219 func (m *Model) commit() bool {240- input := string(m.buf)220+ input := m.line.text()241221 if err := m.set(m.cur, input); err != nil {242222 m.entryError(err, input)243223 return false@@ -260,20 +240,8 @@ func (m *Model) set(a sheet.Addr, input string) error {
260240 func (m *Model) cancelEntry() {261241 m.returnHome()262242 m.mode = modeReady263- m.buf, m.bufPos, m.hint, m.assist = nil, 0, "", assist{}264-}265-266-func (m *Model) insert(text string) {267- for _, r := range text {268- if r == '\n' || r == '\r' || r == '\t' {269- r = ' '270- }271- if !unicode.IsPrint(r) {272- continue273- }274- m.buf = slices.Insert(m.buf, m.bufPos, r)275- m.bufPos++276- }243+ m.line.clear()244+ m.entry.hint, m.entry.assist = "", assist{}277245 }278246 279247 func (m *Model) handlePaste(content string) {@@ -286,14 +254,14 @@ func (m *Model) handlePaste(content string) {
286254 m.startEntry(modeEnter, content)287255 }288256 case modeEnter, modeEdit:289- m.insert(content)257+ m.line.insert(content)290258 case modePrompt:291259 if m.prompt.kind != promptRange {292260 m.promptType(content)293261 }294262 case modeMenu:295263 if o, ok := m.overlay.(textOverlay); ok {296- m.insert(content)264+ m.line.insert(content)297265 o.changed(m)298266 }299267 }@@ -30,24 +30,24 @@ func (m *Model) showFillHandle(a sheet.Addr) bool {
3030 return false3131 }3232 switch {33- case m.drag == dragFill:33+ case m.mouse.drag == dragFill:3434 return true35- case m.drag != dragNone:35+ case m.mouse.drag != dragNone:3636 return false3737 }38- return (m.hover.kind == hitCell || m.hover.kind == hitFillHandle) && m.hover.addr == a38+ return (m.mouse.hover.kind == hitCell || m.mouse.hover.kind == hitFillHandle) && m.mouse.hover.addr == a3939 }4040 4141 // startFill starts dragging the fill handle.4242 func (m *Model) startFill() {43- m.drag = dragFill44- m.fillAt, m.fillTo = m.fillCorner(), m.selection()43+ m.mouse.drag = dragFill44+ m.mouse.fillAt, m.mouse.fillTo = m.fillCorner(), m.selection()4545 }4646 4747 // dragFillTo points the fill at cell a: the selection grows along the4848 // axis a is further out on, as in Sheets.4949 func (m *Model) dragFillTo(a sheet.Addr) {50- m.fillAt = a50+ m.mouse.fillAt = a5151 src := m.selection()5252 down, up := a.Row-src.To.Row, src.From.Row-a.Row5353 right, left := a.Col-src.To.Col, src.From.Col-a.Col@@ -65,13 +65,13 @@ func (m *Model) dragFillTo(a sheet.Addr) {
6565 default:6666 r.From.Col = a.Col6767 }68- m.fillTo = r68+ m.mouse.fillTo = r6969 }7070 7171 // finishFill fills the range the handle was dragged over and selects it.7272 func (m *Model) finishFill() {73- m.drag = dragNone74- src, dst := m.selection(), m.fillTo73+ m.mouse.drag = dragNone74+ src, dst := m.selection(), m.mouse.fillTo7575 if dst == src {7676 return7777 }@@ -88,14 +88,14 @@ func (m *Model) finishFill() {
8888 8989 // cancelFill stops a fill handle drag without filling.9090 func (m *Model) cancelFill() {91- m.drag, m.autoscrolling = dragNone, false92- m.fillTo = m.selection()91+ m.mouse.drag, m.mouse.autoscrolling = dragNone, false92+ m.mouse.fillTo = m.selection()9393 }9494 9595 // fillLine is the context line while dragging the fill handle.9696 func (m *Model) fillLine() string {97- if m.fillTo == m.selection() {97+ if m.mouse.fillTo == m.selection() {9898 return m.th.Key.Render("Fill") + m.th.Muted.Render(" drag down, up, right or left to fill a series or copy ") + m.th.KeyHints("Esc", "cancel")9999 }100- return m.th.Key.Render("Fill "+m.fillTo.String()) + m.th.Muted.Render(" release to fill ") + m.th.KeyHints("Esc", "cancel")100+ return m.th.Key.Render("Fill "+m.mouse.fillTo.String()) + m.th.Muted.Render(" release to fill ") + m.th.KeyHints("Esc", "cancel")101101 }@@ -125,7 +125,7 @@ func (m *Model) openFilterPicker(col int) {
125125 }126126 p.fields[1] = p.cond.Arg127127 m.openOverlay(p)128- m.buf, m.bufPos = nil, 0128+ m.line.clear()129129 p.search()130130 }131131 @@ -133,14 +133,13 @@ func (p *filterPicker) indicator() string { return "FILTER" }
133133 134134 // focus moves editing to field i, keeping the other field's text.135135 func (p *filterPicker) focus(m *Model, i int) {136- p.fields[p.field] = string(m.buf)136+ p.fields[p.field] = m.line.text()137137 p.field = i138- m.buf = []rune(p.fields[i])139- m.bufPos = len(m.buf)138+ m.line.set(p.fields[i])140139 }141140 142141 func (p *filterPicker) changed(m *Model) {143- p.fields[p.field] = string(m.buf)142+ p.fields[p.field] = m.line.text()144143 if p.field == 0 {145144 p.search()146145 }@@ -205,7 +204,7 @@ func (p *filterPicker) cycle(d int) {
205204 206205 // apply sets the column's criteria and closes the picker.207206 func (p *filterPicker) apply(m *Model) {208- p.fields[p.field] = string(m.buf)207+ p.fields[p.field] = m.line.text()209208 var cr sheet.Criteria210209 for _, v := range p.values {211210 if !p.checked[v.Text] {@@ -263,9 +262,9 @@ func (p *filterPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
263262 case p.field == 0 && key == "space":264263 p.toggle(p.sel)265264 default:266- before := string(m.buf)267- m.lineKey(k)268- if string(m.buf) != before {265+ before := m.line.text()266+ m.line.key(k)267+ if m.line.text() != before {269268 p.changed(m)270269 }271270 }@@ -350,7 +349,7 @@ func (p *filterPicker) box(m *Model) (x, y, inner int) {
350349 351350 func (p *filterPicker) cursor(m *Model) (int, int) {352351 x, y, _ := p.box(m)353- caret := ansi.StringWidth(string(m.buf[:m.bufPos]))352+ caret := ansi.StringWidth(m.line.head())354353 if p.field == 1 {355354 return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1356355 }@@ -375,7 +374,7 @@ func (p *filterPicker) layout(m *Model) []box {
375374 if p.cond.Op.TakesArg() {376375 arg := p.fields[1]377376 if p.field == 1 {378- arg = string(m.buf)377+ arg = m.line.text()379378 }380379 if arg == "" && p.field != 1 {381380 arg = m.th.Muted.Render("value")@@ -384,7 +383,7 @@ func (p *filterPicker) layout(m *Model) []box {
384383 }385384 search := p.fields[0]386385 if p.field == 0 {387- search = string(m.buf)386+ search = m.line.text()388387 }389388 input := m.th.Title.Render(searchPrompt) + search390389 if search == "" {@@ -74,7 +74,7 @@ func init() {
7474 func (m *Model) openFind(replace bool) {7575 f, ok := m.overlay.(*findBar)7676 if !ok {77- f = m.lastFind77+ f = m.find7878 if f == nil {7979 f = &findBar{cur: -1}8080 }@@ -90,7 +90,7 @@ func (m *Model) openFind(replace bool) {
9090 m.clearSelection()9191 m.openOverlay(f)9292 // Load the kept query so focus doesn't overwrite it.93- m.buf = []rune(f.fields[f.field])93+ m.line.buf = []rune(f.fields[f.field])9494 }9595 f.replace = f.replace || replace9696 if replace && f.fields[0] != "" {@@ -107,15 +107,14 @@ func (f *findBar) layout(*Model) []box { return nil }
107107 108108 // focus moves editing to field i, keeping the other field's text.109109 func (f *findBar) focus(m *Model, i int) {110- f.fields[f.field] = string(m.buf)110+ f.fields[f.field] = m.line.text()111111 f.field = i112- m.buf = []rune(f.fields[i])113- m.bufPos = len(m.buf)112+ m.line.set(f.fields[i])114113 }115114 116115 // changed re-runs the search as the query is typed.117116 func (f *findBar) changed(m *Model) {118- f.fields[f.field] = string(m.buf)117+ f.fields[f.field] = m.line.text()119118 if f.field == 0 {120119 f.search(m)121120 }@@ -214,7 +213,7 @@ func (f *findBar) nextScope(m *Model) findScope {
214213 func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {215214 switch k.String() {216215 case "esc":217- m.lastFind = f216+ m.find = f218217 m.closeOverlay()219218 case "enter":220219 if f.replace && f.field == 1 {@@ -258,9 +257,9 @@ func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
258257 f.search(m)259258 }260259 default:261- before := string(m.buf)262- m.lineKey(k)263- if string(m.buf) != before {260+ before := m.line.text()261+ m.line.key(k)262+ if m.line.text() != before {264263 f.changed(m)265264 }266265 }@@ -372,7 +371,7 @@ func (f *findBar) parts(m *Model) []findPart {
372371 // fieldText is a field's text: the live edit buffer when focused.373372 func (f *findBar) fieldText(m *Model, i int) string {374373 if f.field == i {375- return string(m.buf)374+ return m.line.text()376375 }377376 return f.fields[i]378377 }@@ -431,7 +430,7 @@ func (f *findBar) cursor(m *Model) (x, y int) {
431430 parts, xs := f.spans(m)432431 for i, p := range parts {433432 if p.field == f.field {434- return xs[i] + ansi.StringWidth(p.text) - ansi.StringWidth(string(m.buf[m.bufPos:])), contextLine433+ return xs[i] + ansi.StringWidth(p.text) - ansi.StringWidth(m.line.tail()), contextLine435434 }436435 }437436 return 0, contextLine@@ -444,7 +443,7 @@ func (f *findBar) mouse(m *Model, e mouseEvent) tea.Cmd {
444443 return nil445444 }446445 if e.y != contextLine {447- m.lastFind = f446+ m.find = f448447 m.closeOverlay()449448 return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})450449 }@@ -70,8 +70,8 @@ func TestFindAsYouType(t *testing.T) {
7070 }7171 // Ctrl+F again reopens the last search.7272 press(t, m, "<ctrl+f>")73- if f := findBarOf(t, m); string(m.buf) != "ren" || len(f.matches) != 3 {74- t.Errorf("reopened with %q", string(m.buf))73+ if f := findBarOf(t, m); m.line.text() != "ren" || len(f.matches) != 3 {74+ t.Errorf("reopened with %q", m.line.text())7575 }7676 }7777 @@ -93,8 +93,8 @@ func TestFunctionListInsertsFunction(t *testing.T) {
9393 t.Errorf("status %q", line(m, m.height-1))9494 }9595 press(t, m, "<enter>")96- if m.mode != modeEnter || string(m.buf) != "=IFERROR(" {97- t.Errorf("mode %v buf %q", m.mode, string(m.buf))96+ if m.mode != modeEnter || m.line.text() != "=IFERROR(" {97+ t.Errorf("mode %v buf %q", m.mode, m.line.text())9898 }9999 }100100 @@ -0,0 +1,105 @@
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+}@@ -36,6 +36,7 @@ type prompt struct {
3636 onText func(m *Model, text string) tea.Cmd3737 onRange func(m *Model, r sheet.Rect) tea.Cmd3838 onCancel func(m *Model)39+ files []string // the file list shown by File Open3940 }4041 4142 func (m *Model) pointing() bool {@@ -45,9 +46,9 @@ func (m *Model) pointing() bool {
4546 func (m *Model) openPrompt(p *prompt, initial string) {4647 m.mode = modePrompt4748 m.prompt = p48- m.buf, m.bufPos = nil, 049- m.hint = ""50- m.insert(initial)49+ m.line.clear()50+ m.entry.hint = ""51+ m.line.insert(initial)5152 }5253 5354 func (m *Model) openText(label, initial string, onText func(*Model, string) tea.Cmd) {@@ -150,7 +151,6 @@ func (m *Model) openSave() tea.Cmd {
150151 }151152 152153 func (m *Model) openRetrieve() tea.Cmd {153- m.files = nil154154 m.openText("Open file:", "", func(m *Model, text string) tea.Cmd {155155 if _, ok := fileio.KindOf(text); ok {156156 return m.confirmImport(text, fileio.Options{})@@ -177,7 +177,7 @@ func (m *Model) promptKey(k tea.KeyPressMsg) tea.Cmd {
177177 m.closePrompt()178178 return nil179179 case "enter":180- text := strings.TrimSpace(string(m.buf))180+ text := strings.TrimSpace(m.line.text())181181 m.closePrompt()182182 if p.kind != promptRange {183183 return p.onText(m, text)@@ -204,43 +204,42 @@ func (m *Model) promptKey(k tea.KeyPressMsg) tea.Cmd {
204204 }205205 if text := typed(k); text != "" {206206 p.typing = true207- m.insert(text)207+ m.line.insert(text)208208 }209209 case p.kind == promptWidth && (key == "left" || key == "right"):210- w, _ := strconv.Atoi(string(m.buf))210+ w, _ := strconv.Atoi(m.line.text())211211 if key == "left" {212212 w--213213 } else {214214 w++215215 }216216 w = clamp(w, 1, 240)217- m.buf, m.bufPos, p.fresh = nil, 0, false218- m.insert(strconv.Itoa(w))217+ m.line.buf, m.line.pos, p.fresh = nil, 0, false218+ m.line.insert(strconv.Itoa(w))219219 m.setWidths(w) // live preview220220 default:221221 if p.fresh && typed(k) != "" {222- m.buf, m.bufPos = nil, 0222+ m.line.clear()223223 }224224 p.fresh = false225225 if p.kind == promptWidth && !isDigits(typed(k)) {226226 return nil227227 }228- m.lineKey(k)228+ m.line.key(k)229229 }230230 return nil231231 }232232 233233 func (m *Model) promptType(text string) {234234 if m.prompt.fresh {235- m.buf, m.bufPos = nil, 0235+ m.line.clear()236236 m.prompt.fresh = false237237 }238- m.insert(text)238+ m.line.insert(text)239239 }240240 241241 func (m *Model) closePrompt() {242242 m.prompt = nil243- m.files = nil244243 m.cancelEntry()245244 }246245 @@ -253,7 +252,7 @@ func (m *Model) fail(msg string) {
253252 // session rather than the sheet carries over: the window, theme, terminal254253 // state and the JEV connection.255254 func (m *Model) reset(s *sheet.Sheet, filename string) {256- *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, lastChart: -1}255+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, charts: chartState{last: -1}}257256 }258257 259258 func isDigits(s string) bool {@@ -52,61 +52,32 @@ type Model struct {
5252 // window size and the selection; see grid.go.5353 grid5454 55- filename string56- changed bool57- saved int // the sheet's StateID when last saved or loaded58-59- copied clipboard // see clipboard.go60- note string // feedback on the last action, e.g. "Undid: clear B3"61-55+ // The file.56+ filename string57+ changed bool58+ saved int // the sheet's StateID when last saved or loaded6259 quitAfterSave bool // "Save and quit" is waiting for the save to finish6360 64- // Mouse: see mouse.go.65- drag dragKind66- lastClick time.Time67- lastHit hit68- hover hit // what's under the mouse, for hover styling69- mouseX, mouseY int // last mouse position, for autoscroll70- autoscrolling bool // an autoscroll tick is pending71- resizeCol int // column being resized by its header border72- fillAt sheet.Addr // where a fill handle drag points, see fill.go73- fillTo sheet.Rect // the range that drag would fill74- shape string // pointer shape last sent to the terminal75-76- // Sheets: see tabs.go.77- home *sheet.Sheet // while pointing into another sheet, the entry's sheet78- places map[*sheet.Sheet]place // where each sheet's cursor and scroll were left79- tabLeft int // the first tab shown when they don't all fit80-81- // tabStart remembers where a run of Tab-committed entries began, so82- // Enter returns to that column on the next row, as in Sheets.83- tabStart int84- tabbing bool85-86- mode mode87-88- // buf is the edit line used by ENTER, EDIT and text prompts.89- buf []rune90- bufPos int91- hint string // shown on the third panel line, e.g. a formula error92-93- point pointer // POINT mode and range prompts94- pointPrefix string // entry text before the reference being pointed at95- pointSuffix string // entry text after the caret while pointing96- assist assist // function and name suggestions while typing, see assist.go97-98- trace *trace // precedents or dependents being shown, see trace.go99-100- overlay overlay // open menu, palette or dialog, if any (modeMenu)101- lastFind *findBar // the last search, reopened by Ctrl+F102- jev *jevRunner // answers JEV functions; nil without an API key103- xfer transfer // imports and downloads, see transfer.go104- prompt *prompt105- files []string // file list shown by File Open106- errMsg string107-108- term terminal // what the terminal supports, see graphics.go109- lastChart int // the chart last selected, for chart commands; -1 for none61+ mode mode62+ note string // feedback on the last action, e.g. "Undid: clear B3"63+ errMsg string // the message ERROR mode shows64+65+ // Components. Each owns its state and the handling of the input it66+ // takes; Model routes messages to them and composes what they draw.67+ line lineEdit // the edit line of entries, prompts and search fields: line.go68+ entry entry // typing into a cell: entry.go69+ point pointer // the cell or range pointed at in POINT mode and range prompts70+ prompt *prompt // a question on the context line: prompt.go71+ overlay overlay // the open menu, picker or bar, if any (modeMenu): overlay.go72+ mouse mouseState // drags, hover and double clicks: mouse.go73+ tabs tabStrip // the sheet tabs and where each sheet was left: tabs.go74+ find *findBar // the last search, reopened by Ctrl+F: find.go75+ charts chartState // chart commands' target: charts.go76+ copied clipboard // what Ctrl+V pastes: clipboard.go77+ trace *trace // precedents or dependents being shown: trace.go78+ xfer transfer // imports and downloads: transfer.go79+ jev *jevRunner // answers JEV functions; nil without an API key: jev.go80+ term terminal // what the terminal supports: graphics.go11081 11182 keyAt time.Time // when the key the next frame answers was pressed, for telemetry11283 @@ -115,7 +86,7 @@ type Model struct {
11586 11687 // New returns a model editing s. filename may be empty.11788 func New(s *sheet.Sheet, filename string) *Model {118- return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(), lastChart: -1}89+ return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(), charts: chartState{last: -1}}11990 }12091 12192 // Init implements tea.Model. It asks the terminal for its background color@@ -170,7 +141,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
170141 case loadedMsg:171142 m.handleLoaded(msg)172143 case filesMsg:173- m.files = msg144+ if m.prompt != nil {145+ m.prompt.files = msg146+ }174147 case jevAnswerMsg:175148 busy := m.jevBusy() != ""176149 m.handleJEVAnswer(msg)@@ -227,7 +200,7 @@ func (m *Model) endUpdate(state int) {
227200 }228201 229202 func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {230- if m.drag == dragFill {203+ if m.mouse.drag == dragFill {231204 if k.String() == "esc" {232205 m.cancelFill()233206 }@@ -259,7 +232,7 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
259232 func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {260233 key := k.String()261234 if m.moveKey(key) {262- m.tabbing = false235+ m.entry.tabbing = false263236 return nil264237 }265238 if i := barMenuFor(key); i >= 0 {@@ -305,8 +278,8 @@ func (m *Model) focus() *sheet.Addr {
305278 switch {306279 case m.mode == modePoint || m.pointing() || m.away():307280 return &m.point.at308- case m.drag == dragFill:309- return &m.fillAt281+ case m.mouse.drag == dragFill:282+ return &m.mouse.fillAt310283 case m.selecting && m.whole == wholeNone:311284 return &m.ext312285 }@@ -202,8 +202,8 @@ func TestPointMode(t *testing.T) {
202202 func TestInvalidFormulaStaysInEdit(t *testing.T) {203203 m := newModel()204204 press(t, m, "=B1+", "<enter>")205- if m.mode != modeEdit || m.hint != "Formula is incomplete" || m.sheet.Cell(addr("A1")) != nil {206- t.Fatalf("mode %v hint %q", m.mode, m.hint)205+ if m.mode != modeEdit || m.entry.hint != "Formula is incomplete" || m.sheet.Cell(addr("A1")) != nil {206+ t.Fatalf("mode %v hint %q", m.mode, m.entry.hint)207207 }208208 press(t, m, "1", "<enter>")209209 if m.mode != modeReady || m.sheet.Value(addr("A1")).Num != 1 || m.cur != addr("A2") {@@ -219,8 +219,8 @@ func TestEditExisting(t *testing.T) {
219219 t.Errorf("input %q, want =243", got)220220 }221221 press(t, m, "<up>", "<enter>")222- if m.mode != modeEdit || string(m.buf) != "=243" {223- t.Errorf("Enter should edit: mode %v buf %q", m.mode, string(m.buf))222+ if m.mode != modeEdit || m.line.text() != "=243" {223+ t.Errorf("Enter should edit: mode %v buf %q", m.mode, m.line.text())224224 }225225 }226226 @@ -321,11 +321,11 @@ func TestDoubleClickEdits(t *testing.T) {
321321 press(t, m, "hi", "<enter>")322322 click(m, cellX(0), gridTop, 0)323323 click(m, cellX(0), gridTop, 0)324- if m.mode != modeEdit || string(m.buf) != "hi" {325- t.Errorf("mode %v buf %q", m.mode, string(m.buf))324+ if m.mode != modeEdit || m.line.text() != "hi" {325+ t.Errorf("mode %v buf %q", m.mode, m.line.text())326326 }327327 press(t, m, "<esc>")328- m.lastClick = time.Time{}328+ m.mouse.lastClick = time.Time{}329329 click(m, cellX(0), gridTop, 0)330330 if m.mode != modeReady {331331 t.Error("a single click edited")@@ -15,6 +15,24 @@ import (
1515 // the formula bar. Clicks work in every mode: clicking a cell while typing1616 // accepts the entry, or inserts the cell's reference into a formula.1717 18+// mouseState is what the mouse is doing: the drag in progress, what's19+// under it, and the last click, to tell double clicks.20+type mouseState struct {21+ drag dragKind22+ lastClick time.Time23+ lastHit hit24+ hover hit // what's under the mouse, for hover styling25+ x, y int // last position, for autoscroll26+ autoscrolling bool // an autoscroll tick is pending27+ resizeCol int // the column being resized by its header border28+ shape string // the pointer shape last sent to the terminal29+30+ // A fill handle drag, see fill.go: where it points, and the range it31+ // would fill.32+ fillAt sheet.Addr33+ fillTo sheet.Rect34+}35+1836 // hitKind is what's under the mouse.1937 type hitKind int2038 @@ -160,7 +178,7 @@ func (m *Model) dragTarget(x, y int) (a sheet.Addr, dc, dr int) {
160178 161179 // dragAnchor is where the drag in progress started.162180 func (m *Model) dragAnchor() sheet.Addr {163- if m.drag == dragPoint {181+ if m.mouse.drag == dragPoint {164182 return m.point.anchor165183 }166184 return m.cur@@ -173,8 +191,8 @@ func (m *Model) editing() bool {
173191 174192 func (m *Model) handlePress(mouse tea.Mouse) tea.Cmd {175193 h := m.hitTest(mouse.X, mouse.Y)176- double := h.kind == m.lastHit.kind && h.addr == m.lastHit.addr && time.Since(m.lastClick) < doubleClick177- m.lastClick, m.lastHit = time.Now(), h194+ double := h.kind == m.mouse.lastHit.kind && h.addr == m.mouse.lastHit.addr && time.Since(m.mouse.lastClick) < doubleClick195+ m.mouse.lastClick, m.mouse.lastHit = time.Now(), h178196 // Right clicks open context menus (context.go) before reaching here.179197 if mouse.Button == tea.MouseLeft {180198 return m.leftPress(h, mouse, double)@@ -198,29 +216,29 @@ func (m *Model) leftPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
198216 case modePrompt:199217 if m.pointing() && h.kind == hitCell {200218 m.point = pointer{at: h.addr, anchor: h.addr}201- m.drag = dragPoint219+ m.mouse.drag = dragPoint202220 }203221 return nil204222 case modePoint:205223 switch h.kind {206224 case hitCell:207225 m.point = pointer{at: h.addr, anchor: h.addr}208- m.drag = dragPoint226+ m.mouse.drag = dragPoint209227 case hitEditLine:210228 m.resumeEntry(m.pointRef())211- m.setCaret(h.x)229+ m.line.setCaret(h.x)212230 }213231 return nil214232 case modeEnter, modeEdit:215233 switch {216234 case h.kind == hitEditLine:217- m.setCaret(h.x)235+ m.line.setCaret(h.x)218236 return nil219- case h.kind == hitCell && m.isFormula() && m.canPoint():220- m.pointPrefix = string(m.buf[:m.bufPos])221- m.pointSuffix = string(m.buf[m.bufPos:])237+ case h.kind == hitCell && m.line.isFormula() && m.line.canPoint():238+ m.entry.prefix = m.line.head()239+ m.entry.suffix = m.line.tail()222240 m.point = pointer{at: h.addr, anchor: h.addr}223- m.mode, m.drag = modePoint, dragPoint241+ m.mode, m.mouse.drag = modePoint, dragPoint224242 return nil225243 case h.kind == hitNone, h.kind == hitPanel, h.kind == hitStatus:226244 return nil@@ -231,7 +249,7 @@ func (m *Model) leftPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
231249 if !m.commit() || away {232250 return nil233251 }234- m.tabbing = false252+ m.entry.tabbing = false235253 double = false236254 }237255 return m.readyPress(h, mouse, double)@@ -242,7 +260,7 @@ func (m *Model) readyPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
242260 switch h.kind {243261 case hitFormulaBar:244262 m.startEdit()245- m.setCaret(h.x)263+ m.line.setCaret(h.x)246264 case hitCorner:247265 m.cur = sheet.Addr{Col: m.left, Row: m.top}248266 m.selecting, m.whole, m.ext = true, wholeAll, m.cur@@ -255,7 +273,7 @@ func (m *Model) readyPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
255273 m.autofit(h.addr.Col)256274 return nil257275 }258- m.resizeCol, m.drag = h.addr.Col, dragResize276+ m.mouse.resizeCol, m.mouse.drag = h.addr.Col, dragResize259277 case hitCell:260278 switch {261279 case shift:@@ -267,7 +285,7 @@ func (m *Model) readyPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
267285 m.cur = h.addr268286 m.clearSelection()269287 }270- m.drag = dragCells288+ m.mouse.drag = dragCells271289 case hitColHeader, hitRowHeader:272290 whole, drag := wholeCols, dragCols273291 if h.kind == hitRowHeader {@@ -277,7 +295,7 @@ func (m *Model) readyPress(h hit, mouse tea.Mouse, double bool) tea.Cmd {
277295 m.cur = h.addr278296 }279297 m.selecting, m.whole, m.ext = true, whole, h.addr280- m.drag = drag298+ m.mouse.drag = drag281299 }282300 return nil283301 }@@ -287,24 +305,11 @@ func isTabHit(k hitKind) bool {
287305 return k == hitTab || k == hitTabAdd || k == hitTabPrev || k == hitTabNext288306 }289307 290-// setCaret puts the edit caret at display column x of the entry.291-func (m *Model) setCaret(x int) {292- w := 0293- for i, r := range m.buf {294- if w >= x {295- m.bufPos = i296- return297- }298- w += ansi.StringWidth(string(r))299- }300- m.bufPos = len(m.buf)301-}302-303308 func (m *Model) handleMotion(mouse tea.Mouse) tea.Cmd {304- m.hover = m.hitTest(mouse.X, mouse.Y)305- m.mouseX, m.mouseY = mouse.X, mouse.Y309+ m.mouse.hover = m.hitTest(mouse.X, mouse.Y)310+ m.mouse.x, m.mouse.y = mouse.X, mouse.Y306311 var cmd tea.Cmd307- if m.drag != dragNone && mouse.Button == tea.MouseLeft {312+ if m.mouse.drag != dragNone && mouse.Button == tea.MouseLeft {308313 cmd = m.dragTo(mouse.X, mouse.Y)309314 }310315 return tea.Batch(cmd, m.pointerShape())@@ -313,16 +318,16 @@ func (m *Model) handleMotion(mouse tea.Mouse) tea.Cmd {
313318 // dragTo extends the drag in progress to (x, y), starting autoscroll when314319 // the mouse is past the edge of the grid.315320 func (m *Model) dragTo(x, y int) tea.Cmd {316- switch m.drag {321+ switch m.mouse.drag {317322 case dragResize:318- m.sheet.SetColWidth(m.resizeCol, clamp(x-m.colStart(m.resizeCol)+1, 1, 240))323+ m.sheet.SetColWidth(m.mouse.resizeCol, clamp(x-m.colStart(m.mouse.resizeCol)+1, 1, 240))319324 m.changed = true320325 return nil321326 case dragTab:322327 return nil // handleMotion tracks the tab under the mouse323328 }324329 a, dc, dr := m.dragTarget(x, y)325- switch m.drag {330+ switch m.mouse.drag {326331 case dragPoint:327332 m.point.at, m.point.anchored = a, a != m.point.anchor || m.point.anchored328333 case dragCells:@@ -334,8 +339,8 @@ func (m *Model) dragTo(x, y int) tea.Cmd {
334339 case dragFill:335340 m.dragFillTo(a)336341 }337- if (dc != 0 || dr != 0) && !m.autoscrolling {338- m.autoscrolling = true342+ if (dc != 0 || dr != 0) && !m.mouse.autoscrolling {343+ m.mouse.autoscrolling = true339344 return autoscrollTick()340345 }341346 return nil@@ -350,19 +355,19 @@ func autoscrollTick() tea.Cmd {
350355 // handleAutoscroll moves a drag that's past the edge of the grid one more351356 // step and keeps ticking until the mouse comes back or is released.352357 func (m *Model) handleAutoscroll() tea.Cmd {353- if m.drag == dragNone || m.drag == dragResize || m.drag == dragTab {354- m.autoscrolling = false358+ if m.mouse.drag == dragNone || m.mouse.drag == dragResize || m.mouse.drag == dragTab {359+ m.mouse.autoscrolling = false355360 return nil356361 }357- _, dc, dr := m.dragTarget(m.mouseX, m.mouseY)362+ _, dc, dr := m.dragTarget(m.mouse.x, m.mouse.y)358363 if dc == 0 && dr == 0 {359- m.autoscrolling = false364+ m.mouse.autoscrolling = false360365 return nil361366 }362367 step := func(a *sheet.Addr) {363368 *a = clampAddr(sheet.Addr{Col: a.Col + dc, Row: m.stepRow(a.Row, dr)})364369 }365- switch m.drag {370+ switch m.mouse.drag {366371 case dragPoint:367372 step(&m.point.at)368373 m.point.anchored = true@@ -370,21 +375,21 @@ func (m *Model) handleAutoscroll() tea.Cmd {
370375 m.selecting = true371376 step(&m.ext)372377 case dragFill:373- step(&m.fillAt)374- m.dragFillTo(m.fillAt)378+ step(&m.mouse.fillAt)379+ m.dragFillTo(m.mouse.fillAt)375380 }376381 m.scrollTo(*m.focus())377382 return autoscrollTick()378383 }379384 380385 func (m *Model) handleRelease() tea.Cmd {381- switch m.drag {386+ switch m.mouse.drag {382387 case dragFill:383388 m.finishFill()384389 case dragTab:385390 m.dropTab()386391 }387- m.drag, m.autoscrolling = dragNone, false392+ m.mouse.drag, m.mouse.autoscrolling = dragNone, false388393 if m.selecting && m.whole == wholeNone && m.ext == m.cur {389394 m.clearSelection()390395 }@@ -421,24 +426,24 @@ func (m *Model) autofit(c int) {
421426 func (m *Model) pointerShape() tea.Cmd {422427 shape := "default"423428 switch {424- case m.drag == dragResize, m.hover.kind == hitColBorder:429+ case m.mouse.drag == dragResize, m.mouse.hover.kind == hitColBorder:425430 shape = "col-resize"426- case m.drag == dragFill, m.hover.kind == hitFillHandle:431+ case m.mouse.drag == dragFill, m.mouse.hover.kind == hitFillHandle:427432 shape = "crosshair"428- case m.mode == modeReady && m.chartAt(m.mouseX, m.mouseY) >= 0:433+ case m.mode == modeReady && m.chartAt(m.mouse.x, m.mouse.y) >= 0:429434 shape = "move"430- case m.hover.kind == hitCell:435+ case m.mouse.hover.kind == hitCell:431436 shape = "cell"432- case m.hover.kind == hitFormulaBar, m.hover.kind == hitEditLine:437+ case m.mouse.hover.kind == hitFormulaBar, m.mouse.hover.kind == hitEditLine:433438 shape = "text"434- case m.hover.kind == hitColHeader, m.hover.kind == hitRowHeader, m.hover.kind == hitCorner, m.hover.kind == hitFilterButton,435- isTabHit(m.hover.kind):439+ case m.mouse.hover.kind == hitColHeader, m.mouse.hover.kind == hitRowHeader, m.mouse.hover.kind == hitCorner, m.mouse.hover.kind == hitFilterButton,440+ isTabHit(m.mouse.hover.kind):436441 shape = "pointer"437442 }438- if shape == m.shape {443+ if shape == m.mouse.shape {439444 return nil440445 }441- m.shape = shape446+ m.mouse.shape = shape442447 return tea.Raw(ansi.SetPointerShape(shape))443448 }444449 @@ -54,14 +54,14 @@ func TestClickFormulaBarEdits(t *testing.T) {
5454 press(t, m, "=1+2", "<enter>", "<up>")5555 line0, x0 := m.formulaBarAt()5656 click(m, x0+2, line0, 0)57- if m.mode != modeEdit || m.bufPos != 2 {58- t.Fatalf("mode %v caret %d", m.mode, m.bufPos)57+ if m.mode != modeEdit || m.line.pos != 2 {58+ t.Fatalf("mode %v caret %d", m.mode, m.line.pos)5959 }6060 // Clicking in the edit line moves the caret.6161 line1, x1 := m.editLineAt()6262 click(m, x1+4, line1, 0)63- if m.bufPos != 4 {64- t.Errorf("caret %d", m.bufPos)63+ if m.line.pos != 4 {64+ t.Errorf("caret %d", m.line.pos)6565 }6666 }6767 @@ -69,8 +69,8 @@ func TestResizeColumnByDragging(t *testing.T) {
6969 m := newModel()7070 border := rowHdrW + sheet.DefaultWidth - 17171 send(m, tea.MouseMotionMsg{X: border, Y: headerLine})72- if m.hover.kind != hitColBorder || !strings.Contains(line(m, headerLine), "▐") {73- t.Fatalf("hover %v header %q", m.hover.kind, line(m, headerLine))72+ if m.mouse.hover.kind != hitColBorder || !strings.Contains(line(m, headerLine), "▐") {73+ t.Fatalf("hover %v header %q", m.mouse.hover.kind, line(m, headerLine))7474 }7575 send(m, tea.MouseClickMsg(leftAt(border, headerLine)))7676 send(m, tea.MouseMotionMsg(leftAt(border+5, headerLine)))@@ -81,7 +81,7 @@ func TestResizeColumnByDragging(t *testing.T) {
8181 t.Errorf("context line %q", line(m, 2))8282 }8383 send(m, tea.MouseReleaseMsg(leftAt(border+5, headerLine)))84- if m.drag != dragNone || !m.changed {84+ if m.mouse.drag != dragNone || !m.changed {8585 t.Error("resize did not finish")8686 }8787 }@@ -101,7 +101,7 @@ func TestDragAutoscrolls(t *testing.T) {
101101 m := newModel()102102 send(m, tea.MouseClickMsg(leftAt(cellX(0), gridTop)))103103 _, cmd := m.Update(tea.MouseMotionMsg(leftAt(cellX(0), m.height-1)))104- if cmd == nil || !m.autoscrolling {104+ if cmd == nil || !m.mouse.autoscrolling {105105 t.Fatal("dragging below the grid did not start autoscroll")106106 }107107 bottom := m.ext.Row@@ -113,7 +113,7 @@ func TestDragAutoscrolls(t *testing.T) {
113113 }114114 send(m, tea.MouseReleaseMsg(leftAt(cellX(0), m.height-1)))115115 send(m, autoscrollMsg{})116- if m.autoscrolling {116+ if m.mouse.autoscrolling {117117 t.Error("autoscroll kept going after release")118118 }119119 }@@ -132,8 +132,8 @@ func TestPointerShapes(t *testing.T) {
132132 {1, m.height - 1, "pointer"}, // the sheet tab133133 } {134134 send(m, tea.MouseMotionMsg{X: tt.x, Y: tt.y})135- if m.shape != tt.want {136- t.Errorf("at %d,%d shape %q, want %q", tt.x, tt.y, m.shape, tt.want)135+ if m.mouse.shape != tt.want {136+ t.Errorf("at %d,%d shape %q, want %q", tt.x, tt.y, m.mouse.shape, tt.want)137137 }138138 }139139 }@@ -25,7 +25,7 @@ type overlay interface {
2525 }2626 2727 // textOverlay is an overlay with a text input, which gets the terminal28-// cursor. The text is edited in m.buf, like any other entry.28+// cursor. The text is edited in Model.line, like any other entry.2929 type textOverlay interface {3030 overlay3131 cursor(m *Model) (x, y int)@@ -70,7 +70,7 @@ func (m *Model) openOverlay(o overlay) {
7070 // closeOverlay closes the open overlay and returns to READY.7171 func (m *Model) closeOverlay() {7272 m.overlay = nil73- m.buf, m.bufPos = nil, 073+ m.line.clear()7474 if m.mode == modeMenu {7575 m.mode = modeReady7676 }@@ -92,7 +92,7 @@ type pickMatch struct {
9292 // picker is a searchable list in a box, in the manner of fzf: a search9393 // field on top, results below with matched characters highlighted, and9494 // the highlighted result's description on the status line. The search is95-// edited in m.buf with the usual line-editing keys.95+// edited in Model.line with the usual line-editing keys.9696 type picker struct {9797 title string9898 placeholder string@@ -114,7 +114,7 @@ const (
114114 115115 func newPicker(m *Model, title, placeholder string, maxW int, items []pickItem) *picker {116116 p := &picker{title: title, placeholder: placeholder, maxW: maxW, items: items}117- m.buf, m.bufPos = nil, 0117+ m.line.clear()118118 p.changed(m)119119 return p120120 }@@ -126,7 +126,7 @@ func (p *picker) indicator() string { return "MENU" }
126126 func (p *picker) changed(m *Model) {127127 p.sel, p.top = 0, 0128128 p.shown = p.shown[:0]129- q := strings.TrimSpace(string(m.buf))129+ q := strings.TrimSpace(m.line.text())130130 if q == "" {131131 for i := range p.items {132132 p.shown = append(p.shown, pickMatch{item: &p.items[i]})@@ -194,9 +194,9 @@ func (p *picker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
194194 case "esc":195195 m.closeOverlay()196196 default:197- before := string(m.buf)198- m.lineKey(k)199- if string(m.buf) != before {197+ before := m.line.text()198+ m.line.key(k)199+ if m.line.text() != before {200200 p.changed(m)201201 }202202 }@@ -205,7 +205,7 @@ func (p *picker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
205205 206206 func (p *picker) pick(m *Model) tea.Cmd {207207 if p.enter != nil {208- if cmd, ok := p.enter(m, strings.TrimSpace(string(m.buf))); ok {208+ if cmd, ok := p.enter(m, strings.TrimSpace(m.line.text())); ok {209209 return cmd210210 }211211 }@@ -275,7 +275,7 @@ func (p *picker) box(m *Model) (x, y, inner int) {
275275 276276 func (p *picker) cursor(m *Model) (int, int) {277277 x, y, _ := p.box(m)278- return x + 1 + ansi.StringWidth(searchPrompt) + ansi.StringWidth(string(m.buf[:m.bufPos])), y + 1278+ return x + 1 + ansi.StringWidth(searchPrompt) + ansi.StringWidth(m.line.head()), y + 1279279 }280280 281281 func (p *picker) layout(m *Model) []box {@@ -284,8 +284,8 @@ func (p *picker) layout(m *Model) []box {
284284 if len(p.shown) > 0 {285285 p.show(rows)286286 }287- input := m.th.Title.Render(searchPrompt) + string(m.buf)288- if len(m.buf) == 0 {287+ input := m.th.Title.Render(searchPrompt) + m.line.text()288+ if len(m.line.buf) == 0 {289289 input += m.th.Muted.Render(p.placeholder)290290 }291291 lines := []string{theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}@@ -119,8 +119,8 @@ func TestPaletteNoMatchAndEsc(t *testing.T) {
119119 t.Errorf("paste searched for %q", selected(t, m))120120 }121121 press(t, m, "<esc>")122- if m.overlay != nil || m.mode != modeReady || len(m.buf) != 0 {123- t.Errorf("Esc: mode %v buf %q", m.mode, string(m.buf))122+ if m.overlay != nil || m.mode != modeReady || len(m.line.buf) != 0 {123+ t.Errorf("Esc: mode %v buf %q", m.mode, m.line.text())124124 }125125 }126126 @@ -116,7 +116,7 @@ func formulaBarTextX() int { return nameBoxW + 1 }
116116 func (m *Model) formulaBar() string {117117 name := m.cur.String()118118 if m.away() {119- name = sheet.Qualified(m.home.Name(), sheet.Rect{From: m.cur, To: m.cur})119+ name = sheet.Qualified(m.entry.home.Name(), sheet.Rect{From: m.cur, To: m.cur})120120 }121121 if m.hasRange() && (m.mode == modeReady || m.mode == modeMenu) {122122 name = m.selection().String()@@ -127,9 +127,9 @@ func (m *Model) formulaBar() string {
127127 box := m.th.Header.Render(theme.PadRight(" "+ansi.Truncate(name, nameBoxW-1, "…"), nameBoxW)) + " "128128 switch m.mode {129129 case modeEnter, modeEdit:130- return box + string(m.buf)130+ return box + m.line.text()131131 case modePoint:132- return box + m.pointPrefix + m.th.Selection.Render(m.pointRef()) + m.pointSuffix132+ return box + m.entry.prefix + m.th.Selection.Render(m.pointRef()) + m.entry.suffix133133 }134134 if c := m.sheet.Cell(m.cur); c != nil {135135 return box + c.Input@@ -144,13 +144,13 @@ func (m *Model) contextLineText() string {
144144 switch {145145 case m.xfer.job != nil:146146 left = m.importLine()147- case m.drag == dragResize:148- left = m.th.Key.Render("Column "+sheet.ColName(m.resizeCol)) + m.th.Muted.Render(" width ") +149- strconv.Itoa(m.sheet.ColWidth(m.resizeCol)) + m.th.Muted.Render(" double-click the border to fit")150- case m.drag == dragFill:147+ case m.mouse.drag == dragResize:148+ left = m.th.Key.Render("Column "+sheet.ColName(m.mouse.resizeCol)) + m.th.Muted.Render(" width ") +149+ strconv.Itoa(m.sheet.ColWidth(m.mouse.resizeCol)) + m.th.Muted.Render(" double-click the border to fit")150+ case m.mouse.drag == dragFill:151151 left = m.fillLine()152- case m.hint != "":153- left = m.th.Warning.Render(m.hint)152+ case m.entry.hint != "":153+ left = m.th.Warning.Render(m.entry.hint)154154 case m.mode == modeReady && m.trace != nil:155155 left, right = m.traceLine()156156 case m.mode == modeReady:@@ -174,14 +174,14 @@ func (m *Model) contextLineText() string {
174174 case m.mode == modePrompt:175175 left, right = m.promptLine()176176 case m.mode == modePoint:177- prefix := []rune(m.pointPrefix)177+ prefix := []rune(m.entry.prefix)178178 var ok bool179179 if left, right, ok = m.signatureLine(prefix, len(prefix), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {180180 left = m.th.KeyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")181181 }182- case (m.mode == modeEnter || m.mode == modeEdit) && m.isFormula() && m.inFunction():183- left, right, _ = m.signatureLine(m.buf, m.bufPos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))184- case m.mode == modeEnter && m.isFormula():182+ case (m.mode == modeEnter || m.mode == modeEdit) && m.line.isFormula() && m.inFunction():183+ left, right, _ = m.signatureLine(m.line.buf, m.line.pos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))184+ case m.mode == modeEnter && m.line.isFormula():185185 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")186186 case m.mode == modeEnter:187187 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "accept and move", "Esc", "cancel")@@ -208,12 +208,12 @@ func (m *Model) promptLine() (left, right string) {
208208 case m.pointing():209209 return m.promptPrefix() + m.th.Selection.Render(m.point.text()),210210 m.th.KeyHints("Arrows", "move", "Shift+arrows", "extend", "Enter", "apply", "Esc", "cancel")211- case len(m.files) > 0:212- return m.promptPrefix() + string(m.buf), m.th.Muted.Render(strings.Join(m.files, " "))211+ case len(m.prompt.files) > 0:212+ return m.promptPrefix() + m.line.text(), m.th.Muted.Render(strings.Join(m.prompt.files, " "))213213 case m.prompt.kind == promptWidth:214- return m.promptPrefix() + string(m.buf), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")214+ return m.promptPrefix() + m.line.text(), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")215215 }216- return m.promptPrefix() + string(m.buf), m.th.KeyHints("Enter", "apply", "Esc", "cancel")216+ return m.promptPrefix() + m.line.text(), m.th.KeyHints("Enter", "apply", "Esc", "cancel")217217 }218218 219219 func (m *Model) promptPrefix() string {@@ -230,9 +230,9 @@ func (m *Model) cursorPos() (x, y int, ok bool) {
230230 }231231 switch {232232 case m.mode == modeEnter, m.mode == modeEdit:233- return formulaBarTextX() + ansi.StringWidth(string(m.buf[:m.bufPos])), formulaLine, true233+ return formulaBarTextX() + ansi.StringWidth(m.line.head()), formulaLine, true234234 case m.mode == modePrompt && !m.pointing():235- return ansi.StringWidth(m.promptPrefix() + string(m.buf[:m.bufPos])), contextLine, true235+ return ansi.StringWidth(m.promptPrefix() + m.line.head()), contextLine, true236236 }237237 return 0, 0, false238238 }@@ -137,10 +137,10 @@ func TestDragIntoFrozenRowsScrollsBack(t *testing.T) {
137137 // Dragging onto the frozen header row scrolls up instead of jumping138138 // to row 1.139139 send(m, tea.MouseMotionMsg(leftAt(cellX(1), gridTop)))140- if m.ext != addr("B7") || !m.autoscrolling {141- t.Fatalf("ext %v autoscrolling %v", m.ext, m.autoscrolling)140+ if m.ext != addr("B7") || !m.mouse.autoscrolling {141+ t.Fatalf("ext %v autoscrolling %v", m.ext, m.mouse.autoscrolling)142142 }143- for i := 0; i < 10 && m.autoscrolling; i++ {143+ for i := 0; i < 10 && m.mouse.autoscrolling; i++ {144144 send(m, autoscrollMsg{})145145 }146146 if m.top != 1 || m.ext.Row > 1 {@@ -21,8 +21,15 @@ import (
2121 //2222 // While a formula is being typed, switching sheets points into the other2323 // sheet, as clicking a tab does in Sheets: the entry stays with its cell24-// (m.home), and the pointer inserts references such as Sheet2!A1. Enter25-// stores the formula and returns to its sheet.24+// (entry.home), and the pointer inserts references such as Sheet2!A1.25+// Enter stores the formula and returns to its sheet.26+27+// tabStrip is the sheet tabs' state: where each sheet was left, and how28+// far the strip is scrolled.29+type tabStrip struct {30+ places map[*sheet.Sheet]place // where each sheet's cursor and scroll were left31+ left int // the first tab shown when they don't all fit32+}2633 2734 // place is where a sheet's cursor and scroll position were when it was2835 // last shown.@@ -133,33 +140,33 @@ func (m *Model) showSheet(s *sheet.Sheet) {
133140 }134141 m.leave()135142 m.sheet = s136- p := m.places[s]143+ p := m.tabs.places[s]137144 m.cur, m.top, m.left = p.cur, p.top, p.left138145 m.clearSelection()139- m.lastChart = -1146+ m.charts.last = -1140147 m.book().SetActive(s)141148 }142149 143150 // leave remembers where the sheet shown is, before another is shown.144151 func (m *Model) leave() {145- if m.places == nil {146- m.places = map[*sheet.Sheet]place{}152+ if m.tabs.places == nil {153+ m.tabs.places = map[*sheet.Sheet]place{}147154 }148155 cur := m.cur149156 if m.away() {150157 cur = m.point.at // the entry's cell is on another sheet151158 }152- m.places[m.sheet] = place{cur: cur, top: m.top, left: m.left}159+ m.tabs.places[m.sheet] = place{cur: cur, top: m.top, left: m.left}153160 }154161 155162 // away reports whether an entry is being typed for a cell on another156163 // sheet than the one shown.157-func (m *Model) away() bool { return m.home != nil && m.home != m.sheet }164+func (m *Model) away() bool { return m.entry.home != nil && m.entry.home != m.sheet }158165 159166 // entrySheet is the sheet the entry being typed goes to.160167 func (m *Model) entrySheet() *sheet.Sheet {161- if m.home != nil {162- return m.home168+ if m.entry.home != nil {169+ return m.entry.home163170 }164171 return m.sheet165172 }@@ -173,24 +180,24 @@ func (m *Model) pointInto(s *sheet.Sheet) {
173180 return174181 }175182 if m.mode != modePoint {176- if !m.isFormula() || !m.canPoint() {183+ if !m.line.isFormula() || !m.line.canPoint() {177184 if m.commit() {178185 m.showSheet(s)179186 }180187 return181188 }182- m.pointPrefix = string(m.buf[:m.bufPos])183- m.pointSuffix = string(m.buf[m.bufPos:])189+ m.entry.prefix = m.line.head()190+ m.entry.suffix = m.line.tail()184191 m.mode = modePoint185192 }186- if m.home == nil {187- m.home = m.sheet193+ if m.entry.home == nil {194+ m.entry.home = m.sheet188195 }189196 cur := m.cur190197 m.leave()191198 m.sheet = s192- p := m.places[s]193- if s == m.home {199+ p := m.tabs.places[s]200+ if s == m.entry.home {194201 p.cur = cur // back home, pointing starts at the entry's cell195202 }196203 m.top, m.left = p.top, p.left@@ -202,15 +209,15 @@ func (m *Model) pointInto(s *sheet.Sheet) {
202209 // returnHome shows the entry's sheet again once the entry is stored or203210 // cancelled.204211 func (m *Model) returnHome() {205- home := m.home206- m.home = nil212+ home := m.entry.home213+ m.entry.home = nil207214 if home == nil || home == m.sheet || !home.Live() {208215 return209216 }210217 cur := m.cur211- m.places[m.sheet] = place{cur: m.point.at, top: m.top, left: m.left}218+ m.tabs.places[m.sheet] = place{cur: m.point.at, top: m.top, left: m.left}212219 m.sheet = home213- p := m.places[home]220+ p := m.tabs.places[home]214221 m.cur, m.top, m.left = cur, p.top, p.left215222 m.book().SetActive(home)216223 }@@ -234,10 +241,10 @@ func (m *Model) afterSheetsChange(prefer *sheet.Sheet, index int) {
234241 case !m.sheet.Live():235242 s := book.Sheet(clamp(index, 0, book.Len()-1))236243 m.sheet = s // the old sheet is gone: nothing to remember of it237- p := m.places[s]244+ p := m.tabs.places[s]238245 m.cur, m.top, m.left = p.cur, p.top, p.left239246 m.clearSelection()240- m.lastChart = -1247+ m.charts.last = -1241248 book.SetActive(s)242249 }243250 }@@ -365,7 +372,7 @@ func (m *Model) allTabs() int {
365372 366373 // tabStrip lays out the tabs in room columns from the left of the status367374 // line: as many as fit, always the shown one, the first shown staying put368-// until the shown one would fall off (m.tabLeft), ‹ and › where tabs are375+// until the shown one would fall off (m.tabs.left), ‹ and › where tabs are369376 // hidden, then the +.370377 func (m *Model) tabStrip(room int) (string, []tabSpan) {371378 sheets := m.book().Sheets()@@ -394,7 +401,7 @@ func (m *Model) tabStrip(room int) (string, []tabSpan) {
394401 }395402 return last, last >= active396403 }397- first := clamp(m.tabLeft, 0, active)404+ first := clamp(m.tabs.left, 0, active)398405 last, ok := fits(first)399406 for !ok && first < active {400407 first++@@ -409,7 +416,7 @@ func (m *Model) tabStrip(room int) (string, []tabSpan) {
409416 }410417 first, last = first-1, l411418 }412- m.tabLeft = first419+ m.tabs.left = first413420 if last < active {414421 last = active // a name too long for the room is cut below415422 }@@ -430,7 +437,7 @@ func (m *Model) tabStrip(room int) (string, []tabSpan) {
430437 switch {431438 case i == active:432439 style = m.th.TabActive433- case m.hover.kind == hitTab && m.hover.addr.Col == i && (m.drag == dragNone || m.drag == dragTab):440+ case m.mouse.hover.kind == hitTab && m.mouse.hover.addr.Col == i && (m.mouse.drag == dragNone || m.mouse.drag == dragTab):434441 style = m.th.TabHover435442 }436443 label := tabLabel(sheets[i])@@ -443,7 +450,7 @@ func (m *Model) tabStrip(room int) (string, []tabSpan) {
443450 part(hitTabNext, 0, m.th.Muted.Render("›"))444451 }445452 addStyle := m.th.Muted446- if m.hover.kind == hitTabAdd {453+ if m.mouse.hover.kind == hitTabAdd {447454 addStyle = m.th.TabHover448455 }449456 part(hitTabAdd, 0, addStyle.Render(" + "))@@ -489,7 +496,7 @@ func (m *Model) tabPress(h hit, double bool) tea.Cmd {
489496 m.openRename()490497 return nil491498 }492- m.drag = dragTab499+ m.mouse.drag = dragTab493500 }494501 return nil495502 }@@ -502,8 +509,8 @@ func (m *Model) stepEntrySheet(d int) {
502509 503510 // dropTab moves the dragged sheet to the tab it was released over.504511 func (m *Model) dropTab() {505- if m.hover.kind == hitTab && m.hover.addr.Col != m.book().Index(m.sheet) {506- to := m.hover.addr.Col512+ if m.mouse.hover.kind == hitTab && m.mouse.hover.addr.Col != m.book().Index(m.sheet) {513+ to := m.mouse.hover.addr.Col507514 m.book().MoveSheet(m.sheet, to)508515 m.note = "Moved " + m.sheet.Name() + " to position " + strconv.Itoa(to+1)509516 }@@ -118,8 +118,8 @@ func TestOpenImportsOtherFormats(t *testing.T) {
118118 t.Errorf("save choices %q", l)119119 }120120 press(t, m, "<enter>")121- if m.mode != modePrompt || string(m.buf) != "data.012" {122- t.Errorf("save as %v %q", m.mode, string(m.buf))121+ if m.mode != modePrompt || m.line.text() != "data.012" {122+ t.Errorf("save as %v %q", m.mode, m.line.text())123123 }124124 press(t, m, "<enter>")125125 if m.filename != "data.012" {@@ -163,11 +163,11 @@ func TestSQLiteTablePicker(t *testing.T) {
163163 164164 m.runCommand("file.import")165165 press(t, m, "<enter>", "query", "<enter>")166- if m.mode != modePrompt || string(m.buf) != "SELECT * FROM orders" {167- t.Fatalf("query prompt %v %q", m.mode, string(m.buf))166+ if m.mode != modePrompt || m.line.text() != "SELECT * FROM orders" {167+ t.Fatalf("query prompt %v %q", m.mode, m.line.text())168168 }169169 press(t, m, "<ctrl+a>")170- m.buf, m.bufPos = []rune("SELECT sum(total) AS s FROM orders"), len("SELECT sum(total) AS s FROM orders")170+ m.line.buf, m.line.pos = []rune("SELECT sum(total) AS s FROM orders"), len("SELECT sum(total) AS s FROM orders")171171 press(t, m, "<enter>")172172 if m.sheet.Value(addr("A2")).Num != 12.5 {173173 t.Errorf("query A2 %+v err %q", m.sheet.Value(addr("A2")), m.errMsg)@@ -32,8 +32,8 @@ func (m *Model) highlight() (sheet.Rect, bool) {
3232 return m.point.rect(), true3333 case m.away(): // the selection is on the entry's sheet3434 return sheet.Rect{}, false35- case m.drag == dragFill:36- return m.fillTo, true35+ case m.mouse.drag == dragFill:36+ return m.mouse.fillTo, true3737 }3838 if r, ok := m.barRange(); ok {3939 return r, true@@ -75,7 +75,7 @@ func (m *Model) headerRow() string {
7575 style = m.th.HeaderActive7676 case selecting && c >= sel.From.Col && c <= sel.To.Col:7777 style = m.th.HeaderSel78- case m.hover.addr.Col == c && (m.hover.kind == hitColHeader || m.hover.kind == hitColBorder || m.hover.kind == hitFilterButton):78+ case m.mouse.hover.addr.Col == c && (m.mouse.hover.kind == hitColHeader || m.mouse.hover.kind == hitColBorder || m.mouse.hover.kind == hitFilterButton):7979 style = m.th.HeaderHover8080 default:8181 plain = true@@ -105,10 +105,10 @@ func (m *Model) headerRow() string {
105105 // showHandle reports whether column c's resize handle is visible: while106106 // hovering it or dragging it.107107 func (m *Model) showHandle(c int) bool {108- if m.drag == dragResize {109- return m.resizeCol == c108+ if m.mouse.drag == dragResize {109+ return m.mouse.resizeCol == c110110 }111- return m.hover.kind == hitColBorder && m.hover.addr.Col == c111+ return m.mouse.hover.kind == hitColBorder && m.mouse.hover.addr.Col == c112112 }113113 114114 func (m *Model) gridRow(row int) string {@@ -126,7 +126,7 @@ func (m *Model) gridRow(row int) string {
126126 hdr = m.th.HeaderActive127127 case selecting && row >= sel.From.Row && row <= sel.To.Row:128128 hdr = m.th.HeaderSel129- case m.hover.kind == hitRowHeader && m.hover.addr.Row == row:129+ case m.mouse.hover.kind == hitRowHeader && m.mouse.hover.addr.Row == row:130130 hdr = m.th.HeaderHover131131 }132132 var b strings.Builder@@ -220,7 +220,7 @@ func (m *Model) renderSpan(sp span, base lipgloss.Style, colored bool) string {
220220 // inCellText shows the entry being typed inside the cell, keeping the end221221 // of long entries visible, as Sheets does.222222 func (m *Model) inCellText(w int) string {223- text := " " + string(m.buf)223+ text := " " + m.line.text()224224 if over := ansi.StringWidth(text) - w; over > 0 {225225 text = ansi.TruncateLeft(text, over+1, "…")226226 }