From db0c8a140322219ee542a3b3d2826024b7cee6de Mon Sep 17 00:00:00 2001 From: sloum Date: Sun, 14 Mar 2021 19:45:56 -0700 Subject: [PATCH] Initial commit of mostly working spreadsheet program --- .gitignore | 2 + cell.go | 191 ++++++++++++++++++++++++++++++++ main.go | 114 +++++++++++++++++++ notes.md | 19 ++++ sheet.go | 221 +++++++++++++++++++++++++++++++++++++ termios/consts_linux.go | 10 ++ termios/consts_nonlinux.go | 10 ++ termios/termios.go | 65 +++++++++++ workbook.go | 109 ++++++++++++++++++ 9 files changed, 741 insertions(+) create mode 100644 .gitignore create mode 100644 cell.go create mode 100644 main.go create mode 100644 notes.md create mode 100644 sheet.go create mode 100644 termios/consts_linux.go create mode 100644 termios/consts_nonlinux.go create mode 100644 termios/termios.go create mode 100644 workbook.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db3ed03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +tally +*.tss diff --git a/cell.go b/cell.go new file mode 100644 index 0000000..b1046e8 --- /dev/null +++ b/cell.go @@ -0,0 +1,191 @@ +package main + +import ( + "fmt" + "strconv" + "strings" +) + +type cell struct { + kind int // Enum for the type of cell + rawVal string // A raw string of the value + num float64 // A quick reference field for the current value + expr []string // Fields representing an expression + mask string // What the user sees + mods []int // Enums of modifiers on the cell +} + +func (c cell) String(width int, selected bool) string { + mods := "" + if selected { + mods += "\033[7m" + } + return fmt.Sprintf("%s%*.*s\033[0m", mods, width, width, c.mask) +} + +func (c *cell) Edit(row, col int) { + line, err := GetLine(fmt.Sprintf("\033[2KUpdate %c%d: \033[?25h", col+64, row)) + if err != nil { + panic(err) + } + fmt.Print("\033[?25l") + line = strings.TrimSpace(line) + c.Update(line) +} + +func (c *cell) Update(val string) bool { + if val == c.rawVal || len(val) == 0 { + // If nothing was changed or the change is empty just return + return false + } + c.rawVal = val + num, err := strconv.ParseFloat(val, 64) + if err == nil { + c.kind = Number + c.num = num + c.mask = strconv.FormatFloat(num, 'f', -1, 64) + } else if strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"") { + c.kind = Text + c.mask = strings.Replace(val[1:len(val)-1], "\\_", " ", -1) + } else { + c.kind = Expr + c.expr = strings.Fields(val) + } + return true +} + +func (c *cell) Calculate() { + if c.kind != Expr { + return + } + if len(c.expr) < 2 { + c.mask = "#Err" + return + } + insideString := false + s := makeStack() + + for _, v := range c.expr { + if IsAddr(v) { + c, err := GetCell(Addr2Point(v)) + if err != nil { + c.mask = "#Err" + return + } + if c.kind == Text { + v = c.rawVal + } else { + v = c.mask + } + } else if IsRange(v) { + // TODO Apply range + } + + if s.ptr >= 0 && s.text { + c.mask = "#Err" + return + } + + switch true { + case strings.HasPrefix(v, "\""): + if s.ptr >= 0 { + c.mask = "#Err" + return + } + if !strings.HasSuffix(v, "\"") { + insideString = true + } + s.str.WriteString(strings.Replace(v, "\"", "", -1)) + s.text = true + case insideString: + if s.ptr >= 0 { + c.mask = "#Err" + return + } + if strings.HasSuffix(v, "\"") { + insideString = false + v = strings.Replace(v, "\"", "", -1) + } + s.str.WriteRune(' ') + s.str.WriteString(v) + case IsFunc(v): + if s.text { + c.mask = "#Err" + return + } else if v == "+" { + s.Add() + } else if v == "-" { + s.Subtract() + } else if v == "/" { + s.Divide() + } else if v == "*" { + s.Multiply() + } + default: + v, err := strconv.ParseFloat(v, 64) + if err != nil { + c.mask = "#Err" + return + } + s.Push(v) + } + } + + if s.text { + c.mask = strings.Replace(s.str.String(), "\\_", " ", -1) + } else if s.ptr == 0 { + c.mask = strconv.FormatFloat(s.data[0], 'f', -1, 64) + } else { + c.mask = "Err" + } +} + +type stack struct { + data [100]float64 + ptr int + text bool + str strings.Builder +} + +func makeStack() stack { + return stack{data: [100]float64{}, ptr: -1, text: false} +} + +func (s *stack) Push(v float64) { + if s.ptr >= 99 { + panic("Stack overflow") + } + s.ptr++ + s.data[s.ptr] = v +} + +func (s *stack) Pop() float64 { + if s.ptr < 0 { + panic("Stack underflow") + } + s.ptr-- + return s.data[s.ptr+1] +} + +func (s *stack) Add() { + if s.text { + val := strconv.FormatFloat(s.Pop(), 'f', -1, 64) + s.str.WriteString(val) + } else { + s.Push(s.Pop() + s.Pop()) + } +} + +func (s *stack) Subtract() { + second := s.Pop() + s.Push(s.Pop() - second) +} + +func (s *stack) Multiply() { + s.Push(s.Pop() * s.Pop()) +} + +func (s *stack) Divide() { + second := s.Pop() + s.Push(s.Pop() / second) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..6b8bbd4 --- /dev/null +++ b/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strconv" + "strings" + "tildegit.org/sloum/spreadsheet/termios" +) + +const ( + Empty int = iota + Text + Number + Expr + + Bold + Italic +) + +var wb workbook +var reAddr *regexp.Regexp = regexp.MustCompile(`^[A-Z][0-9]+$`) +var reAddrRange *regexp.Regexp = regexp.MustCompile(`^[A-Z][0-9]+:[A-Z][0-9]+$`) + +type point struct { + row int + col int +} + +func runCommand(elems []string) { + +} + +func Getch() rune { + reader := bufio.NewReader(os.Stdin) + char, _, err := reader.ReadRune() + if err != nil { + return 0 + } + return char +} + +func GetLine(prefix string) (string, error) { + defer termios.SetCharMode() + termios.SetLineMode() + + reader := bufio.NewReader(os.Stdin) + fmt.Print(prefix) + text, err := reader.ReadString('\n') + if err != nil { + return "", err + } + + return text[:len(text)-1], nil +} + +func DigitCount(num int) int { + counter := 0 + for ; num > 0; num /= 10 { + counter++ + } + return counter +} + +func GetCell(p point) (cell, error) { + if p.row > wb.sheets[wb.sheet].rows || p.col > wb.sheets[wb.sheet].cols { + return cell{}, fmt.Errorf("Invalid reference") + } + return wb.sheets[wb.sheet].cells[p.row][p.col], nil +} + +func Addr2Point(addr string) point { + p := point{} + p.col = int(addr[0]) - 65 + p.row, _ = strconv.Atoi(addr[1:]) + p.row-- + return p +} + +func Point2Addr(p point) string { + var s strings.Builder + s.WriteRune(rune(p.col) + 65) + s.WriteString(strconv.Itoa(p.row+1)) + return s.String() +} + +func ApplyPointDiff(p1, p2 point) point { + return point{row: p2.row - p1.row, col: p2.col - p1.col} +} + +func IsAddr(addr string) bool { + return reAddr.MatchString(addr) +} + +func IsRange(addr string) bool { + return reAddrRange.MatchString(addr) +} + +func IsFunc(val string) bool { + switch strings.ToUpper(val) { + case "+", "-", "/", "*", "^", "MIN", "MAX", "SQRT", + "ROUND", "SUBSTR", "UPPER", "LOWER", "SUM": + return true + default: + return false + } +} + +func main() { + wb = makeWorkbook("test.qsh") + wb.Run() +} diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..3c6de2d --- /dev/null +++ b/notes.md @@ -0,0 +1,19 @@ +- Sheets can have any number of rows +- Sheets have a max number of cols (26) +- Text and Float are the only types +- Expressions are forth style: A2 B3 + C2 A2 - + +- Ranges work via ':', for example: A2:C6 + + +- Available expression functions: +, - . \*, /, POW, MIN, MAX, SQRT, ROUND, SUBSTR, UPPER, LOWER, CAPITAL, SUM + + +TODO: + - Write expression parser + - Add methods for running expressions + - Figure out relative reference logic (when you copy/paste an expression) + - File save/save-as, custom format? + - File load: custom format, csv + - Ability to add/remove/rename sheets + - Improve TUI + - Make manpage + - Make Makefile diff --git a/sheet.go b/sheet.go new file mode 100644 index 0000000..6096ff9 --- /dev/null +++ b/sheet.go @@ -0,0 +1,221 @@ +package main + +import ( + "fmt" + "strings" +) + +type sheet struct { + name string + selection point + cells [][]cell + cols int + rows int + zoom int + rowOff int + colOff int + yankBuff cell + yankPoint point +} + +func (s sheet) Draw(termCols, termRows int) { + var selected bool + rowDigits := DigitCount(len(s.cells)) + width := (termCols - rowDigits) / s.zoom + if width * s.zoom + rowDigits + 1 > termCols { + width -= 1 + } + + // Print column header + fmt.Printf("%s ", strings.Repeat(" ", rowDigits)) + for i := s.colOff+1; i <= s.colOff + s.zoom; i++ { + if i > s.cols { + break + } + pre := "\033[7m" + if i == s.selection.col { + pre = "" + } + fmt.Printf("%s%-*c\033[0m", pre, width, i+64) + } + fmt.Print("\n") + + // Print Rows + for row, r := range s.cells[s.rowOff:] { + if row >= termRows-5 { + break + } + mod := "\033[7m" + if row + s.rowOff == s.selection.row-1 { + mod = "" + } + fmt.Printf("%s%-*d \033[0m", mod, rowDigits, row+1+s.rowOff) + + // Print Cells + for col, cell := range r[s.colOff:] { + if col >= s.zoom { + break + } + selected = false + if s.selection.row-1 == row + s.rowOff && s.selection.col-1 == col + s.colOff { + selected = true + } + fmt.Printf(cell.String(width, selected)) + } + fmt.Print("\n\033[2K") + } +} + +func (s sheet) CurrentValue(raw bool) string { + if raw { + return s.cells[s.selection.row-1][s.selection.col-1].rawVal + } + return s.cells[s.selection.row-1][s.selection.col-1].mask +} + +func (s *sheet) Recalculate() { + for row, _ := range s.cells { + for col, _ := range s.cells[row] { + // TODO do this concurrently + s.cells[row][col].Calculate() + } + } +} + +func (s *sheet) AddRows(count int) { + for ;count > 0; count-- { + s.cells = append(s.cells, make([]cell, s.cols)) + s.rows++ + } +} + +func (s *sheet) AddCols(count int) { + for ;count > 0; count-- { + for i, _ := range s.cells { + s.cells[i] = append(s.cells[i], cell{}) + } + s.cols++ + } +} + +func (s *sheet) Yank() { + c := s.cells[s.selection.row-1][s.selection.col-1] + s.yankBuff = cell{} + s.yankBuff.kind = c.kind + s.yankBuff.rawVal = c.rawVal + s.yankBuff.num = c.num + s.yankBuff.mask = c.mask + s.yankBuff.expr = append(s.yankBuff.expr, c.expr...) + s.yankBuff.mods = append(s.yankBuff.mods, c.mods...) + s.yankPoint = point{row: s.selection.row-1, col: s.selection.col-1} +} + +func (s *sheet) Paste() { + c := cell{} + c.kind = s.yankBuff.kind + c.rawVal = s.yankBuff.rawVal + c.num = s.yankBuff.num + c.mask = s.yankBuff.mask + c.expr = append(c.expr, s.yankBuff.expr...) + c.mods = append(c.mods, s.yankBuff.mods...) + s.cells[s.selection.row-1][s.selection.col-1] = c +} + +func (s *sheet) PasteRelative() { + c := cell{} + c.kind = s.yankBuff.kind + c.rawVal = s.yankBuff.rawVal + c.num = s.yankBuff.num + c.mask = s.yankBuff.mask + c.expr = append(c.expr, s.yankBuff.expr...) + c.mods = append(c.mods, s.yankBuff.mods...) + if c.kind == Expr { + for i, v := range c.expr { + if IsAddr(v) { + refPoint := Addr2Point(v) + diff := point{row: s.yankPoint.row-s.selection.row+1, col: s.yankPoint.col-s.selection.col+1} + diffApply := point{row: refPoint.row-diff.row, col: refPoint.col-diff.col} + p2a := Point2Addr(diffApply) + c.expr[i] = p2a + } + } + c.rawVal = strings.Join(c.expr, " ") + } + s.cells[s.selection.row-1][s.selection.col-1] = c +} + +func (s *sheet) moveSelection(dir rune, termRows int) { + switch dir { + case Left: + if s.selection.col > 1 { + s.selection.col-- + } + if s.selection.col <= s.colOff { + s.colOff-- + } + case Right: + s.selection.col++ + if s.selection.col > s.cols && s.selection.col < 27 { + s.AddCols(1) + } + if s.selection.col > s.colOff + s.zoom && s.cols > s.zoom { + s.colOff++ + } + if s.selection.col > 26 { + s.selection.col = 26 + } + case Up: + if s.selection.row > 1 { + s.selection.row-- + } + if s.selection.row <= s.rowOff { + s.rowOff-- + } + case Down: + s.selection.row++ + if s.selection.row > s.rows { + s.AddRows(1) + } + if s.selection.row > s.rowOff + termRows - 5 && s.rows > termRows - 5 { + s.rowOff++ + } + case RowStart: + s.selection.col = 1 + s.colOff = 0 + case ToTop: + s.selection.row = 1 + s.rowOff = 0 + case RowEnd: + s.selection.col = s.cols + s.colOff = s.cols - s.zoom + if s.colOff < 0 { + s.colOff = 0 + } + case ToBottom: + s.selection.row = s.rows + s.rowOff = s.rows - termRows + 5 + if s.rowOff < 0 { + s.rowOff = 0 + } + } +} + +func (s *sheet) ZoomIn() { + if s.zoom == 1 { + return + } + s.zoom-- // Zooming in reduces number of cols viewed +} + +func (s *sheet) ZoomOut() { + s.zoom++ // Zooming out increases number of cols viewed +} + +func makeSheet(name string) sheet { + if name == "" { + name = "Sheet1" + } + row := make([][]cell,1, 26) + row[0] = make([]cell, 1, 26) + return sheet{name, point{1,1}, row, 1, 1, 6, 0, 0, cell{}, point{}} +} diff --git a/termios/consts_linux.go b/termios/consts_linux.go new file mode 100644 index 0000000..ae6e076 --- /dev/null +++ b/termios/consts_linux.go @@ -0,0 +1,10 @@ +// +build linux + +package termios + +import "syscall" + +const ( + getTermiosIoctl = syscall.TCGETS + setTermiosIoctl = syscall.TCSETS +) diff --git a/termios/consts_nonlinux.go b/termios/consts_nonlinux.go new file mode 100644 index 0000000..ca0daf7 --- /dev/null +++ b/termios/consts_nonlinux.go @@ -0,0 +1,10 @@ +// +build !linux + +package termios + +import "syscall" + +const ( + getTermiosIoctl = syscall.TIOCGETA + setTermiosIoctl = syscall.TIOCSETAF +) diff --git a/termios/termios.go b/termios/termios.go new file mode 100644 index 0000000..79385be --- /dev/null +++ b/termios/termios.go @@ -0,0 +1,65 @@ +package termios + +import ( + "os" + "runtime" + "syscall" + "unsafe" +) + +type winsize struct { + Row uint16 + Col uint16 + Xpixel uint16 + Ypixel uint16 +} + +var fd = os.Stdin.Fd() +var initial = getTermios() + +func ioctl(fd, request, argp uintptr) error { + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, fd, request, argp); e != 0 { + return e + } + return nil +} + +func GetWindowSize() (int, int) { + var value winsize + ioctl(fd, syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&value))) + return int(value.Col), int(value.Row) +} + +func getTermios() syscall.Termios { + var value syscall.Termios + err := ioctl(fd, getTermiosIoctl, uintptr(unsafe.Pointer(&value))) + if err != nil { + panic(err) + } + return value +} + +func setTermios(termios syscall.Termios) { + err := ioctl(fd, setTermiosIoctl, uintptr(unsafe.Pointer(&termios))) + if err != nil { + panic(err) + } + runtime.KeepAlive(termios) +} + +func SetCharMode() { + t := getTermios() + t.Lflag = t.Lflag ^ syscall.ICANON + t.Lflag = t.Lflag ^ syscall.ECHO + setTermios(t) +} + +func SetLineMode() { + var t = getTermios() + t.Lflag = t.Lflag | (syscall.ICANON | syscall.ECHO) + setTermios(t) +} + +func Restore() { + setTermios(initial) +} diff --git a/workbook.go b/workbook.go new file mode 100644 index 0000000..7d80c00 --- /dev/null +++ b/workbook.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "os" + "strings" + "tildegit.org/sloum/spreadsheet/termios" +) + +const ( + Left rune = 'h' + Right rune = 'l' + Up rune = 'k' + Down rune = 'j' + Quit rune = 'Q' + Edit rune = '\n' + EditAlt rune = ' ' + Del rune = 'd' + Com rune = ':' + ZoomIn rune = '+' + ZoomOut rune = '-' + Yank rune = 'y' + Paste rune = 'p' + PasteRelative rune = 'P' + ToTop rune = 'g' + ToBottom rune = 'G' + RowStart rune = '^' + RowEnd rune = '$' +) + +type workbook struct { + name string + path string + sheets []sheet + sheet int + termRows int + termCols int +} + +func (w workbook) Draw() { + fmt.Print("\033[0;0H") + fmt.Printf("FILE: \033[1m%-*.*s\033[0m\n", w.termCols-6, w.termCols-6, w.path) + fmt.Printf("EXPR: %-*.*s\n", + w.termCols-6, w.termCols-6, + w.sheets[w.sheet].CurrentValue(true)) + w.sheets[w.sheet].Draw(w.termCols,w.termRows) +} + +func (w *workbook) Run() { + defer termios.Restore() + termios.SetCharMode() + fmt.Print("\033[?25l\033[2J") + var input rune + for { + w.Draw() + input = Getch() + switch input { + case Left, Right, Up, Down, ToTop, RowStart, ToBottom, RowEnd: + w.sheets[w.sheet].moveSelection(input, w.termRows) + case Quit: + termios.Restore() + fmt.Print("\033[?25h") + os.Exit(0) + case Edit, EditAlt: + 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.sheets[w.sheet].Recalculate() + case Del: + 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) + } + runCommand(strings.Fields(line)) + case ZoomIn: + w.sheets[w.sheet].ZoomIn() + fmt.Print("\033[2J") + case ZoomOut: + w.sheets[w.sheet].ZoomOut() + fmt.Print("\033[2J") + case Yank: + w.sheets[w.sheet].Yank() + case Paste: + w.sheets[w.sheet].Paste() + w.sheets[w.sheet].Recalculate() + case PasteRelative: + w.sheets[w.sheet].PasteRelative() + w.sheets[w.sheet].Recalculate() + } + } +} + +func makeWorkbook(path string) workbook { + // TODO parse path and get file name + // set name of strict to the filename + // or untitled.qsh if no filename; set + // path to the full path + cols, rows := termios.GetWindowSize() + sh := makeSheet("Sheet1") + wb = workbook{path, path, make([]sheet,1), 0, rows, cols} + wb.sheets[0] = sh + return wb +} + +func (w workbook) header() string { + return fmt.Sprintf("\033[1;7m %s \033[0m %s\n",w.name, w.sheets[w.sheet].name) +} +