package main import ( "encoding/json" "fmt" "os" "reflect" "regexp" "strings" "tildegit.org/sloum/swim/termios" "time" ) const ( swimLogo string = "\033[7m swim ▟\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 }