Typing replaces a cell and shows in the cell as you type; Enter commits and moves down (back to the starting column after a run of Tabs), Tab moves right, Enter/F2/double-click edit. Arrows after an operator in a formula point at cells, and Shift+arrows point at ranges. Selection works like Sheets: Shift+arrows, Ctrl+arrow data-edge jumps, Ctrl+A, Ctrl+Space/Shift+Space, mouse drag, Shift+click and header clicks. The active cell stays distinct from the selection, selected headers are highlighted and the status line shows Sum, Avg and Count. The wheel scrolls without moving the selection. Ctrl+S/Ctrl+O/Ctrl+Q/Ctrl+G follow Sheets, Ctrl+Q confirms unsaved changes, and / no longer opens the menu (F10 does). Line 3 shows the keys that apply in each mode.
- commit
- 5f568595b3f92541fcc6e62b831e309b59a859c4
- parent
- 831c0ee3c9fc05990afb631490c177fbe9f41383
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 96 | +++++-- |
| A | internal/ | 293 | +++++++++++++++++++++ |
| M | internal/ | 124 | ++++++--- |
| M | internal/ | 423 | +++++++----------------------- |
| M | internal/ | 397 | +++++++++++++++++++++------- |
| A | internal/ | 260 | ++++++++++++++++++ |
| M | internal/ | 4 | + |
| M | internal/ | 247 | ++++++++++++------ |
@@ -9,11 +9,11 @@ import (
99 )1010 1111 // command is a user-facing action. Every action is registered once here12-// and reached from the slash menu, key bindings, help and the command13-// palette, so titles, descriptions and shortcuts stay consistent.12+// and reached from menus, key bindings, help and the command palette, so13+// titles, descriptions and shortcuts stay consistent.1414 type command struct {1515 id string16- title string // shown in the palette and help, e.g. "File Save"16+ title string // shown in menus, the palette and help, e.g. "Save"1717 desc string // one line, shown on the third panel line1818 run func(m *Model) tea.Cmd1919 }@@ -29,15 +29,25 @@ func register(cmds ...*command) {
2929 }3030 }3131 32-// keymap binds READY-mode keys to command IDs. Movement and typing are33-// handled separately.32+// keymap binds READY-mode keys to command IDs, following Google Sheets33+// where it has a shortcut. Movement and typing are handled separately.3434 var keymap = map[string]string{35- "/": "menu",36- "<": "menu",37- "f1": "help",38- "f2": "edit",39- "f5": "goto",40- "delete": "cell.erase",35+ "f10": "menu",36+ "f1": "help",37+ "ctrl+/": "help",38+ "enter": "edit",39+ "f2": "edit",40+ "f5": "goto",41+ "ctrl+g": "goto",42+ "delete": "clear",43+ "backspace": "clear",44+ "esc": "select.none",45+ "ctrl+a": "select.all",46+ "ctrl+space": "select.columns",47+ "shift+space": "select.rows",48+ "ctrl+s": "file.save",49+ "ctrl+o": "file.open",50+ "ctrl+q": "quit",4151 }4252 4353 // keysFor returns the shortcuts bound to a command, sorted.@@ -63,43 +73,65 @@ func (m *Model) runCommand(id string) tea.Cmd {
6373 6474 func init() {6575 register(66- &command{id: "menu", title: "Menu", desc: "Open the slash menu", run: func(m *Model) tea.Cmd {76+ &command{id: "menu", title: "Menu", desc: "Open the menu", run: func(m *Model) tea.Cmd {6777 m.openMenu()6878 return nil6979 }},70- &command{id: "help", title: "Help", desc: "Show keys and functions", run: func(m *Model) tea.Cmd {80+ &command{id: "help", title: "Keyboard shortcuts", desc: "Show keys and functions", run: func(m *Model) tea.Cmd {7181 m.mode = modeHelp7282 return nil7383 }},74- &command{id: "edit", title: "Edit Cell", desc: "Edit the current cell's contents", run: (*Model).startEdit},75- &command{id: "goto", title: "Go To", desc: "Move the cell pointer to an address", run: func(m *Model) tea.Cmd {84+ &command{id: "edit", title: "Edit cell", desc: "Edit the active cell's contents", run: (*Model).startEdit},85+ &command{id: "goto", title: "Go to", desc: "Move to a cell address", run: func(m *Model) tea.Cmd {7686 m.openGoto()7787 return nil7888 }},79- &command{id: "cell.erase", title: "Erase Cell", desc: "Erase the current cell", run: func(m *Model) tea.Cmd {80- m.set(m.cur, "")89+ &command{id: "clear", title: "Clear", desc: "Clear the contents of the selected cells", run: func(m *Model) tea.Cmd {90+ m.sheet.EraseRange(m.selection())91+ m.changed = true8192 return nil8293 }},83- &command{id: "column.width", title: "Worksheet Column Set-Width", desc: "Specify a width for the current column", run: (*Model).openWidth},84- &command{id: "column.reset", title: "Worksheet Column Reset-Width", desc: "Return the current column to the default width", run: func(m *Model) tea.Cmd {85- m.sheet.SetColWidth(m.cur.Col, 0)86- m.changed = true94+ &command{id: "select.none", title: "Deselect", desc: "Collapse the selection to the active cell", run: func(m *Model) tea.Cmd {95+ m.clearSelection()8796 return nil8897 }},89- &command{id: "worksheet.erase", title: "Worksheet Erase", desc: "Erase the entire worksheet from memory", run: func(m *Model) tea.Cmd {90- m.reset(sheet.New(), "")98+ &command{id: "select.all", title: "Select all", desc: "Select the data, then the whole sheet", run: (*Model).selectAll},99+ &command{id: "select.columns", title: "Select columns", desc: "Select the whole columns of the selection", run: (*Model).selectColumns},100+ &command{id: "select.rows", title: "Select rows", desc: "Select the whole rows of the selection", run: (*Model).selectRows},101+ &command{id: "column.width", title: "Column width", desc: "Set the width of the selected columns", run: (*Model).openWidth},102+ &command{id: "column.reset", title: "Reset column width", desc: "Return the selected columns to the default width", run: func(m *Model) tea.Cmd {103+ r := m.selection()104+ for c := r.From.Col; c <= r.To.Col; c++ {105+ m.sheet.SetColWidth(c, 0)106+ }107+ m.changed = true91108 return nil92109 }},93- &command{id: "range.erase", title: "Range Erase", desc: "Erase the cell or range", run: func(m *Model) tea.Cmd {94- m.openRange("Enter range to erase:", func(m *Model, r sheet.Rect) tea.Cmd {95- m.sheet.EraseRange(r)96- m.changed = true97- return nil98- })110+ &command{id: "file.new", title: "New", desc: "Start a new, empty sheet", run: func(m *Model) tea.Cmd {111+ m.reset(sheet.New(), "")99112 return nil100113 }},101- &command{id: "file.save", title: "File Save", desc: "Store the entire worksheet in a file", run: (*Model).openSave},102- &command{id: "file.retrieve", title: "File Retrieve", desc: "Erase the current worksheet and display the selected file", run: (*Model).openRetrieve},103- &command{id: "quit", title: "Quit", desc: "End the session", run: func(*Model) tea.Cmd { return tea.Quit }},114+ &command{id: "file.save", title: "Save", desc: "Save the sheet", run: (*Model).save},115+ &command{id: "file.saveas", title: "Save as", desc: "Save the sheet under a new name", run: (*Model).openSave},116+ &command{id: "file.open", title: "Open", desc: "Open a sheet, replacing this one", run: (*Model).openRetrieve},117+ &command{id: "quit", title: "Quit", desc: "Close one23", run: (*Model).quit},104118 )105119 }120+121+// save writes to the current file, asking for a name the first time.122+func (m *Model) save() tea.Cmd {123+ if m.filename == "" {124+ return m.openSave()125+ }126+ return saveCmd(m.sheet, m.filename)127+}128+129+// quit exits, confirming first when there are unsaved changes.130+func (m *Model) quit() tea.Cmd {131+ if !m.changed {132+ return tea.Quit133+ }134+ m.openMenu()135+ m.menu = append(m.menu, menuLevel{items: quitConfirm})136+ return nil137+}@@ -0,0 +1,293 @@
1+package ui2+3+import (4+ "errors"5+ "slices"6+ "strings"7+ "unicode"8+ "unicode/utf8"9+10+ tea "charm.land/bubbletea/v2"11+12+ "one23/internal/sheet"13+)14+15+// Entry follows Google Sheets: typing replaces the cell (ENTER), Enter or16+// F2 edits it in place (EDIT). Enter commits and moves down, Tab commits17+// and moves right, Esc cancels. While typing a formula, arrow keys after an18+// operator point at cells (POINT) and insert their references.19+20+func (m *Model) startEntry(md mode, text string) {21+ m.mode = md22+ m.buf, m.bufPos, m.hint = nil, 0, ""23+ m.clearSelection()24+ m.insert(text)25+}26+27+// startEdit edits the active cell's contents with the caret at the end.28+func (m *Model) startEdit() tea.Cmd {29+ m.startEntry(modeEdit, "")30+ if c := m.sheet.Cell(m.cur); c != nil {31+ m.buf = []rune(c.Input)32+ m.bufPos = len(m.buf)33+ }34+ return nil35+}36+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+42+// enterKey handles ENTER mode. Arrow keys accept the entry and move, except43+// in a formula where a reference may follow the caret: then they point.44+func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {45+ key := k.String()46+ if m.commitKey(key) || m.cancelKey(key) {47+ return nil48+ }49+ if key == "f2" {50+ m.mode = modeEdit51+ return nil52+ }53+ if m.isFormula() && m.canPoint() && m.startPoint(key) {54+ return nil55+ }56+ if m.isFormula() && (key == "left" || key == "right") {57+ m.lineKey(k) // move the caret within a formula58+ return nil59+ }60+ if isMoveKey(key) {61+ if m.commit() {62+ m.navigate(key, &m.cur)63+ }64+ return nil65+ }66+ m.lineKey(k)67+ return nil68+}69+70+// editKey handles EDIT mode, where left and right move the caret.71+func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {72+ key := k.String()73+ if m.commitKey(key) || m.cancelKey(key) {74+ return nil75+ }76+ if m.isFormula() && m.canPoint() && strings.HasPrefix(key, "shift+") && m.startPoint(key) {77+ return nil78+ }79+ switch key {80+ case "up", "down", "pgup", "pgdown":81+ if m.commit() {82+ m.navigate(key, &m.cur)83+ }84+ default:85+ m.lineKey(k)86+ }87+ return nil88+}89+90+// commitKey handles the keys that accept an entry, moving afterwards the91+// way Sheets does. It reports whether key was one of them.92+func (m *Model) commitKey(key string) bool {93+ switch key {94+ case "enter", "shift+enter", "tab", "shift+tab", "ctrl+enter":95+ default:96+ return false97+ }98+ if !m.commit() {99+ return true100+ }101+ switch key {102+ case "enter":103+ if m.tabbing {104+ m.cur.Col = m.tabStart105+ m.tabbing = false106+ }107+ m.cur.Row++108+ case "shift+enter":109+ m.cur.Row--110+ case "tab":111+ if !m.tabbing {112+ m.tabStart, m.tabbing = m.cur.Col, true113+ }114+ m.cur.Col++115+ case "shift+tab":116+ m.cur.Col--117+ }118+ m.cur = clampAddr(m.cur)119+ return true120+}121+122+func (m *Model) cancelKey(key string) bool {123+ if key != "esc" {124+ return false125+ }126+ m.cancelEntry()127+ return true128+}129+130+// lineKey applies line-editing keys to buf.131+func (m *Model) lineKey(k tea.KeyPressMsg) {132+ switch k.String() {133+ case "left":134+ m.bufPos = max(m.bufPos-1, 0)135+ case "right":136+ m.bufPos = min(m.bufPos+1, len(m.buf))137+ case "home", "ctrl+a":138+ m.bufPos = 0139+ case "end", "ctrl+e":140+ m.bufPos = len(m.buf)141+ case "backspace":142+ if m.bufPos > 0 {143+ m.buf = slices.Delete(m.buf, m.bufPos-1, m.bufPos)144+ m.bufPos--145+ }146+ case "delete":147+ if m.bufPos < len(m.buf) {148+ m.buf = slices.Delete(m.buf, m.bufPos, m.bufPos+1)149+ }150+ default:151+ m.insert(typed(k))152+ }153+}154+155+// canPoint reports whether the text before the caret ends where a cell156+// reference may follow.157+func (m *Model) canPoint() bool {158+ if m.bufPos == 0 {159+ return false160+ }161+ return strings.ContainsRune("=+-*/^(,;<>&:", m.buf[m.bufPos-1])162+}163+164+// startPoint enters POINT mode if key moves (Shift extends). The pointer165+// starts at the active cell.166+func (m *Model) startPoint(key string) bool {167+ if base, ok := extendKey(key); ok {168+ key = base169+ m.point = pointer{at: m.cur, anchor: m.cur, anchored: true}170+ } else if isMoveKey(key) && key != "tab" && key != "shift+tab" {171+ m.point = pointer{at: m.cur}172+ } else {173+ return false174+ }175+ m.pointPrefix = string(m.buf[:m.bufPos])176+ m.pointSuffix = string(m.buf[m.bufPos:])177+ m.mode = modePoint178+ m.navigate(key, &m.point.at)179+ return true180+}181+182+// pointKey handles POINT mode. Arrows move the pointer, Shift+arrows183+// extend it to a range, and anything else puts the reference into the184+// formula and carries on typing.185+func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {186+ key := k.String()187+ if m.pointMoveKey(key) {188+ return nil189+ }190+ switch key {191+ case "esc", "backspace":192+ m.resumeEntry("")193+ return nil194+ case "enter", "shift+enter", "tab", "shift+tab", "ctrl+enter":195+ m.resumeEntry(m.point.text())196+ m.commitKey(key)197+ return nil198+ case ":":199+ if !m.point.anchored {200+ m.point.anchor, m.point.anchored = m.point.at, true201+ return nil202+ }203+ }204+ if text := typed(k); text != "" {205+ m.resumeEntry(m.point.text())206+ m.insert(text)207+ }208+ return nil209+}210+211+// resumeEntry leaves POINT mode, inserting ref at the caret.212+func (m *Model) resumeEntry(ref string) {213+ m.mode = modeEnter214+ m.buf = []rune(m.pointPrefix + ref + m.pointSuffix)215+ m.bufPos = utf8.RuneCountInString(m.pointPrefix + ref)216+}217+218+// commit stores the edit line in the active cell. An invalid formula stays219+// in EDIT mode with the caret at the problem and the reason on line 3.220+func (m *Model) commit() bool {221+ input := string(m.buf)222+ if err := m.set(m.cur, input); err != nil {223+ var pe *sheet.ParseError224+ m.mode = modeEdit225+ m.hint = err.Error()226+ if errors.As(err, &pe) {227+ m.bufPos = utf8.RuneCountInString(input[:min(pe.Pos, len(input))])228+ }229+ return false230+ }231+ m.cancelEntry()232+ return true233+}234+235+func (m *Model) set(a sheet.Addr, input string) error {236+ if err := m.sheet.Set(a, input); err != nil {237+ return err238+ }239+ m.changed = true240+ return nil241+}242+243+func (m *Model) cancelEntry() {244+ m.mode = modeReady245+ m.buf, m.bufPos, m.hint = nil, 0, ""246+}247+248+func (m *Model) insert(text string) {249+ for _, r := range text {250+ if r == '\n' || r == '\r' || r == '\t' {251+ r = ' '252+ }253+ if !unicode.IsPrint(r) {254+ continue255+ }256+ m.buf = slices.Insert(m.buf, m.bufPos, r)257+ m.bufPos++258+ }259+}260+261+func (m *Model) handlePaste(content string) {262+ switch m.mode {263+ case modeReady:264+ if content = strings.TrimSpace(content); content != "" {265+ m.startEntry(modeEnter, content)266+ }267+ case modeEnter, modeEdit:268+ m.insert(content)269+ case modePrompt:270+ if m.prompt.kind != promptRange {271+ m.promptType(content)272+ }273+ }274+}275+276+// pointer is a cell or range being selected in POINT mode or a range277+// prompt.278+type pointer struct {279+ at, anchor sheet.Addr280+ anchored bool281+}282+283+func (p pointer) rect() sheet.Rect {284+ if !p.anchored {285+ return sheet.Rect{From: p.at, To: p.at}286+ }287+ return sheet.NewRect(p.anchor, p.at)288+}289+290+// text is the reference as inserted into a formula, e.g. B3 or B3:B5.291+func (p pointer) text() string {292+ return p.rect().String()293+}@@ -30,34 +30,40 @@ type menuLevel struct {
3030 sel int3131 }3232 33+// rootMenu is interim: the Sheets-style menu bar replaces it.3334 var rootMenu = []menuItem{34- {name: "Worksheet", items: []menuItem{35- {name: "Column", items: []menuItem{36- {name: "Set-Width", cmd: "column.width"},37- {name: "Reset-Width", cmd: "column.reset"},38- }},39- {name: "Erase", items: []menuItem{40- {name: "No", desc: "Do not erase the worksheet; return to READY mode"},41- {name: "Yes", descFn: unsavedWarning("Erase the entire worksheet from memory"), cmd: "worksheet.erase"},42- }},43- }},44- {name: "Range", items: []menuItem{45- {name: "Erase", cmd: "range.erase"},46- }},4735 {name: "File", items: []menuItem{36+ {name: "New", descFn: unsavedWarning("Start a new, empty sheet"), cmd: "file.new"},37+ {name: "Open", cmd: "file.open"},4838 {name: "Save", cmd: "file.save"},49- {name: "Retrieve", cmd: "file.retrieve"},39+ {name: "As", desc: "Save the sheet under a new name", cmd: "file.saveas"},40+ {name: "Quit", cmd: "quit"},41+ }},42+ {name: "Edit", items: []menuItem{43+ {name: "Clear", cmd: "clear"},44+ {name: "Go to", cmd: "goto"},45+ {name: "Select all", cmd: "select.all"},5046 }},51- {name: "Quit", items: []menuItem{52- {name: "No", desc: "Do not end the session; return to READY mode"},53- {name: "Yes", descFn: unsavedWarning("End the session"), cmd: "quit"},47+ {name: "Format", items: []menuItem{48+ {name: "Column width", cmd: "column.width"},49+ {name: "Reset column width", cmd: "column.reset"},5450 }},5551 }5652 53+// quitConfirm is shown by Quit when there are unsaved changes.54+var quitConfirm = []menuItem{55+ {name: "Cancel", desc: "Keep working"},56+ {name: "Quit without saving", descFn: unsavedWarning("Close one23"), cmd: "quit.force"},57+}58+59+func init() {60+ register(&command{id: "quit.force", title: "Quit without saving", desc: "Close one23, discarding changes", run: func(*Model) tea.Cmd { return tea.Quit }})61+}62+5763 func unsavedWarning(desc string) func(m *Model) string {5864 return func(m *Model) string {5965 if m.changed {60- return "WORKSHEET CHANGES NOT SAVED! " + desc + " anyway?"66+ return "You have unsaved changes. " + desc + " anyway?"6167 }6268 return desc6369 }@@ -108,13 +114,33 @@ func (m *Model) menuKey(k tea.KeyPressMsg) tea.Cmd {
108114 m.mode = modeReady109115 }110116 default:117+ // A letter opens the only item starting with it, or cycles through118+ // the items when several do.111119 text := strings.ToUpper(typed(k))120+ if text == "" {121+ return nil122+ }123+ var matches []int112124 for i, it := range lvl.items {113- if text != "" && strings.HasPrefix(strings.ToUpper(it.name), text) {114- lvl.sel = i115- return m.choose(it)125+ if strings.HasPrefix(strings.ToUpper(it.name), text) {126+ matches = append(matches, i)116127 }117128 }129+ switch len(matches) {130+ case 0:131+ case 1:132+ lvl.sel = matches[0]133+ return m.choose(lvl.items[lvl.sel])134+ default:135+ next := matches[0]136+ for _, i := range matches {137+ if i > lvl.sel {138+ next = i139+ break140+ }141+ }142+ lvl.sel = next143+ }118144 }119145 return nil120146 }@@ -169,17 +195,18 @@ func (m *Model) openText(label, initial string, onText func(*Model, string) tea.
169195 m.openPrompt(&prompt{kind: promptText, label: label, indicator: "EDIT", fresh: true, onText: onText}, initial)170196 }171197 172-// openRange asks for a range, pre-anchored on the current cell as in 1-2-3.198+// openRange asks for a range, starting from the current selection.173199 func (m *Model) openRange(label string, onRange func(*Model, sheet.Rect) tea.Cmd) {200+ r := m.selection()174201 m.openPrompt(&prompt{kind: promptRange, label: label, indicator: "POINT", onRange: onRange}, "")175- m.point = pointer{at: m.cur, anchor: m.cur, anchored: true}202+ m.point = pointer{anchor: r.From, at: r.To, anchored: r.From != r.To}176203 }177204 178205 func (m *Model) openGoto() {179- m.openText("Enter address to go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {206+ m.openText("Go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {180207 a, ok := sheet.ParseAddr(strings.TrimSpace(text))181208 if !ok {182- m.fail("Invalid cell address: " + text)209+ m.fail("Not a cell address: " + text)183210 return nil184211 }185212 m.cur = a@@ -188,35 +215,54 @@ func (m *Model) openGoto() {
188215 m.prompt.indicator = "POINT"189216 }190217 218+// openWidth asks for the width of the selected columns, previewing it219+// live as the arrows change it. Esc restores the original widths.191220 func (m *Model) openWidth() tea.Cmd {192- col, orig := m.cur.Col, m.sheet.ColWidth(m.cur.Col)221+ r := m.selection()222+ orig := map[int]int{}223+ for c := r.From.Col; c <= r.To.Col; c++ {224+ orig[c] = m.sheet.ColWidth(c)225+ }226+ restore := func(m *Model) {227+ for c, w := range orig {228+ m.sheet.SetColWidth(c, w)229+ }230+ }193231 m.openPrompt(&prompt{194232 kind: promptWidth,195- label: "Enter column width (1..240):",196- indicator: "POINT",233+ label: "Column width (1-240):",234+ indicator: "WIDTH",197235 fresh: true,198236 onText: func(m *Model, text string) tea.Cmd {199237 w, err := strconv.Atoi(text)200238 if err != nil || w < 1 || w > 240 {201- m.sheet.SetColWidth(col, orig)239+ restore(m)202240 m.fail("Column width must be between 1 and 240")203241 return nil204242 }205- m.sheet.SetColWidth(col, w)243+ m.setWidths(w)206244 m.changed = true207245 return nil208246 },209- onCancel: func(m *Model) { m.sheet.SetColWidth(col, orig) },210- }, strconv.Itoa(orig))247+ onCancel: restore,248+ }, strconv.Itoa(m.sheet.ColWidth(m.cur.Col)))211249 return nil212250 }213251 252+// setWidths sets the width of every selected column.253+func (m *Model) setWidths(w int) {254+ r := m.selection()255+ for c := r.From.Col; c <= r.To.Col; c++ {256+ m.sheet.SetColWidth(c, w)257+ }258+}259+214260 func (m *Model) openSave() tea.Cmd {215261 name := m.filename216262 if name == "" {217263 name = "SHEET1" + sheet.FileExt218264 }219- m.openText("Enter save file name:", name, func(m *Model, text string) tea.Cmd {265+ m.openText("Save as:", name, func(m *Model, text string) tea.Cmd {220266 return saveCmd(m.sheet, withExt(text))221267 })222268 return nil@@ -224,7 +270,7 @@ func (m *Model) openSave() tea.Cmd {
224270 225271 func (m *Model) openRetrieve() tea.Cmd {226272 m.files = nil227- m.openText("Name of file to retrieve:", "", func(m *Model, text string) tea.Cmd {273+ m.openText("Open file:", "", func(m *Model, text string) tea.Cmd {228274 return loadCmd(withExt(text))229275 })230276 return listFilesCmd@@ -264,10 +310,10 @@ func (m *Model) promptKey(k tea.KeyPressMsg) tea.Cmd {
264310 265311 switch {266312 case m.pointing():267- if m.navigate(key, &m.point.at) {313+ if m.pointMoveKey(key) {268314 return nil269315 }270- if key == "." {316+ if key == ":" {271317 m.point.anchor, m.point.anchored = m.point.at, true272318 return nil273319 }@@ -285,7 +331,7 @@ func (m *Model) promptKey(k tea.KeyPressMsg) tea.Cmd {
285331 w = clamp(w, 1, 240)286332 m.buf, m.bufPos, p.fresh = nil, 0, false287333 m.insert(strconv.Itoa(w))288- m.sheet.SetColWidth(m.cur.Col, w) // live preview334+ m.setWidths(w) // live preview289335 default:290336 if p.fresh && typed(k) != "" {291337 m.buf, m.bufPos = nil, 0@@ -383,7 +429,7 @@ func listFilesCmd() tea.Msg {
383429 384430 func (m *Model) handleSaved(msg savedMsg) {385431 if msg.err != nil {386- m.fail(fmt.Sprintf("Cannot save %s: %v", msg.name, msg.err))432+ m.fail(fmt.Sprintf("Couldn't save %s: %v", msg.name, msg.err))387433 return388434 }389435 m.filename = msg.name@@ -392,7 +438,7 @@ func (m *Model) handleSaved(msg savedMsg) {
392438 393439 func (m *Model) handleLoaded(msg loadedMsg) {394440 if msg.err != nil {395- m.fail(fmt.Sprintf("Cannot retrieve %s: %v", msg.name, msg.err))441+ m.fail(fmt.Sprintf("Couldn't open %s: %v", msg.name, msg.err))396442 return397443 }398444 m.reset(msg.sheet, msg.name)@@ -1,28 +1,24 @@
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.1+// Package ui is the Bubble Tea front end. Inside the grid it behaves like2+// Google Sheets (keys, selection, entry); around it, the control panel and3+// mode indicator give it a 1-2-3 look.34 package ui45 56 import (6- "errors"7- "slices"8- "strings"9- "unicode"10- "unicode/utf8"7+ "time"118 129 tea "charm.land/bubbletea/v2"1310 1411 "one23/internal/sheet"1512 )1613 17-// mode is the state shown in the mode indicator, as in 1-2-3.14+// mode is the state shown in the mode indicator.1815 type mode int1916 2017 const (2118 modeReady mode = iota22- modeLabel23- modeValue24- modeEdit25- modePoint19+ modeEnter // typing a new entry, which replaces the cell20+ modeEdit // editing existing contents with a movable caret21+ modePoint // arrowing to a cell or range to insert into a formula2622 modeMenu2723 modePrompt2824 modeHelp@@ -30,7 +26,7 @@ const (
3026 )3127 3228 func (m mode) String() string {33- return [...]string{"READY", "LABEL", "VALUE", "EDIT", "POINT", "MENU", "", "HELP", "ERROR"}[m]29+ return [...]string{"READY", "ENTER", "EDIT", "POINT", "MENU", "", "HELP", "ERROR"}[m]3430 }3531 3632 // Layout: three control panel lines, the column header, the grid, and a@@ -42,29 +38,46 @@ const (
4238 rowHdrW = 64339 )4440 41+// doubleClick is the longest gap between two clicks that edits a cell.42+const doubleClick = 400 * time.Millisecond43+4544 // Model is the whole application state.4645 type Model struct {4746 sheet *sheet.Sheet4847 filename string4948 changed bool5049 51- cur sheet.Addr // cell pointer50+ cur sheet.Addr // the active cell5251 top, left int // first visible row and column5352 width, height int5453 54+ // Selection: see selection.go.55+ selecting bool56+ ext sheet.Addr // the moving corner of the selection57+ whole wholeKind58+ drag dragKind59+ lastClick time.Time60+ lastAddr sheet.Addr61+62+ // tabStart remembers where a run of Tab-committed entries began, so63+ // Enter returns to that column on the next row, as in Sheets.64+ tabStart int65+ tabbing bool66+5567 mode mode5668 57- // buf is the edit line used by LABEL, VALUE, EDIT and text prompts.69+ // buf is the edit line used by ENTER, EDIT and text prompts.5870 buf []rune5971 bufPos int60- hint string // shown on the third panel line, e.g. a parse error72+ hint string // shown on the third panel line, e.g. a formula error6173 6274 point pointer // POINT mode and range prompts63- pointPrefix string // entry text before the address being pointed at75+ pointPrefix string // entry text before the reference being pointed at76+ pointSuffix string // entry text after the caret while pointing6477 6578 menu []menuLevel6679 prompt *prompt67- files []string // file list shown by File Retrieve80+ files []string // file list shown by File Open6881 errMsg string6982 7083 th theme@@ -81,21 +94,24 @@ func (m *Model) Init() tea.Cmd { return tea.RequestBackgroundColor }
8194 8295 // Update implements tea.Model.8396 func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {97+ before, beforeMode := *m.focus(), m.mode8498 var cmd tea.Cmd8599 switch msg := msg.(type) {86100 case tea.WindowSizeMsg:87101 m.width, m.height = msg.Width, msg.Height102+ beforeMode = -1 // keep the focus visible after a resize88103 case tea.BackgroundColorMsg:89104 m.th = newTheme(msg.IsDark())90105 case tea.KeyPressMsg:91- if msg.String() == "ctrl+c" {92- return m, tea.Quit93- }94106 cmd = m.handleKey(msg)95107 case tea.PasteMsg:96108 m.handlePaste(msg.Content)97109 case tea.MouseClickMsg:98110 m.handleClick(msg.Mouse())111+ case tea.MouseMotionMsg:112+ m.handleMotion(msg.Mouse())113+ case tea.MouseReleaseMsg:114+ m.handleRelease()99115 case tea.MouseWheelMsg:100116 m.handleWheel(msg.Mouse())101117 case savedMsg:@@ -105,7 +121,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
105121 case filesMsg:106122 m.files = msg107123 }108- m.scrollTo(*m.focus())124+ // Scroll only when the focus moves, so the mouse wheel can look around125+ // without the view snapping back, as in Sheets.126+ if f := *m.focus(); f != before || m.mode != beforeMode {127+ m.scrollTo(f)128+ }109129 return m, cmd110130 }111131 @@ -113,8 +133,8 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
113133 switch m.mode {114134 case modeReady:115135 return m.readyKey(k)116- case modeLabel, modeValue:117- return m.entryKey(k)136+ case modeEnter:137+ return m.enterKey(k)118138 case modeEdit:119139 return m.editKey(k)120140 case modePoint:@@ -131,29 +151,22 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
131151 }132152 133153 func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {134- if m.navigate(k.String(), &m.cur) {154+ key := k.String()155+ if m.moveKey(key) {156+ m.tabbing = false135157 return nil136158 }137- if id, ok := keymap[k.String()]; ok {159+ if id, ok := keymap[key]; ok {138160 return m.runCommand(id)139161 }140162 if text := typed(k); text != "" {141- m.startEntry(entryMode(text), text)142- }143- return nil144-}145-146-// startEdit enters EDIT mode on the current cell's contents.147-func (m *Model) startEdit() tea.Cmd {148- m.startEntry(modeEdit, "")149- if c := m.sheet.Cell(m.cur); c != nil {150- m.buf = []rune(c.Input)151- m.bufPos = len(m.buf)163+ m.startEntry(modeEnter, text)152164 }153165 return nil154166 }155167 156-// navigate applies a movement key to a, returning false if key isn't one.168+// navigate applies a movement key to a, returning false if it isn't one.169+// Keys follow Google Sheets.157170 func (m *Model) navigate(key string, a *sheet.Addr) bool {158171 rows, cols := m.visibleRows(), m.visibleCols(m.left)159172 switch key {@@ -162,34 +175,36 @@ func (m *Model) navigate(key string, a *sheet.Addr) bool {
162175 case "down":163176 a.Row++164177 case "left", "shift+tab":165- if key == "shift+tab" {166- a.Col -= cols167- m.left -= cols168- } else {169- a.Col--170- }178+ a.Col--171179 case "right", "tab":172- if key == "tab" {173- a.Col += cols174- m.left += cols175- } else {176- a.Col++177- }180+ a.Col++181+ case "ctrl+up":182+ *a = m.sheet.Edge(*a, 0, -1)183+ case "ctrl+down":184+ *a = m.sheet.Edge(*a, 0, 1)178185 case "ctrl+left":179- a.Col -= cols180- m.left -= cols181- case "ctrl+right":182- a.Col += cols183- m.left += cols186+ *a = m.sheet.Edge(*a, -1, 0)187+ case "ctrl+right", "end":188+ *a = m.sheet.Edge(*a, 1, 0)184189 case "pgup":185190 a.Row -= rows186191 m.top -= rows187192 case "pgdown":188193 a.Row += rows189194 m.top += rows195+ case "alt+pgup":196+ a.Col -= cols197+ m.left -= cols198+ case "alt+pgdown":199+ a.Col += cols200+ m.left += cols190201 case "home":202+ a.Col = 0203+ case "ctrl+home":191204 *a = sheet.Addr{}192- m.top, m.left = 0, 0205+ case "ctrl+end":206+ used, _ := m.sheet.UsedRange()207+ *a = used.To193208 default:194209 return false195210 }@@ -199,221 +214,28 @@ func (m *Model) navigate(key string, a *sheet.Addr) bool {
199214 return true200215 }201216 202-func (m *Model) startEntry(md mode, text string) {203- m.mode = md204- m.buf = nil205- m.bufPos = 0206- m.hint = ""207- m.insert(text)208-}209-210-func entryMode(text string) mode {211- if sheet.IsValueEntry(text) {212- return modeValue213- }214- return modeLabel215-}216-217-// entryKey handles LABEL and VALUE modes: typing appends, and a movement218-// key accepts the entry and then moves, as in 1-2-3.219-func (m *Model) entryKey(k tea.KeyPressMsg) tea.Cmd {220- key := k.String()221- switch key {222- case "enter":223- m.commit()224- return nil225- case "esc":226- m.cancelEntry()227- return nil228- case "f2":229- m.mode = modeEdit230- return nil231- case "backspace":232- if m.bufPos > 0 {233- m.buf = slices.Delete(m.buf, m.bufPos-1, m.bufPos)234- m.bufPos--235- }236- if len(m.buf) == 0 {237- m.cancelEntry()238- }239- return nil240- }241- if isMoveKey(key) {242- if m.mode == modeValue && canPoint(m.buf) {243- m.mode = modePoint244- m.pointPrefix = string(m.buf)245- m.point = pointer{at: m.cur}246- m.navigate(key, &m.point.at)247- return nil248- }249- if m.commit() {250- m.navigate(key, &m.cur)251- }252- return nil253- }254- m.insert(typed(k))255- return nil256-}257-258-// editKey handles EDIT mode, where arrows move within the entry.259-func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {260- switch key := k.String(); key {261- case "enter":262- m.commit()263- case "esc":264- m.cancelEntry()265- case "up", "down", "pgup", "pgdown":266- if m.commit() {267- m.navigate(key, &m.cur)268- }269- default:270- m.lineKey(k)271- }272- return nil273-}274-275-// lineKey applies line-editing keys to buf. It reports whether the key was276-// consumed.277-func (m *Model) lineKey(k tea.KeyPressMsg) bool {278- switch k.String() {279- case "left":280- m.bufPos = max(m.bufPos-1, 0)281- case "right":282- m.bufPos = min(m.bufPos+1, len(m.buf))283- case "home", "ctrl+a":284- m.bufPos = 0285- case "end", "ctrl+e":286- m.bufPos = len(m.buf)287- case "backspace":288- if m.bufPos > 0 {289- m.buf = slices.Delete(m.buf, m.bufPos-1, m.bufPos)290- m.bufPos--291- }292- case "delete":293- if m.bufPos < len(m.buf) {294- m.buf = slices.Delete(m.buf, m.bufPos, m.bufPos+1)295- }296- default:297- text := typed(k)298- m.insert(text)299- return text != ""300- }301- return true302-}303-304-// pointKey handles POINT mode: arrows move a pointer whose address (or305-// range, once anchored with '.') is spliced into the formula.306-func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {307- key := k.String()308- if m.navigate(key, &m.point.at) {309- return nil310- }217+func isMoveKey(key string) bool {311218 switch key {312- case ".":313- if !m.point.anchored {314- m.point.anchor, m.point.anchored = m.point.at, true315- }316- case "esc":317- if m.point.anchored {318- m.point.anchored = false319- return nil320- }321- m.resumeValue(m.pointPrefix)322- case "backspace":323- m.resumeValue(m.pointPrefix)324- case "enter":325- m.resumeValue(m.pointPrefix + m.point.text())326- m.commit()327- default:328- if text := typed(k); text != "" {329- m.resumeValue(m.pointPrefix + m.point.text() + text)330- }331- }332- return nil333-}334-335-func (m *Model) resumeValue(text string) {336- m.startEntry(modeValue, text)337-}338-339-// commit stores the edit line in the current cell. An invalid formula puts340-// the user in EDIT mode with the cursor at the problem, like 1-2-3 does.341-func (m *Model) commit() bool {342- input := string(m.buf)343- if err := m.set(m.cur, input); err != nil {344- var pe *sheet.ParseError345- m.mode = modeEdit346- m.hint = err.Error()347- if errors.As(err, &pe) {348- m.bufPos = utf8.RuneCountInString(input[:min(pe.Pos, len(input))])349- }350- return false351- }352- m.cancelEntry()353- return true354-}355-356-func (m *Model) set(a sheet.Addr, input string) error {357- if err := m.sheet.Set(a, input); err != nil {358- return err359- }360- m.changed = true361- return nil362-}363-364-func (m *Model) cancelEntry() {365- m.mode = modeReady366- m.buf, m.bufPos, m.hint = nil, 0, ""367-}368-369-func (m *Model) insert(text string) {370- for _, r := range text {371- if r == '\n' || r == '\r' || r == '\t' {372- r = ' '373- }374- if !unicode.IsPrint(r) {375- continue376- }377- m.buf = slices.Insert(m.buf, m.bufPos, r)378- m.bufPos++379- }380-}381-382-func (m *Model) handlePaste(content string) {383- switch m.mode {384- case modeReady:385- content = strings.TrimSpace(content)386- if content != "" {387- m.startEntry(entryMode(content), content)388- }389- case modeLabel, modeValue, modeEdit:390- m.insert(content)391- case modePrompt:392- if m.prompt.kind != promptRange {393- m.promptType(content)394- }395- }396-}397-398-func (m *Model) handleClick(mouse tea.Mouse) {399- if mouse.Button != tea.MouseLeft {400- return401- }402- a, ok := m.cellAt(mouse.X, mouse.Y)403- if !ok {404- return405- }406- switch {407- case m.mode == modeReady:408- m.cur = a409- case m.mode == modePoint, m.pointing():410- m.point.at = a219+ case "up", "down", "left", "right", "tab", "shift+tab", "pgup", "pgdown",220+ "alt+pgup", "alt+pgdown", "home", "end", "ctrl+home", "ctrl+end",221+ "ctrl+up", "ctrl+down", "ctrl+left", "ctrl+right":222+ return true411223 }224+ return false412225 }413226 414227 func (m *Model) handleWheel(mouse tea.Mouse) {415228 const step = 3416- switch mouse.Button {229+ button := mouse.Button230+ if mouse.Mod.Contains(tea.ModShift) { // Shift+wheel scrolls sideways231+ switch button {232+ case tea.MouseWheelUp:233+ button = tea.MouseWheelLeft234+ case tea.MouseWheelDown:235+ button = tea.MouseWheelRight236+ }237+ }238+ switch button {417239 case tea.MouseWheelUp:418240 m.top = max(m.top-step, 0)419241 case tea.MouseWheelDown:@@ -422,43 +244,21 @@ func (m *Model) handleWheel(mouse tea.Mouse) {
422244 m.left = max(m.left-1, 0)423245 case tea.MouseWheelRight:424246 m.left = min(m.left+1, sheet.MaxCols-1)425- default:426- return427247 }428- // Keep the focused cell on screen rather than snapping back to it.429- f := m.focus()430- f.Row = clamp(f.Row, m.top, m.top+m.visibleRows()-1)431- f.Col = clamp(f.Col, m.left, m.left+m.visibleCols(m.left)-1)432248 }433249 434-// focus is the cell the viewport follows: the pointer while pointing,435-// otherwise the cell pointer.250+// focus is the cell the viewport follows: the pointer while pointing, the251+// moving corner while extending a selection, otherwise the active cell.436252 func (m *Model) focus() *sheet.Addr {437- if m.mode == modePoint || m.pointing() {253+ switch {254+ case m.mode == modePoint || m.pointing():438255 return &m.point.at256+ case m.selecting && m.whole == wholeNone:257+ return &m.ext439258 }440259 return &m.cur441260 }442261 443-// cellAt maps a screen position to a cell.444-func (m *Model) cellAt(x, y int) (sheet.Addr, bool) {445- row := y - gridTop446- if row < 0 || row >= m.visibleRows() || x < rowHdrW {447- return sheet.Addr{}, false448- }449- cx := rowHdrW450- for c := m.left; c < sheet.MaxCols; c++ {451- cx += m.sheet.ColWidth(c)452- if x < cx {453- return sheet.Addr{Col: c, Row: m.top + row}, true454- }455- if cx >= m.width {456- break457- }458- }459- return sheet.Addr{}, false460-}461-462262 func (m *Model) visibleRows() int {463263 return max(m.height-gridTop-1, 1)464264 }@@ -488,23 +288,6 @@ func (m *Model) scrollTo(a sheet.Addr) {
488288 }489289 }490290 491-func isMoveKey(key string) bool {492- switch key {493- case "up", "down", "left", "right", "pgup", "pgdown", "tab", "shift+tab", "ctrl+left", "ctrl+right", "home":494- return true495- }496- return false497-}498-499-// canPoint reports whether the entry so far ends where a cell address may500-// follow, so movement keys start POINT mode instead of accepting the entry.501-func canPoint(buf []rune) bool {502- if len(buf) == 0 {503- return false504- }505- return strings.ContainsRune("+-*/^(,;=<>&#", buf[len(buf)-1])506-}507-508291 // typed returns the printable text of a key press, if any.509292 func typed(k tea.KeyPressMsg) string {510293 if k.Mod.Contains(tea.ModCtrl) || k.Mod.Contains(tea.ModAlt) {@@ -523,23 +306,3 @@ func clampAddr(a sheet.Addr) sheet.Addr {
523306 Row: clamp(a.Row, 0, sheet.MaxRows-1),524307 }525308 }526-527-// pointer is a cell or range being selected with the arrow keys.528-type pointer struct {529- at, anchor sheet.Addr530- anchored bool531-}532-533-func (p pointer) rect() sheet.Rect {534- if !p.anchored {535- return sheet.Rect{From: p.at, To: p.at}536- }537- return sheet.NewRect(p.anchor, p.at)538-}539-540-func (p pointer) text() string {541- if !p.anchored {542- return p.at.String()543- }544- return p.anchor.String() + ".." + p.at.String()545-}@@ -2,9 +2,9 @@ package ui
22 33 import (44 "os"5- "path/filepath"65 "strings"76 "testing"7+ "time"88 99 tea "charm.land/bubbletea/v2"1010 "github.com/charmbracelet/x/ansi"@@ -12,47 +12,73 @@ import (
1212 "one23/internal/sheet"1313 )1414 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,15+var named = map[string]tea.Key{16+ "enter": {Code: tea.KeyEnter}, "esc": {Code: tea.KeyEscape}, "backspace": {Code: tea.KeyBackspace},17+ "tab": {Code: tea.KeyTab}, "delete": {Code: tea.KeyDelete}, "home": {Code: tea.KeyHome},18+ "up": {Code: tea.KeyUp}, "down": {Code: tea.KeyDown}, "left": {Code: tea.KeyLeft}, "right": {Code: tea.KeyRight},19+ "pgdown": {Code: tea.KeyPgDown}, "f1": {Code: tea.KeyF1}, "f2": {Code: tea.KeyF2}, "f5": {Code: tea.KeyF5},20+ "f10": {Code: tea.KeyF10}, "space": {Code: tea.KeySpace},2021 }2122 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.23+// press sends keys to m. Named keys go in angle brackets with optional24+// modifiers, e.g. "<shift+down>" or "<ctrl+s>"; other text is typed a25+// character at a time. Commands run and their messages are fed back,26+// except tea.Quit, which is returned.2527 func press(t *testing.T, m *Model, keys ...string) tea.Msg {2628 t.Helper()2729 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- }3930 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)31+ name, ok := strings.CutPrefix(k, "<")32+ if !ok {33+ for _, r := range k {34+ if msg := send(m, tea.KeyPressMsg{Code: r, Text: string(r)}); msg != nil {35+ last = msg36+ }4537 }46- send(tea.KeyPressMsg{Code: code})4738 continue4839 }49- for _, r := range k {50- send(tea.KeyPressMsg{Code: r, Text: string(r)})40+ parts := strings.Split(strings.TrimSuffix(name, ">"), "+")41+ var mod tea.KeyMod42+ for _, p := range parts[:len(parts)-1] {43+ switch p {44+ case "shift":45+ mod |= tea.ModShift46+ case "ctrl":47+ mod |= tea.ModCtrl48+ case "alt":49+ mod |= tea.ModAlt50+ }51+ }52+ base := parts[len(parts)-1]53+ key, ok := named[base]54+ if !ok && len(base) == 1 {55+ key, ok = tea.Key{Code: rune(base[0])}, true56+ }57+ if !ok {58+ t.Fatalf("unknown key %q", k)59+ }60+ key.Mod = mod61+ if msg := send(m, tea.KeyPressMsg(key)); msg != nil {62+ last = msg5163 }5264 }5365 return last5466 }5567 68+// send delivers msg and runs resulting commands, returning a QuitMsg if69+// one is produced.70+func send(m *Model, msg tea.Msg) tea.Msg {71+ _, cmd := m.Update(msg)72+ for cmd != nil {73+ out := cmd()74+ if _, ok := out.(tea.QuitMsg); ok {75+ return out76+ }77+ _, cmd = m.Update(out)78+ }79+ return nil80+}81+5682 func newModel() *Model {5783 m := New(sheet.New(), "")5884 m.Update(tea.WindowSizeMsg{Width: 80, Height: 20})@@ -63,116 +89,261 @@ func screen(m *Model) string {
6389 return ansi.Strip(m.View().Content)6490 }6591 92+func line(m *Model, i int) string {93+ return strings.TrimRight(strings.Split(screen(m), "\n")[i], " ")94+}95+6696 func addr(s string) sheet.Addr {6797 a, _ := sheet.ParseAddr(s)6898 return a6999 }70100 71-func TestEntryAndMove(t *testing.T) {101+func input(m *Model, a string) string {102+ if c := m.sheet.Cell(addr(a)); c != nil {103+ return c.Input104+ }105+ return ""106+}107+108+func TestEnterCommitsAndMovesDown(t *testing.T) {72109 m := newModel()73- press(t, m, "12", "<down>", "30", "<down>", "@SUM(A1..A2)", "<enter>")74- if m.mode != modeReady || m.cur != addr("A3") {110+ press(t, m, "12", "<enter>", "30", "<enter>", "=SUM(A1:A2)", "<enter>")111+ if m.mode != modeReady || m.cur != addr("A4") {75112 t.Fatalf("mode %v at %v", m.mode, m.cur)76113 }77114 if got := m.sheet.Value(addr("A3")).Num; got != 42 {78115 t.Errorf("A3 = %v", got)79116 }80- if s := screen(m); !strings.Contains(s, "A3: @SUM(A1..A2)") || !strings.Contains(s, " 42 ") {81- t.Errorf("screen:\n%s", s)117+ press(t, m, "<up>")118+ if !strings.HasPrefix(line(m, 0), " A3 =SUM(A1:A2)") {119+ t.Errorf("formula bar %q", line(m, 0))82120 }83121 }84122 85-func TestLabelSpill(t *testing.T) {123+func TestTabThenEnterReturnsToStartColumn(t *testing.T) {124+ m := newModel()125+ press(t, m, "<right>", "Name", "<tab>", "Qty", "<tab>", "Price", "<enter>")126+ if m.cur != addr("B2") {127+ t.Errorf("cur %v, want B2", m.cur)128+ }129+ if input(m, "D1") != "Price" {130+ t.Errorf("D1 = %q", input(m, "D1"))131+ }132+}133+134+func TestArrowsAcceptAndMove(t *testing.T) {135+ m := newModel()136+ press(t, m, "hello", "<right>")137+ if input(m, "A1") != "hello" || m.cur != addr("B1") {138+ t.Errorf("A1 %q cur %v", input(m, "A1"), m.cur)139+ }140+ press(t, m, "x", "<esc>")141+ if input(m, "B1") != "" || m.mode != modeReady {142+ t.Errorf("Esc kept %q", input(m, "B1"))143+ }144+}145+146+func TestTextOverflow(t *testing.T) {86147 m := newModel()87148 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)149+ if row := line(m, gridTop); !strings.Contains(row, " Quarterly revenue report") {150+ t.Errorf("text did not overflow: %q", row)91151 }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)152+ press(t, m, "<up>", "<right>", "5", "<enter>")153+ if row := line(m, gridTop); !strings.Contains(row, " Quarterly 5") {154+ t.Errorf("overflow not cut off: %q", row)97155 }98156 }99157 100158 func TestPointMode(t *testing.T) {101159 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))160+ press(t, m, "1", "<enter>", "2", "<enter>")161+ press(t, m, "=SUM(", "<up>", "<up>")162+ if m.mode != modePoint || line(m, 1) != "=SUM(A1" {163+ t.Fatalf("mode %v, edit line %q", m.mode, line(m, 1))106164 }107- press(t, m, ".", "<down>")108- if panelText(m, 1) != "@SUM(A1..A2" {109- t.Fatalf("line 2 %q", panelText(m, 1))165+ press(t, m, "<shift+down>")166+ if line(m, 1) != "=SUM(A1:A2" {167+ t.Fatalf("edit line %q", line(m, 1))110168 }111169 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)170+ if input(m, "A3") != "=SUM(A1:A2)" || m.sheet.Value(addr("A3")).Num != 3 || m.cur != addr("A4") {171+ t.Errorf("A3 %q = %v, cur %v", input(m, "A3"), m.sheet.Value(addr("A3")), m.cur)115172 }116173 }117174 118-func TestInvalidFormulaGoesToEdit(t *testing.T) {175+func TestInvalidFormulaStaysInEdit(t *testing.T) {119176 m := newModel()120- press(t, m, "+B1+", "<enter>")121- if m.mode != modeEdit || m.hint == "" || m.sheet.Cell(addr("A1")) != nil {177+ press(t, m, "=B1+", "<enter>")178+ if m.mode != modeEdit || m.hint != "Formula is incomplete" || m.sheet.Cell(addr("A1")) != nil {122179 t.Fatalf("mode %v hint %q", m.mode, m.hint)123180 }124181 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")))182+ if m.mode != modeReady || m.sheet.Value(addr("A1")).Num != 1 || m.cur != addr("A2") {183+ t.Errorf("mode %v A1 %+v cur %v", m.mode, m.sheet.Value(addr("A1")), m.cur)127184 }128185 }129186 130187 func TestEditExisting(t *testing.T) {131188 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)189+ press(t, m, "=2*3", "<enter>", "<up>", "<f2>", "<left>", "<backspace>", "4", "<enter>")190+ // Caret lands before "3", Backspace removes "*", then "4" is inserted.191+ if got := input(m, "A1"); got != "=243" {192+ t.Errorf("input %q, want =243", got)193+ }194+ press(t, m, "<up>", "<enter>")195+ if m.mode != modeEdit || string(m.buf) != "=243" {196+ t.Errorf("Enter should edit: mode %v buf %q", m.mode, string(m.buf))136197 }137198 }138199 139-func TestMenuColumnWidth(t *testing.T) {200+func TestShiftSelectStatsAndClear(t *testing.T) {140201 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))202+ press(t, m, "1", "<enter>", "2", "<enter>", "3", "<enter>", "<ctrl+home>")203+ press(t, m, "<shift+down>", "<shift+down>")204+ if got := m.selection().String(); got != "A1:A3" || m.cur != addr("A1") {205+ t.Fatalf("selection %s cur %v", got, m.cur)206+ }207+ status := line(m, m.height-1)208+ for _, want := range []string{"A1:A3", "Sum 6", "Avg 2", "Count 3"} {209+ if !strings.Contains(status, want) {210+ t.Errorf("status %q missing %q", status, want)211+ }144212 }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))213+ press(t, m, "<delete>")214+ if m.sheet.Len() != 0 {215+ t.Errorf("%d cells left after clear", m.sheet.Len())148216 }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))217+ press(t, m, "<down>")218+ if m.hasRange() {219+ t.Error("arrow did not collapse the selection")220+ }221+}222+223+func TestCtrlArrowJumps(t *testing.T) {224+ m := newModel()225+ press(t, m, "1", "<enter>", "2", "<enter>", "3", "<enter>", "<ctrl+home>")226+ press(t, m, "<ctrl+down>")227+ if m.cur != addr("A3") {228+ t.Errorf("ctrl+down to %v", m.cur)229+ }230+ press(t, m, "<ctrl+home>", "<ctrl+shift+down>")231+ if got := m.selection().String(); got != "A1:A3" {232+ t.Errorf("ctrl+shift+down selected %s", got)233+ }234+}235+236+func TestSelectAllColumnsRows(t *testing.T) {237+ m := newModel()238+ press(t, m, "<right>", "<down>", "x", "<enter>", "<ctrl+a>")239+ if got := m.selection().String(); got != "A1:B2" {240+ t.Errorf("ctrl+a selected %s", got)241+ }242+ press(t, m, "<ctrl+a>")243+ if m.whole != wholeAll {244+ t.Error("second ctrl+a did not select everything")245+ }246+ press(t, m, "<esc>", "<ctrl+space>")247+ if got := m.selection(); got.From.Row != 0 || got.To.Row != sheet.MaxRows-1 {248+ t.Errorf("ctrl+space selected %s", got)249+ }250+ press(t, m, "<esc>", "<shift+space>")251+ if got := m.selection(); got.From.Col != 0 || got.To.Col != sheet.MaxCols-1 {252+ t.Errorf("shift+space selected %s", got)253+ }254+}255+256+func click(m *Model, x, y int, mod tea.KeyMod) {257+ send(m, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft, Mod: mod})258+ send(m, tea.MouseReleaseMsg{X: x, Y: y, Button: tea.MouseLeft})259+}260+261+// cellX returns a screen x inside column c when scrolled to column A.262+func cellX(c int) int { return rowHdrW + c*sheet.DefaultWidth + 2 }263+264+func TestMouseSelection(t *testing.T) {265+ m := newModel()266+ click(m, cellX(1), gridTop+2, 0)267+ if m.cur != addr("B3") || m.hasRange() {268+ t.Fatalf("click: cur %v", m.cur)269+ }270+ click(m, cellX(2), gridTop+4, tea.ModShift)271+ if got := m.selection().String(); got != "B3:C5" {272+ t.Errorf("shift+click selected %s", got)273+ }274+275+ send(m, tea.MouseClickMsg{X: cellX(0), Y: gridTop, Button: tea.MouseLeft})276+ send(m, tea.MouseMotionMsg{X: cellX(1), Y: gridTop + 1, Button: tea.MouseLeft})277+ send(m, tea.MouseReleaseMsg{X: cellX(1), Y: gridTop + 1, Button: tea.MouseLeft})278+ if got := m.selection().String(); got != "A1:B2" || m.cur != addr("A1") {279+ t.Errorf("drag selected %s, cur %v", got, m.cur)280+ }281+282+ click(m, cellX(3), headerLine, 0)283+ if got := m.selection(); got.From != addr("D1") || got.To != addr("D8192") {284+ t.Errorf("column header selected %s", got)285+ }286+ click(m, 1, gridTop+3, 0)287+ if got := m.selection(); got.From != addr("A4") || got.To.Col != sheet.MaxCols-1 {288+ t.Errorf("row header selected %s", got)289+ }290+}291+292+func TestDoubleClickEdits(t *testing.T) {293+ m := newModel()294+ press(t, m, "hi", "<enter>")295+ click(m, cellX(0), gridTop, 0)296+ click(m, cellX(0), gridTop, 0)297+ if m.mode != modeEdit || string(m.buf) != "hi" {298+ t.Errorf("mode %v buf %q", m.mode, string(m.buf))153299 }154300 press(t, m, "<esc>")155- if m.sheet.ColWidth(0) != 15 {156- t.Errorf("width after esc %d", m.sheet.ColWidth(0))301+ m.lastClick = time.Time{}302+ click(m, cellX(0), gridTop, 0)303+ if m.mode != modeReady {304+ t.Error("a single click edited")157305 }158306 }159307 160-func TestRangeErase(t *testing.T) {308+func TestWheelDoesNotSnapBack(t *testing.T) {161309 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())310+ for range 5 {311+ send(m, tea.MouseWheelMsg{Button: tea.MouseWheelDown})166312 }167- press(t, m, "/re", "<backspace>", "c1", "<enter>")168- if m.sheet.Len() != 0 {169- t.Errorf("typed range not erased")313+ if m.top != 15 || m.cur != addr("A1") {314+ t.Fatalf("top %d cur %v", m.top, m.cur)315+ }316+ press(t, m, "<down>")317+ if m.top != 1 {318+ t.Errorf("moving the cursor should bring it back into view, top %d", m.top)319+ }320+}321+322+func TestMenuColumnWidthOnSelection(t *testing.T) {323+ m := newModel()324+ press(t, m, "<shift+right>", "<f10>")325+ if line(m, 1) != "File Edit Format" {326+ t.Fatalf("menu line %q", line(m, 1))327+ }328+ press(t, m, "f") // two items start with F: cycles to Format329+ press(t, m, "<enter>", "c", "15", "<enter>")330+ if m.mode != modeReady || m.sheet.ColWidth(0) != 15 || m.sheet.ColWidth(1) != 15 {331+ t.Errorf("mode %v widths %d %d", m.mode, m.sheet.ColWidth(0), m.sheet.ColWidth(1))332+ }333+ // Arrows preview live; Esc restores.334+ press(t, m, "<f10>", "<left>", "<enter>", "c", "<right>", "<right>")335+ if m.sheet.ColWidth(0) != 17 {336+ t.Errorf("preview width %d", m.sheet.ColWidth(0))337+ }338+ press(t, m, "<esc>")339+ if m.sheet.ColWidth(0) != 15 {340+ t.Errorf("width after esc %d", m.sheet.ColWidth(0))170341 }171342 }172343 173344 func TestGoto(t *testing.T) {174345 m := newModel()175- press(t, m, "<f5>", "Z100", "<enter>")346+ press(t, m, "<ctrl+g>", "Z100", "<enter>")176347 if m.cur != addr("Z100") || m.top == 0 || m.left == 0 {177348 t.Errorf("cur %v top %d left %d", m.cur, m.top, m.left)178349 }@@ -186,49 +357,58 @@ func TestGoto(t *testing.T) {
186357 }187358 }188359 189-func TestSaveAndRetrieve(t *testing.T) {360+func TestSaveAndOpen(t *testing.T) {190361 t.Chdir(t.TempDir())191362 m := newModel()192- press(t, m, "Hello", "<down>", "42", "<enter>")193- press(t, m, "/fs", "budget", "<enter>")363+ press(t, m, "Hello", "<enter>", "42", "<enter>")364+ press(t, m, "<ctrl+s>", "budget", "<enter>")194365 if m.filename != "budget.o23" || m.changed {195366 t.Fatalf("filename %q changed %v err %q", m.filename, m.changed, m.errMsg)196367 }197- if _, err := os.Stat(filepath.Join(".", "budget.o23")); err != nil {368+ if _, err := os.Stat("budget.o23"); err != nil {198369 t.Fatal(err)199370 }371+ // Ctrl+S again saves without asking.372+ press(t, m, "7", "<enter>", "<ctrl+s>")373+ if m.mode != modeReady || m.changed {374+ t.Errorf("second save: mode %v changed %v", m.mode, m.changed)375+ }200376 201377 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))378+ press(t, m2, "<ctrl+o>")379+ if !strings.Contains(line(m2, 2), "budget.o23") {380+ t.Errorf("file list %q", line(m2, 2))205381 }206382 press(t, m2, "budget", "<enter>")207383 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)384+ t.Errorf("opened A2 %+v file %q", m2.sheet.Value(addr("A2")), m2.filename)209385 }210-211- press(t, m2, "/fr", "missing", "<enter>")386+ press(t, m2, "<ctrl+o>", "missing", "<enter>")212387 if m2.mode != modeError {213388 t.Errorf("mode %v", m2.mode)214389 }215390 }216391 217-func TestQuit(t *testing.T) {392+func TestQuitConfirmsUnsavedChanges(t *testing.T) {218393 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)394+ if _, ok := press(t, m, "<ctrl+q>").(tea.QuitMsg); !ok {395+ t.Fatal("Ctrl+Q with no changes did not quit")221396 }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))397+ m = newModel()398+ press(t, m, "1", "<enter>")399+ if msg := press(t, m, "<ctrl+q>"); msg != nil || m.mode != modeMenu {400+ t.Fatalf("Ctrl+Q with changes: %v mode %v", msg, m.mode)401+ }402+ press(t, m, "<right>")403+ if !strings.Contains(line(m, 2), "unsaved changes") {404+ t.Errorf("no unsaved warning: %q", line(m, 2))225405 }226406 if _, ok := press(t, m, "<enter>").(tea.QuitMsg); !ok {227- t.Error("Quit Yes did not quit")407+ t.Error("Quit without saving did not quit")228408 }229409 }230410 231-func TestScrollFollowsPointer(t *testing.T) {411+func TestScrollFollowsCursor(t *testing.T) {232412 m := newModel()233413 for range 40 {234414 press(t, m, "<down>")@@ -236,12 +416,19 @@ func TestScrollFollowsPointer(t *testing.T) {
236416 if m.top == 0 || m.cur.Row != 40 || m.cur.Row >= m.top+m.visibleRows() {237417 t.Errorf("top %d cur %v rows %d", m.top, m.cur, m.visibleRows())238418 }239- press(t, m, "<home>")419+ press(t, m, "<ctrl+home>")240420 if m.top != 0 || m.cur != (sheet.Addr{}) {241- t.Errorf("home: top %d cur %v", m.top, m.cur)421+ t.Errorf("ctrl+home: top %d cur %v", m.top, m.cur)242422 }243423 }244424 245-func panelText(m *Model, line int) string {246- return strings.TrimRight(strings.Split(screen(m), "\n")[line], " ")425+func TestHelpListsShortcuts(t *testing.T) {426+ m := newModel()427+ press(t, m, "<f1>")428+ s := screen(m)429+ for _, want := range []string{"Ctrl+S", "Save the sheet", "Backspace / Del", "SUM"} {430+ if !strings.Contains(s, want) {431+ t.Errorf("help missing %q", want)432+ }433+ }247434 }@@ -0,0 +1,260 @@
1+package ui2+3+import (4+ "strings"5+ "time"6+7+ tea "charm.land/bubbletea/v2"8+9+ "one23/internal/sheet"10+)11+12+// Selection follows Google Sheets: the active cell (m.cur) stays put while13+// an extension corner (m.ext) moves with Shift+arrows, Shift+click or a14+// mouse drag. Clicking a column or row header selects whole columns or15+// rows, and the corner selects everything.16+17+type wholeKind int18+19+const (20+ wholeNone wholeKind = iota21+ wholeCols22+ wholeRows23+ wholeAll24+)25+26+type dragKind int27+28+const (29+ dragNone dragKind = iota30+ dragCells31+ dragCols32+ dragRows33+ dragPoint // dragging out a range in POINT mode or a range prompt34+)35+36+// selection returns the selected range; just the active cell when nothing37+// is selected.38+func (m *Model) selection() sheet.Rect {39+ if !m.selecting {40+ return sheet.Rect{From: m.cur, To: m.cur}41+ }42+ r := sheet.NewRect(m.cur, m.ext)43+ switch m.whole {44+ case wholeCols:45+ r.From.Row, r.To.Row = 0, sheet.MaxRows-146+ case wholeRows:47+ r.From.Col, r.To.Col = 0, sheet.MaxCols-148+ case wholeAll:49+ r = sheet.Rect{To: sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}}50+ }51+ return r52+}53+54+// hasRange reports whether more than one cell is selected.55+func (m *Model) hasRange() bool {56+ r := m.selection()57+ return r.From != r.To58+}59+60+func (m *Model) clearSelection() {61+ m.selecting, m.whole = false, wholeNone62+ m.ext = m.cur63+}64+65+// extendKey reports whether key is a Shift-modified movement key and66+// returns the movement without Shift. Shift+Tab is its own movement.67+func extendKey(key string) (string, bool) {68+ if key == "shift+tab" || !strings.Contains(key, "shift+") {69+ return "", false70+ }71+ base := strings.Replace(key, "shift+", "", 1)72+ return base, isMoveKey(base)73+}74+75+// moveKey handles a movement key in READY mode: plain keys move the active76+// cell and drop the selection, Shift+keys extend it.77+func (m *Model) moveKey(key string) bool {78+ if base, ok := extendKey(key); ok {79+ if !m.selecting {80+ m.selecting, m.ext = true, m.cur81+ }82+ m.whole = wholeNone83+ return m.navigate(base, &m.ext)84+ }85+ if m.navigate(key, &m.cur) {86+ m.clearSelection()87+ return true88+ }89+ return false90+}91+92+// pointMoveKey handles movement in POINT mode and range prompts: arrows93+// move the pointer and Shift+arrows stretch it into a range, as when94+// selecting a range while typing a formula in Sheets.95+func (m *Model) pointMoveKey(key string) bool {96+ if key == "tab" || key == "shift+tab" {97+ return false98+ }99+ if base, ok := extendKey(key); ok {100+ if !m.point.anchored {101+ m.point.anchor, m.point.anchored = m.point.at, true102+ }103+ return m.navigate(base, &m.point.at)104+ }105+ if m.navigate(key, &m.point.at) {106+ m.point.anchored = false107+ return true108+ }109+ return false110+}111+112+// selectColumns selects the columns spanned by the selection (Ctrl+Space).113+func (m *Model) selectColumns() tea.Cmd {114+ if !m.selecting {115+ m.ext = m.cur116+ }117+ m.selecting, m.whole = true, wholeCols118+ return nil119+}120+121+// selectRows selects the rows spanned by the selection (Shift+Space).122+func (m *Model) selectRows() tea.Cmd {123+ if !m.selecting {124+ m.ext = m.cur125+ }126+ m.selecting, m.whole = true, wholeRows127+ return nil128+}129+130+func (m *Model) selectAll() tea.Cmd {131+ used, ok := m.sheet.UsedRange()132+ if ok && !(m.selecting && m.whole == wholeNone && m.selection() == used) {133+ m.cur, m.ext = used.From, used.To134+ m.selecting, m.whole = true, wholeNone135+ return nil136+ }137+ m.cur = sheet.Addr{Col: m.left, Row: m.top}138+ m.selecting, m.whole, m.ext = true, wholeAll, m.cur139+ return nil140+}141+142+// hit is what's under the mouse.143+type hit struct {144+ kind dragKind // dragCells, dragCols or dragRows; dragNone for the corner145+ addr sheet.Addr146+ ok bool147+}148+149+// hitTest maps a screen position to a cell, header or the corner. Rows and150+// columns past the edge of the grid clamp one step beyond it, so dragging151+// off the grid scrolls.152+func (m *Model) hitTest(x, y int) hit {153+ rows := m.visibleRows()154+ row := clamp(m.top+y-gridTop, max(m.top-1, 0), min(m.top+rows, sheet.MaxRows-1))155+ col := m.colAt(x)156+ switch {157+ case y == headerLine && x < rowHdrW:158+ return hit{kind: dragNone, ok: true}159+ case y == headerLine:160+ return hit{kind: dragCols, addr: sheet.Addr{Col: col, Row: m.top}, ok: true}161+ case y > headerLine && x < rowHdrW:162+ return hit{kind: dragRows, addr: sheet.Addr{Col: m.left, Row: row}, ok: true}163+ case y > headerLine:164+ return hit{kind: dragCells, addr: sheet.Addr{Col: col, Row: row}, ok: true}165+ }166+ return hit{}167+}168+169+// colAt returns the column under x, clamped one past the visible columns.170+func (m *Model) colAt(x int) int {171+ if x < rowHdrW {172+ return max(m.left-1, 0)173+ }174+ cx := rowHdrW175+ for c := m.left; c < sheet.MaxCols; c++ {176+ cx += m.sheet.ColWidth(c)177+ if x < cx {178+ return c179+ }180+ }181+ return sheet.MaxCols - 1182+}183+184+func (m *Model) handleClick(mouse tea.Mouse) {185+ if mouse.Button != tea.MouseLeft {186+ return187+ }188+ h := m.hitTest(mouse.X, mouse.Y)189+ if !h.ok {190+ return191+ }192+ if m.mode == modePoint || m.pointing() {193+ if h.kind == dragCells {194+ m.point.at, m.point.anchor, m.point.anchored = h.addr, h.addr, false195+ m.drag = dragPoint196+ }197+ return198+ }199+ if m.mode != modeReady {200+ return201+ }202+ shift := mouse.Mod.Contains(tea.ModShift)203+ switch h.kind {204+ case dragNone:205+ m.cur = sheet.Addr{Col: m.left, Row: m.top}206+ m.selecting, m.whole, m.ext = true, wholeAll, m.cur207+ return208+ case dragCells:209+ switch {210+ case shift:211+ m.selecting, m.whole, m.ext = true, wholeNone, h.addr212+ case h.addr == m.lastAddr && time.Since(m.lastClick) < doubleClick:213+ m.cur = h.addr214+ m.clearSelection()215+ m.startEdit()216+ return217+ default:218+ m.cur = h.addr219+ m.clearSelection()220+ }221+ m.lastClick, m.lastAddr = time.Now(), h.addr222+ case dragCols, dragRows:223+ whole := wholeCols224+ if h.kind == dragRows {225+ whole = wholeRows226+ }227+ if !shift || m.whole != whole {228+ m.cur = h.addr229+ }230+ m.selecting, m.whole, m.ext = true, whole, h.addr231+ }232+ m.drag = h.kind233+}234+235+func (m *Model) handleMotion(mouse tea.Mouse) {236+ if m.drag == dragNone || mouse.Button != tea.MouseLeft {237+ return238+ }239+ h := m.hitTest(mouse.X, mouse.Y)240+ switch m.drag {241+ case dragPoint:242+ if h.addr != m.point.anchor {243+ m.point.anchored = true244+ }245+ m.point.at = h.addr246+ case dragCells:247+ m.selecting, m.ext = true, h.addr248+ case dragCols:249+ m.ext.Col = m.colAt(mouse.X)250+ case dragRows:251+ m.ext.Row = h.addr.Row252+ }253+}254+255+func (m *Model) handleRelease() {256+ m.drag = dragNone257+ if m.selecting && m.whole == wholeNone && m.ext == m.cur {258+ m.clearSelection()259+ }260+}@@ -15,6 +15,7 @@ type theme struct {
1515 indicator lipgloss.Style // mode indicator, top right1616 header lipgloss.Style // column letters and row numbers1717 headerActive lipgloss.Style // header of the focused row and column18+ headerSel lipgloss.Style // headers of selected rows and columns1819 pointer lipgloss.Style // the cell pointer1920 selection lipgloss.Style // a range being pointed at2021 menuSelected lipgloss.Style // highlighted menu item@@ -22,6 +23,7 @@ type theme struct {
2223 warning lipgloss.Style // recoverable problems, e.g. a formula error2324 error lipgloss.Style // ERROR mode message2425 muted lipgloss.Style // secondary text: key hints, file lists26+ key lipgloss.Style // a key name inside a hint, e.g. "Enter"2527 errorCell lipgloss.Style // cells whose value is ERR or NA2628 }2729 @@ -39,6 +41,7 @@ func newTheme(dark bool) theme {
3941 indicator: accent.Bold(true),4042 header: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),4143 headerActive: accent.Bold(true),44+ headerSel: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),4245 pointer: accent,4346 selection: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),4447 menuSelected: accent.Bold(true),@@ -46,6 +49,7 @@ func newTheme(dark bool) theme {
4649 warning: lipgloss.NewStyle().Foreground(lipgloss.Yellow),4750 error: lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true),4851 muted: lipgloss.NewStyle().Foreground(muted),52+ key: lipgloss.NewStyle().Bold(true),4953 errorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),5054 }5155 }@@ -2,6 +2,7 @@ package ui
22 33 import (44 "fmt"5+ "math"56 "path/filepath"67 "slices"78 "strconv"@@ -33,8 +34,12 @@ func (m *Model) View() tea.View {
3334 v.AltScreen = true3435 v.MouseMode = tea.MouseModeCellMotion3536 v.WindowTitle = "one23 - " + m.displayName()37+ if m.changed {38+ v.WindowTitle += " (modified)"39+ }3640 if x, ok := m.cursorX(); ok {3741 v.Cursor = tea.NewCursor(x, 1)42+ v.Cursor.Shape = tea.CursorBar3843 }3944 return v4045 }@@ -46,15 +51,12 @@ func (m *Model) displayName() string {
4651 return filepath.Base(m.filename)4752 }4853 49-// panelLine1 shows the current cell's address, width and contents, with the50-// mode indicator on the right.54+// panelLine1 is the formula bar: the active cell's address and contents as55+// typed, with the mode indicator on the right.5156 func (m *Model) panelLine1() string {52- left := m.cur.String() + ":"53- if w := m.sheet.ColWidth(m.cur.Col); w != sheet.DefaultWidth {54- left += fmt.Sprintf(" [W%d]", w)55- }57+ left := m.th.header.Render(padRight(" "+m.cur.String(), rowHdrW-1)) + " "5658 if c := m.sheet.Cell(m.cur); c != nil {57- left += " " + c.Input59+ left += c.Input5860 }5961 ind := m.mode.String()6062 if m.mode == modePrompt {@@ -63,19 +65,19 @@ func (m *Model) panelLine1() string {
6365 ind = m.th.indicator.Render(" " + ind + " ")6466 gap := m.width - ansi.StringWidth(left) - ansi.StringWidth(ind)6567 if gap < 1 {66- left = ansi.Truncate(left, m.width-ansi.StringWidth(ind)-1, "")67- gap = 168+ left = ansi.Truncate(left, m.width-ansi.StringWidth(ind)-2, "…")69+ gap = m.width - ansi.StringWidth(left) - ansi.StringWidth(ind)6870 }69- return left + strings.Repeat(" ", gap) + ind71+ return left + strings.Repeat(" ", max(gap, 1)) + ind7072 }7173 7274 // panelLine2 is the edit line, the menu, or a prompt.7375 func (m *Model) panelLine2() string {7476 switch m.mode {75- case modeLabel, modeValue, modeEdit:77+ case modeEnter, modeEdit:7678 return string(m.buf)7779 case modePoint:78- return m.pointPrefix + m.point.text()80+ return m.pointPrefix + m.th.selection.Render(m.point.text()) + m.pointSuffix7981 case modeMenu:8082 lvl := m.menu[len(m.menu)-1]8183 parts := make([]string, len(lvl.items))@@ -88,7 +90,7 @@ func (m *Model) panelLine2() string {
8890 return strings.Join(parts, " ")8991 case modePrompt:9092 if m.pointing() {91- return m.prompt.label + " " + m.point.text()93+ return m.promptPrefix() + m.th.selection.Render(m.point.text())9294 }9395 return m.promptPrefix() + string(m.buf)9496 }@@ -99,29 +101,48 @@ func (m *Model) promptPrefix() string {
99101 return m.prompt.label + " "100102 }101103 102-// panelLine3 describes the highlighted menu item, lists files, or shows a103-// hint such as a formula error.104+// panelLine3 explains the current state: the highlighted menu item, a105+// formula error, or which keys do what.104106 func (m *Model) panelLine3() string {105107 switch {108+ case m.hint != "":109+ return m.th.warning.Render(m.hint)106110 case m.mode == modeMenu:107111 lvl := m.menu[len(m.menu)-1]108112 return lvl.items[lvl.sel].description(m)109113 case m.mode == modePrompt && len(m.files) > 0:110114 return m.th.muted.Render(strings.Join(m.files, " "))111115 case m.mode == modePrompt && m.prompt.kind == promptWidth:112- return m.th.muted.Render("Type a width or use the left and right arrows")113- case m.pointing() || m.mode == modePoint:114- return m.th.muted.Render("Arrows move, . anchors a range, Esc unanchors, Enter accepts")115- case m.hint != "":116- return m.th.warning.Render(m.hint)116+ return m.keyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")117+ case m.pointing():118+ return m.keyHints("Arrows", "move", "Shift+arrows", "extend", "Enter", "apply", "Esc", "cancel")119+ case m.mode == modePrompt:120+ return m.keyHints("Enter", "apply", "Esc", "cancel")121+ case m.mode == modePoint:122+ return m.keyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")123+ case m.mode == modeEnter && m.isFormula():124+ return m.keyHints("Enter", "accept", "Tab", "accept and go right", "Arrows after an operator", "pick cells", "Esc", "cancel")125+ case m.mode == modeEnter:126+ return m.keyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "accept and move", "Esc", "cancel")127+ case m.mode == modeEdit:128+ return m.keyHints("Enter", "accept", "Left/Right", "move the caret", "Esc", "cancel")117129 }118130 return ""119131 }120132 133+// keyHints renders key and description pairs, e.g. "Enter accept".134+func (m *Model) keyHints(pairs ...string) string {135+ parts := make([]string, 0, len(pairs)/2)136+ for i := 0; i+1 < len(pairs); i += 2 {137+ parts = append(parts, m.th.key.Render(pairs[i])+" "+m.th.muted.Render(pairs[i+1]))138+ }139+ return strings.Join(parts, m.th.muted.Render(" "))140+}141+121142 // cursorX returns where the terminal cursor goes on the edit line.122143 func (m *Model) cursorX() (int, bool) {123144 switch {124- case m.mode == modeLabel, m.mode == modeValue, m.mode == modeEdit:145+ case m.mode == modeEnter, m.mode == modeEdit:125146 return ansi.StringWidth(string(m.buf[:m.bufPos])), true126147 case m.mode == modePrompt && !m.pointing():127148 return ansi.StringWidth(m.promptPrefix() + string(m.buf[:m.bufPos])), true@@ -129,15 +150,38 @@ func (m *Model) cursorX() (int, bool) {
129150 return 0, false130151 }131152 153+// active is the cell drawn as the cell pointer: the pointer while154+// pointing, otherwise the active cell (which stays put while a selection is155+// extended, as in Sheets).156+func (m *Model) active() sheet.Addr {157+ if m.mode == modePoint || m.pointing() {158+ return m.point.at159+ }160+ return m.cur161+}162+163+// highlight is the range drawn as selected: the pointer's range while164+// pointing, otherwise the selection.165+func (m *Model) highlight() (sheet.Rect, bool) {166+ if m.mode == modePoint || m.pointing() {167+ return m.point.rect(), true168+ }169+ return m.selection(), m.hasRange()170+}171+132172 func (m *Model) headerRow() string {133173 var b strings.Builder134174 b.WriteString(m.th.header.Render(strings.Repeat(" ", rowHdrW)))135- focus := *m.focus()175+ focus := m.active()176+ sel, selecting := m.highlight()136177 for i := range m.visibleCols(m.left) {137178 c := m.left + i138179 style := m.th.header139- if c == focus.Col {180+ switch {181+ case c == focus.Col:140182 style = m.th.headerActive183+ case selecting && c >= sel.From.Col && c <= sel.To.Col:184+ style = m.th.headerSel141185 }142186 b.WriteString(style.Render(center(sheet.ColName(c), m.sheet.ColWidth(c))))143187 }@@ -148,21 +192,23 @@ func (m *Model) gridRow(row int) string {
148192 if row >= sheet.MaxRows {149193 return ""150194 }151- focus := *m.focus()195+ focus := m.active()196+ sel, selecting := m.highlight()152197 hdr := m.th.header153- if row == focus.Row {198+ switch {199+ case row == focus.Row:154200 hdr = m.th.headerActive201+ case selecting && row >= sel.From.Row && row <= sel.To.Row:202+ hdr = m.th.headerSel155203 }156204 var b strings.Builder157205 b.WriteString(hdr.Render(padLeft(strconv.Itoa(row+1), rowHdrW-1) + " "))158206 159- var sel sheet.Rect160- selecting := m.mode == modePoint || m.pointing()161- if selecting {162- sel = m.point.rect()163- }164207 for i, text := range m.rowText(row) {165208 a := sheet.Addr{Col: m.left + i, Row: row}209+ if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) {210+ text = m.inCellText(m.sheet.ColWidth(a.Col))211+ }166212 switch {167213 case a == focus:168214 b.WriteString(m.th.pointer.Render(text))@@ -177,9 +223,20 @@ func (m *Model) gridRow(row int) string {
177223 return b.String()178224 }179225 226+// inCellText shows the entry being typed inside the cell, keeping the end227+// of long entries visible, as Sheets does.228+func (m *Model) inCellText(w int) string {229+ text := " " + string(m.buf)230+ if over := ansi.StringWidth(text) - w; over > 0 {231+ text = ansi.TruncateLeft(text, over+1, "…")232+ }233+ return padRight(text, w)234+}235+180236 // rowText returns the visible text of each column in row, each exactly the181-// column's width. Left-aligned labels spill into blank cells to the right,182-// including labels that start left of the viewport.237+// column's width. Text starts after one column of padding and overflows238+// into blank cells to the right, including text that starts left of the239+// viewport, as in Sheets.183240 func (m *Model) rowText(row int) []string {184241 ncols := m.visibleCols(m.left)185242 out := make([]string, ncols)@@ -190,8 +247,8 @@ func (m *Model) rowText(row int) []string {
190247 if cell == nil {191248 continue192249 }193- if cell.Align() == '\'' {194- spill = cell.Value.Str250+ if cell.Value.Kind == sheet.Text {251+ spill = " " + cell.Value.Str195252 for k := c; k < m.left && spill != ""; k++ {196253 _, spill = cut(spill, m.sheet.ColWidth(k))197254 }@@ -204,9 +261,13 @@ func (m *Model) rowText(row int) []string {
204261 w := m.sheet.ColWidth(c)205262 cell := m.sheet.Cell(sheet.Addr{Col: c, Row: row})206263 switch {264+ case cell != nil && cell.Value.Kind == sheet.Text:265+ var head string266+ head, spill = cut(" "+cell.Value.Str, w)267+ out[i] = padRight(head, w)207268 case cell != nil:208269 spill = ""209- out[i] = cellText(cell, w, &spill)270+ out[i] = padRight(sheet.FormatValue(cell.Value, w), w)210271 case spill != "":211272 var head string212273 head, spill = cut(spill, w)@@ -218,59 +279,58 @@ func (m *Model) rowText(row int) []string {
218279 return out219280 }220281 221-// cellText formats a non-blank cell to width w. Left-aligned label text222-// that doesn't fit is returned through spill.223-func cellText(c *sheet.Cell, w int, spill *string) string {224- v := c.Value225- switch {226- case v.Kind == sheet.Number || v.Kind == sheet.Error:227- return sheet.FormatValue(v, w)228- case c.Align() == '"':229- return padLeft(ansi.Truncate(v.Str, w, ""), w)230- case c.Align() == '^':231- return center(ansi.Truncate(v.Str, w, ""), w)232- case c.Align() == '\'':233- var head string234- head, *spill = cut(v.Str, w)235- return padRight(head, w)236- }237- // String formula results are left-aligned and don't spill.238- return padRight(ansi.Truncate(v.Str, w, ""), w)239-}240-241282 func (m *Model) statusLine() string {242283 if m.mode == modeError {243- return m.th.error.Render(m.errMsg) + m.th.muted.Render(" (press any key)")284+ return m.th.error.Render(m.errMsg) + m.th.muted.Render(" press any key")244285 }245286 left := m.displayName()246287 if m.changed {247- left += " [modified]"288+ left += m.th.muted.Render(" modified")248289 }249- var ind []string250290 if m.sheet.Circular {251- ind = append(ind, "CIRC")291+ left += " " + m.th.warning.Render("Circular reference")292+ }293+ var right string294+ if m.hasRange() && m.mode == modeReady {295+ r := m.selection()296+ st := m.sheet.RangeStats(r)297+ parts := []string{m.th.key.Render(r.String())}298+ if st.Nums > 0 {299+ parts = append(parts,300+ m.th.muted.Render("Sum ")+fmtStat(st.Sum),301+ m.th.muted.Render("Avg ")+fmtStat(st.Sum/float64(st.Nums)))302+ }303+ parts = append(parts, m.th.muted.Render("Count ")+strconv.Itoa(st.Count))304+ right = strings.Join(parts, " ")305+ } else {306+ right = m.keyHints("F1", "help", "F10", "menu", "Ctrl+Q", "quit")252307 }253- ind = append(ind, "F1 Help / Menu")254- right := m.th.muted.Render(strings.Join(ind, " "))255308 gap := max(m.width-ansi.StringWidth(left)-ansi.StringWidth(right), 1)256309 return left + strings.Repeat(" ", gap) + right257310 }258311 312+// fmtStat formats a status line statistic with at most two decimals, as313+// Sheets does.314+func fmtStat(v float64) string {315+ return strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64)316+}317+259318 func (m *Model) helpLines() []string {260319 lines := []string{261- m.th.indicator.Render(" HELP ") + " one23 (press any key to return)",320+ m.th.indicator.Render(" HELP ") + " Keyboard shortcuts " + m.th.muted.Render("(press any key to return)"),262321 "",263- " Arrows Move the cell pointer",264- " PgUp / PgDn Move one screen up or down",265- " Tab / Shift+Tab Move one screen right or left",266- " Home Go to A1",267- " Ctrl+C Quit immediately",322+ " Arrows Move Shift+arrows Select",323+ " Ctrl+arrows Jump to the edge of the data (add Shift to select)",324+ " Tab / Shift+Tab Right / left PgUp / PgDn Screen up / down",325+ " Home / Ctrl+Home Column A / A1 Ctrl+End Last used cell",326+ " Mouse Click, drag or Shift+click to select; click headers",327+ " for whole columns or rows; double-click to edit",268328 "",269329 }270330 // Shortcuts come from the keymap so help can't drift from behavior.271331 var ids []string272332 for id := range commands {273- if len(keysFor(id)) > 0 {333+ if len(keysFor(id)) > 0 && id != "help" {274334 ids = append(ids, id)275335 }276336 }@@ -280,34 +340,59 @@ func (m *Model) helpLines() []string {
280340 for i, k := range keys {281341 keys[i] = keyLabel(k)282342 }283- lines = append(lines, fmt.Sprintf(" %-20s%s", strings.Join(keys, " or "), commands[id].desc))343+ lines = append(lines, fmt.Sprintf(" %-20s%s", strings.Join(keys, " / "), commands[id].desc))284344 }285- return append(lines,345+ names := make([]string, 0, len(sheet.Funcs()))346+ for _, f := range sheet.Funcs() {347+ names = append(names, f.Name)348+ }349+ lines = append(lines,286350 "",287- " Entries",288- " Text Label (prefix ' left, \" right, ^ center)",289- " 0-9 + - . ( @ # $ Value or formula, e.g. +A1*2, @SUM(A1..A5)",290- " Arrow after + ( , POINT mode: arrow to a cell, . anchors a range",351+ " Typing replaces the cell. Start with = for a formula, ' to force text.",352+ " While typing a formula, arrows after an operator pick cells.",291353 "",292- " Functions: @SUM @AVG @COUNT @MIN @MAX @ABS @INT @SQRT @ROUND @MOD",293- " @IF @PI @TRUE @FALSE @ERR @NA",294- " Operators: + - * / ^ & = <> < > <= >= #AND# #OR# #NOT#",295354 )355+ return append(lines, wrapWords(" Functions: ", names, m.width)...)356+}357+358+// wrapWords lays out words after prefix, wrapping to width and indenting359+// continuation lines under the first word.360+func wrapWords(prefix string, words []string, width int) []string {361+ var lines []string362+ cur := prefix363+ indent := strings.Repeat(" ", len(prefix))364+ for i, w := range words {365+ if i > 0 && len(cur)+1+len(w) > width {366+ lines = append(lines, cur)367+ cur = indent + w368+ continue369+ }370+ if i > 0 {371+ cur += " "372+ }373+ cur += w374+ }375+ return append(lines, cur)296376 }297377 298-// keyLabel formats a key binding for display, e.g. "delete" -> "Del".378+// keyLabel formats a key binding for display, e.g. "ctrl+s" -> "Ctrl+S".299379 func keyLabel(k string) string {300380 switch k {301381 case "delete":302382 return "Del"303- case "enter":304- return "Enter"383+ case "backspace":384+ return "Backspace"305385 case "esc":306386 return "Esc"307387 }308388 parts := strings.Split(k, "+")309389 for i, p := range parts {310- if len(p) > 1 {390+ switch {391+ case len(p) == 1:392+ parts[i] = strings.ToUpper(p)393+ case p[0] == 'f' && len(p) <= 3 && p[1] >= '0' && p[1] <= '9':394+ parts[i] = strings.ToUpper(p)395+ default:311396 parts[i] = strings.ToUpper(p[:1]) + p[1:]312397 }313398 }