2

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 a Cmd tell bubbletea what command to execute;
  • Update() method is used to respond to external events, return a modified model, and the command that bubbletea
  • 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 or q : Exit the program;
  • up or k : move the cursor up;
  • down or j : move the cursor down;
  • enter or : Switch the completion status of the item at the cursor.

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

  1. bubbletea GitHub:https://github.com/charmbracelet/bubbletea
  2. bubble GitHub:https://github.com/charmbracelet/bubbles
  3. Go daily one library GitHub: https://github.com/darjun/go-daily-lib
  4. 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~


darjun
2.9k 声望358 粉丝