package main // swim is a project management board for the terminal of unix or // unix-like systems. // // Copyright (C) 2021 Brian Evans, All Rights Reserved // // This program is free, as in beer, software: you can redistribute it // and/or modify it under the terms of the Floodgap Free Software license. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // Floodgap Free Software License for more details. // // You should have received a copy of the Floodgap Free Software License // along with this program. If not, see: // 1. http://www.floodgap.com/software/ffsl/ // 2. gopher://gopher.floodgap.com/1/ffsl/ import ( "bufio" "encoding/json" "flag" "fmt" "io/ioutil" "os" "os/signal" "os/user" "path/filepath" "strings" "syscall" "tildegit.org/sloum/swim/termios" "tildegit.org/sloum/swim/qline" ) var board Board var fp string var unsavedChanges bool func ExpandedAbsFilepath(p string) string { if strings.HasPrefix(p, "~/") { usr, _ := user.Current() homedir := usr.HomeDir p = filepath.Join(homedir, p[2:]) } path, _ := filepath.Abs(p) return path } 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) fmt.Print("\033[?25h") text, err := reader.ReadString('\n') if err != nil { return "", err } fmt.Print("\033[?25l") return text[:len(text)-1], nil } func GetEditableLine(prompt, defaultText string) string { fmt.Printf("%s%s\033[2K\033[?25h", upAndLeft, style.Input) str := qline.GetInput(prompt, defaultText, board.width) fmt.Print("\033[?25l") fmt.Print(cursorEnd) return str } func GetCommandLine(prefix string) (string, error) { fmt.Printf("%s%s\033[2K", upAndLeft, style.Input) line, err := GetLine(prefix) fmt.Print(cursorEnd) return line, err } func GetConfirmation(prefix string) (bool, error) { ln, err := GetCommandLine(prefix) if err != nil { return false, err } ln = strings.ToLower(ln) switch ln { case "y", "yes", "yeah", "yup", "ya", "yaas", "aye": return true, nil default: return false, nil } } func GetAndConfirmCommandLine(prefix string) (string, error) { var conf rune var err error var line string for { line, err = GetCommandLine(prefix) if err != nil { return line, err } if line == "" { return line, fmt.Errorf("Cancelled input") } VerifyQuery: fmt.Print(upAndLeft) // Move up one and over all fmt.Print(style.Input) fmt.Printf("%s%s%sIs %q correct? (y/n/c)", cursorEnd, upAndLeft, style.Input, line) conf = Getch() if conf == 'y' || conf == '\n' { break } else if conf == 'n' { fmt.Print(cursorEnd) continue } else if conf == 'c' { err = fmt.Errorf("Cancelled input") break } else { fmt.Print(cursorEnd) goto VerifyQuery } } return line, err } // Adapted From: // https://gist.github.com/kennwhite/306317d81ab4a885a965e25aa835b8ef func WrapText(text string, lineWidth int) string { var wrapped strings.Builder words := strings.Fields(strings.TrimSpace(text)) if len(words) == 0 { return text } wrapped.WriteString(words[0]) spaceLeft := lineWidth - wrapped.Len() for _, word := range words[1:] { if len(word)+1 > spaceLeft { wrapped.WriteRune('\n') wrapped.WriteString(word) spaceLeft = lineWidth - len(word) } else { wrapped.WriteRune(' ') wrapped.WriteString(word) spaceLeft -= 1 + len(word) } } return wrapped.String() } func Quit() { if unsavedChanges { quitAnyway, _ := GetConfirmation("There are unsaved changed. Quit anyway? (y, n) ") if !quitAnyway { board.SetMessage("Quitting with unsaved work cancelled", false) return } } termios.Restore() fmt.Print(cursorEnd) fmt.Print("\033[0m\033[?25h") os.Exit(0) } func LoadFile(path string, cols, rows int) { fp = ExpandedAbsFilepath(path) bytes, err := ioutil.ReadFile(fp) if err != nil { board = DefaultBoard(cols, rows) board.SetMessage("Could not load file at path, new file created", false) return } err = json.Unmarshal(bytes, &board) if err != nil { fmt.Fprintf(os.Stderr, "Could not understand input file:\n%s\n", err.Error()) os.Exit(1) } board.width = cols board.height = rows board.laneOff = 0 board.Current = -1 board.StoryOpen = false if len(board.Lanes) > 0 { board.Current = 0 } for i := range board.Lanes { board.Lanes[i].ResetLoadPositions() for s := range board.Lanes[i].Stories { board.Lanes[i].Stories[s].offset = 0 } } board.SetMessage(fmt.Sprintf("Loaded file: %s", path), false) } func handleSignals(c <-chan os.Signal) { for { switch <-c { case syscall.SIGTSTP: termios.Restore() fmt.Print("\033[?25h") _ = syscall.Kill(syscall.Getpid(), syscall.SIGSTOP) case syscall.SIGCONT: termios.SetCharMode() fmt.Print("\033[?25l") board.Draw() case syscall.SIGINT: termios.Restore() fmt.Print("\033[?25h") os.Exit(1) } } } func InitNewBoard() { b := DefaultBoard(80,30) b.CreateLane("Backlog") b.CreateLane("Active") b.CreateLane("Complete") b.message = "Welcome to SWIM" workingPath, err := os.Getwd() if err != nil { panic(err.Error()) } filename := filepath.Base(workingPath) + ".swim" filename = strings.Replace(filename, " ", "_", -1) savepath := filepath.Join(workingPath, filename) b.Write([]string{"write", savepath}) fmt.Printf("Swim file initialized: %s", savepath) Quit() } func DefaultBoard(cols, rows int) Board { return Board{ Title: "", Lanes: make([]Lane, 0, 1), Current: -1, laneOff: 0, message: "Welcome to SWIM", msgErr: false, width: cols, height: rows, Zoom: 3} } func SetColorFromFlag(f string) int { f = strings.ToUpper(f) switch f { case "SIMPLE": return SimpleColor case "256": return EightBitColor case "TRUE": return TrueColor case "NONE", "OFF": return Term default: return -1 } } func printHelp() { header := `swim - project board Syntax: swim [flags] [option or filepath] Examples: swim swim -h swim init swim ./my-board.swim swim -color true ./my-board.swim Options: init initialize a board with three default lanes Flags: ` _, _ = fmt.Fprintf(os.Stdout, header) flag.PrintDefaults() } func main() { flag.Usage = printHelp colors := flag.String("color", "", "Color mode: Simple, 256, True, None" ) flag.Parse() args := flag.Args() style.Init(SetColorFromFlag(*colors)) cols, rows := termios.GetWindowSize() if len(args) > 0 { if strings.ToLower(args[0]) == "init" { InitNewBoard() } else { LoadFile(args[0], cols, rows) } } else { fp = "" board = DefaultBoard(cols, rows) } // watch for signals, send them to be handled c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGTSTP, syscall.SIGCONT, syscall.SIGINT) go handleSignals(c) board.Run() }