commit bee32ca733be5cab445ac1b94d0b19ac0b455efa Author: Christian Rocha Date: Fri Jan 10 16:02:04 2020 -0500 Initial commit. First pass. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..b2c4dbf --- /dev/null +++ b/example/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "tea" +) + +type Model int + +func main() { + p := tea.NewProgram(0, update, view) + p.Start() +} + +func update(msg tea.Msg, model tea.Model) (tea.Model, tea.Cmd) { + m, _ := model.(Model) + + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch msg { + + case "j": + m += 1 + if m > 3 { + m = 3 + } + + case "k": + m -= 1 + if m < 0 { + m = 0 + } + + case "q": + return m, tea.Quit + + } + } + + return m, nil +} + +func view(model tea.Model) string { + m, _ := model.(Model) + + choices := fmt.Sprintf( + "%s\n%s\n%s\n%s", + checkbox("Plant carrots", m == 0), + checkbox("Go to the market", m == 1), + checkbox("Read something", m == 2), + checkbox("See friends", m == 3), + ) + + return fmt.Sprintf( + "What to do today?\n\n%s.\n\n(press j/k to select or q to quit)", + choices, + ) +} + +func checkbox(label string, checked bool) string { + check := " " + if checked { + check = "x" + } + return fmt.Sprintf("[%s] %s", check, label) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fca1f83 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module tea + +go 1.13 + +require ( + github.com/pkg/term v0.0.0-20190109203006-aa71e9d9e942 + github.com/tj/go-terminput v1.0.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..97b96b1 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/pkg/term v0.0.0-20190109203006-aa71e9d9e942 h1:A7GG7zcGjl3jqAqGPmcNjd/D9hzL95SuoOQAaFNdLU0= +github.com/pkg/term v0.0.0-20190109203006-aa71e9d9e942/go.mod h1:eCbImbZ95eXtAUIbLAuAVnBnwf83mjf6QIVH8SHYwqQ= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/tj/go-terminput v1.0.0 h1:X928u7ohxr7Dfzlxppvc5Zql5Lwd1bOH5VyIL2RPrFw= +github.com/tj/go-terminput v1.0.0/go.mod h1:8zzAs+cqdjZlTxE9DbGyjKDfGNxaJjC5bvg4vkWX2V0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= diff --git a/key.go b/key.go new file mode 100644 index 0000000..5d38bb3 --- /dev/null +++ b/key.go @@ -0,0 +1,32 @@ +package tea + +import ( + "errors" + "io" + "unicode/utf8" +) + +// KeyPressMsg contains information about a keypress +type KeyPressMsg string + +// ReadKey reads keypress input from a TTY and returns a string representation +// of a key +func ReadKey(r io.Reader) (string, error) { + var buf [256]byte + + // Read and block + _, err := r.Read(buf[:]) + if err != nil { + return "", err + } + + // TODO: non-rune keys like arrows, meta keys, and so on + + // Read "normal" key + c, _ := utf8.DecodeRune(buf[:]) + if c == utf8.RuneError { + return "", errors.New("no such rune") + } + + return string(c), nil +} diff --git a/tea.go b/tea.go new file mode 100644 index 0000000..9bbc53f --- /dev/null +++ b/tea.go @@ -0,0 +1,168 @@ +package tea + +import ( + "fmt" + "io" + "strings" + + "github.com/pkg/term" +) + +// Escape sequence +const esc = "\033[" + +// Msg represents an action. It's used by Update to update the UI. +type Msg interface{} + +// Model contains the data for an application +type Model interface{} + +// Cmd is an IO operation. If it's nil it's considered a no-op. +type Cmd func() Msg + +// Update is called when a message is received. It may update the model and/or +// send a command. +type Update func(Msg, Model) (Model, Cmd) + +// View produces a string which will be rendered to the terminal +type View func(Model) string + +// Program is a terminal user interface +type Program struct { + model Model + update Update + view View + rw io.ReadWriter +} + +// Quit command +func Quit() Msg { + return quitMsg{} +} + +// Signals that the program should quit +type quitMsg struct{} + +// NewProgram creates a new Program +func NewProgram(model Model, update Update, view View) *Program { + return &Program{ + model: model, + update: update, + view: view, + // TODO: subscriptions + } +} + +// Start initializes the program +// TODO: error channel +func (p *Program) Start() error { + var ( + model = p.model + cmd Cmd + cmds = make(chan Cmd) + msgs = make(chan Msg) + done = make(chan struct{}) + ) + + tty, err := term.Open("/dev/tty") + if err != nil { + return err + } + + p.rw = tty + tty.SetRaw() + defer func() { + showCursor() + tty.Restore() + }() + + // Render initial view + hideCursor() + p.render(model, true) + + // Subscribe to user input + // TODO: move to program struct to allow for subscriptions to other things, + // too, like timers, frames, download/upload progress and so on. + go func() { + for { + select { + default: + msg, _ := ReadKey(p.rw) + msgs <- KeyPressMsg(msg) + } + } + }() + + // Process commands + go func() { + for { + select { + case <-done: + return + case cmd := <-cmds: + if cmd != nil { + go func() { + msgs <- cmd() + }() + } + } + } + }() + + // Handle updates and draw + for { + select { + case msg := <-msgs: + if _, ok := msg.(quitMsg); ok { + close(done) + return nil + } + + model, cmd = p.update(msg, model) + cmds <- cmd // process command (if any) + p.render(model, false) + p.model = model + } + } +} + +// Render a view +func (p *Program) render(model Model, init bool) { + view := p.view(model) + + // We need to add carriage returns to ensure that the cursor travels to the + // start of a column after a newline + view = strings.Replace(view, "\n", "\r\n", -1) + + if !init { + clearLines(strings.Count(view, "\r\n")) + } + io.WriteString(p.rw, view) +} + +func hideCursor() { + fmt.Printf(esc + "?25l") +} + +func showCursor() { + fmt.Printf(esc + "?25h") +} + +func cursorUp(n int) { + fmt.Printf(esc+"%dF", n) +} + +func clearLine() { + fmt.Printf(esc + "2K") +} + +func clearLines(n int) { + for i := 0; i < n; i++ { + cursorUp(1) + clearLine() + } +} + +func clearScreen() { + fmt.Printf(esc + "2J" + esc + "3J" + esc + "1;1H") +}