Introduction
bubbletea
is a simple, compact, and very convenient framework for writing TUI (terminal User Interface, console interface program) programs. Built-in simple event handling mechanism can respond to external events, such as keyboard keys. Let's take a look together. Let's take a look at bubbletea
can make:
Thanks kiyonlin recommendation.
Quick to use
The code in this article uses Go Modules.
Create a directory and initialize:
$ mkdir bubbletea && cd bubbletea
$ go mod init github.com/darjun/go-daily-lib/bubbletea
Install the bubbletea
library:
$ go get -u github.com/charmbracelet/bubbletea
bubbletea
programs need to have a type that bubbletea.Model
type Model interface {
Init() Cmd
Update(Msg) (Model, Cmd)
View() string
}
Init()
method will be called immediately when the program starts, it will do some initialization work, and return aCmd
tellbubbletea
what command to execute;Update()
method is used to respond to external events, return a modified model, and the command thatbubbletea
View()
method is used to return the text string displayed on the console.
Let's implement a Todo List. First define the model:
type model struct {
todos []string
cursor int
selected map[int]struct{}
}
todos
: all pending items;cursor
: the cursor position on the interface;selected
: The identification has been completed.
Without any initialization work, implement an empty Init()
method and return nil
:
import (
tea "github.com/charmbracelet/bubbletea"
)
func (m model) Init() tea.Cmd {
return nil
}
We need to respond to key events and implement the Update()
method. When a key event occurs, the Update()
method tea.Msg
as the parameter. By performing type assertion on the parameter tea.Msg
, we can handle different events accordingly:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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.todos)-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
}
Convention:
ctrl+c
orq
: Exit the program;up
ork
: move the cursor up;down
orj
: move the cursor down;enter
or
When processing the ctrl+c
or q
key, a special tea.Quit
returned to notify bubbletea
that the program needs to be exited.
Finally, the View()
method is implemented. The string returned by this method is the text finally displayed on the console. We can assemble according to the model data in the form we want:
func (m model) View() string {
s := "todo list:\n\n"
for i, choice := range m.todos {
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
}
The cursor position with >
logo, matters have been completed to increase x
identity.
After the model type is defined, an object of the model needs to be created;
var initModel = model{
todos: []string{"cleanning", "wash clothes", "write a blog"},
selected: make(map[int]struct{}),
}
In order to make the program work, we have to create a bubbletea
application object, through bubbletea.NewProgram()
complete, then call this object Start()
method to begin:
func main() {
cmd := tea.NewProgram(initModel)
if err := cmd.Start(); err != nil {
fmt.Println("start failed:", err)
os.Exit(1)
}
}
run:
GitHub Trending
A simple Todo application may seem meaningless. Next, let's write a program that pulls the GitHub Trending repository and displays it on the console.
The interface of Github Trending is as follows:
You can select language (Spoken Language, local language), language (Language, programming language) and time range (Today, This week, This month). Since GitHub does not provide an official API for trending, we can only crawl the web page to analyze it ourselves. Fortunately, Go has a powerful analysis tool goquery , which provides powerful functions comparable to jQuery. I also wrote an article to introduce it before- Go, a daily library of goquery .
Open the Chrome console and click the Elements tab to view the structure of each entry:
Basic version
Define the model:
type model struct {
repos []*Repo
err error
}
The repos
field represents the list of Trending warehouses that are pulled. The structure Repo
as follows, and the meaning of the fields are commented, which is very clear:
type Repo struct {
Name string // 仓库名
Author string // 作者名
Link string // 链接
Desc string // 描述
Lang string // 语言
Stars int // 星数
Forks int // fork 数
Add int // 周期内新增
BuiltBy []string // 贡献值 avatar img 链接
}
err
field indicates the error value of the pull failure setting. In order to execute the network request to pull the Trending list when the program starts, we let the Init()
method of the tea.Cmd
type 060d08652b3efb:
func (m model) Init() tea.Cmd {
return fetchTrending
}
func fetchTrending() tea.Msg {
repos, err := getTrending("", "daily")
if err != nil {
return errMsg{err}
}
return repos
}
tea.Cmd
type of 060d08652b3f46 is:
// src/github.com/charmbracelet/bubbletea/tea.go
type Cmd func() Msg
tea.Cmd
bottom layer of 060d08652b3fec is a function type. The function has no parameters and returns a tea.Msg
object.
fetchTrending()
function pulls the Today's Trending list from GitHub, and if it encounters an error, it returns the value of error
Here we temporarily ignore getTrending()
function. This has little to do with the point we want to talk about. If you are interested in children's shoes, you can go to my GitHub repository to view the detailed code.
If you need to do some operations when the program starts, it will usually return a tea.Cmd
Init()
method. tea
will execute this function in the background, and finally pass the returned tea.Msg
to the model's Update()
method.
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c", "esc":
return m, tea.Quit
default:
return m, nil
}
case errMsg:
m.err = msg
return m, nil
case []*Repo:
m.repos = msg
return m, nil
default:
return m, nil
}
}
Update()
method is also relatively simple. First of all, we still need to listen to key events. We agree to press q or ctrl+c or esc to exit the program. The string corresponding to the specific button indicates that you can view the document or the source code bubbletea/key.go
file . Receive errMsg
, indicating that the network request has failed, and record the error value. Received []*Repo
, indicating that the Trending warehouse list returned correctly, and record it. In the View()
function, we display information such as pulling, pulling failure, and correct pulling:
func (m model) View() string {
var s string
if m.err != nil {
s = fmt.Sprintf("Fetch trending failed: %v", m.err)
} else if len(m.repos) > 0 {
for _, repo := range m.repos {
s += repoText(repo)
}
s += "--------------------------------------"
} else {
s = " Fetching GitHub trending ..."
}
s += "\n\n"
s += "Press q or ctrl + c or esc to exit..."
return s + "\n"
}
The logic is very clear. If the err
field is not nil
, it means failure, otherwise there is warehouse data and warehouse information is displayed. Otherwise, it is being pulled. Finally, a prompt message is displayed, telling the customer how to exit the program.
The display logic of each warehouse item is as follows, divided into 3 columns, basic information, description and link:
func repoText(repo *Repo) string {
s := "--------------------------------------\n"
s += fmt.Sprintf(`Repo: %s | Language: %s | Stars: %d | Forks: %d | Stars today: %d
`, repo.Name, repo.Lang, repo.Stars, repo.Forks, repo.Add)
s += fmt.Sprintf("Desc: %s\n", repo.Desc)
s += fmt.Sprintf("Link: %s\n", repo.Link)
return s
}
go run main.go
cannot be used for multi-file operation):
Get failed (domestic GitHub is unstable, you will always encounter 😭 if you try a few more times):
Successfully obtained:
Make the interface more beautiful
We have seen too much black and white, can we make the font appear in different colors? of course can. bubbletea
can use the lipgloss
library to add various colors to the text. We have defined 4 colors. The RBG value of the color is the one I picked http://tool.chinaz.com/tools/pagecolor.aspx
var (
cyan = lipgloss.NewStyle().Foreground(lipgloss.Color("#00FFFF"))
green = lipgloss.NewStyle().Foreground(lipgloss.Color("#32CD32"))
gray = lipgloss.NewStyle().Foreground(lipgloss.Color("#696969"))
gold = lipgloss.NewStyle().Foreground(lipgloss.Color("#B8860B"))
)
To change the color of the text, you only need to call the 060d08652b455e method of the Render()
For example, we want to change the prompt to dark gray and dark yellow for the middle text. Modify the View()
method:
func (m model) View() string {
var s string
if m.err != nil {
s = gold.Render(fmt.Sprintf("fetch trending failed: %v", m.err))
} else if len(m.repos) > 0 {
for _, repo := range m.repos {
s += repoText(repo)
}
s += cyan.Render("--------------------------------------")
} else {
s = gold.Render(" Fetching GitHub trending ...")
}
s += "\n\n"
s += gray.Render("Press q or ctrl + c or esc to exit...")
return s + "\n"
}
Then we use cyan for the basic information of the warehouse, green for description, and dark gray for links:
func repoText(repo *Repo) string {
s := cyan.Render("--------------------------------------") + "\n"
s += fmt.Sprintf(`Repo: %s | Language: %s | Stars: %s | Forks: %s | Stars today: %s
`, cyan.Render(repo.Name), cyan.Render(repo.Lang), cyan.Render(strconv.Itoa(repo.Stars)),
cyan.Render(strconv.Itoa(repo.Forks)), cyan.Render(strconv.Itoa(repo.Add)))
s += fmt.Sprintf("Desc: %s\n", green.Render(repo.Desc))
s += fmt.Sprintf("Link: %s\n", gray.Render(repo.Link))
return s
}
Run again:
success:
Well, it looks much better now.
I am not lazy
Sometimes the network is very slow, adding a reminder that the request is being processed can make us more at ease (the program is still running, not lazy). bubbletea
brother warehouse bubbles
provides a feature called spinner
component, it just shows some of the characters have been changed, it caused us a sense of the task being processed. spinner
in the github.com/charmbracelet/bubbles/spinner
package and needs to be introduced first. Then add the spinner.Model
field to the model:
type model struct {
repos []*Repo
err error
spinner spinner.Model
}
When creating the model spinner.Model
object at the same time. We specify the text color of spinner
var purple = lipgloss.NewStyle().Foreground(lipgloss.Color("#800080"))
func newModel() model {
sp := spinner.NewModel()
sp.Style = purple
return model{
spinner: sp,
}
}
spinner
triggers its change state through Tick
, so you need to return to trigger Tick
of Cmd
Init()
method. But it needs to return fetchTrending
. bubbletea
provides Batch
can merge two Cmd
together and return:
func (m model) Init() tea.Cmd {
return tea.Batch(
spinner.Tick,
fetchTrending,
)
}
Then we need to update spinner
Update()
method. Init()
method returns spinner.Tick
will produce spinner.TickMsg
, we do deal with them:
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
spinner.Update(msg)
returns a tea.Cmd
object to drive the next Tick
.
Finally, in the View()
method, we display spinner
Call its View()
method to return the current state of the string, spelled in the position we want to display:
func (m model) View() string {
var s string
if m.err != nil {
s = gold.Render(fmt.Sprintf("fetch trending failed: %v", m.err))
} else if len(m.repos) > 0 {
for _, repo := range m.repos {
s += repoText(repo)
}
s += cyan.Render("--------------------------------------")
} else {
// 这里
s = m.spinner.View() + gold.Render(" Fetching GitHub trending ...")
}
s += "\n\n"
s += gray.Render("Press q or ctrl + c or esc to exit...")
return s + "\n"
}
run:
Pagination
Since we returned to many GitHub repositories at one time, we want to display them in pages, and each page will display 5 items. You can press pageup
and pagedown
turn the pages. First, add two fields to the model, the current page and the total number of pages:
const (
CountPerPage = 5
)
type model struct {
// ...
curPage int
totalPage int
}
When pulling to the warehouse, calculate the total number of pages:
case []*Repo:
m.repos = msg
m.totalPage = (len(msg) + CountPerPage - 1) / CountPerPage
return m, nil
In addition, you need to monitor the page turning button:
case "pgdown":
if m.curPage < m.totalPage-1 {
m.curPage++
}
return m, nil
case "pgup":
if m.curPage > 0 {
m.curPage--
}
return m, nil
In the View()
method, we calculate which warehouses need to be displayed according to the current page:
start, end := m.curPage*CountPerPage, (m.curPage+1)*CountPerPage
if end > len(m.repos) {
end = len(m.repos)
}
for _, repo := range m.repos[start:end] {
s += repoText(repo)
}
s += cyan.Render("--------------------------------------")
Finally, if the total number of pages is greater than 1, a page turning button prompt will be given:
if m.totalPage > 1 {
s += gray.Render("Pagedown to next page, pageup to prev page.")
s += "\n"
}
run:
Great, we only showed 5 pages. Try turning the page:
to sum up
bubbletea
provides a basic framework for TUI program operation. What we want to display, the style of display, and which events we want to handle are all specified by ourselves. There are many sample programs in the examples
bubbletea
warehouse. Children's shoes who are interested in writing TUI programs must not miss it. In addition, its brother warehouse bubbles
also provides many components.
If you find a fun and useful Go language library, welcome to submit an issue on the Go Daily Library GitHub😄
reference
- bubbletea GitHub:https://github.com/charmbracelet/bubbletea
- bubble GitHub:https://github.com/charmbracelet/bubbles
- Go daily one library GitHub: https://github.com/darjun/go-daily-lib
- issue:https://github.com/darjun/go-daily-lib/issues/22
I
My blog: https://darjun.github.io
Welcome to follow my WeChat public account [GoUpUp], learn together and make progress together~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。