diff --git a/board.go b/board.go new file mode 100644 index 0000000..7ec88f4 --- /dev/null +++ b/board.go @@ -0,0 +1,179 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" + "tildegit.org/sloum/swim/termios" +) + +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 + Body string + Created time.Time + Lanes []Lane + Current int // Index of current lane + Message string + MsgErr bool + Width int + Height int + Zoom int +} + +func (b *Board) Run() { + defer termios.Restore() + termios.SetCharMode() + + var ch rune + for { + b.Draw() + ch = Getch() + + switch ch { + case 'Q': + termios.Restore() + os.Exit(0) + case 'N': + b.CreateLane() + case 'n': + b.CreateStory() + case '\n': + // View current story + case 'h', 'j', 'k', 'l': + // Move cursor, context dependent + // If a story is open, will scroll + // the story, otherwise will select + // a story + case 'c': + // Comment on current story + case 'a': + // Archive the current story + case 'd': + // Delete current story + case 'D': + // Delete current lane + case 'e': + // Edit current story + case ':': + b.EnterCommand() + case '+': + // b.ZoomIn() + case '-': + // b.ZoomOut() + } + } +} + +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() { + laneTitle, err := GetAndConfirmCommandLine("Lane Title: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } + b.Lanes = append(b.Lanes, MakeLane(laneTitle)) + if b.Current < 0 { + b.Current = 0 + } +} + +func (b *Board) CreateStory() { + b.Lanes[b.Current].CreateStory(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) PrintLanes() string { + var out strings.Builder + laneWidth := b.Width / b.Zoom + + if len(b.Lanes) == 0 { + for i := 0; i < b.Height - 3; i++ { + out.WriteString(fmt.Sprintf("%s%*.*s%s\n", style.Lane, b.Width, b.Width, " ", styleOff)) + } + return out.String() + } + laneText := make([][]string, 0, len(b.Lanes)) + maxLen := 0 + for _, l := range b.Lanes { + s := l.StringSlice(laneWidth) + laneText = append(laneText, s) + if len(s) > maxLen { + maxLen = len(s) + } + } + for i := 0; i < b.Height - 3; i++ { + for li, l := range laneText { + // TODO fix this + if li >= b.Zoom { + out.WriteRune('\n') + break + } + if i < len(l) { + out.WriteString(l[i]) + } else { + out.WriteString(fmt.Sprintf("%s%*.*s%s", style.Lane, laneWidth, laneWidth, " ", styleOff)) + } + } + } + out.WriteString(styleOff) + return out.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(":") + if err != nil { + b.SetMessage(err.Error(), true) + } + b.SetMessage(command, false) +} + +func (b Board) PrintMessage() string { + var out strings.Builder + 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)) + return out.String() +} + +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()) +} + diff --git a/colors.go b/colors.go new file mode 100644 index 0000000..855fba2 --- /dev/null +++ b/colors.go @@ -0,0 +1,52 @@ +package main + +const ( + SimpleColor int = iota + EightBitColor + TrueColor +) + +type Styles struct { + Mode int + Header string + Message string + MessageErr string + Lane string + Input string +} + + +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 + "MessageErr": "\033[97;41m", // bright white on red + "Lane": "\033[30;104m", // black on bright blue + "Input": "\033[30;107m"}, // black on bright white + EightBitColor: map[string]string{ + "Header": "\033[48;5;254m\033[38;5;21\033[1m", + "Message": "\033[48;5;35m\033[38;5;231m", + "MessageErr": "\033[48;5;124m\033[38;5;231m", + "Lane": "\033[48;5;63m\033[38;5;235m", + "Input": "\033[48;5;231m\033[38;5;235"}, + TrueColor: map[string]string{ + "Header": "", + "Message": "", + "MessageErr": "", + "Lane": "", + "Input": ""}} + +func (s *Styles) Init(mode int) { + if mode == TrueColor || mode == EightBitColor { + s.Mode = mode + } else { + s.Mode = SimpleColor + } + s.Header = colors[s.Mode]["Header"] + s.Message = colors[s.Mode]["Message"] + s.MessageErr = colors[s.Mode]["MessageErr"] + s.Lane = colors[s.Mode]["Lane"] + s.Input = colors[s.Mode]["Input"] +} diff --git a/comment.go b/comment.go new file mode 100644 index 0000000..9d9c8fd --- /dev/null +++ b/comment.go @@ -0,0 +1,11 @@ +package main + +import ( + "time" +) + +type Comment struct { + User string + Body string + Created time.Time +} diff --git a/lane.go b/lane.go new file mode 100644 index 0000000..ee66d73 --- /dev/null +++ b/lane.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" +) + +type Lane struct { + Title string + Stories []Story + Current int // Index of current story +} + +func (l *Lane) CreateStory(b *Board) { + storyTitle, err := GetAndConfirmCommandLine("Story Title: ") + if err != nil { + b.SetMessage(err.Error(), true) + return + } + l.Stories = append(l.Stories, MakeStory(storyTitle)) + if l.Current < 0 { + l.Current = 0 + } +} + +func (l *Lane) StringSlice(width int) []string { + out := make([]string, 0, len(l.Stories) * 3 + 1) + for _, story := range l.Stories { + out = append(out, fmt.Sprintf("%s%*.*s%s", style.Lane, width, width, " ", styleOff)) + out = append(out, fmt.Sprintf("%s %s%-*.*s%s %s", style.Lane, style.Input, width-2, width-2, story.Title, style.Lane, styleOff)) + } + if len(out) > 0 { + out = append(out, fmt.Sprintf("%s%*.*s%s", style.Lane, width, width, " ", styleOff)) + } + return out +} + +func MakeLane(title string) Lane { + return Lane{title, make([]Story,0,5), -1} +} diff --git a/main.go b/main.go index c10b1d9..6f206db 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,89 @@ package main -func main() { - print("This will be a swim lane project planning application") +import ( + "bufio" + "fmt" + "os" + "tildegit.org/sloum/swim/termios" + "time" +) + +var board Board + +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 GetCommandLine(prefix string) (string, error) { + fmt.Print(upAndLeft) // Move up one and over all + fmt.Print(style.Input) + line, err := GetLine(prefix) + fmt.Print(cursorEnd) + return line, err +} + +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 + } + 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' { + break + } else if conf == 'n' { + continue + } else if conf == 'c' { + err = fmt.Errorf("Cancelled") + break + } else { + goto VerifyQuery + } + } + return line, err +} + +func main() { + 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, + Message: "Welcome to SWIM", + MsgErr: false, + Width: cols, + Height: rows, + Zoom: 3} + + board.Run() } diff --git a/notes.md b/notes.md index 4628e21..18298f8 100644 --- a/notes.md +++ b/notes.md @@ -7,22 +7,21 @@ A project planning board for the terminal. 1. story - title string - - details string + - body string - points int - tag int // enum representing a color - - assignee string // username - - reviewer string // username + - users []string - comments []comment - created time.time // the time the story was created 2. comment - user string - - comment string + - body string - created time.time 3. lane - title string - stories []story 4. board - title string - - details string + - body string - created time.time - lanes []lane diff --git a/story.go b/story.go new file mode 100644 index 0000000..050dffb --- /dev/null +++ b/story.go @@ -0,0 +1,20 @@ +package main + +import ( + "time" +) + +type Story struct { + Title string + Body string + Users []string + Tag int + Tasks []Task + Comments []Comment + Created time.Time + Updated time.Time +} + +func MakeStory(title string) Story { + return Story{title,"", make([]string,0,2), -1, make([]Task,0,2), make([]Comment,0,2), time.Now(), time.Now()} +} diff --git a/swim b/swim new file mode 100755 index 0000000..3264d4f Binary files /dev/null and b/swim differ diff --git a/task.go b/task.go new file mode 100644 index 0000000..6063b2c --- /dev/null +++ b/task.go @@ -0,0 +1,7 @@ +package main + +type Task struct { + Body string + Complete bool +} + diff --git a/termios/consts_linux.go b/termios/consts_linux.go new file mode 100644 index 0000000..ae6e076 --- /dev/null +++ b/termios/consts_linux.go @@ -0,0 +1,10 @@ +// +build linux + +package termios + +import "syscall" + +const ( + getTermiosIoctl = syscall.TCGETS + setTermiosIoctl = syscall.TCSETS +) diff --git a/termios/consts_nonlinux.go b/termios/consts_nonlinux.go new file mode 100644 index 0000000..ca0daf7 --- /dev/null +++ b/termios/consts_nonlinux.go @@ -0,0 +1,10 @@ +// +build !linux + +package termios + +import "syscall" + +const ( + getTermiosIoctl = syscall.TIOCGETA + setTermiosIoctl = syscall.TIOCSETAF +) diff --git a/termios/termios.go b/termios/termios.go new file mode 100644 index 0000000..79385be --- /dev/null +++ b/termios/termios.go @@ -0,0 +1,65 @@ +package termios + +import ( + "os" + "runtime" + "syscall" + "unsafe" +) + +type winsize struct { + Row uint16 + Col uint16 + Xpixel uint16 + Ypixel uint16 +} + +var fd = os.Stdin.Fd() +var initial = getTermios() + +func ioctl(fd, request, argp uintptr) error { + if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, fd, request, argp); e != 0 { + return e + } + return nil +} + +func GetWindowSize() (int, int) { + var value winsize + ioctl(fd, syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&value))) + return int(value.Col), int(value.Row) +} + +func getTermios() syscall.Termios { + var value syscall.Termios + err := ioctl(fd, getTermiosIoctl, uintptr(unsafe.Pointer(&value))) + if err != nil { + panic(err) + } + return value +} + +func setTermios(termios syscall.Termios) { + err := ioctl(fd, setTermiosIoctl, uintptr(unsafe.Pointer(&termios))) + if err != nil { + panic(err) + } + runtime.KeepAlive(termios) +} + +func SetCharMode() { + t := getTermios() + t.Lflag = t.Lflag ^ syscall.ICANON + t.Lflag = t.Lflag ^ syscall.ECHO + setTermios(t) +} + +func SetLineMode() { + var t = getTermios() + t.Lflag = t.Lflag | (syscall.ICANON | syscall.ECHO) + setTermios(t) +} + +func Restore() { + setTermios(initial) +}