diff --git a/board.go b/board.go index 093bf9f..429aa36 100644 --- a/board.go +++ b/board.go @@ -1,10 +1,11 @@ package main import ( + "encoding/json" "fmt" + "os" "reflect" "strings" - "time" "tildegit.org/sloum/swim/termios" ) @@ -17,23 +18,22 @@ const ( ) type Board struct { - Title string - Body string - Created time.Time - Lanes []Lane - Current int // Index of current lane - LaneOff int - Message string - MsgErr bool - Width int - Height int - Zoom int + 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"` } func (b *Board) Run() { defer termios.Restore() termios.SetCharMode() + fmt.Print("\033[?25l") var ch rune for { @@ -54,8 +54,6 @@ func (b *Board) Run() { b.Move(ch) case 'D': // Delete current story - case 'e': - // Edit current story case ':': b.EnterCommand() case '+': @@ -63,19 +61,19 @@ func (b *Board) Run() { case '-': b.ZoomOut() default: - b.SetMessage(fmt.Sprintf("There is no action bount to '%c'", ch), true) + b.SetMessage(fmt.Sprintf("There is no action bound to '%c'", ch), true) } } } func (b *Board) ClearMessage() { - b.Message = "" - b.MsgErr = false + b.message = "" + b.msgErr = false } func (b *Board) SetMessage(msg string, isError bool) { - b.Message = msg - b.MsgErr = isError + b.message = msg + b.msgErr = isError } func (b *Board) CreateLane() { @@ -101,30 +99,30 @@ func (b *Board) CreateStory() { } 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) + 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) + 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++ { + 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 + 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++ { + 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 { @@ -140,13 +138,13 @@ func (b Board) LaneHeaderRow(width, pad int) string { func (b Board) PrintLanes() string { var out strings.Builder - laneWidth := b.Width / b.Zoom + laneWidth := b.width / b.Zoom laneSlices := b.GetLaneSlices(laneWidth) - pad := b.Width - laneWidth * b.Zoom + pad := b.width - laneWidth * b.Zoom out.WriteString(b.LaneHeaderRow(laneWidth, pad)) - for row := 0; row < b.Height - 4; row++ { + for row := 0; row < b.height - 4; row++ { for _, l := range laneSlices { if row < len(l) { out.WriteString(l[row]) @@ -164,25 +162,52 @@ func (b Board) PrintLanes() string { } func (b *Board) EnterCommand() { - var out strings.Builder - out.WriteString(upAndLeft) // Move up one and over all - out.WriteString(style.Input) - fmt.Print(out.String()) - command, err := GetLine(":") + command, err := GetCommandLine(":") if err != nil { b.SetMessage(err.Error(), true) } - b.SetMessage(command, false) + 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 "q", "quit": + Quit() + case "set", "s": + if len(f) > 2 { + target := strings.ToLower(f[1]) + switch target { + 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: 'update [target] [location]'", true) + } + case "user", "toggle": + 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) PrintMessage() string { var out strings.Builder - if b.MsgErr { + if b.msgErr { out.WriteString(style.MessageErr) } else { out.WriteString(style.Message) } - out.WriteString(fmt.Sprintf("%-*.*s%s", b.Width, b.Width, b.Message, styleOff)) + out.WriteString(fmt.Sprintf("%-*.*s%s", b.width, b.width, b.message, styleOff)) return out.String() } @@ -193,7 +218,7 @@ func (b *Board) ZoomIn() { } func (b *Board) ZoomOut() { - if b.Width / (b.Zoom+1) > 15 { + if b.width / (b.Zoom+1) > 15 { b.Zoom += 1 } } @@ -207,8 +232,8 @@ func (b *Board) Move(ch rune) { // move left a lane if b.Current > 0 { b.Current -= 1 - if b.Current < b.LaneOff { - b.LaneOff -= 1 + if b.Current < b.laneOff { + b.laneOff -= 1 } } else { b.SetMessage("Cannot move further left", true) @@ -217,8 +242,8 @@ func (b *Board) Move(ch rune) { // 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 + if b.Current > b.laneOff + b.Zoom - 1 { + b.laneOff += 1 } } else { b.SetMessage("Cannot move further right", true) @@ -227,8 +252,8 @@ func (b *Board) Move(ch rune) { // 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 * 2 + 1 > b.Height-5 { - b.Lanes[b.Current].StoryOff += 1 + if b.Lanes[b.Current].Current * 2 + 1 > b.height-5 { + b.Lanes[b.Current].storyOff += 1 } } else { b.SetMessage("Cannot move further down", true) @@ -237,8 +262,8 @@ func (b *Board) Move(ch rune) { // 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 + 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) @@ -328,3 +353,28 @@ func (b Board) Draw() { 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:], " ")) + } + f, err := os.Create(path) + if err != nil { + b.SetMessage(err.Error(), true) + return + } + defer f.Close() + f.Write(j) + b.SetMessage("File written", false) +} + diff --git a/colors.go b/colors.go index 4a6035e..a8cdc8d 100644 --- a/colors.go +++ b/colors.go @@ -22,7 +22,7 @@ var style Styles var colors = map[int]map[string]string{ SimpleColor: map[string]string{ "Header": "\033[34;107;1m", // bold blue on bright white - "Message": "\033[97;42m", // bright white on green + "Message": "\033[97;44m", // bright white on blue "MessageErr": "\033[97;41m", // bright white on red "Lane": "\033[30;104m", // black on bright blue "LaneSelected": "\033[30;103m", // black on bright yellow diff --git a/comment.go b/comment.go index 9d9c8fd..94a7d91 100644 --- a/comment.go +++ b/comment.go @@ -5,7 +5,7 @@ import ( ) type Comment struct { - User string - Body string - Created time.Time + User string `json:"CommentUser"` + Body string `json:"CommentBody"` + Created time.Time `json:"CommentCreated"` } diff --git a/lane.go b/lane.go index 9a0359b..df6c13a 100644 --- a/lane.go +++ b/lane.go @@ -2,13 +2,14 @@ package main import ( "fmt" + "strings" ) type Lane struct { - Title string - Stories []Story - Current int // Index of current story - StoryOff int // offset for the lane slice + Title string `json:"LaneTitle"` + Stories []Story `json:"Stories"` + Current int `json:"CurrentStory"` // Index of current story + storyOff int // offset for the lane slice } func (l *Lane) CreateStory(b *Board) { @@ -42,10 +43,10 @@ func (l Lane) Header(width int, selected bool) string { func (l Lane) StringSlice(width int, selected bool) []string { out := make([]string, 0, len(l.Stories) * 3 + 1) - for i := l.StoryOff; i < len(l.Stories); i++ { + for i := l.storyOff; i < len(l.Stories); i++ { leadIn := " " if selected && l.Current == i { - leadIn = "\033[1m➜\033[21m " + leadIn = "\033[1m➜ " } out = append(out, fmt.Sprintf("%s%*.*s%s", style.Lane, width, width, " ", styleOff)) out = append(out, fmt.Sprintf("%s %s%s%-*.*s%s %s", style.Lane, style.Input, leadIn, width-4, width-4, l.Stories[i].Title, style.Lane, styleOff)) @@ -56,6 +57,22 @@ func (l Lane) StringSlice(width int, selected bool) []string { return out } +func (l *Lane) Update(args []string, b *Board) { + location := strings.ToLower(args[0]) + switch location { + case "title", "t", "name", "n": + title, err := GetAndConfirmCommandLine("New lane title: ") + if err != nil { + b.SetMessage(err.Error(), true) + break + } + l.Title = title + b.SetMessage("Lane title updated", false) + default: + b.SetMessage(fmt.Sprintf("Unknown lane location %q", args[0]), true) + } +} + func MakeLane(title string) Lane { return Lane{title, make([]Story,0,5), -1, 0} } diff --git a/main.go b/main.go index 63507af..596cfa6 100644 --- a/main.go +++ b/main.go @@ -2,14 +2,30 @@ package main import ( "bufio" + "encoding/json" + "flag" "fmt" + "io/ioutil" "os" + "os/user" + "path/filepath" "strings" "tildegit.org/sloum/swim/termios" - "time" ) 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) @@ -53,6 +69,9 @@ func GetAndConfirmCommandLine(prefix string) (string, error) { 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) @@ -63,7 +82,7 @@ func GetAndConfirmCommandLine(prefix string) (string, error) { fmt.Print(cursorEnd) continue } else if conf == 'c' { - err = fmt.Errorf("Cancelled") + err = fmt.Errorf("Cancelled input") break } else { fmt.Print(cursorEnd) @@ -100,25 +119,49 @@ func WrapText(text string, lineWidth int) string { func Quit() { termios.Restore() - fmt.Print("\033[?25h") + fmt.Print(cursorEnd) + fmt.Print("\033[0m\n\033[?25h") os.Exit(0) } +func LoadFile(path string, cols, rows int) { + p := ExpandedAbsFilepath(path) + bytes, err := ioutil.ReadFile(p) + if err != nil { + fmt.Fprintf(os.Stderr, "Could not open file %q\n", path) + os.Exit(1) + } + fp = p + 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.message = fmt.Sprintf("Loaded file: %s", path) +} + func main() { + flag.Parse() + args := flag.Args() style.Init(SimpleColor) cols, rows := termios.GetWindowSize() - board = Board{ - Title: "My Test Board", - Body: "Some misc info", - Created: time.Now(), - Lanes: make([]Lane, 0, 1), - Current: -1, - LaneOff: 0, - Message: "Welcome to SWIM", - MsgErr: false, - Width: cols, - Height: rows, - Zoom: 3} + if len(args) > 0 { + LoadFile(args[0], cols, rows) + } else { + fp = "" + board = Board{ + Title: "My Test Board", + Lanes: make([]Lane, 0, 1), + Current: -1, + laneOff: 0, + message: "Welcome to SWIM", + msgErr: false, + width: cols, + height: rows, + Zoom: 3} + } board.Run() } diff --git a/story.go b/story.go index 0a17336..8c0069d 100644 --- a/story.go +++ b/story.go @@ -3,26 +3,28 @@ package main import ( "fmt" "os/user" + "sort" + "strconv" "strings" "time" ) type Story struct { - Title string - Body string - Users []string - Tag int - Tasks []Task - Comments []Comment - Created time.Time - Updated time.Time - Offset int - StSlice []string - Points int + Title string `json:"StoryTitle"` + Body string `json:"StoryBody"` + Users []string `json:"StoryUsers"` + Tag int `json:"StoryTag"` + Tasks []Task `json:"StoryTasks"` + Comments []Comment `json:"StoryComments"` + Created time.Time `json:"StoryCreated"` + Updated time.Time `json:"StoryUpdated"` + offset int + stSlice []string + Points int `json:"StoryPoints"` } func (s *Story) View(b *Board) { - s.BuildStorySlice(b.Width) + s.BuildStorySlice(b.width) var ch rune for { s.Draw(b) @@ -30,16 +32,23 @@ func (s *Story) View(b *Board) { switch ch { case 'j', 'k': + b.ClearMessage() s.Scroll(ch, b) case ':': b.EnterCommand() - case 'x': - s.Offset = 0 + case 'h': + s.offset = 0 return case 'Q': Quit() case 'c', 'C': s.AddComment(b) + case 't', 'T': + s.AddTask(b) + case 'u': + s.Update([]string{"user"}, b) + case 'p': + s.Update([]string{"points"}, b) } } } @@ -56,7 +65,7 @@ func (s *Story) AddComment(b *Board) { return } s.Comments = append(s.Comments, Comment{u.Name, comment, time.Now()}) - s.BuildStorySlice(b.Width) + s.BuildStorySlice(b.width) } func (s *Story) BuildStorySlice(width int) { @@ -82,12 +91,12 @@ func (s *Story) BuildStorySlice(width int) { out.WriteRune('\n') out.WriteRune('\n') for i, task := range s.Tasks { - out.WriteString(fmt.Sprintf("%2d. ", i+1)) if task.Complete { - out.WriteString("☒ ") + out.WriteString("✔ ") } else { - out.WriteString("☐ ") + out.WriteString(" ") } + out.WriteString(fmt.Sprintf("%2d. ", i+1)) out.WriteString(WrapText(task.Body, width-6)) out.WriteRune('\n') } @@ -107,19 +116,19 @@ func (s *Story) BuildStorySlice(width int) { out.WriteRune('\n') out.WriteRune('\n') } - s.StSlice = strings.Split(out.String(), "\n") + s.stSlice = strings.Split(out.String(), "\n") } func (s Story) Draw(b *Board) { var out strings.Builder out.WriteString(cursorHome) out.WriteString(b.PrintHeader()) - for i := 0; i < b.Height-3; i++ { - index := i+s.Offset - if index >= len(s.StSlice) { - out.WriteString(fmt.Sprintf("%*.*s\n", b.Width, b.Width, "")) + for i := 0; i < b.height-3; i++ { + index := i+s.offset + if index >= len(s.stSlice) { + out.WriteString(fmt.Sprintf("%*.*s\n", b.width, b.width, "")) } else { - out.WriteString(fmt.Sprintf("%-*.*s\n", b.Width, b.Width, s.StSlice[index])) + out.WriteString(fmt.Sprintf("%-*.*s\n", b.width, b.width, s.stSlice[index])) } } out.WriteString(b.PrintInputArea()) @@ -130,20 +139,128 @@ func (s Story) Draw(b *Board) { func (s *Story) Scroll(dir rune, b *Board) { switch dir { case 'j': - if s.Offset + b.Height - 3 < len(s.StSlice) { - s.Offset += 1 + if s.offset + b.height - 3 < len(s.stSlice) { + s.offset += 1 } else { b.SetMessage("Cannot move further down", true) } case 'k': - if s.Offset > 0 { - s.Offset -= 1 + if s.offset > 0 { + s.offset -= 1 } else { b.SetMessage("Cannot move further up", true) } } } +func (s *Story) Update(args []string, b *Board) { + location := strings.ToLower(args[0]) + switch location { + case "title", "t": + title, err := GetAndConfirmCommandLine("New story title: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } + s.Title = title + s.Updated = time.Now() + b.SetMessage("Story title updated", false) + s.BuildStorySlice(b.width) + case "description", "d", "desc", "body", "b": + body, err := GetAndConfirmCommandLine("New story description: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } + s.Body = body + s.Updated = time.Now() + b.SetMessage("Story body updated", false) + s.BuildStorySlice(b.width) + case "points", "sp", "pts", "p": + if len(args) != 2 { + ps, err := GetAndConfirmCommandLine("New story description: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } + args = append(args, ps) + } + val, err := strconv.Atoi(args[1]) + if err != nil { + b.SetMessage(err.Error(), true) + return + } + s.Points = val + s.Updated = time.Now() + b.SetMessage("Story points updated", false) + s.BuildStorySlice(b.width) + case "user": + var users []string + if len(args) == 1 { + u, err := GetAndConfirmCommandLine("User(s) to add: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } + users = strings.Fields(u) + } else { + users = args[1:] + } + s.AddRemoveUser(users, b) + case "toggle": + if len(args) < 2 { + n, err := GetAndConfirmCommandLine("Task # to toggle: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } + args = append(args, n) + } + num, err := strconv.Atoi(args[1]) + num -= 1 + if err != nil || num < 0 || num >= len(s.Tasks) { + b.SetMessage("Invalid task number", true) + return + } + s.Tasks[num].Complete = !s.Tasks[num].Complete + b.SetMessage("Task state updated", false) + default: + b.SetMessage(fmt.Sprintf("Unknown story location %q", args[0]), true) + } + s.BuildStorySlice(b.width) +} + +func (s *Story) AddRemoveUser(users []string, b *Board) { + var found bool + for _, user := range users { + found = false + for i, u := range s.Users { + if user == u { + found = true + s.Users[i] = s.Users[len(s.Users)-1] + s.Users = s.Users[:len(s.Users)-1] + break + } + } + if !found { + s.Users = append(s.Users, user) + } + } + sort.Strings(s.Users) + b.SetMessage("Updated user list", false) +} + +func (s *Story) AddTask(b *Board) { + body, err := GetAndConfirmCommandLine("New task: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } + s.Tasks = append(s.Tasks, Task{body, false}) + s.BuildStorySlice(b.width) + b.SetMessage("Task added", false) +} + func (s Story) Duplicate() Story { out := Story{} out.Title = s.Title diff --git a/task.go b/task.go index 6063b2c..0ee82ac 100644 --- a/task.go +++ b/task.go @@ -1,7 +1,7 @@ package main type Task struct { - Body string - Complete bool + Body string `json:"TaskBody"` + Complete bool `json:"TaskComplete"` }