2
1
Fork 0
swim/board.go

659 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"encoding/json"
"fmt"
"os"
"reflect"
"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)
}
}
func (b *Board) Run() {
defer termios.Restore()
termios.SetCharMode()
fmt.Print("\033[?25l")
go b.PollForTermSize()
var ch rune
for {
b.Draw()
ch = Getch()
switch ch {
case 'Q':
Quit()
case 'N':
b.CreateLane("")
case 'n':
b.CreateStory("")
case '\n':
b.StoryOpen = true
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()
default:
b.SetMessage(fmt.Sprintf("There is no action bound to '%c'", ch), true)
}
}
}
func (b *Board) ClearMessage() {
b.message = ""
b.msgErr = false
}
func (b *Board) SetMessage(msg string, isError bool) {
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) {
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 {
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 {
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)
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)
}
laneText[i-b.laneOff] = s
}
return laneText
}
func (b Board) LaneHeaderRow(width, pad int) string {
var out strings.Builder
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
laneWidth := b.width / b.Zoom
laneSlices := b.GetLaneSlices(laneWidth)
pad := b.width - laneWidth * b.Zoom
out.WriteString(b.LaneHeaderRow(laneWidth, pad))
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(":", "")
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()
}
case "regex", "re", "regexp":
if len(f) < 4 {
b.SetMessage("Not enough arguments: regex [target] [location] [/pattern/replacement/]", true)
return
}
b.Regex(f[1:])
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)
default:
b.SetMessage(fmt.Sprintf("Unknown target %q", f[1]), true)
}
} 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 {
b.SetMessage("More info needed: 'delete [target] [[location]]'", true)
}
case "set", "s":
if len(f) > 2 {
target := strings.ToLower(f[1])
switch target {
case "board", "b", "bo", "boa", "boar":
b.Update(f[2:])
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)
}
case "user", "toggle", "t", "u":
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
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() {
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
if b.Current < b.laneOff {
b.laneOff -= 1
}
} 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
if b.Current > b.laneOff + b.Zoom - 1 {
b.laneOff += 1
}
} 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 {
b.Lanes[b.Current].storyOff += 1
}
} 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
if b.Lanes[b.Current].Current < b.Lanes[b.Current].storyOff {
b.Lanes[b.Current].storyOff -= 1
}
} 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':
// 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':
// 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)
}
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)
}
}
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
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())
}
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)
if err != nil {
b.SetMessage(err.Error(), true)
return
}
fp = path
defer f.Close()
f.Write(j)
b.SetMessage("File written", false)
unsavedChanges = false
}