diff --git a/README.md b/README.md index 90e2829..2f9654c 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ When starting a new sheet from scratch the workbook will have one sheet with one To move around the sheet, use vi-like keys: -- `h`, `j`, `k`, `l`: Left, Down, Up, Right -- `^`, `$`: Move to First or Last column in current row -- `g`, `G`: Move to First or Last row in current column +- `h`, `j`, `k`, `l`, or _arrow keys_: Left, Down, Up, Right +- `^`, `$`, `home`, `end`: Move to First or Last column in current row +- `g`, `G`, `PageUp`, `PageDown`: Move to First or Last row in current column #### Data @@ -62,7 +62,7 @@ When referencing a cell in an expressions, such as `A2`, you may optionally "loc #### Deleting Data -To delete the contents of a cell, that is - to revert it to an empty state, press `d`. +To delete the contents of a cell, that is - to revert it to an empty state, press `d`, `Backspace`, or `Delete` #### Yank / Paste diff --git a/cell.go b/cell.go index fc5f42b..6763470 100644 --- a/cell.go +++ b/cell.go @@ -63,17 +63,14 @@ func (c *cell) ToggleMod(mod int) { } } -func (c *cell) Edit(row, col int, text bool) { - line, err := GetLine(fmt.Sprintf("\033[2KUpdate %c%d: \033[?25h", col+64, row)) - if err != nil { - panic(err) - } +func (c *cell) Edit(row, col, width int, text bool) { + line := GetEditLine(fmt.Sprintf("%c%d > ", col+64, row), c.rawVal, width) if len(line) == 0 { return } fmt.Print("\033[?25l") line = strings.TrimSpace(line) - if text { + if text && (!strings.HasPrefix(line, "\"") && !strings.HasSuffix(line, "\"")){ line = fmt.Sprintf("\"%s\"", line) } updated := c.Update(line) diff --git a/main.go b/main.go index 048b04a..328857a 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "strings" "syscall" "tildegit.org/sloum/tally/termios" + "tildegit.org/sloum/tally/qline" ) const ( @@ -95,13 +96,16 @@ func runCommand(elems []string) { } } -func Getch() rune { +func Getch() (rune, error) { reader := bufio.NewReader(os.Stdin) - char, _, err := reader.ReadRune() - if err != nil { - return 0 - } - return char + return qline.ReadKey(reader) +} + +func GetEditLine(prefix, content string, width int) string { + fmt.Print("\033[?25h") + s := qline.GetInput(prefix, content, width) + fmt.Print("\033[?25h") + return s } func GetLine(prefix string) (string, error) { diff --git a/qline/qline.go b/qline/qline.go new file mode 100644 index 0000000..e3e2140 --- /dev/null +++ b/qline/qline.go @@ -0,0 +1,286 @@ +package qline + +// qline is a line input library for terminals that utilize +// vt100 compatible escape sequences +// +// Copyright © 2021 Brian Evans +// +// Permission is hereby granted, free of charge, to any +// person obtaining a copy of this software and associated +// documentation files (the “Software”), to deal in the +// Software without restriction, including without +// limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software +// is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice +// shall be included in all copies or substantial portions +// of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +// KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO +// THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +// CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +const ( + UpArrow rune = iota - 20 + DownArrow + LeftArrow + RightArrow + Delete + Home + End + PageUp + PageDown + Escape rune = 27 + NewLine rune = 10 + CarriageReturn rune = 13 + BackSpace rune = 127 +) + +var ( + width int +) + +type buffer struct { + buf []rune + cursor int + maxWidth int + offset int + cursorStart int + prompt string +} + +func GetInput(prompt string, content string, cols int) string { + b := buffer{make([]rune, 0, (len(content)+1)*2), 0, cols-len(prompt), 0, 0, prompt} + + fmt.Printf("\033[1m%s\033[22m", prompt) + + var ch rune + var err error + reader := bufio.NewReader(os.Stdin) + + b.seedContent(content) + b.printBuf() + + for { + ch, err = ReadKey(reader) + if err != nil { + continue + } + if ch == CarriageReturn || ch == NewLine { + break + } + + if isControl(ch) { + b.controlInput(ch) + } else { + b.addChar(ch, true) + } + } + return b.string() +} + + +func (lb buffer) string() string { + return string(lb.buf) +} + +func (lb *buffer) deleteChar() { + if lb.cursor == len(lb.buf) { + return + } else if lb.cursor == len(lb.buf)-1 { + lb.buf = lb.buf[:len(lb.buf)-1] + } else { + lb.buf = append(lb.buf[:lb.cursor], lb.buf[lb.cursor+1:]...) + } + if lb.offset > 0 { + lb.offset-- + } +} + +func (lb *buffer) addChar(c rune, echo bool) { + if c < 9 || (c > 10 && c < 13) || (c > 13 && c < 32) { + return + } + if lb.cursor == len(lb.buf) { + lb.buf = append(lb.buf, c) + lb.cursor++ + } else { + lb.buf = append(lb.buf[:lb.cursor+1], lb.buf[lb.cursor:]...) + lb.buf[lb.cursor] = c + lb.cursor++ + } + if lb.cursor - lb.offset > lb.maxWidth { + lb.offset++ + } + if echo { + lb.printBuf() + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func (lb buffer) printBuf() { + out := lb.buf[lb.offset:min(len(lb.buf), lb.offset+lb.maxWidth)] + fmt.Printf("\r%s%s\033[0K\r\033[%dC", lb.prompt, string(out), lb.cursor - lb.offset + len(lb.prompt)) +} + +func (lb *buffer) controlInput(c rune) { + switch c { + case Delete: + lb.deleteChar() + case BackSpace: + if lb.cursor > 0 { + lb.cursor-- + lb.deleteChar() + } + case LeftArrow: + if lb.offset > 0 && lb.cursor - lb.offset == 0 { + lb.offset-- + } + if lb.cursor > 0 { + lb.cursor-- + } + case RightArrow: + // This is still mildly funky, but works enough + for ;lb.cursor - lb.offset >= lb.maxWidth && lb.cursor < len(lb.buf); { + lb.offset++ + } + if lb.cursor < len(lb.buf) { + lb.cursor++ + } + case Home: + lb.offset = 0 + lb.cursor = 0 + case End: + lb.cursor = len(lb.buf) + lb.offset = max(lb.cursor - lb.maxWidth, 0) + } + lb.printBuf() +} + +func (lb *buffer) seedContent(s string) { + for _, r := range s { + lb.addChar(r, false) + } +} + +func parseCursorPosition(esc string) (int, int, error) { + var row, col int + r := strings.NewReader(esc) + _, err := fmt.Fscanf(r, "\033[%d;%dR", &row, &col) + return row, col, err +} + + +func ReadKey(reader *bufio.Reader) (rune, error) { + char, _, err := reader.ReadRune() + if err != nil { + return 0, err + } + avail := reader.Buffered() + if char == Escape && avail > 0 { + var b strings.Builder + b.WriteRune(27) + for ; avail > 0; avail-- { + c, _, e := reader.ReadRune() + if e != nil { + break + } + b.WriteRune(c) + } + escSeq := b.String() + switch true { + case escSeq == "\033[A": + char = UpArrow + case escSeq == "\033[B": + char = DownArrow + case escSeq == "\033[C": + char = RightArrow + case escSeq == "\033[D": + char = LeftArrow + case escSeq == "\033[5~": + char = PageUp + case escSeq == "\033[6~": + char = PageDown + case isHomeKey(escSeq): + char = Home + case isEndKey(escSeq): + char = End + case isDeleteKey(escSeq): + char = Delete + case escSeq[len(escSeq)-1] == 'R': + // This is a request for cursor position + _, cols, err := parseCursorPosition(escSeq) + if err == nil { + err = fmt.Errorf("response") + } + return rune(cols), err + } + } + return char, nil +} + +func isControl(c rune) bool { + switch c { + case UpArrow, DownArrow, RightArrow, LeftArrow, PageUp, PageDown, Home, End, Delete, BackSpace, CarriageReturn, NewLine, Escape: + return true + default: + return false + } +} + +func isDeleteKey(seq string) bool { + switch seq { + case "\033[3~", "\033[P": + return true + default: + return false + } +} + +func isHomeKey(seq string) bool { + switch seq { + case "\033[1~", "\033[7~", "\033[H", "\033OH": + return true + default: + return false + } +} + +func isEndKey(seq string) bool { + switch seq { + case "\033[4~", "\033[8~", "\033[F", "\033OF": + return true + default: + return false + } +} + diff --git a/sheet.go b/sheet.go index 47d6646..92af982 100644 --- a/sheet.go +++ b/sheet.go @@ -3,6 +3,7 @@ package main import ( "fmt" "strings" + "tildegit.org/sloum/tally/qline" ) type sheet struct { @@ -173,14 +174,14 @@ func (s *sheet) PasteRelative() { func (s *sheet) moveSelection(dir rune, termRows int) { switch dir { - case Left: + case Left, qline.LeftArrow: if s.selection.col > 1 { s.selection.col-- } if s.selection.col <= s.colOff { s.colOff-- } - case Right: + case Right, qline.RightArrow: s.selection.col++ if s.selection.col > s.cols && s.selection.col < 27 { s.AddCols(1) @@ -191,14 +192,14 @@ func (s *sheet) moveSelection(dir rune, termRows int) { if s.selection.col > 26 { s.selection.col = 26 } - case Up: + case Up, qline.UpArrow: if s.selection.row > 1 { s.selection.row-- } if s.selection.row <= s.rowOff { s.rowOff-- } - case Down: + case Down, qline.DownArrow: s.selection.row++ if s.selection.row > s.rows { s.AddRows(1) @@ -206,19 +207,19 @@ func (s *sheet) moveSelection(dir rune, termRows int) { if s.selection.row > s.rowOff + termRows - 5 && s.rows > termRows - 5 { s.rowOff++ } - case RowStart: + case RowStart, qline.Home: s.selection.col = 1 s.colOff = 0 - case ToTop: + case ToTop, qline.PageUp: s.selection.row = 1 s.rowOff = 0 - case RowEnd: + case RowEnd, qline.End: s.selection.col = s.cols s.colOff = s.cols - s.zoom if s.colOff < 0 { s.colOff = 0 } - case ToBottom: + case ToBottom, qline.PageDown: s.selection.row = s.rows s.rowOff = s.rows - termRows + 5 if s.rowOff < 0 { diff --git a/tally.1 b/tally.1 index 4961d9a..4e71f9e 100644 --- a/tally.1 +++ b/tally.1 @@ -22,22 +22,26 @@ These commands work as a single keypress anytime \fBtally\fP is not taking in a .TP .B h, j, k, l -Move the selection one cell left (h), down (j), up (k), or right (l). +Move the selection one cell left (h), down (j), up (k), or right (l). Arrow keys will also perform the same function. .TP .B -^ +d, , +Revert a cell to its empty state/delete its content. +.TP +.B +^, Move the selection to the first column of the current row. .TP .B -$ +$, Move the selection to the last column of the current row. .TP .B -g +g, Move the selection to the frist row of the current column. .TP .B -G +G, Move the selection to the last row of the current column. .TP .B diff --git a/workbook.go b/workbook.go index 8c86536..af24ea9 100644 --- a/workbook.go +++ b/workbook.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "tildegit.org/sloum/tally/qline" "tildegit.org/sloum/tally/termios" "time" ) @@ -75,17 +76,21 @@ func (w *workbook) Run() { go w.PollForTermSize() var input rune + var chErr error for { w.Draw() - input = Getch() + input, chErr = Getch() + if chErr != nil { + continue + } switch input { - case Left, Right, Up, Down, ToTop, RowStart, ToBottom, RowEnd: + case Left, Right, Up, Down, ToTop, RowStart, ToBottom, RowEnd, qline.LeftArrow, qline.RightArrow, qline.DownArrow, qline.UpArrow, qline.Home, qline.End, qline.PageUp, qline.PageDown: w.sheets[w.sheet].moveSelection(input, w.termRows) case Quit: if modified { fmt.Print("There are unsaved changes. Quit anyway? [y/n]") - answer := Getch() - if answer == 'n' || answer == 'N' { + answer, err := Getch() + if err != nil || answer == 'n' || answer == 'N' { continue } fmt.Print("\n") @@ -98,16 +103,13 @@ func (w *workbook) Run() { if input == EditText { quote = true } - w.sheets[w.sheet].cells[w.sheets[w.sheet].selection.row-1][w.sheets[w.sheet].selection.col-1].Edit(w.sheets[w.sheet].selection.row, w.sheets[w.sheet].selection.col, quote) + w.sheets[w.sheet].cells[w.sheets[w.sheet].selection.row-1][w.sheets[w.sheet].selection.col-1].Edit(w.sheets[w.sheet].selection.row, w.sheets[w.sheet].selection.col, w.termCols, quote) w.sheets[w.sheet].Recalculate() - case Del: + case Del, qline.Delete, qline.BackSpace: w.sheets[w.sheet].cells[w.sheets[w.sheet].selection.row-1][w.sheets[w.sheet].selection.col-1] = cell{} w.sheets[w.sheet].Recalculate() case Com: - line, err := GetLine(":") - if err != nil { - panic(err) - } + line := GetEditLine(":", "", w.termCols) runCommand(strings.Fields(line)) case ZoomIn: w.sheets[w.sheet].ZoomIn()