diff --git a/tutorials/basics/README.md b/tutorials/basics/README.md new file mode 100644 index 0000000..b9f6435 --- /dev/null +++ b/tutorials/basics/README.md @@ -0,0 +1,235 @@ +# Bubble Tea Tutorial + +Bubble Tea is based on the functional design paradigms of [The Elm +Architecture][elm]. It might not seem very Go-like at first, but once you get +used to the general structure you'll find that most of the idomatic Go things +you know and love are still relevant and useful here. + +By the way, the non-annotated version of of this program is available +[on GitHub](https://github.com/charmbracelet/bubbletea/master/tutorials/basics). + +This tutorial assumes you have a working knowledge of Go. + +[elm]: https://guide.elm-lang.org/architecture/ + +## Enough! Let's get to it. + +For this tutorial we're making a to-do list. + +To start we'll define our package and import some libraries. Our only external +import will be the Bubble Tea, library, which we'll call `tea` for short. + +```go + package main + + import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + ) +``` + +Bubble Tea programs are comprised of a model that describes the application +state and three simple functions that are centered around the model: + +Initialize +: A function that returns the model's initial state. +Update +: A function that handles incoming events and updates the model accordingly. +View +: A function that renders the UI based on the data in the model. + +## The Model + +So let's start by defining our application's model. The model is simply the +application's state. It can be any type, but a `struct` usually makes the most +sense. + +```go + type model struct { + choices []string // items on the to-do list + cursor int // which to-do list item our cursor is pointing at + selected map[int]struct{} // which to-do items are selected + } +``` + +## Initialize + +Next we'll define a function that will initialize our application. An +initialize function returns a model representing our application's initial +state, as well as a `Cmd` that could perform some initial I/O. For now, we +don't need to do any I/O, so for the command we'll just return nil, which +translate to "no command." + +```go + func initialize() (tea.Model, tea.Cmd) { + m := model{ + + // Our to-do list is just a grocery list + choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, + + // A map which indicates which choices are selected. We're using + // the map like a mathematical set. The keys refer to the indexes + // of the `choices` slice, above. + selected: make(map[int]struct{}), + } + + // Return the model and `nil`, which means "no I/O right now, please." + return m, nil + } +``` + +## Update + +Next we'll define the update function. The update function is called when +"things happen." It's job is to look at what has happened and return an +updated model based on whatever happened. It can also return a `Cmd` and make +more things happen, but we'll get into that later. + +In our case, when a user presses the down arrow `update`'s job is to notice +that the down arrow was pressed and move the cursor accordingly (or not). + +The "something happened" comes in as a `Msg`, which can be any type. Messages +indicate some I/O happened, such as a keypress, timer, or a response from +a server. + +We usually figure out which type of `Msg` we received with a type switch, but +you could also use a type assertion. + +For now, we'll just deal with `tea.KeyMsg`, which are automatically sent to +the update function when keys are pressed. + +```go + func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { + m, _ := mdl.(model) + + switch msg := msg.(type) { + + // Is it a key press? + case tea.KeyMsg: + + // Cool, what was the actual key pressed? + switch msg.String() { + + // These keys should exit the program. + case "ctrl+c", "q": + return m, tea.Quit + + // The "up" and "k" keys move the cursor up + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + // The "down" and "j" keys move the cursor down + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + + // The "enter" key and the spacebar (a literal space) toggle + // the selected state for the item that the cursor is pointing at. + case "enter", " ": + _, ok := m.selected[m.cursor] + if ok { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = struct{}{} + } + } + } + + // Return the updated model to the Bubble Tea runtime for processing. + // Note that we're not returning a command. + return m, nil + } +``` + +You may have noticed that "ctrl+c" and "q" above return a `tea.Quit` with the +model. That's a special command which instructs the Bubble Tea runtime to exit, +effectively quitting the program. + +## The View + +At last, it's time to render our UI. Of all the functions, the view is the +simplest. A model, in it's current state, comes in and a `string` comes out. +That string is our UI! + +Because the view describes the entire UI of your application, you don't have +to worry about redraw logic and stuff like that. Bubble Tea takes care of it +for you. + +```go + func view(mdl tea.Model) string { + m, _ := mdl.(model) + + // The header + s := "What should we buy at the market?\n\n" + + // Iterate over our choices + for i, choice := range m.choices { + + // Is the cursor pointing at this choice? + cursor := " " // no cursor + if m.cursor == i { + cursor = ">" // cursor! + } + + // Is this choice selected? + checked := " " // not selected + if _, ok := m.selected[i]; ok { + checked = "x" // selected! + } + + // Render the row + s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) + } + + // The footer + s += "\nPress q to quit.\n" + + // Send off the UI to rendered + return s + } +``` + +## All togeher now + +The last step is to simply run our program. We pass our functions to +`tea.NewProgram` and let it rip: + +```go + func main() { + p := tea.NewProgram(initialize, update, view) + if err := p.Start(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } + } +``` + +## What's next? + +This tutorial covers the basics of building an interactive terminal UI, but +in the real world you'll also need to perform I/O. To learn about that have a +look at the [Cmd Tutorial][cmd]. It's pretty simple. + +There are also several [examples][examples] available. Many of the examples +make use of [Bubbles][bubbles], the little Bubble Tea component library which +includes handy things like a text input component, spinners and a viewport. + +Of course, there are also [Go Docs][docs] for Bubble Tea. + +[cmd]: http://github.com/charmbracelet/bubbletea/tree/master/tutorials/cmds/ +[examples]: http://github.com/charmbracelet/bubbletea/tree/master/examples +[bubbles]: https://github.com/charmbracelet/bubbles +[docs]: https://pkg.go.dev/github.com/charmbracelet/glow?tab=doc + +## Feedback + +We'd love to hear your thoughts on this tutorial. Please feel free to reach out +anytime. + +* [Twitter](https://twitter.com/charmcli) +* [The Fediverse](https://mastodon.technology/@charm) diff --git a/tutorials/basics/main.go b/tutorials/basics/main.go new file mode 100644 index 0000000..b4c1f13 --- /dev/null +++ b/tutorials/basics/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +type model struct { + cursor int + choices []string + selected map[int]struct{} +} + +func initialize() (tea.Model, tea.Cmd) { + return model{ + choices: []string{"Carrots", "Celery", "Kohlrabi"}, + selected: make(map[int]struct{}), + }, nil +} + +func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { + m, _ := mdl.(model) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.choices)-1 { + m.cursor++ + } + case "enter", " ": + _, ok := m.selected[m.cursor] + if ok { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = struct{}{} + } + } + } + + return m, nil +} + +func view(mdl tea.Model) string { + m, _ := mdl.(model) + + s := "What should we buy at the market?\n\n" + + for i, choice := range m.choices { + cursor := " " + if m.cursor == i { + cursor = ">" + } + + checked := " " + if _, ok := m.selected[i]; ok { + checked = "x" + } + + s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) + } + + s += "\nPress q to quit.\n" + + return s +} + +func main() { + p := tea.NewProgram(initialize, update, view) + if err := p.Start(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +} diff --git a/tutorials/go.mod b/tutorials/go.mod new file mode 100644 index 0000000..10cd811 --- /dev/null +++ b/tutorials/go.mod @@ -0,0 +1,12 @@ +module tutorial + +go 1.14 + +require ( + github.com/charmbracelet/bubbletea v0.10.2 // indirect + github.com/muesli/termenv v0.6.0 // indirect + golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 // indirect + golang.org/x/sys v0.0.0-20200722175500-76b94024e4b6 // indirect +) + +replace github.com/charmbracelet/bubbletea => ../ diff --git a/tutorials/go.sum b/tutorials/go.sum new file mode 100644 index 0000000..ac51c25 --- /dev/null +++ b/tutorials/go.sum @@ -0,0 +1,30 @@ +github.com/charmbracelet/bubbletea v0.10.2 h1:He5Ybch9TiqErJH2B02JUNkPU36GnPwXCuxwASGZyn4= +github.com/charmbracelet/bubbletea v0.10.2/go.mod h1:wjGGC5pyYvpuls0so+w4Zv+aZQW7RoPvsi9UBcDlSl8= +github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4= +github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= +github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/muesli/termenv v0.5.3-0.20200625163851-04b5c30e4c04 h1:Wr876oXlAk6avTWi0daXAriOr+r5fqIuyDmtNc/KwY0= +github.com/muesli/termenv v0.5.3-0.20200625163851-04b5c30e4c04/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= +github.com/muesli/termenv v0.6.0 h1:zxvzTBmo4ZcxhNGGWeMz+Tttm51eF5bmPjfy4MCRYlk= +github.com/muesli/termenv v0.6.0/go.mod h1:SohX91w6swWA4AYU+QmPx+aSgXhWO0juiyID9UZmbpA= +github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03 h1:pd4YKIqCB0U7O2I4gWHgEUA2mCEOENmco0l/bM957bU= +github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03/go.mod h1:Z9+Ul5bCbBKnbCvdOWbLqTHhJiYV414CURZJba6L8qA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM= +golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 h1:DZhuSZLsGlFL4CmhA8BcRA0mnthyA/nZ00AqCUo7vHg= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4 h1:5/PjkGUjvEU5Gl6BxmvKRPpqo2uNMv4rcHBMwzk/st8= +golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200722175500-76b94024e4b6 h1:X9xIZ1YU8bLZA3l6gqDUHSFiD0GFI9S548h6C8nDtOY= +golang.org/x/sys v0.0.0-20200722175500-76b94024e4b6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=