314 lines
6.6 KiB
Go
314 lines
6.6 KiB
Go
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
|
|
|
|
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)
|
|
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":
|
|
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() {
|
|
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()
|
|
}
|