2
1
Fork 0
swim/board.go

659 lines
17 KiB
Go
Raw Permalink Normal View History

package main
import (
2021-03-25 06:22:54 +00:00
"encoding/json"
"fmt"
2021-03-25 06:22:54 +00:00
"os"
"reflect"
2021-03-28 02:57:29 +00:00
"regexp"
"strings"
"tildegit.org/sloum/swim/termios"
"time"
)
const (
swimLogo string = "\033[7m ▟\033[27m "
cursorHome string = "\033[0;0H"
styleOff string = "\033[0m"
upAndLeft string = "\033[1A\033[500D"
cursorEnd string = "\033[500;500H"
)
type Board struct {
Title string `json:"BoardTitle"`
Lanes []Lane `json:"Lanes"`
Current int `json:"CurrentLane"` // Index of current lane
laneOff int
message string
msgErr bool
width int
height int
Zoom int `json:"Zoom"`
StoryOpen bool
}
func (b *Board) PollForTermSize() {
for {
var w, h = termios.GetWindowSize()
if h != b.height || w != b.width {
b.height = h
b.width = w
if b.StoryOpen {
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].offset = 0
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].Draw(b)
} else {
for l := range b.Lanes {
if len(b.Lanes[l].Stories) == 0 {
continue
}
b.Lanes[l].Current = 0
b.Lanes[l].storyOff = 0
}
b.Draw()
}
}
time.Sleep(500 * time.Millisecond)
}
}
2021-03-24 22:43:40 +00:00
func (b *Board) Run() {
defer termios.Restore()
termios.SetCharMode()
2021-03-25 06:22:54 +00:00
fmt.Print("\033[?25l")
go b.PollForTermSize()
var ch rune
for {
b.Draw()
ch = Getch()
switch ch {
case 'Q':
2021-03-24 22:43:40 +00:00
Quit()
case 'N':
b.CreateLane("")
case 'n':
b.CreateStory("")
case '\n':
b.StoryOpen = true
2021-03-24 22:43:40 +00:00
b.ViewStory()
b.StoryOpen = false
case 'g':
if len(b.Lanes[b.Current].Stories) > 0 {
b.Lanes[b.Current].Current = 0
b.Lanes[b.Current].storyOff = 0
}
case 'G':
if len(b.Lanes[b.Current].Stories) > 0 {
b.Lanes[b.Current].Current = len(b.Lanes[b.Current].Stories)-1
if b.Lanes[b.Current].Current * 3 + 1 > b.height-5 {
off := b.Lanes[b.Current].Current - 3 % (b.height-5)
if off+1 > b.height-5 {
b.Lanes[b.Current].storyOff = off
} else {
b.Lanes[b.Current].storyOff = off+1
}
}
}
case 'h', 'j', 'k', 'l', 'H', 'L', 'K', 'J':
b.ClearMessage()
b.Move(ch)
case ':':
b.EnterCommand()
case '+':
b.ZoomIn()
case '-':
b.ZoomOut()
2021-03-24 22:43:40 +00:00
default:
2021-03-25 06:22:54 +00:00
b.SetMessage(fmt.Sprintf("There is no action bound to '%c'", ch), true)
}
}
}
func (b *Board) ClearMessage() {
2021-03-25 06:22:54 +00:00
b.message = ""
b.msgErr = false
}
func (b *Board) SetMessage(msg string, isError bool) {
2021-03-25 06:22:54 +00:00
b.message = msg
b.msgErr = isError
}
func (b *Board) CreateLane(name string) {
if name == "" {
name = GetEditableLine("Lane title: ", "")
if name == "" {
b.SetMessage("Lane creation canceled", true)
return
}
}
b.Lanes = append(b.Lanes, MakeLane(name))
if b.Current < 0 {
b.Current = 0
}
unsavedChanges = true
b.SetMessage("Lane created", false)
}
func (b *Board) CreateStory(name string) {
2021-03-24 22:43:40 +00:00
if b.Current < 0 {
b.SetMessage("You must create a lane first", true)
return
}
b.Lanes[b.Current].CreateStory(name, b)
}
func (b Board) PrintHeader() string {
2021-03-25 06:22:54 +00:00
return fmt.Sprintf("%s%s%-*.*s%s\n", style.Header, swimLogo, b.width-12, b.width-12, b.Title, styleOff)
}
func (b Board) PrintInputArea() string {
2021-03-25 06:22:54 +00:00
return fmt.Sprintf("%s%-*.*s%s\n", style.Input, b.width, b.width, " ", styleOff)
}
func (b Board) GetLaneSlices(width int) [][]string {
laneText := make([][]string, b.Zoom)
2021-03-25 06:22:54 +00:00
for i := b.laneOff; i < b.laneOff + b.Zoom; i++ {
var s []string
if i < len(b.Lanes) {
s = b.Lanes[i].StringSlice(width, i == b.Current)
} else {
s = make([]string, 0)
}
2021-03-25 06:22:54 +00:00
laneText[i-b.laneOff] = s
}
return laneText
}
func (b Board) LaneHeaderRow(width, pad int) string {
var out strings.Builder
2021-03-25 06:22:54 +00:00
for i := b.laneOff; i < b.laneOff + b.Zoom; i++ {
if i < len(b.Lanes) {
out.WriteString(b.Lanes[i].Header(width, i == b.Current))
} else {
out.WriteString(fmt.Sprintf("%s%*.*s%s", style.Lane, width, width, " ", styleOff))
}
}
if pad > 0 {
out.WriteString(fmt.Sprintf("%s%*.*s%s", style.Lane, pad, pad, " ", styleOff))
}
out.WriteRune('\n')
return out.String()
}
func (b Board) PrintLanes() string {
var out strings.Builder
2021-03-25 06:22:54 +00:00
laneWidth := b.width / b.Zoom
laneSlices := b.GetLaneSlices(laneWidth)
2021-03-25 06:22:54 +00:00
pad := b.width - laneWidth * b.Zoom
out.WriteString(b.LaneHeaderRow(laneWidth, pad))
2021-03-25 06:22:54 +00:00
for row := 0; row < b.height - 4; row++ {
for _, l := range laneSlices {
if row < len(l) {
out.WriteString(l[row])
} else {
out.WriteString(fmt.Sprintf("%s%*.*s%s", style.Lane, laneWidth, laneWidth, " ", styleOff))
}
}
if pad > 0 {
out.WriteString(fmt.Sprintf("%s%*.*s%s", style.Lane, pad, pad, " ", styleOff))
}
out.WriteRune('\n')
}
out.WriteString(styleOff)
return out.String()
}
func (b *Board) EnterCommand() {
command := GetEditableLine(":", "")
2021-03-25 06:22:54 +00:00
if command == "" {
return
}
f := strings.Fields(command)
mainCom := strings.ToLower(f[0])
switch mainCom {
case "write", "wq", "w":
b.Write(f)
if mainCom == "wq" {
Quit()
}
2021-03-28 02:57:29 +00:00
case "regex", "re", "regexp":
if len(f) < 4 {
b.SetMessage("Not enough arguments: regex [target] [location] [/pattern/replacement/]", true)
return
}
b.Regex(f[1:])
2021-03-25 06:22:54 +00:00
case "q", "quit":
Quit()
case "c", "create":
if len(f) >= 2 {
target := strings.ToLower(f[1])
name := ""
if len(f) > 2 {
name = strings.Join(f[2:], " ")
}
switch target {
case "lane", "l", "la", "lan":
b.CreateLane(name)
case "story", "s", "st", "sto", "stor":
b.CreateStory(name)
case "task", "tas", "ta", "t":
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].AddTask(name, b)
case "comment", "com", "c":
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].AddComment(name, b)
2021-04-05 03:14:43 +00:00
default:
b.SetMessage(fmt.Sprintf("Unknown target %q", f[1]), true)
}
2021-04-05 03:14:43 +00:00
} else {
b.SetMessage("More info needed: 'create [target|location] [[value]]'", true)
}
case "d", "del", "delete":
if len(f) > 1 {
target := strings.ToLower(f[1])
switch target {
case "lane", "l", "la", "lan":
b.DeleteLane()
case "story", "s", "st", "sto", "stor":
b.Lanes[b.Current].DeleteStory(b)
case "t", "ta", "tas", "task":
val := ""
if len(f) > 2 {
val = f[2]
}
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].DeleteTask(val, b)
default:
b.SetMessage(fmt.Sprintf("Unknown target %q", f[1]), true)
}
} else {
2021-04-05 03:14:43 +00:00
b.SetMessage("More info needed: 'delete [target] [[location]]'", true)
}
2021-03-25 06:22:54 +00:00
case "set", "s":
if len(f) > 2 {
target := strings.ToLower(f[1])
switch target {
case "board", "b", "bo", "boa", "boar":
b.Update(f[2:])
2021-03-25 06:22:54 +00:00
case "lane", "l", "la", "lan":
b.Lanes[b.Current].Update(f[2:], b)
case "story", "s", "st", "sto", "stor":
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].Update(f[2:], b)
default:
b.SetMessage(fmt.Sprintf("Unknown target %q", f[1]), true)
}
} else {
b.SetMessage("More info needed: 'set [target] [location] [[value]]'", true)
2021-03-25 06:22:54 +00:00
}
case "user", "toggle", "t", "u":
2021-03-25 06:22:54 +00:00
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].Update(f, b)
default:
b.SetMessage(fmt.Sprintf("Unknown command %q", f[0]), true)
}
}
func (b *Board) Update(args []string) {
location := strings.ToLower(args[0])
switch location {
case "title", "t", "name", "n":
var title string
var err error
if len(args) == 1 {
title = GetEditableLine("Board title: ", b.Title)
if title == "" {
b.SetMessage(err.Error(), true)
break
}
} else {
title = strings.Join(args[1:], " ")
}
b.Title = title
b.SetMessage("Board title updated", false)
}
}
func (b Board) PrintMessage() string {
var out strings.Builder
2021-03-25 06:22:54 +00:00
if b.msgErr {
out.WriteString(style.MessageErr)
} else {
out.WriteString(style.Message)
}
msg := b.message
if len(msg) > b.width {
msg = msg[:b.width] + "…"
}
out.WriteString(fmt.Sprintf("%-*s%s", b.width, msg, styleOff))
return out.String()
}
func (b *Board) ZoomIn() {
if b.Zoom > 1 {
b.Zoom -= 1
if b.Current + 1 > b.laneOff + b.Zoom {
b.laneOff += 1
}
}
}
func (b *Board) ZoomOut() {
2021-03-25 06:22:54 +00:00
if b.width / (b.Zoom+1) > 15 {
b.Zoom += 1
if len(b.Lanes) < b.laneOff + b.Zoom {
b.laneOff -= 1
if b.laneOff < 0 {
b.laneOff = 0
}
}
}
}
func (b *Board) Move(ch rune) {
if len(b.Lanes) == 0 {
return
}
switch ch {
case 'h':
// move left a lane
if b.Current > 0 {
b.Current -= 1
2021-03-25 06:22:54 +00:00
if b.Current < b.laneOff {
b.laneOff -= 1
2021-03-24 22:43:40 +00:00
}
} else {
b.SetMessage("Cannot move further left", true)
}
case 'l':
// move selection right a lane
if b.Current < len(b.Lanes)-1 {
b.Current += 1
2021-03-25 06:22:54 +00:00
if b.Current > b.laneOff + b.Zoom - 1 {
b.laneOff += 1
2021-03-24 22:43:40 +00:00
}
} else {
b.SetMessage("Cannot move further right", true)
}
case 'j':
// move selection down a story
if b.Lanes[b.Current].Current < len(b.Lanes[b.Current].Stories)-1 {
b.Lanes[b.Current].Current += 1
if b.Lanes[b.Current].Current * 3 + 1 - (b.Lanes[b.Current].storyOff*3) >= b.height-4 {
2021-03-25 06:22:54 +00:00
b.Lanes[b.Current].storyOff += 1
2021-03-24 22:43:40 +00:00
}
} else {
b.SetMessage("Cannot move further down", true)
}
case 'k':
// move selection up a story
if b.Lanes[b.Current].Current > 0 {
b.Lanes[b.Current].Current -= 1
2021-03-25 06:22:54 +00:00
if b.Lanes[b.Current].Current < b.Lanes[b.Current].storyOff {
b.Lanes[b.Current].storyOff -= 1
2021-03-24 22:43:40 +00:00
}
} else {
b.SetMessage("Cannot move further up", true)
}
case 'H':
// move story left a lane
if b.Current == 0 {
b.SetMessage("Cannot move story left", true)
break
}
storyIndex := b.Lanes[b.Current].Current
if len(b.Lanes[b.Current].Stories) <= 0 {
goto MoveLeft
}
b.Lanes[b.Current-1].Stories = append(b.Lanes[b.Current-1].Stories, b.Lanes[b.Current].Stories[storyIndex].Duplicate())
if storyIndex == len(b.Lanes[b.Current].Stories)-1 {
b.Lanes[b.Current].Stories = b.Lanes[b.Current].Stories[:len(b.Lanes[b.Current].Stories)-1]
b.Lanes[b.Current].Current -= 1
} else {
b.Lanes[b.Current].Stories[storyIndex] = b.Lanes[b.Current].Stories[len(b.Lanes[b.Current].Stories)-1]
b.Lanes[b.Current].Stories = b.Lanes[b.Current].Stories[:len(b.Lanes[b.Current].Stories)-1]
}
b.Lanes[b.Current-1].Current = len(b.Lanes[b.Current-1].Stories)-1
MoveLeft: b.Move('h')
unsavedChanges = true
case 'L':
// move story right a lane
if b.Current == len(b.Lanes)-1 {
b.SetMessage("Cannot move story right", true)
break
}
storyIndex := b.Lanes[b.Current].Current
if len(b.Lanes[b.Current].Stories) <= 0 {
goto MoveRight
}
b.Lanes[b.Current+1].Stories = append(b.Lanes[b.Current+1].Stories, b.Lanes[b.Current].Stories[storyIndex].Duplicate())
if storyIndex == len(b.Lanes[b.Current].Stories)-1 {
b.Lanes[b.Current].Stories = b.Lanes[b.Current].Stories[:len(b.Lanes[b.Current].Stories)-1]
b.Lanes[b.Current].Current -= 1
} else {
b.Lanes[b.Current].Stories[storyIndex] = b.Lanes[b.Current].Stories[len(b.Lanes[b.Current].Stories)-1]
b.Lanes[b.Current].Stories = b.Lanes[b.Current].Stories[:len(b.Lanes[b.Current].Stories)-1]
}
b.Lanes[b.Current+1].Current = len(b.Lanes[b.Current+1].Stories)-1
MoveRight: b.Move('l')
unsavedChanges = true
case 'K':
2021-03-24 22:43:40 +00:00
// moves story up
storyIndex := b.Lanes[b.Current].Current
if storyIndex <= 0 {
b.SetMessage("Cannot move story up", true)
break
}
swapper := reflect.Swapper(b.Lanes[b.Current].Stories)
swapper(storyIndex, storyIndex-1)
b.Move('k')
unsavedChanges = true
case 'J':
2021-03-24 22:43:40 +00:00
// moves story down
storyIndex := b.Lanes[b.Current].Current
if storyIndex > len(b.Lanes[b.Current].Stories)-2 {
b.SetMessage("Cannot move story down", true)
break
}
swapper := reflect.Swapper(b.Lanes[b.Current].Stories)
swapper(storyIndex, storyIndex+1)
b.Move('j')
unsavedChanges = true
}
}
func (b *Board) DeleteLane() {
if len(b.Lanes) < 1 {
b.SetMessage("There are no lanes to delete", true)
return
}
cont, err := GetConfirmation("Are you sure? Type 'yes' to delete: ")
if err != nil {
b.SetMessage(err.Error(), true)
return
} else if !cont {
b.SetMessage("Deletion canceled", true)
return
}
if len(b.Lanes[b.Current].Stories) > 0 {
cont, err = GetConfirmation("Are you really sure? There are stories in the lane... ")
if err != nil {
b.SetMessage(err.Error(), true)
return
} else if !cont {
b.SetMessage("Deletion canceled", true)
return
}
}
if b.Current == len(b.Lanes)-1 {
b.Lanes = b.Lanes[:len(b.Lanes)-1]
} else {
b.Lanes = append(b.Lanes[:b.Current], b.Lanes[b.Current+1:]...)
}
if b.Current > len(b.Lanes)-1 {
b.Current -= 1
}
unsavedChanges = true
b.SetMessage("Lane deleted", false)
}
2021-03-24 22:43:40 +00:00
func (b *Board) ViewStory() {
if b.Current > -1 {
if b.Lanes[b.Current].Current > -1 {
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].View(b)
}
} else {
b.SetMessage("There is no story to view", true)
}
}
2021-03-28 02:57:29 +00:00
func (b *Board) Regex(args []string) {
target := strings.ToLower(args[0])
location := strings.ToLower(args[1])
exp := strings.Join(args[2:], " ")
if !strings.HasPrefix(exp, "/") {
b.SetMessage("Invalid expression, must start with \"/\"", true)
return
}
if len(exp) < 5 {
b.SetMessage("Invalid expression", true)
return
}
exp = strings.Replace(exp, "\\/", "~@!", -1)
findReplaceFlag := strings.Split(exp[1:], "/")
if len(findReplaceFlag) < 2 {
b.SetMessage("Invalid expression, no replacement value", true)
return
}
for i := range findReplaceFlag {
findReplaceFlag[i] = strings.Replace(findReplaceFlag[i], "~@!", "\\/", -1)
}
re, err := regexp.Compile(findReplaceFlag[0])
if err != nil {
b.SetMessage(err.Error(), true)
return
}
var src string
switch target {
case "board", "b", "bo", "boa", "boar":
if !strings.HasPrefix(location, "t") {
b.SetMessage(fmt.Sprintf("Invalid location, %q, for %s", location, target), true)
return
}
src = b.Title
b.Title = re.ReplaceAllLiteralString(src, findReplaceFlag[1])
case "lanes":
if !strings.HasPrefix(location, "t") {
b.SetMessage(fmt.Sprintf("Invalid location, %q, for %s", location, target), true)
return
}
for i := range b.Lanes {
src = b.Lanes[i].Title
b.Lanes[i].Title = re.ReplaceAllLiteralString(src, findReplaceFlag[1])
}
case "lane", "l", "la", "lan":
if !strings.HasPrefix(location, "t") {
b.SetMessage(fmt.Sprintf("Invalid location, %q, for %s", location, target), true)
return
}
src = b.Lanes[b.Current].Title
b.Lanes[b.Current].Title = re.ReplaceAllLiteralString(src, findReplaceFlag[1])
case "stories!":
for l := range b.Lanes {
for i := range b.Lanes[l].Stories {
switch location {
case "title", "t", "ti", "tit", "titl":
src = b.Lanes[l].Stories[i].Title
b.Lanes[l].Stories[i].Title = re.ReplaceAllLiteralString(src, findReplaceFlag[1])
case "description", "desc", "d", "de":
src = b.Lanes[l].Stories[i].Body
b.Lanes[l].Stories[i].Body = re.ReplaceAllLiteralString(src, findReplaceFlag[1])
default:
b.SetMessage(fmt.Sprintf("Invalid location, %q, for %s", location, target), true)
return
}
}
}
case "stories":
for i := range b.Lanes[b.Current].Stories {
switch location {
case "title", "t", "ti", "tit", "titl":
src = b.Lanes[b.Current].Stories[i].Title
b.Lanes[b.Current].Stories[i].Title = re.ReplaceAllLiteralString(src, findReplaceFlag[1])
case "description", "desc", "d", "de":
src = b.Lanes[b.Current].Stories[i].Body
b.Lanes[b.Current].Stories[i].Body = re.ReplaceAllLiteralString(src, findReplaceFlag[1])
default:
b.SetMessage(fmt.Sprintf("Invalid location, %q, for %s", location, target), true)
return
}
}
case "story", "s", "st", "sto", "stor":
switch location {
case "title", "t", "ti", "tit", "titl":
src = b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].Title
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].Title = re.ReplaceAllLiteralString(src, findReplaceFlag[1])
case "description", "desc", "d", "de":
src = b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].Body
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].Body = re.ReplaceAllLiteralString(src, findReplaceFlag[1])
default:
b.SetMessage(fmt.Sprintf("Invalid location, %q, for %s", location, target), true)
return
}
default:
b.SetMessage(fmt.Sprintf("Invalid target %q", target), true)
return
}
if b.StoryOpen && strings.HasPrefix(target, "s") {
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].BuildStorySlice(b.width)
b.Lanes[b.Current].Stories[b.Lanes[b.Current].Current].Draw(b)
}
unsavedChanges = true
2021-03-28 02:57:29 +00:00
b.SetMessage("Regular expression applied", false)
}
func (b Board) Draw() {
var out strings.Builder
out.WriteString(cursorHome)
out.WriteString(b.PrintHeader())
out.WriteString(b.PrintLanes())
out.WriteString(b.PrintInputArea())
out.WriteString(b.PrintMessage())
fmt.Print(out.String())
}
2021-03-25 06:22:54 +00:00
func (b *Board) Write(com []string) {
j, err := json.Marshal(b)
if err != nil {
b.SetMessage(err.Error(), true)
return
}
var path string
if len(com) < 2 && fp == "" {
b.SetMessage("No path was provided for file write", true)
return
} else if len(com) < 2 {
path = fp
} else {
path = ExpandedAbsFilepath(strings.Join(com[1:], " "))
}
var perms os.FileMode = 0664
fstats, err := os.Stat(path)
if err == nil {
perms = fstats.Mode()
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perms)
2021-03-25 06:22:54 +00:00
if err != nil {
b.SetMessage(err.Error(), true)
return
}
fp = path
2021-03-25 06:22:54 +00:00
defer f.Close()
f.Write(j)
b.SetMessage("File written", false)
unsavedChanges = false
2021-03-25 06:22:54 +00:00
}