prompt gets its own file and its own key, paste and line methods, so Model routes to it as it does to overlays. promptKey (cognitive complexity 31) splits into cancel, accept, pointing, width steps and typing. What was left of menu.go becomes file.go (saving and opening), and the column width prompt joins autofit in columns.go.
- commit
- 793815b5b35bb4e1377247d3a4eb005c4ed7893b
- parent
- 69583be6abf28b39029853202156c1a4a18799ee
- author
- Dami <dami.etoile@gmail.com>
| A | internal/ | 79 | +++++++ |
| M | internal/ | 11 | - |
| M | internal/ | 2 | +- |
| A | internal/ | 155 | +++++++++++++ |
| D | internal/ | 357 | ------------------------------ |
| M | internal/ | 7 | +- |
| M | internal/ | 24 | -- |
| M | internal/ | 23 | +-- |
| A | internal/ | 190 | ++++++++++++++++ |
| M | internal/ | 35 | +++ |
@@ -0,0 +1,79 @@
1+package ui2+3+import (4+ "strconv"5+6+ tea "charm.land/bubbletea/v2"7+ "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/charmbracelet/x/ansi"9+)10+11+// Column widths: typed on the context line with a live preview, dragged12+// by the header border (mouse.go), or fitted to the contents by13+// double-clicking it, as in Sheets.14+15+// openWidth asks for the width of the selected columns, previewing it16+// live as the arrows change it. Esc restores the original widths.17+func (m *Model) openWidth() tea.Cmd {18+ r := m.selection()19+ orig := map[int]int{}20+ for c := r.From.Col; c <= r.To.Col; c++ {21+ orig[c] = m.sheet.ColWidth(c)22+ }23+ restore := func(m *Model) {24+ for c, w := range orig {25+ m.sheet.SetColWidth(c, w)26+ }27+ }28+ m.openPrompt(&prompt{29+ kind: promptWidth,30+ label: "Column width (1-240):",31+ indicator: "WIDTH",32+ fresh: true,33+ onText: func(m *Model, text string) tea.Cmd {34+ w, err := strconv.Atoi(text)35+ if err != nil || w < 1 || w > 240 {36+ restore(m)37+ m.fail("Column width must be between 1 and 240")38+ return nil39+ }40+ m.setWidths(w)41+ m.changed = true42+ return nil43+ },44+ onCancel: restore,45+ }, strconv.Itoa(m.sheet.ColWidth(m.cur.Col)))46+ return nil47+}48+49+// setWidths sets the width of every selected column.50+func (m *Model) setWidths(w int) {51+ r := m.selection()52+ for c := r.From.Col; c <= r.To.Col; c++ {53+ m.sheet.SetColWidth(c, w)54+ }55+}56+57+// autofit sizes column c to its widest content, as double-clicking a58+// header border does in Sheets. Selected columns fit together.59+func (m *Model) autofit(c int) {60+ cols := []int{c}61+ if r := m.selection(); m.whole == wholeCols && c >= r.From.Col && c <= r.To.Col {62+ cols = cols[:0]63+ for k := r.From.Col; k <= r.To.Col; k++ {64+ cols = append(cols, k)65+ }66+ }67+ widest := map[int]int{}68+ for _, a := range m.sheet.Addrs() {69+ if cell := m.sheet.Cell(a); cell != nil {70+ widest[a.Col] = max(widest[a.Col], ansi.StringWidth(cell.Value.String()))71+ }72+ }73+ for _, k := range cols {74+ // One column of padding each side, and never narrower than the75+ // header letters.76+ m.sheet.SetColWidth(k, clamp(widest[k]+2, len(sheet.ColName(k))+2, 240))77+ }78+ m.changed = true79+}@@ -158,14 +158,3 @@ func init() {
158158 &command{id: "quit", title: "Quit", desc: "Close 012", run: (*Model).quit},159159 )160160 }161-162-// save writes to the current file, asking for a name the first time.163-func (m *Model) save() tea.Cmd {164- if m.filename == "" && m.xfer.source != "" {165- return m.saveImported()166- }167- if m.filename == "" {168- return m.openSave()169- }170- return saveCmd(m.sheet, m.filename)171-}@@ -257,7 +257,7 @@ func (m *Model) handlePaste(content string) {
257257 m.line.insert(content)258258 case modePrompt:259259 if m.prompt.kind != promptRange {260- m.promptType(content)260+ m.prompt.paste(m, content)261261 }262262 case modeMenu:263263 if o, ok := m.overlay.(textOverlay); ok {@@ -0,0 +1,155 @@
1+package ui2+3+import (4+ "fmt"5+ "log/slog"6+ "os"7+ "path/filepath"8+ "slices"9+ "strings"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/fileio"14+ "github.com/FelineStateMachine/012/internal/sheet"15+ "github.com/FelineStateMachine/012/internal/telemetry"16+)17+18+// Saving and opening .012 files. Files are written and read in the19+// background; other formats go through transfer.go.20+21+func (m *Model) openSave() tea.Cmd {22+ name := m.filename23+ if name == "" {24+ name = m.displayBase() + sheet.FileExt25+ }26+ m.openText("Save as:", name, func(m *Model, text string) tea.Cmd {27+ return saveCmd(m.sheet, withExt(text))28+ })29+ return nil30+}31+32+func (m *Model) openRetrieve() tea.Cmd {33+ m.openText("Open file:", "", func(m *Model, text string) tea.Cmd {34+ if _, ok := fileio.KindOf(text); ok {35+ return m.confirmImport(text, fileio.Options{})36+ }37+ return loadCmd(withExt(text))38+ })39+ return listFilesCmd40+}41+42+// reset starts over on s, as File > New and Open do. What belongs to the43+// session rather than the sheet carries over: the window, theme, terminal44+// state and the JEV connection.45+func (m *Model) reset(s *sheet.Sheet, filename string) {46+ *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}}47+}48+49+func withExt(name string) string {50+ if filepath.Ext(name) == "" {51+ return name + sheet.FileExt52+ }53+ return name54+}55+56+type savedMsg struct {57+ name string58+ err error59+}60+61+type loadedMsg struct {62+ name string63+ sheet *sheet.Sheet64+ err error65+}66+67+type filesMsg []string68+69+// saveCmd writes the worksheet atomically: to a temporary file first, then70+// renamed over the target.71+func saveCmd(s *sheet.Sheet, name string) tea.Cmd {72+ span := telemetry.Start("save")73+ var buf strings.Builder74+ err := s.Write(&buf)75+ if telemetry.Enabled() {76+ span.End(slog.Int("cells", s.Len()), slog.Int("bytes", buf.Len()))77+ }78+ return func() tea.Msg {79+ if err != nil {80+ return savedMsg{name, err}81+ }82+ tmp := name + ".tmp"83+ if err := os.WriteFile(tmp, []byte(buf.String()), 0o644); err != nil {84+ return savedMsg{name, err}85+ }86+ return savedMsg{name, os.Rename(tmp, name)}87+ }88+}89+90+func loadCmd(name string) tea.Cmd {91+ return func() tea.Msg {92+ f, err := os.Open(name)93+ if err != nil {94+ return loadedMsg{name: name, err: err}95+ }96+ defer f.Close()97+ span := telemetry.Start("open")98+ s, err := sheet.Read(f)99+ if err != nil {100+ span.Fail(err)101+ } else if telemetry.Enabled() {102+ span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", openSize(f)))103+ }104+ return loadedMsg{name, s, err}105+ }106+}107+108+// openSize is the size of an open file, for telemetry; -1 if unknown.109+func openSize(f *os.File) int64 {110+ st, err := f.Stat()111+ if err != nil {112+ return -1113+ }114+ return st.Size()115+}116+117+func listFilesCmd() tea.Msg {118+ files, _ := filepath.Glob("*" + sheet.FileExt)119+ slices.Sort(files)120+ return filesMsg(files)121+}122+123+func (m *Model) handleSaved(msg savedMsg) tea.Cmd {124+ quit := m.quitAfterSave125+ m.quitAfterSave = false126+ if msg.err != nil {127+ m.fail(fmt.Sprintf("Couldn't save %s: %v", msg.name, msg.err))128+ return nil129+ }130+ m.filename = msg.name131+ m.changed = false132+ if quit {133+ return m.exit()134+ }135+ return nil136+}137+138+func (m *Model) handleLoaded(msg loadedMsg) {139+ if msg.err != nil {140+ m.fail(fmt.Sprintf("Couldn't open %s: %v", msg.name, msg.err))141+ return142+ }143+ m.reset(msg.sheet, msg.name)144+}145+146+// save writes to the current file, asking for a name the first time.147+func (m *Model) save() tea.Cmd {148+ if m.filename == "" && m.xfer.source != "" {149+ return m.saveImported()150+ }151+ if m.filename == "" {152+ return m.openSave()153+ }154+ return saveCmd(m.sheet, m.filename)155+}@@ -1,357 +0,0 @@
1-package ui2-3-import (4- "fmt"5- "log/slog"6- "os"7- "path/filepath"8- "slices"9- "strconv"10- "strings"11- "unicode"12-13- tea "charm.land/bubbletea/v2"14-15- "github.com/FelineStateMachine/012/internal/fileio"16- "github.com/FelineStateMachine/012/internal/sheet"17- "github.com/FelineStateMachine/012/internal/telemetry"18-)19-20-type promptKind int21-22-const (23- promptText promptKind = iota24- promptRange25- promptWidth26-)27-28-// prompt is a question on the context line, such as a file name or a29-// range to act on.30-type prompt struct {31- kind promptKind32- label string33- indicator string34- fresh bool // the default is still showing; typing replaces it35- typing bool // range prompts: the user is typing instead of pointing36- onText func(m *Model, text string) tea.Cmd37- onRange func(m *Model, r sheet.Rect) tea.Cmd38- onCancel func(m *Model)39- files []string // the file list shown by File Open40-}41-42-func (m *Model) pointing() bool {43- return m.mode == modePrompt && m.prompt.kind == promptRange && !m.prompt.typing44-}45-46-func (m *Model) openPrompt(p *prompt, initial string) {47- m.mode = modePrompt48- m.prompt = p49- m.line.clear()50- m.entry.hint = ""51- m.line.insert(initial)52-}53-54-func (m *Model) openText(label, initial string, onText func(*Model, string) tea.Cmd) {55- m.openPrompt(&prompt{kind: promptText, label: label, indicator: "EDIT", fresh: true, onText: onText}, initial)56-}57-58-// openRange asks for a range, starting from the current selection.59-func (m *Model) openRange(label string, onRange func(*Model, sheet.Rect) tea.Cmd) {60- r := m.selection()61- m.openPrompt(&prompt{kind: promptRange, label: label, indicator: "POINT", onRange: onRange}, "")62- m.point = pointer{anchor: r.From, at: r.To, anchored: r.From != r.To}63-}64-65-func (m *Model) openGoto() {66- m.openText("Go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {67- text = strings.TrimSpace(text)68- // A sheet may lead: Sheet2!A1, 'Q3 plan'!B2:C9, or just Sheet2!.69- target := m.sheet70- if name, rest := sheet.SplitSheet(text); name != "" {71- if target = m.book().Lookup(name); target == nil {72- m.fail("There's no sheet named " + name)73- return nil74- }75- if text = rest; text == "" {76- m.showSheet(target)77- return nil78- }79- }80- r, ok := sheet.ParseRange(text)81- if n, named := m.sheet.LookupName(text); named && !n.Gone() && target == m.sheet {82- r, ok, target = n.Range, true, n.Sheet83- }84- if !ok {85- m.fail("Not a cell, range or named range: " + text)86- return nil87- }88- m.showSheet(target)89- if r.From == r.To {90- m.clearSelection()91- m.cur = r.From92- return nil93- }94- m.selectRect(r)95- return nil96- })97- m.prompt.indicator = "POINT"98-}99-100-// openWidth asks for the width of the selected columns, previewing it101-// live as the arrows change it. Esc restores the original widths.102-func (m *Model) openWidth() tea.Cmd {103- r := m.selection()104- orig := map[int]int{}105- for c := r.From.Col; c <= r.To.Col; c++ {106- orig[c] = m.sheet.ColWidth(c)107- }108- restore := func(m *Model) {109- for c, w := range orig {110- m.sheet.SetColWidth(c, w)111- }112- }113- m.openPrompt(&prompt{114- kind: promptWidth,115- label: "Column width (1-240):",116- indicator: "WIDTH",117- fresh: true,118- onText: func(m *Model, text string) tea.Cmd {119- w, err := strconv.Atoi(text)120- if err != nil || w < 1 || w > 240 {121- restore(m)122- m.fail("Column width must be between 1 and 240")123- return nil124- }125- m.setWidths(w)126- m.changed = true127- return nil128- },129- onCancel: restore,130- }, strconv.Itoa(m.sheet.ColWidth(m.cur.Col)))131- return nil132-}133-134-// setWidths sets the width of every selected column.135-func (m *Model) setWidths(w int) {136- r := m.selection()137- for c := r.From.Col; c <= r.To.Col; c++ {138- m.sheet.SetColWidth(c, w)139- }140-}141-142-func (m *Model) openSave() tea.Cmd {143- name := m.filename144- if name == "" {145- name = m.displayBase() + sheet.FileExt146- }147- m.openText("Save as:", name, func(m *Model, text string) tea.Cmd {148- return saveCmd(m.sheet, withExt(text))149- })150- return nil151-}152-153-func (m *Model) openRetrieve() tea.Cmd {154- m.openText("Open file:", "", func(m *Model, text string) tea.Cmd {155- if _, ok := fileio.KindOf(text); ok {156- return m.confirmImport(text, fileio.Options{})157- }158- return loadCmd(withExt(text))159- })160- return listFilesCmd161-}162-163-// promptKey handles keys while a prompt is open.164-func (m *Model) promptKey(k tea.KeyPressMsg) tea.Cmd {165- p := m.prompt166- key := k.String()167- switch key {168- case "esc":169- if m.pointing() && m.point.anchored {170- m.point.anchored = false171- return nil172- }173- if p.onCancel != nil {174- p.onCancel(m)175- }176- m.quitAfterSave = false // cancelling Save as cancels Save and quit177- m.closePrompt()178- return nil179- case "enter":180- text := strings.TrimSpace(m.line.text())181- m.closePrompt()182- if p.kind != promptRange {183- return p.onText(m, text)184- }185- r := m.point.rect()186- if p.typing {187- var ok bool188- if r, ok = sheet.ParseRange(text); !ok {189- m.fail("Invalid range: " + text)190- return nil191- }192- }193- return p.onRange(m, r)194- }195-196- switch {197- case m.pointing():198- if m.pointMoveKey(key) {199- return nil200- }201- if key == ":" {202- m.point.anchor, m.point.anchored = m.point.at, true203- return nil204- }205- if text := typed(k); text != "" {206- p.typing = true207- m.line.insert(text)208- }209- case p.kind == promptWidth && (key == "left" || key == "right"):210- w, _ := strconv.Atoi(m.line.text())211- if key == "left" {212- w--213- } else {214- w++215- }216- w = clamp(w, 1, 240)217- m.line.buf, m.line.pos, p.fresh = nil, 0, false218- m.line.insert(strconv.Itoa(w))219- m.setWidths(w) // live preview220- default:221- if p.fresh && typed(k) != "" {222- m.line.clear()223- }224- p.fresh = false225- if p.kind == promptWidth && !isDigits(typed(k)) {226- return nil227- }228- m.line.key(k)229- }230- return nil231-}232-233-func (m *Model) promptType(text string) {234- if m.prompt.fresh {235- m.line.clear()236- m.prompt.fresh = false237- }238- m.line.insert(text)239-}240-241-func (m *Model) closePrompt() {242- m.prompt = nil243- m.cancelEntry()244-}245-246-func (m *Model) fail(msg string) {247- m.mode = modeError248- m.errMsg = msg249-}250-251-// reset starts over on s, as File > New and Open do. What belongs to the252-// session rather than the sheet carries over: the window, theme, terminal253-// state and the JEV connection.254-func (m *Model) reset(s *sheet.Sheet, filename string) {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}}256-}257-258-func isDigits(s string) bool {259- return !strings.ContainsFunc(s, func(r rune) bool { return !unicode.IsDigit(r) })260-}261-262-func withExt(name string) string {263- if filepath.Ext(name) == "" {264- return name + sheet.FileExt265- }266- return name267-}268-269-type savedMsg struct {270- name string271- err error272-}273-274-type loadedMsg struct {275- name string276- sheet *sheet.Sheet277- err error278-}279-280-type filesMsg []string281-282-// saveCmd writes the worksheet atomically: to a temporary file first, then283-// renamed over the target.284-func saveCmd(s *sheet.Sheet, name string) tea.Cmd {285- span := telemetry.Start("save")286- var buf strings.Builder287- err := s.Write(&buf)288- if telemetry.Enabled() {289- span.End(slog.Int("cells", s.Len()), slog.Int("bytes", buf.Len()))290- }291- return func() tea.Msg {292- if err != nil {293- return savedMsg{name, err}294- }295- tmp := name + ".tmp"296- if err := os.WriteFile(tmp, []byte(buf.String()), 0o644); err != nil {297- return savedMsg{name, err}298- }299- return savedMsg{name, os.Rename(tmp, name)}300- }301-}302-303-func loadCmd(name string) tea.Cmd {304- return func() tea.Msg {305- f, err := os.Open(name)306- if err != nil {307- return loadedMsg{name: name, err: err}308- }309- defer f.Close()310- span := telemetry.Start("open")311- s, err := sheet.Read(f)312- if err != nil {313- span.Fail(err)314- } else if telemetry.Enabled() {315- span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", openSize(f)))316- }317- return loadedMsg{name, s, err}318- }319-}320-321-// openSize is the size of an open file, for telemetry; -1 if unknown.322-func openSize(f *os.File) int64 {323- st, err := f.Stat()324- if err != nil {325- return -1326- }327- return st.Size()328-}329-330-func listFilesCmd() tea.Msg {331- files, _ := filepath.Glob("*" + sheet.FileExt)332- slices.Sort(files)333- return filesMsg(files)334-}335-336-func (m *Model) handleSaved(msg savedMsg) tea.Cmd {337- quit := m.quitAfterSave338- m.quitAfterSave = false339- if msg.err != nil {340- m.fail(fmt.Sprintf("Couldn't save %s: %v", msg.name, msg.err))341- return nil342- }343- m.filename = msg.name344- m.changed = false345- if quit {346- return m.exit()347- }348- return nil349-}350-351-func (m *Model) handleLoaded(msg loadedMsg) {352- if msg.err != nil {353- m.fail(fmt.Sprintf("Couldn't open %s: %v", msg.name, msg.err))354- return355- }356- m.reset(msg.sheet, msg.name)357-}@@ -221,7 +221,7 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
221221 case modeMenu:222222 return m.overlay.key(m, k)223223 case modePrompt:224- return m.promptKey(k)224+ return m.prompt.key(m, k)225225 case modeError:226226 m.errMsg = ""227227 m.mode = modeReady@@ -304,3 +304,8 @@ func clampAddr(a sheet.Addr) sheet.Addr {
304304 Row: clamp(a.Row, 0, sheet.MaxRows-1),305305 }306306 }307+308+func (m *Model) fail(msg string) {309+ m.mode = modeError310+ m.errMsg = msg311+}@@ -396,30 +396,6 @@ func (m *Model) handleRelease() tea.Cmd {
396396 return nil397397 }398398 399-// autofit sizes column c to its widest content, as double-clicking a400-// header border does in Sheets. Selected columns fit together.401-func (m *Model) autofit(c int) {402- cols := []int{c}403- if r := m.selection(); m.whole == wholeCols && c >= r.From.Col && c <= r.To.Col {404- cols = cols[:0]405- for k := r.From.Col; k <= r.To.Col; k++ {406- cols = append(cols, k)407- }408- }409- widest := map[int]int{}410- for _, a := range m.sheet.Addrs() {411- if cell := m.sheet.Cell(a); cell != nil {412- widest[a.Col] = max(widest[a.Col], ansi.StringWidth(cell.Value.String()))413- }414- }415- for _, k := range cols {416- // One column of padding each side, and never narrower than the417- // header letters.418- m.sheet.SetColWidth(k, clamp(widest[k]+2, len(sheet.ColName(k))+2, 240))419- }420- m.changed = true421-}422-423399 // pointerShape asks the terminal for the pointer shape that fits what's424400 // under the mouse (OSC 22, supported by Ghostty, kitty, foot and xterm;425401 // others ignore it).@@ -172,7 +172,7 @@ func (m *Model) contextLineText() string {
172172 left = o.line(m)173173 }174174 case m.mode == modePrompt:175- left, right = m.promptLine()175+ left, right = m.prompt.line(m)176176 case m.mode == modePoint:177177 prefix := []rune(m.entry.prefix)178178 var ok bool@@ -201,25 +201,6 @@ func (m *Model) spread(left, right string) string {
201201 return left + strings.Repeat(" ", gap) + right202202 }203203 204-// promptLine is an open prompt, e.g. "Save as: budget.012", and the keys205-// or choices that go with it.206-func (m *Model) promptLine() (left, right string) {207- switch {208- case m.pointing():209- return m.promptPrefix() + m.th.Selection.Render(m.point.text()),210- m.th.KeyHints("Arrows", "move", "Shift+arrows", "extend", "Enter", "apply", "Esc", "cancel")211- case len(m.prompt.files) > 0:212- return m.promptPrefix() + m.line.text(), m.th.Muted.Render(strings.Join(m.prompt.files, " "))213- case m.prompt.kind == promptWidth:214- return m.promptPrefix() + m.line.text(), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")215- }216- return m.promptPrefix() + m.line.text(), m.th.KeyHints("Enter", "apply", "Esc", "cancel")217-}218-219-func (m *Model) promptPrefix() string {220- return m.prompt.label + " "221-}222-223204 // cursorPos returns where the terminal cursor goes: in the formula bar224205 // while typing an entry, on the context line in a text prompt, or in an225206 // overlay's search field.@@ -232,7 +213,7 @@ func (m *Model) cursorPos() (x, y int, ok bool) {
232213 case m.mode == modeEnter, m.mode == modeEdit:233214 return formulaBarTextX() + ansi.StringWidth(m.line.head()), formulaLine, true234215 case m.mode == modePrompt && !m.pointing():235- return ansi.StringWidth(m.promptPrefix() + m.line.head()), contextLine, true216+ return ansi.StringWidth(m.prompt.prefix() + m.line.head()), contextLine, true236217 }237218 return 0, 0, false238219 }@@ -0,0 +1,190 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+ "unicode"7+8+ tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// Prompts are questions on the context line, answered by typing (a file14+// name, a width) or by pointing at a range with the arrows, as in 1-2-3.15+// The mode indicator says what's being asked, e.g. EDIT, POINT or WIDTH.16+// The text is edited in Model.line; a range is pointed at with17+// Model.point.18+19+type promptKind int20+21+const (22+ promptText promptKind = iota23+ promptRange24+ promptWidth25+)26+27+// prompt is a question on the context line, such as a file name or a28+// range to act on.29+type prompt struct {30+ kind promptKind31+ label string32+ indicator string33+ fresh bool // the default is still showing; typing replaces it34+ typing bool // range prompts: the user is typing instead of pointing35+ onText func(m *Model, text string) tea.Cmd36+ onRange func(m *Model, r sheet.Rect) tea.Cmd37+ onCancel func(m *Model)38+ files []string // the file list shown by File Open39+}40+41+// pointing reports whether a range prompt is taking the arrows.42+func (m *Model) pointing() bool {43+ return m.mode == modePrompt && m.prompt.kind == promptRange && !m.prompt.typing44+}45+46+func (m *Model) openPrompt(p *prompt, initial string) {47+ m.mode = modePrompt48+ m.prompt = p49+ m.line.clear()50+ m.entry.hint = ""51+ m.line.insert(initial)52+}53+54+// openText asks for text, starting from initial, which typing replaces.55+func (m *Model) openText(label, initial string, onText func(*Model, string) tea.Cmd) {56+ m.openPrompt(&prompt{kind: promptText, label: label, indicator: "EDIT", fresh: true, onText: onText}, initial)57+}58+59+// openRange asks for a range, starting from the current selection.60+func (m *Model) openRange(label string, onRange func(*Model, sheet.Rect) tea.Cmd) {61+ r := m.selection()62+ m.openPrompt(&prompt{kind: promptRange, label: label, indicator: "POINT", onRange: onRange}, "")63+ m.point = pointer{anchor: r.From, at: r.To, anchored: r.From != r.To}64+}65+66+func (m *Model) closePrompt() {67+ m.prompt = nil68+ m.cancelEntry()69+}70+71+// key handles a key while the prompt is open.72+func (p *prompt) key(m *Model, k tea.KeyPressMsg) tea.Cmd {73+ switch key := k.String(); {74+ case key == "esc":75+ p.cancel(m)76+ case key == "enter":77+ return p.accept(m)78+ case m.pointing():79+ p.pointKey(m, k)80+ case p.kind == promptWidth && (key == "left" || key == "right"):81+ p.stepWidth(m, key)82+ default:83+ p.typeKey(m, k)84+ }85+ return nil86+}87+88+// cancel backs out: of a range being stretched, or of the prompt.89+func (p *prompt) cancel(m *Model) {90+ if m.pointing() && m.point.anchored {91+ m.point.anchored = false92+ return93+ }94+ if p.onCancel != nil {95+ p.onCancel(m)96+ }97+ m.quitAfterSave = false // cancelling Save as cancels Save and quit98+ m.closePrompt()99+}100+101+// accept closes the prompt and acts on the answer.102+func (p *prompt) accept(m *Model) tea.Cmd {103+ text := strings.TrimSpace(m.line.text())104+ m.closePrompt()105+ if p.kind != promptRange {106+ return p.onText(m, text)107+ }108+ r := m.point.rect()109+ if p.typing {110+ var ok bool111+ if r, ok = sheet.ParseRange(text); !ok {112+ m.fail("Invalid range: " + text)113+ return nil114+ }115+ }116+ return p.onRange(m, r)117+}118+119+// pointKey moves or stretches the range being pointed at; typing switches120+// to typing the range instead.121+func (p *prompt) pointKey(m *Model, k tea.KeyPressMsg) {122+ key := k.String()123+ switch {124+ case m.pointMoveKey(key):125+ case key == ":":126+ m.point.anchor, m.point.anchored = m.point.at, true127+ case typed(k) != "":128+ p.typing = true129+ m.line.insert(typed(k))130+ }131+}132+133+// stepWidth changes a width by one, previewing it live.134+func (p *prompt) stepWidth(m *Model, key string) {135+ w, _ := strconv.Atoi(m.line.text())136+ if key == "left" {137+ w--138+ } else {139+ w++140+ }141+ w = clamp(w, 1, 240)142+ m.line.clear()143+ p.fresh = false144+ m.line.insert(strconv.Itoa(w))145+ m.setWidths(w) // live preview146+}147+148+// typeKey edits the answer; the first key typed replaces the default.149+func (p *prompt) typeKey(m *Model, k tea.KeyPressMsg) {150+ if p.fresh && typed(k) != "" {151+ m.line.clear()152+ }153+ p.fresh = false154+ if p.kind == promptWidth && !isDigits(typed(k)) {155+ return156+ }157+ m.line.key(k)158+}159+160+// paste types pasted text into the answer.161+func (p *prompt) paste(m *Model, text string) {162+ if p.fresh {163+ m.line.clear()164+ p.fresh = false165+ }166+ m.line.insert(text)167+}168+169+// line is the prompt as the context line shows it, e.g. "Save as:170+// budget.012", and the keys or choices that go with it.171+func (p *prompt) line(m *Model) (left, right string) {172+ switch {173+ case m.pointing():174+ return p.prefix() + m.th.Selection.Render(m.point.text()),175+ m.th.KeyHints("Arrows", "move", "Shift+arrows", "extend", "Enter", "apply", "Esc", "cancel")176+ case len(p.files) > 0:177+ return p.prefix() + m.line.text(), m.th.Muted.Render(strings.Join(p.files, " "))178+ case p.kind == promptWidth:179+ return p.prefix() + m.line.text(), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")180+ }181+ return p.prefix() + m.line.text(), m.th.KeyHints("Enter", "apply", "Esc", "cancel")182+}183+184+func (p *prompt) prefix() string {185+ return p.label + " "186+}187+188+func isDigits(s string) bool {189+ return !strings.ContainsFunc(s, func(r rune) bool { return !unicode.IsDigit(r) })190+}@@ -209,3 +209,38 @@ func isMoveKey(key string) bool {
209209 }210210 return false211211 }212+213+func (m *Model) openGoto() {214+ m.openText("Go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {215+ text = strings.TrimSpace(text)216+ // A sheet may lead: Sheet2!A1, 'Q3 plan'!B2:C9, or just Sheet2!.217+ target := m.sheet218+ if name, rest := sheet.SplitSheet(text); name != "" {219+ if target = m.book().Lookup(name); target == nil {220+ m.fail("There's no sheet named " + name)221+ return nil222+ }223+ if text = rest; text == "" {224+ m.showSheet(target)225+ return nil226+ }227+ }228+ r, ok := sheet.ParseRange(text)229+ if n, named := m.sheet.LookupName(text); named && !n.Gone() && target == m.sheet {230+ r, ok, target = n.Range, true, n.Sheet231+ }232+ if !ok {233+ m.fail("Not a cell, range or named range: " + text)234+ return nil235+ }236+ m.showSheet(target)237+ if r.From == r.To {238+ m.clearSelection()239+ m.cur = r.From240+ return nil241+ }242+ m.selectRect(r)243+ return nil244+ })245+ m.prompt.indicator = "POINT"246+}