cat / 012
clone

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

Add Bubble Tea v2 UI with 1-2-3 modes and slash menu

Dami committed ff1d0c7
READY/LABEL/VALUE/EDIT/POINT/MENU modes, three-line control panel,
label spill-over, POINT mode formula building, Worksheet/Range/File/Quit
menus with first-letter selection, F2 edit, F5 goto, mouse and paste.
commit
ff1d0c7a5fdb3c9645cb81ff84e514d7c96f1581
parent
a73d6a9ac432ae46a668d647aa171ad11482abbd
author
Dami <dami.etoile@gmail.com>
5 files changed, +1556 -0
Acmd/one23/main.go45++
Ainternal/ui/menu.go407++++++++++++++++++++++
Ainternal/ui/model.go544++++++++++++++++++++++++++++++
Ainternal/ui/model_test.go247++++++++++++++
Ainternal/ui/view.go313+++++++++++++++++
A cmd/one23/main.go +45 -0
A internal/ui/menu.go +407 -0
@@ -0,0 +1,407 @@
1+package ui2+3+import (4+ "fmt"5+ "os"6+ "path/filepath"7+ "slices"8+ "strconv"9+ "strings"10+ "unicode"11+12+ tea "charm.land/bubbletea/v2"13+14+ "one23/internal/sheet"15+)16+17+// menuItem is one entry in the slash menu. An item either opens a submenu18+// (items) or runs an action.19+type menuItem struct {20+ name string21+ desc string22+ descFn func(m *Model) string // overrides desc when set23+ items []menuItem24+ action func(m *Model) tea.Cmd25+}26+27+type menuLevel struct {28+ items []menuItem29+ sel int30+}31+32+var rootMenu = []menuItem{33+ {name: "Worksheet", items: []menuItem{34+ {name: "Column", items: []menuItem{35+ {name: "Set-Width", desc: "Specify a width for the current column", action: (*Model).openWidth},36+ {name: "Reset-Width", desc: "Return the current column to the default width", action: func(m *Model) tea.Cmd {37+ m.sheet.SetColWidth(m.cur.Col, 0)38+ m.changed = true39+ return nil40+ }},41+ }},42+ {name: "Erase", items: []menuItem{43+ {name: "No", desc: "Do not erase the worksheet; return to READY mode", action: noop},44+ {name: "Yes", descFn: unsavedWarning("Erase the entire worksheet from memory"), action: func(m *Model) tea.Cmd {45+ m.reset(sheet.New(), "")46+ return nil47+ }},48+ }},49+ }},50+ {name: "Range", items: []menuItem{51+ {name: "Erase", desc: "Erase the cell or range", action: func(m *Model) tea.Cmd {52+ m.openRange("Enter range to erase:", func(m *Model, r sheet.Rect) tea.Cmd {53+ m.sheet.EraseRange(r)54+ m.changed = true55+ return nil56+ })57+ return nil58+ }},59+ }},60+ {name: "File", items: []menuItem{61+ {name: "Save", desc: "Store the entire worksheet in a file", action: (*Model).openSave},62+ {name: "Retrieve", desc: "Erase the current worksheet and display the selected file", action: (*Model).openRetrieve},63+ }},64+ {name: "Quit", items: []menuItem{65+ {name: "No", desc: "Do not end the session; return to READY mode", action: noop},66+ {name: "Yes", descFn: unsavedWarning("End the session"), action: func(*Model) tea.Cmd { return tea.Quit }},67+ }},68+}69+70+func noop(*Model) tea.Cmd { return nil }71+72+func unsavedWarning(desc string) func(m *Model) string {73+ return func(m *Model) string {74+ if m.changed {75+ return "WORKSHEET CHANGES NOT SAVED! " + desc + " anyway?"76+ }77+ return desc78+ }79+}80+81+func (it menuItem) description(m *Model) string {82+ switch {83+ case it.descFn != nil:84+ return it.descFn(m)85+ case it.items != nil:86+ names := make([]string, len(it.items))87+ for i, sub := range it.items {88+ names[i] = sub.name89+ }90+ return strings.Join(names, " ")91+ }92+ return it.desc93+}94+95+func (m *Model) openMenu() {96+ m.mode = modeMenu97+ m.menu = []menuLevel{{items: rootMenu}}98+}99+100+// menuKey handles MENU mode: arrows highlight, Enter or an item's first101+// letter selects, Esc backs out one level.102+func (m *Model) menuKey(k tea.KeyPressMsg) tea.Cmd {103+ lvl := &m.menu[len(m.menu)-1]104+ n := len(lvl.items)105+ switch k.String() {106+ case "left", "shift+tab":107+ lvl.sel = (lvl.sel + n - 1) % n108+ case "right", "tab", "space":109+ lvl.sel = (lvl.sel + 1) % n110+ case "home":111+ lvl.sel = 0112+ case "end":113+ lvl.sel = n - 1114+ case "enter":115+ return m.choose(lvl.items[lvl.sel])116+ case "esc":117+ m.menu = m.menu[:len(m.menu)-1]118+ if len(m.menu) == 0 {119+ m.mode = modeReady120+ }121+ default:122+ text := strings.ToUpper(typed(k))123+ for i, it := range lvl.items {124+ if text != "" && strings.HasPrefix(strings.ToUpper(it.name), text) {125+ lvl.sel = i126+ return m.choose(it)127+ }128+ }129+ }130+ return nil131+}132+133+func (m *Model) choose(it menuItem) tea.Cmd {134+ if it.items != nil {135+ m.menu = append(m.menu, menuLevel{items: it.items})136+ return nil137+ }138+ m.menu = nil139+ m.mode = modeReady140+ return it.action(m)141+}142+143+type promptKind int144+145+const (146+ promptText promptKind = iota147+ promptRange148+ promptWidth149+)150+151+// prompt is a question on the second control panel line, such as a file152+// name or a range to act on.153+type prompt struct {154+ kind promptKind155+ label string156+ indicator string157+ fresh bool // the default is still showing; typing replaces it158+ typing bool // range prompts: the user is typing instead of pointing159+ onText func(m *Model, text string) tea.Cmd160+ onRange func(m *Model, r sheet.Rect) tea.Cmd161+ onCancel func(m *Model)162+}163+164+func (m *Model) pointing() bool {165+ return m.mode == modePrompt && m.prompt.kind == promptRange && !m.prompt.typing166+}167+168+func (m *Model) openPrompt(p *prompt, initial string) {169+ m.mode = modePrompt170+ m.prompt = p171+ m.buf, m.bufPos = nil, 0172+ m.hint = ""173+ m.insert(initial)174+}175+176+func (m *Model) openText(label, initial string, onText func(*Model, string) tea.Cmd) {177+ m.openPrompt(&prompt{kind: promptText, label: label, indicator: "EDIT", fresh: true, onText: onText}, initial)178+}179+180+// openRange asks for a range, pre-anchored on the current cell as in 1-2-3.181+func (m *Model) openRange(label string, onRange func(*Model, sheet.Rect) tea.Cmd) {182+ m.openPrompt(&prompt{kind: promptRange, label: label, indicator: "POINT", onRange: onRange}, "")183+ m.point = pointer{at: m.cur, anchor: m.cur, anchored: true}184+}185+186+func (m *Model) openGoto() {187+ m.openText("Enter address to go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {188+ a, ok := sheet.ParseAddr(strings.TrimSpace(text))189+ if !ok {190+ m.fail("Invalid cell address: " + text)191+ return nil192+ }193+ m.cur = a194+ return nil195+ })196+ m.prompt.indicator = "POINT"197+}198+199+func (m *Model) openWidth() tea.Cmd {200+ col, orig := m.cur.Col, m.sheet.ColWidth(m.cur.Col)201+ m.openPrompt(&prompt{202+ kind: promptWidth,203+ label: "Enter column width (1..240):",204+ indicator: "POINT",205+ fresh: true,206+ onText: func(m *Model, text string) tea.Cmd {207+ w, err := strconv.Atoi(text)208+ if err != nil || w < 1 || w > 240 {209+ m.sheet.SetColWidth(col, orig)210+ m.fail("Column width must be between 1 and 240")211+ return nil212+ }213+ m.sheet.SetColWidth(col, w)214+ m.changed = true215+ return nil216+ },217+ onCancel: func(m *Model) { m.sheet.SetColWidth(col, orig) },218+ }, strconv.Itoa(orig))219+ return nil220+}221+222+func (m *Model) openSave() tea.Cmd {223+ name := m.filename224+ if name == "" {225+ name = "SHEET1" + sheet.FileExt226+ }227+ m.openText("Enter save file name:", name, func(m *Model, text string) tea.Cmd {228+ return saveCmd(m.sheet, withExt(text))229+ })230+ return nil231+}232+233+func (m *Model) openRetrieve() tea.Cmd {234+ m.files = nil235+ m.openText("Name of file to retrieve:", "", func(m *Model, text string) tea.Cmd {236+ return loadCmd(withExt(text))237+ })238+ return listFilesCmd239+}240+241+// promptKey handles keys while a prompt is open.242+func (m *Model) promptKey(k tea.KeyPressMsg) tea.Cmd {243+ p := m.prompt244+ key := k.String()245+ switch key {246+ case "esc":247+ if m.pointing() && m.point.anchored {248+ m.point.anchored = false249+ return nil250+ }251+ if p.onCancel != nil {252+ p.onCancel(m)253+ }254+ m.closePrompt()255+ return nil256+ case "enter":257+ text := strings.TrimSpace(string(m.buf))258+ m.closePrompt()259+ if p.kind != promptRange {260+ return p.onText(m, text)261+ }262+ r := m.point.rect()263+ if p.typing {264+ var ok bool265+ if r, ok = sheet.ParseRange(text); !ok {266+ m.fail("Invalid range: " + text)267+ return nil268+ }269+ }270+ return p.onRange(m, r)271+ }272+273+ switch {274+ case m.pointing():275+ if m.navigate(key, &m.point.at) {276+ return nil277+ }278+ if key == "." {279+ m.point.anchor, m.point.anchored = m.point.at, true280+ return nil281+ }282+ if text := typed(k); text != "" {283+ p.typing = true284+ m.insert(text)285+ }286+ case p.kind == promptWidth && (key == "left" || key == "right"):287+ w, _ := strconv.Atoi(string(m.buf))288+ if key == "left" {289+ w--290+ } else {291+ w++292+ }293+ w = clamp(w, 1, 240)294+ m.buf, m.bufPos, p.fresh = nil, 0, false295+ m.insert(strconv.Itoa(w))296+ m.sheet.SetColWidth(m.cur.Col, w) // live preview297+ default:298+ if p.fresh && typed(k) != "" {299+ m.buf, m.bufPos = nil, 0300+ }301+ p.fresh = false302+ if p.kind == promptWidth && !isDigits(typed(k)) {303+ return nil304+ }305+ m.lineKey(k)306+ }307+ return nil308+}309+310+func (m *Model) promptType(text string) {311+ if m.prompt.fresh {312+ m.buf, m.bufPos = nil, 0313+ m.prompt.fresh = false314+ }315+ m.insert(text)316+}317+318+func (m *Model) closePrompt() {319+ m.prompt = nil320+ m.files = nil321+ m.cancelEntry()322+}323+324+func (m *Model) fail(msg string) {325+ m.mode = modeError326+ m.errMsg = msg327+}328+329+func (m *Model) reset(s *sheet.Sheet, filename string) {330+ *m = Model{sheet: s, filename: filename, width: m.width, height: m.height}331+}332+333+func isDigits(s string) bool {334+ return !strings.ContainsFunc(s, func(r rune) bool { return !unicode.IsDigit(r) })335+}336+337+func withExt(name string) string {338+ if filepath.Ext(name) == "" {339+ return name + sheet.FileExt340+ }341+ return name342+}343+344+type savedMsg struct {345+ name string346+ err error347+}348+349+type loadedMsg struct {350+ name string351+ sheet *sheet.Sheet352+ err error353+}354+355+type filesMsg []string356+357+// saveCmd writes the worksheet atomically: to a temporary file first, then358+// renamed over the target.359+func saveCmd(s *sheet.Sheet, name string) tea.Cmd {360+ var buf strings.Builder361+ err := s.Write(&buf)362+ return func() tea.Msg {363+ if err != nil {364+ return savedMsg{name, err}365+ }366+ tmp := name + ".tmp"367+ if err := os.WriteFile(tmp, []byte(buf.String()), 0o644); err != nil {368+ return savedMsg{name, err}369+ }370+ return savedMsg{name, os.Rename(tmp, name)}371+ }372+}373+374+func loadCmd(name string) tea.Cmd {375+ return func() tea.Msg {376+ f, err := os.Open(name)377+ if err != nil {378+ return loadedMsg{name: name, err: err}379+ }380+ defer f.Close()381+ s, err := sheet.Read(f)382+ return loadedMsg{name, s, err}383+ }384+}385+386+func listFilesCmd() tea.Msg {387+ files, _ := filepath.Glob("*" + sheet.FileExt)388+ slices.Sort(files)389+ return filesMsg(files)390+}391+392+func (m *Model) handleSaved(msg savedMsg) {393+ if msg.err != nil {394+ m.fail(fmt.Sprintf("Cannot save %s: %v", msg.name, msg.err))395+ return396+ }397+ m.filename = msg.name398+ m.changed = false399+}400+401+func (m *Model) handleLoaded(msg loadedMsg) {402+ if msg.err != nil {403+ m.fail(fmt.Sprintf("Cannot retrieve %s: %v", msg.name, msg.err))404+ return405+ }406+ m.reset(msg.sheet, msg.name)407+}
A internal/ui/model.go +544 -0
@@ -0,0 +1,544 @@
1+// Package ui is the Bubble Tea front end: a 1-2-3 style control panel,2+// worksheet grid, slash menu and modal key handling.3+package ui4+5+import (6+ "errors"7+ "slices"8+ "strings"9+ "unicode"10+ "unicode/utf8"11+12+ tea "charm.land/bubbletea/v2"13+14+ "one23/internal/sheet"15+)16+17+// mode is the state shown in the mode indicator, as in 1-2-3.18+type mode int19+20+const (21+ modeReady mode = iota22+ modeLabel23+ modeValue24+ modeEdit25+ modePoint26+ modeMenu27+ modePrompt28+ modeHelp29+ modeError30+)31+32+func (m mode) String() string {33+ return [...]string{"READY", "LABEL", "VALUE", "EDIT", "POINT", "MENU", "", "HELP", "ERROR"}[m]34+}35+36+// Layout: three control panel lines, the column header, the grid, and a37+// status line.38+const (39+ panelLines = 340+ headerLine = panelLines41+ gridTop = panelLines + 142+ rowHdrW = 643+)44+45+// Model is the whole application state.46+type Model struct {47+ sheet *sheet.Sheet48+ filename string49+ changed bool50+51+ cur sheet.Addr // cell pointer52+ top, left int // first visible row and column53+ width, height int54+55+ mode mode56+57+ // buf is the edit line used by LABEL, VALUE, EDIT and text prompts.58+ buf []rune59+ bufPos int60+ hint string // shown on the third panel line, e.g. a parse error61+62+ point pointer // POINT mode and range prompts63+ pointPrefix string // entry text before the address being pointed at64+65+ menu []menuLevel66+ prompt *prompt67+ files []string // file list shown by File Retrieve68+ errMsg string69+}70+71+// New returns a model editing s. filename may be empty.72+func New(s *sheet.Sheet, filename string) *Model {73+ return &Model{sheet: s, filename: filename, width: 80, height: 24}74+}75+76+// Init implements tea.Model.77+func (m *Model) Init() tea.Cmd { return nil }78+79+// Update implements tea.Model.80+func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {81+ var cmd tea.Cmd82+ switch msg := msg.(type) {83+ case tea.WindowSizeMsg:84+ m.width, m.height = msg.Width, msg.Height85+ case tea.KeyPressMsg:86+ if msg.String() == "ctrl+c" {87+ return m, tea.Quit88+ }89+ cmd = m.handleKey(msg)90+ case tea.PasteMsg:91+ m.handlePaste(msg.Content)92+ case tea.MouseClickMsg:93+ m.handleClick(msg.Mouse())94+ case tea.MouseWheelMsg:95+ m.handleWheel(msg.Mouse())96+ case savedMsg:97+ m.handleSaved(msg)98+ case loadedMsg:99+ m.handleLoaded(msg)100+ case filesMsg:101+ m.files = msg102+ }103+ m.scrollTo(*m.focus())104+ return m, cmd105+}106+107+func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {108+ switch m.mode {109+ case modeReady:110+ return m.readyKey(k)111+ case modeLabel, modeValue:112+ return m.entryKey(k)113+ case modeEdit:114+ return m.editKey(k)115+ case modePoint:116+ return m.pointKey(k)117+ case modeMenu:118+ return m.menuKey(k)119+ case modePrompt:120+ return m.promptKey(k)121+ case modeHelp, modeError:122+ m.errMsg = ""123+ m.mode = modeReady124+ }125+ return nil126+}127+128+func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {129+ if m.navigate(k.String(), &m.cur) {130+ return nil131+ }132+ switch k.String() {133+ case "/", "<":134+ m.openMenu()135+ case "f1":136+ m.mode = modeHelp137+ case "f2":138+ m.startEntry(modeEdit, "")139+ if c := m.sheet.Cell(m.cur); c != nil {140+ m.buf = []rune(c.Input)141+ m.bufPos = len(m.buf)142+ }143+ case "f5":144+ m.openGoto()145+ case "delete":146+ m.set(m.cur, "")147+ default:148+ if text := typed(k); text != "" {149+ m.startEntry(entryMode(text), text)150+ }151+ }152+ return nil153+}154+155+// navigate applies a movement key to a, returning false if key isn't one.156+func (m *Model) navigate(key string, a *sheet.Addr) bool {157+ rows, cols := m.visibleRows(), m.visibleCols(m.left)158+ switch key {159+ case "up":160+ a.Row--161+ case "down":162+ a.Row++163+ case "left", "shift+tab":164+ if key == "shift+tab" {165+ a.Col -= cols166+ m.left -= cols167+ } else {168+ a.Col--169+ }170+ case "right", "tab":171+ if key == "tab" {172+ a.Col += cols173+ m.left += cols174+ } else {175+ a.Col++176+ }177+ case "ctrl+left":178+ a.Col -= cols179+ m.left -= cols180+ case "ctrl+right":181+ a.Col += cols182+ m.left += cols183+ case "pgup":184+ a.Row -= rows185+ m.top -= rows186+ case "pgdown":187+ a.Row += rows188+ m.top += rows189+ case "home":190+ *a = sheet.Addr{}191+ m.top, m.left = 0, 0192+ default:193+ return false194+ }195+ *a = clampAddr(*a)196+ m.top = clamp(m.top, 0, sheet.MaxRows-1)197+ m.left = clamp(m.left, 0, sheet.MaxCols-1)198+ return true199+}200+201+func (m *Model) startEntry(md mode, text string) {202+ m.mode = md203+ m.buf = nil204+ m.bufPos = 0205+ m.hint = ""206+ m.insert(text)207+}208+209+func entryMode(text string) mode {210+ if sheet.IsValueEntry(text) {211+ return modeValue212+ }213+ return modeLabel214+}215+216+// entryKey handles LABEL and VALUE modes: typing appends, and a movement217+// key accepts the entry and then moves, as in 1-2-3.218+func (m *Model) entryKey(k tea.KeyPressMsg) tea.Cmd {219+ key := k.String()220+ switch key {221+ case "enter":222+ m.commit()223+ return nil224+ case "esc":225+ m.cancelEntry()226+ return nil227+ case "f2":228+ m.mode = modeEdit229+ return nil230+ case "backspace":231+ if m.bufPos > 0 {232+ m.buf = slices.Delete(m.buf, m.bufPos-1, m.bufPos)233+ m.bufPos--234+ }235+ if len(m.buf) == 0 {236+ m.cancelEntry()237+ }238+ return nil239+ }240+ if isMoveKey(key) {241+ if m.mode == modeValue && canPoint(m.buf) {242+ m.mode = modePoint243+ m.pointPrefix = string(m.buf)244+ m.point = pointer{at: m.cur}245+ m.navigate(key, &m.point.at)246+ return nil247+ }248+ if m.commit() {249+ m.navigate(key, &m.cur)250+ }251+ return nil252+ }253+ m.insert(typed(k))254+ return nil255+}256+257+// editKey handles EDIT mode, where arrows move within the entry.258+func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {259+ switch key := k.String(); key {260+ case "enter":261+ m.commit()262+ case "esc":263+ m.cancelEntry()264+ case "up", "down", "pgup", "pgdown":265+ if m.commit() {266+ m.navigate(key, &m.cur)267+ }268+ default:269+ m.lineKey(k)270+ }271+ return nil272+}273+274+// lineKey applies line-editing keys to buf. It reports whether the key was275+// consumed.276+func (m *Model) lineKey(k tea.KeyPressMsg) bool {277+ switch k.String() {278+ case "left":279+ m.bufPos = max(m.bufPos-1, 0)280+ case "right":281+ m.bufPos = min(m.bufPos+1, len(m.buf))282+ case "home", "ctrl+a":283+ m.bufPos = 0284+ case "end", "ctrl+e":285+ m.bufPos = len(m.buf)286+ case "backspace":287+ if m.bufPos > 0 {288+ m.buf = slices.Delete(m.buf, m.bufPos-1, m.bufPos)289+ m.bufPos--290+ }291+ case "delete":292+ if m.bufPos < len(m.buf) {293+ m.buf = slices.Delete(m.buf, m.bufPos, m.bufPos+1)294+ }295+ default:296+ text := typed(k)297+ m.insert(text)298+ return text != ""299+ }300+ return true301+}302+303+// pointKey handles POINT mode: arrows move a pointer whose address (or304+// range, once anchored with '.') is spliced into the formula.305+func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {306+ key := k.String()307+ if m.navigate(key, &m.point.at) {308+ return nil309+ }310+ switch key {311+ case ".":312+ if !m.point.anchored {313+ m.point.anchor, m.point.anchored = m.point.at, true314+ }315+ case "esc":316+ if m.point.anchored {317+ m.point.anchored = false318+ return nil319+ }320+ m.resumeValue(m.pointPrefix)321+ case "backspace":322+ m.resumeValue(m.pointPrefix)323+ case "enter":324+ m.resumeValue(m.pointPrefix + m.point.text())325+ m.commit()326+ default:327+ if text := typed(k); text != "" {328+ m.resumeValue(m.pointPrefix + m.point.text() + text)329+ }330+ }331+ return nil332+}333+334+func (m *Model) resumeValue(text string) {335+ m.startEntry(modeValue, text)336+}337+338+// commit stores the edit line in the current cell. An invalid formula puts339+// the user in EDIT mode with the cursor at the problem, like 1-2-3 does.340+func (m *Model) commit() bool {341+ input := string(m.buf)342+ if err := m.set(m.cur, input); err != nil {343+ var pe *sheet.ParseError344+ m.mode = modeEdit345+ m.hint = err.Error()346+ if errors.As(err, &pe) {347+ m.bufPos = utf8.RuneCountInString(input[:min(pe.Pos, len(input))])348+ }349+ return false350+ }351+ m.cancelEntry()352+ return true353+}354+355+func (m *Model) set(a sheet.Addr, input string) error {356+ if err := m.sheet.Set(a, input); err != nil {357+ return err358+ }359+ m.changed = true360+ return nil361+}362+363+func (m *Model) cancelEntry() {364+ m.mode = modeReady365+ m.buf, m.bufPos, m.hint = nil, 0, ""366+}367+368+func (m *Model) insert(text string) {369+ for _, r := range text {370+ if r == '\n' || r == '\r' || r == '\t' {371+ r = ' '372+ }373+ if !unicode.IsPrint(r) {374+ continue375+ }376+ m.buf = slices.Insert(m.buf, m.bufPos, r)377+ m.bufPos++378+ }379+}380+381+func (m *Model) handlePaste(content string) {382+ switch m.mode {383+ case modeReady:384+ content = strings.TrimSpace(content)385+ if content != "" {386+ m.startEntry(entryMode(content), content)387+ }388+ case modeLabel, modeValue, modeEdit:389+ m.insert(content)390+ case modePrompt:391+ if m.prompt.kind != promptRange {392+ m.promptType(content)393+ }394+ }395+}396+397+func (m *Model) handleClick(mouse tea.Mouse) {398+ if mouse.Button != tea.MouseLeft {399+ return400+ }401+ a, ok := m.cellAt(mouse.X, mouse.Y)402+ if !ok {403+ return404+ }405+ switch {406+ case m.mode == modeReady:407+ m.cur = a408+ case m.mode == modePoint, m.pointing():409+ m.point.at = a410+ }411+}412+413+func (m *Model) handleWheel(mouse tea.Mouse) {414+ const step = 3415+ switch mouse.Button {416+ case tea.MouseWheelUp:417+ m.top = max(m.top-step, 0)418+ case tea.MouseWheelDown:419+ m.top = min(m.top+step, sheet.MaxRows-m.visibleRows())420+ case tea.MouseWheelLeft:421+ m.left = max(m.left-1, 0)422+ case tea.MouseWheelRight:423+ m.left = min(m.left+1, sheet.MaxCols-1)424+ default:425+ return426+ }427+ // Keep the focused cell on screen rather than snapping back to it.428+ f := m.focus()429+ f.Row = clamp(f.Row, m.top, m.top+m.visibleRows()-1)430+ f.Col = clamp(f.Col, m.left, m.left+m.visibleCols(m.left)-1)431+}432+433+// focus is the cell the viewport follows: the pointer while pointing,434+// otherwise the cell pointer.435+func (m *Model) focus() *sheet.Addr {436+ if m.mode == modePoint || m.pointing() {437+ return &m.point.at438+ }439+ return &m.cur440+}441+442+// cellAt maps a screen position to a cell.443+func (m *Model) cellAt(x, y int) (sheet.Addr, bool) {444+ row := y - gridTop445+ if row < 0 || row >= m.visibleRows() || x < rowHdrW {446+ return sheet.Addr{}, false447+ }448+ cx := rowHdrW449+ for c := m.left; c < sheet.MaxCols; c++ {450+ cx += m.sheet.ColWidth(c)451+ if x < cx {452+ return sheet.Addr{Col: c, Row: m.top + row}, true453+ }454+ if cx >= m.width {455+ break456+ }457+ }458+ return sheet.Addr{}, false459+}460+461+func (m *Model) visibleRows() int {462+ return max(m.height-gridTop-1, 1)463+}464+465+// visibleCols returns how many whole columns fit starting at left.466+func (m *Model) visibleCols(left int) int {467+ n, w := 0, rowHdrW468+ for c := left; c < sheet.MaxCols; c++ {469+ w += m.sheet.ColWidth(c)470+ if w > m.width {471+ break472+ }473+ n++474+ }475+ return max(n, 1)476+}477+478+// scrollTo moves the viewport the minimum amount needed to show a.479+func (m *Model) scrollTo(a sheet.Addr) {480+ rows := m.visibleRows()481+ m.top = clamp(m.top, a.Row-rows+1, a.Row)482+ if a.Col < m.left {483+ m.left = a.Col484+ }485+ for a.Col >= m.left+m.visibleCols(m.left) {486+ m.left++487+ }488+}489+490+func isMoveKey(key string) bool {491+ switch key {492+ case "up", "down", "left", "right", "pgup", "pgdown", "tab", "shift+tab", "ctrl+left", "ctrl+right", "home":493+ return true494+ }495+ return false496+}497+498+// canPoint reports whether the entry so far ends where a cell address may499+// follow, so movement keys start POINT mode instead of accepting the entry.500+func canPoint(buf []rune) bool {501+ if len(buf) == 0 {502+ return false503+ }504+ return strings.ContainsRune("+-*/^(,;=<>&#", buf[len(buf)-1])505+}506+507+// typed returns the printable text of a key press, if any.508+func typed(k tea.KeyPressMsg) string {509+ if k.Mod.Contains(tea.ModCtrl) || k.Mod.Contains(tea.ModAlt) {510+ return ""511+ }512+ return k.Text513+}514+515+func clamp(v, lo, hi int) int {516+ return max(lo, min(v, hi))517+}518+519+func clampAddr(a sheet.Addr) sheet.Addr {520+ return sheet.Addr{521+ Col: clamp(a.Col, 0, sheet.MaxCols-1),522+ Row: clamp(a.Row, 0, sheet.MaxRows-1),523+ }524+}525+526+// pointer is a cell or range being selected with the arrow keys.527+type pointer struct {528+ at, anchor sheet.Addr529+ anchored bool530+}531+532+func (p pointer) rect() sheet.Rect {533+ if !p.anchored {534+ return sheet.Rect{From: p.at, To: p.at}535+ }536+ return sheet.NewRect(p.anchor, p.at)537+}538+539+func (p pointer) text() string {540+ if !p.anchored {541+ return p.at.String()542+ }543+ return p.anchor.String() + ".." + p.at.String()544+}
A internal/ui/model_test.go +247 -0
@@ -0,0 +1,247 @@
1+package ui2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+12+ "one23/internal/sheet"13+)14+15+var named = map[string]rune{16+ "enter": tea.KeyEnter, "esc": tea.KeyEscape, "backspace": tea.KeyBackspace,17+ "up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight,18+ "f2": tea.KeyF2, "f5": tea.KeyF5, "delete": tea.KeyDelete, "home": tea.KeyHome,19+ "pgdown": tea.KeyPgDown, "tab": tea.KeyTab,20+}21+22+// press sends keys to m. Each argument is a named key in <angle brackets>23+// or literal text typed one character at a time. Commands are run and24+// their messages fed back, except tea.Quit, which is returned.25+func press(t *testing.T, m *Model, keys ...string) tea.Msg {26+ t.Helper()27+ var last tea.Msg28+ send := func(msg tea.Msg) {29+ _, cmd := m.Update(msg)30+ for cmd != nil {31+ out := cmd()32+ if _, ok := out.(tea.QuitMsg); ok {33+ last = out34+ return35+ }36+ _, cmd = m.Update(out)37+ }38+ }39+ for _, k := range keys {40+ if name, ok := strings.CutPrefix(k, "<"); ok {41+ name = strings.TrimSuffix(name, ">")42+ code, ok := named[name]43+ if !ok {44+ t.Fatalf("unknown key %q", name)45+ }46+ send(tea.KeyPressMsg{Code: code})47+ continue48+ }49+ for _, r := range k {50+ send(tea.KeyPressMsg{Code: r, Text: string(r)})51+ }52+ }53+ return last54+}55+56+func newModel() *Model {57+ m := New(sheet.New(), "")58+ m.Update(tea.WindowSizeMsg{Width: 80, Height: 20})59+ return m60+}61+62+func screen(m *Model) string {63+ return ansi.Strip(m.View().Content)64+}65+66+func addr(s string) sheet.Addr {67+ a, _ := sheet.ParseAddr(s)68+ return a69+}70+71+func TestEntryAndMove(t *testing.T) {72+ m := newModel()73+ press(t, m, "12", "<down>", "30", "<down>", "@SUM(A1..A2)", "<enter>")74+ if m.mode != modeReady || m.cur != addr("A3") {75+ t.Fatalf("mode %v at %v", m.mode, m.cur)76+ }77+ if got := m.sheet.Value(addr("A3")).Num; got != 42 {78+ t.Errorf("A3 = %v", got)79+ }80+ if s := screen(m); !strings.Contains(s, "A3: @SUM(A1..A2)") || !strings.Contains(s, " 42 ") {81+ t.Errorf("screen:\n%s", s)82+ }83+}84+85+func TestLabelSpill(t *testing.T) {86+ m := newModel()87+ press(t, m, "Quarterly revenue report", "<enter>")88+ row := strings.Split(screen(m), "\n")[gridTop]89+ if !strings.Contains(row, "Quarterly revenue report") {90+ t.Errorf("label did not spill: %q", row)91+ }92+ // A filled neighbor cuts the spill off.93+ press(t, m, "<right>", "5", "<enter>")94+ row = strings.Split(screen(m), "\n")[gridTop]95+ if !strings.Contains(row, "Quarterly 5 ") {96+ t.Errorf("spill not truncated: %q", row)97+ }98+}99+100+func TestPointMode(t *testing.T) {101+ m := newModel()102+ press(t, m, "1", "<down>", "2", "<down>", "<down>")103+ press(t, m, "@SUM(", "<up>", "<up>", "<up>")104+ if m.mode != modePoint || panelText(m, 1) != "@SUM(A1" {105+ t.Fatalf("mode %v, line 2 %q", m.mode, panelText(m, 1))106+ }107+ press(t, m, ".", "<down>")108+ if panelText(m, 1) != "@SUM(A1..A2" {109+ t.Fatalf("line 2 %q", panelText(m, 1))110+ }111+ press(t, m, ")", "<enter>")112+ c := m.sheet.Cell(addr("A4"))113+ if c == nil || c.Input != "@SUM(A1..A2)" || c.Value.Num != 3 {114+ t.Errorf("A4 = %+v", c)115+ }116+}117+118+func TestInvalidFormulaGoesToEdit(t *testing.T) {119+ m := newModel()120+ press(t, m, "+B1+", "<enter>")121+ if m.mode != modeEdit || m.hint == "" || m.sheet.Cell(addr("A1")) != nil {122+ t.Fatalf("mode %v hint %q", m.mode, m.hint)123+ }124+ press(t, m, "1", "<enter>")125+ if m.mode != modeReady || m.sheet.Value(addr("A1")).Num != 1 {126+ t.Errorf("mode %v A1 %+v", m.mode, m.sheet.Value(addr("A1")))127+ }128+}129+130+func TestEditExisting(t *testing.T) {131+ m := newModel()132+ press(t, m, "+2*3", "<enter>", "<f2>", "<left>", "<backspace>", "4", "<enter>")133+ // Cursor lands before "3", Backspace removes "*", then "4" is inserted.134+ if got := m.sheet.Cell(addr("A1")).Input; got != "+243" {135+ t.Errorf("input %q, want +243", got)136+ }137+}138+139+func TestMenuColumnWidth(t *testing.T) {140+ m := newModel()141+ press(t, m, "/")142+ if !strings.Contains(panelText(m, 1), "Worksheet Range File Quit") {143+ t.Fatalf("menu line %q", panelText(m, 1))144+ }145+ press(t, m, "wcs", "15", "<enter>")146+ if m.mode != modeReady || m.sheet.ColWidth(0) != 15 {147+ t.Errorf("mode %v width %d", m.mode, m.sheet.ColWidth(0))148+ }149+ // Arrows preview live; Esc restores.150+ press(t, m, "/wcs", "<right>", "<right>")151+ if m.sheet.ColWidth(0) != 17 {152+ t.Errorf("preview width %d", m.sheet.ColWidth(0))153+ }154+ press(t, m, "<esc>")155+ if m.sheet.ColWidth(0) != 15 {156+ t.Errorf("width after esc %d", m.sheet.ColWidth(0))157+ }158+}159+160+func TestRangeErase(t *testing.T) {161+ m := newModel()162+ press(t, m, "1", "<right>", "2", "<right>", "3", "<enter>", "<home>")163+ press(t, m, "/re", "<right>", "<enter>")164+ if m.sheet.Cell(addr("A1")) != nil || m.sheet.Cell(addr("B1")) != nil || m.sheet.Cell(addr("C1")) == nil {165+ t.Errorf("cells after erase: %d", m.sheet.Len())166+ }167+ press(t, m, "/re", "<backspace>", "c1", "<enter>")168+ if m.sheet.Len() != 0 {169+ t.Errorf("typed range not erased")170+ }171+}172+173+func TestGoto(t *testing.T) {174+ m := newModel()175+ press(t, m, "<f5>", "Z100", "<enter>")176+ if m.cur != addr("Z100") || m.top == 0 || m.left == 0 {177+ t.Errorf("cur %v top %d left %d", m.cur, m.top, m.left)178+ }179+ press(t, m, "<f5>", "nope", "<enter>")180+ if m.mode != modeError {181+ t.Errorf("mode %v", m.mode)182+ }183+ press(t, m, "<esc>")184+ if m.mode != modeReady {185+ t.Errorf("mode %v after dismiss", m.mode)186+ }187+}188+189+func TestSaveAndRetrieve(t *testing.T) {190+ t.Chdir(t.TempDir())191+ m := newModel()192+ press(t, m, "Hello", "<down>", "42", "<enter>")193+ press(t, m, "/fs", "budget", "<enter>")194+ if m.filename != "budget.o23" || m.changed {195+ t.Fatalf("filename %q changed %v err %q", m.filename, m.changed, m.errMsg)196+ }197+ if _, err := os.Stat(filepath.Join(".", "budget.o23")); err != nil {198+ t.Fatal(err)199+ }200+201+ m2 := newModel()202+ press(t, m2, "/fr")203+ if !strings.Contains(panelText(m2, 2), "budget.o23") {204+ t.Errorf("file list %q", panelText(m2, 2))205+ }206+ press(t, m2, "budget", "<enter>")207+ if m2.sheet.Value(addr("A2")).Num != 42 || m2.filename != "budget.o23" {208+ t.Errorf("retrieved A2 %+v file %q", m2.sheet.Value(addr("A2")), m2.filename)209+ }210+211+ press(t, m2, "/fr", "missing", "<enter>")212+ if m2.mode != modeError {213+ t.Errorf("mode %v", m2.mode)214+ }215+}216+217+func TestQuit(t *testing.T) {218+ m := newModel()219+ if msg := press(t, m, "/qn"); msg != nil || m.mode != modeReady {220+ t.Fatalf("Quit No: %v mode %v", msg, m.mode)221+ }222+ press(t, m, "1", "<enter>", "/q", "<right>")223+ if !strings.Contains(panelText(m, 2), "NOT SAVED") {224+ t.Errorf("no unsaved warning: %q", panelText(m, 2))225+ }226+ if _, ok := press(t, m, "<enter>").(tea.QuitMsg); !ok {227+ t.Error("Quit Yes did not quit")228+ }229+}230+231+func TestScrollFollowsPointer(t *testing.T) {232+ m := newModel()233+ for range 40 {234+ press(t, m, "<down>")235+ }236+ if m.top == 0 || m.cur.Row != 40 || m.cur.Row >= m.top+m.visibleRows() {237+ t.Errorf("top %d cur %v rows %d", m.top, m.cur, m.visibleRows())238+ }239+ press(t, m, "<home>")240+ if m.top != 0 || m.cur != (sheet.Addr{}) {241+ t.Errorf("home: top %d cur %v", m.top, m.cur)242+ }243+}244+245+func panelText(m *Model, line int) string {246+ return strings.TrimRight(strings.Split(screen(m), "\n")[line], " ")247+}
A internal/ui/view.go +313 -0
@@ -0,0 +1,313 @@
1+package ui2+3+import (4+ "fmt"5+ "path/filepath"6+ "strconv"7+ "strings"8+9+ tea "charm.land/bubbletea/v2"10+ "charm.land/lipgloss/v2"11+ "github.com/charmbracelet/x/ansi"12+13+ "one23/internal/sheet"14+)15+16+// Styles use the 16 ANSI colors so the sheet follows the user's terminal17+// theme.18+var (19+ indicatorStyle = lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black).Bold(true)20+ headerStyle = lipgloss.NewStyle().Background(lipgloss.BrightBlack).Foreground(lipgloss.BrightWhite)21+ headerCurStyle = lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black).Bold(true)22+ pointerStyle = lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black)23+ rangeStyle = lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(lipgloss.BrightWhite)24+ menuSelStyle = lipgloss.NewStyle().Reverse(true)25+ hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Yellow)26+ errorStyle = lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true)27+ dimStyle = lipgloss.NewStyle().Foreground(lipgloss.BrightBlack)28+)29+30+// View implements tea.Model.31+func (m *Model) View() tea.View {32+ var lines []string33+ if m.mode == modeHelp {34+ lines = m.helpLines()35+ } else {36+ lines = append(lines, m.panelLine1(), m.panelLine2(), m.panelLine3(), m.headerRow())37+ for i := range m.visibleRows() {38+ lines = append(lines, m.gridRow(m.top+i))39+ }40+ lines = append(lines, m.statusLine())41+ }42+ for i, l := range lines {43+ lines[i] = ansi.Truncate(l, m.width, "")44+ }45+46+ v := tea.NewView(strings.Join(lines, "\n"))47+ v.AltScreen = true48+ v.MouseMode = tea.MouseModeCellMotion49+ v.WindowTitle = "one23 - " + m.displayName()50+ if x, ok := m.cursorX(); ok {51+ v.Cursor = tea.NewCursor(x, 1)52+ }53+ return v54+}55+56+func (m *Model) displayName() string {57+ if m.filename == "" {58+ return "untitled"59+ }60+ return filepath.Base(m.filename)61+}62+63+// panelLine1 shows the current cell's address, width and contents, with the64+// mode indicator on the right.65+func (m *Model) panelLine1() string {66+ left := m.cur.String() + ":"67+ if w := m.sheet.ColWidth(m.cur.Col); w != sheet.DefaultWidth {68+ left += fmt.Sprintf(" [W%d]", w)69+ }70+ if c := m.sheet.Cell(m.cur); c != nil {71+ left += " " + c.Input72+ }73+ ind := m.mode.String()74+ if m.mode == modePrompt {75+ ind = m.prompt.indicator76+ }77+ ind = indicatorStyle.Render(" " + ind + " ")78+ gap := m.width - ansi.StringWidth(left) - ansi.StringWidth(ind)79+ if gap < 1 {80+ left = ansi.Truncate(left, m.width-ansi.StringWidth(ind)-1, "")81+ gap = 182+ }83+ return left + strings.Repeat(" ", gap) + ind84+}85+86+// panelLine2 is the edit line, the menu, or a prompt.87+func (m *Model) panelLine2() string {88+ switch m.mode {89+ case modeLabel, modeValue, modeEdit:90+ return string(m.buf)91+ case modePoint:92+ return m.pointPrefix + m.point.text()93+ case modeMenu:94+ lvl := m.menu[len(m.menu)-1]95+ parts := make([]string, len(lvl.items))96+ for i, it := range lvl.items {97+ parts[i] = it.name98+ if i == lvl.sel {99+ parts[i] = menuSelStyle.Render(it.name)100+ }101+ }102+ return strings.Join(parts, " ")103+ case modePrompt:104+ if m.pointing() {105+ return m.prompt.label + " " + m.point.text()106+ }107+ return m.promptPrefix() + string(m.buf)108+ }109+ return ""110+}111+112+func (m *Model) promptPrefix() string {113+ return m.prompt.label + " "114+}115+116+// panelLine3 describes the highlighted menu item, lists files, or shows a117+// hint such as a formula error.118+func (m *Model) panelLine3() string {119+ switch {120+ case m.mode == modeMenu:121+ lvl := m.menu[len(m.menu)-1]122+ return lvl.items[lvl.sel].description(m)123+ case m.mode == modePrompt && len(m.files) > 0:124+ return dimStyle.Render(strings.Join(m.files, " "))125+ case m.mode == modePrompt && m.prompt.kind == promptWidth:126+ return dimStyle.Render("Type a width or use the left and right arrows")127+ case m.pointing() || m.mode == modePoint:128+ return dimStyle.Render("Arrows move, . anchors a range, Esc unanchors, Enter accepts")129+ case m.hint != "":130+ return hintStyle.Render(m.hint)131+ }132+ return ""133+}134+135+// cursorX returns where the terminal cursor goes on the edit line.136+func (m *Model) cursorX() (int, bool) {137+ switch {138+ case m.mode == modeLabel, m.mode == modeValue, m.mode == modeEdit:139+ return ansi.StringWidth(string(m.buf[:m.bufPos])), true140+ case m.mode == modePrompt && !m.pointing():141+ return ansi.StringWidth(m.promptPrefix() + string(m.buf[:m.bufPos])), true142+ }143+ return 0, false144+}145+146+func (m *Model) headerRow() string {147+ var b strings.Builder148+ b.WriteString(headerStyle.Render(strings.Repeat(" ", rowHdrW)))149+ focus := *m.focus()150+ for i := range m.visibleCols(m.left) {151+ c := m.left + i152+ style := headerStyle153+ if c == focus.Col {154+ style = headerCurStyle155+ }156+ b.WriteString(style.Render(center(sheet.ColName(c), m.sheet.ColWidth(c))))157+ }158+ return b.String()159+}160+161+func (m *Model) gridRow(row int) string {162+ if row >= sheet.MaxRows {163+ return ""164+ }165+ focus := *m.focus()166+ hdr := headerStyle167+ if row == focus.Row {168+ hdr = headerCurStyle169+ }170+ var b strings.Builder171+ b.WriteString(hdr.Render(padRight(strconv.Itoa(row+1), rowHdrW)))172+173+ var sel sheet.Rect174+ selecting := m.mode == modePoint || m.pointing()175+ if selecting {176+ sel = m.point.rect()177+ }178+ for i, text := range m.rowText(row) {179+ a := sheet.Addr{Col: m.left + i, Row: row}180+ switch {181+ case a == focus:182+ b.WriteString(pointerStyle.Render(text))183+ case selecting && sel.Contains(a):184+ b.WriteString(rangeStyle.Render(text))185+ default:186+ b.WriteString(text)187+ }188+ }189+ return b.String()190+}191+192+// rowText returns the visible text of each column in row, each exactly the193+// column's width. Left-aligned labels spill into blank cells to the right,194+// including labels that start left of the viewport.195+func (m *Model) rowText(row int) []string {196+ ncols := m.visibleCols(m.left)197+ out := make([]string, ncols)198+199+ var spill string200+ for c := m.left - 1; c >= 0; c-- {201+ cell := m.sheet.Cell(sheet.Addr{Col: c, Row: row})202+ if cell == nil {203+ continue204+ }205+ if cell.Align() == '\'' {206+ spill = cell.Value.Str207+ for k := c; k < m.left && spill != ""; k++ {208+ _, spill = cut(spill, m.sheet.ColWidth(k))209+ }210+ }211+ break212+ }213+214+ for i := range ncols {215+ c := m.left + i216+ w := m.sheet.ColWidth(c)217+ cell := m.sheet.Cell(sheet.Addr{Col: c, Row: row})218+ switch {219+ case cell != nil:220+ spill = ""221+ out[i] = cellText(cell, w, &spill)222+ case spill != "":223+ var head string224+ head, spill = cut(spill, w)225+ out[i] = padRight(head, w)226+ default:227+ out[i] = strings.Repeat(" ", w)228+ }229+ }230+ return out231+}232+233+// cellText formats a non-blank cell to width w. Left-aligned label text234+// that doesn't fit is returned through spill.235+func cellText(c *sheet.Cell, w int, spill *string) string {236+ v := c.Value237+ switch {238+ case v.Kind == sheet.Number || v.Kind == sheet.Error:239+ return sheet.FormatValue(v, w)240+ case c.Align() == '"':241+ return padLeft(ansi.Truncate(v.Str, w, ""), w)242+ case c.Align() == '^':243+ return center(ansi.Truncate(v.Str, w, ""), w)244+ case c.Align() == '\'':245+ var head string246+ head, *spill = cut(v.Str, w)247+ return padRight(head, w)248+ }249+ // String formula results are left-aligned and don't spill.250+ return padRight(ansi.Truncate(v.Str, w, ""), w)251+}252+253+func (m *Model) statusLine() string {254+ if m.mode == modeError {255+ return errorStyle.Render(m.errMsg) + dimStyle.Render(" (press any key)")256+ }257+ left := m.displayName()258+ if m.changed {259+ left += " [modified]"260+ }261+ var ind []string262+ if m.sheet.Circular {263+ ind = append(ind, "CIRC")264+ }265+ ind = append(ind, "F1 Help / Menu")266+ right := dimStyle.Render(strings.Join(ind, " "))267+ gap := max(m.width-ansi.StringWidth(left)-ansi.StringWidth(right), 1)268+ return left + strings.Repeat(" ", gap) + right269+}270+271+func (m *Model) helpLines() []string {272+ return []string{273+ indicatorStyle.Render(" HELP ") + " one23 keys (press any key to return)",274+ "",275+ " Arrows Move the cell pointer",276+ " PgUp / PgDn Move one screen up or down",277+ " Tab / Shift+Tab Move one screen right or left",278+ " Home Go to A1",279+ " F5 Go to a cell address",280+ " F2 Edit the current cell",281+ " Del Erase the current cell",282+ " / or < Open the menu (type an item's first letter)",283+ " Ctrl+C Quit immediately",284+ "",285+ " Entries",286+ " Text Label (prefix ' left, \" right, ^ center)",287+ " 0-9 + - . ( @ # Value or formula, e.g. +A1*2, @SUM(A1..A5)",288+ " Arrows after an operator enter POINT mode; . anchors a range",289+ "",290+ " Functions: @SUM @AVG @COUNT @MIN @MAX @ABS @INT @SQRT @ROUND @MOD",291+ " @IF @PI @TRUE @FALSE @ERR @NA",292+ " Operators: + - * / ^ & = <> < > <= >= #AND# #OR# #NOT#",293+ }294+}295+296+// cut splits s after w display columns.297+func cut(s string, w int) (head, tail string) {298+ head = ansi.Truncate(s, w, "")299+ return head, s[len(head):]300+}301+302+func padRight(s string, w int) string {303+ return s + strings.Repeat(" ", max(w-ansi.StringWidth(s), 0))304+}305+306+func padLeft(s string, w int) string {307+ return strings.Repeat(" ", max(w-ansi.StringWidth(s), 0)) + s308+}309+310+func center(s string, w int) string {311+ pad := max(w-ansi.StringWidth(s), 0)312+ return strings.Repeat(" ", pad/2) + s + strings.Repeat(" ", pad-pad/2)313+}