乐趣区

关于godailylib:Go-每日一库之-bubbletea

简介

bubbletea是一个简略、玲珑、能够十分不便地用来编写 TUI(terminal User Interface,控制台界面程序)程序的框架。内置简略的事件处理机制,能够对外部事件做出响应,如键盘按键。一起来看下吧。先看看 bubbletea 能做出什么成果:

感激 kiyonlin 举荐。

疾速应用

本文代码应用 Go Modules。

创立目录并初始化:

$ mkdir bubbletea && cd bubbletea
$ go mod init github.com/darjun/go-daily-lib/bubbletea

装置 bubbletea 库:

$ go get -u github.com/charmbracelet/bubbletea

bubbletea程序都须要有一个实现 bubbletea.Model 接口的类型:

type Model interface {Init() Cmd
  Update(Msg) (Model, Cmd)
  View() string}
  • Init()办法在程序启动时会立即调用,它会做一些初始化工作,并返回一个 Cmd 通知 bubbletea 要执行什么命令;
  • Update()办法用来响应内部事件,返回一个批改后的模型,和想要 bubbletea 执行的命令;
  • View()办法用于返回在管制台上显示的文本字符串。

上面咱们来实现一个 Todo List。首先定义模型:

type model struct {todos    []string
  cursor   int
  selected map[int]struct{}}
  • todos:所有待实现事项;
  • cursor:界面上光标地位;
  • selected:已实现标识。

不须要任何初始化工作,实现一个空的 Init() 办法,并返回nil

import (tea "github.com/charmbracelet/bubbletea")
func (m model) Init() tea.Cmd {return nil}

咱们须要响应按键事件,实现 Update() 办法。按键事件产生时会以相应的 tea.Msg 为参数调用 Update() 办法。通过对参数 tea.Msg 进行类型断言,咱们能够对不同的事件进行对应的解决:

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
}

约定:

  • ctrl+cq:退出程序;
  • upk:向上挪动光标;
  • downj:向下挪动光标;
  • enter :切换光标处事项的实现状态。

解决 ctrl+cq按键时,返回一个非凡的 tea.Quit,告诉bubbletea 须要退出程序。

最初实现 View() 办法,这个办法返回的字符串就是最终显示在管制台上的文本。咱们能够依照本人想要的模式,依据模型数据拼装:

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
}

光标所在位置用 > 标识,已实现的事项减少 x 标识。

模型类型定义好了之后,须要创立一个该模型的对象;

var initModel = model{todos:    []string{"cleanning", "wash clothes", "write a blog"},
  selected: make(map[int]struct{}),
}

为了让程序工作,咱们还要创立一个 bubbletea 的利用对象,通过 bubbletea.NewProgram() 实现,而后调用这个对象的 Start() 办法开始执行:

func main() {cmd := tea.NewProgram(initModel)
  if err := cmd.Start(); err != nil {fmt.Println("start failed:", err)
    os.Exit(1)
  }
}

运行:

GitHub Trending

一个简略的 Todo 利用看起来如同没什么意思。接下来,咱们一起编写一个拉取 GitHub Trending 仓库并显示在控制台的程序。

Github Trending 的界面如下:

能够抉择语言(Spoken Language,本地语言)、语言(Language,编程语言)和工夫范畴(Today,This week,This month)。因为 GitHub 没有提供 trending 的官网 API,咱们只能爬取网页本人来剖析。好在 Go 有一个弱小的剖析工具 goquery,提供了堪比 jQuery 的弱小性能。我之前也写过一篇文章介绍它——Go 每日一库之 goquery。

关上 Chrome 控制台,点击 Elements 页签,查看每个条目标构造:

根底版本

定义模型:

type model struct {repos []*Repo
  err   error
}

其中 repos 字段示意拉取到的 Trending 仓库列表,构造体 Repo 如下,字段含意都有正文,很清晰了:

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字段示意拉取失败设置的谬误值。为了让程序启动时,就去执行网络申请拉取 Trending 的列表,咱们让模型的 Init() 办法返回一个 tea.Cmd 类型的值:

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类型为:

// src/github.com/charmbracelet/bubbletea/tea.go
type Cmd func() Msg

tea.Cmd底层是一个函数类型,函数无参数,并且返回一个 tea.Msg 对象。

fetchTrending()函数拉取 GitHub 的今日 Trending 列表,如果遇到谬误,则返回 error 值。这里咱们临时疏忽 getTrending() 函数的实现,这个与咱们要说的重点关系不大,感兴趣的童鞋能够去我的 GitHub 仓库查看具体代码。

程序启动时如果须要做一些操作,通常就会在 Init() 办法中返回一个 tea.Cmdtea 后盾会执行这个函数,最终将返回的 tea.Msg 传给模型的 Update() 办法。

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()办法也比较简单,首先还是须要监听按键事件,咱们约定按下 q 或 ctrl+c 或 esc 退出程序。具体按键对应的字符串示意能够查看文档或源码 bubbletea/key.go 文件 。接管到errMsg 类型的音讯,示意网络申请失败了,记录谬误值。接管到 []*Repo 类型的音讯,示意正确返回的 Trending 仓库列表,记录下来。在 View() 函数中,咱们显示正在拉取,拉取失败和正确拉取等信息:

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"
}

逻辑很清晰,如果 err 字段不为 nil 示意失败,否则有仓库数据,显示仓库信息。否则正在拉取中。最初显示一条提示信息,通知客户怎么退出程序。

每个仓库项的显示逻辑如下,分为 3 列,根底信息、形容和链接:

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):

获取失败(国内 GitHub 不稳固,多试几次总会遇到😭):

获取胜利:

让界面更好看

黑红色咱们曾经看了太多太多了,能不能让字体出现不同的色彩呢?当然能够。bubbletea能够利用 lipgloss 库给文本增加各种色彩,咱们定义了 4 种颜色,色彩的 RBG 值是我在 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"))
)

想要将文本变为什么色彩,只须要调用对应色彩对象的 Render() 办法将文本传入即可。例如咱们想让提醒变为暗灰色,两头文字应用暗黄色,批改 View() 办法:

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"
}

而后仓库的根本信息咱们用青色(cyan),形容用绿色,链接用暗灰色:

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
}

再次运行:

胜利:

嗯,当初难看多了。

我没有偷懒

有时候网络很慢,加上一个申请正在解决的提醒能让咱们更释怀(程序还在跑,没偷懒)。bubbletea的兄弟仓库 bubbles 提供了一个叫做 spinner 的组件,它只是显示一些字符,始终在变动,给咱们造成一种工作正在解决中的感觉。spinnergithub.com/charmbracelet/bubbles/spinner 包中,须要先引入。而后在模型中减少 spinner.Model 字段:

type model struct {repos   []*Repo
  err     error
  spinner spinner.Model
}

创立模型时,同时须要初始化 spinner.Model 对象,咱们指定 spinner 的文本色彩为紫色:

var purple = lipgloss.NewStyle().Foreground(lipgloss.Color("#800080"))

func newModel() model {sp := spinner.NewModel()
  sp.Style = purple

  return model{spinner: sp,}
}

spinner通过 Tick 来触发其扭转状态,所以须要在 Init() 办法中返回触发 TickCmd。然而又须要返回 fetchTrendingbubbletea 提供了 Batch 能够将两个 Cmd 合并在一起返回:

func (m model) Init() tea.Cmd {
  return tea.Batch(
    spinner.Tick,
    fetchTrending,
  )
}

而后 Update() 办法中咱们须要更新 spinnerInit() 办法返回的 spinner.Tick 会产生spinner.TickMsg,咱们对其做解决:

case spinner.TickMsg:
  var cmd tea.Cmd
  m.spinner, cmd = m.spinner.Update(msg)
  return m, cmd

spinner.Update(msg)返回一个 tea.Cmd 对象驱动下一次Tick

最初在 View() 办法中,咱们将 spinner 显示进去。调用其 View() 办法返回以后状态的字符串,拼在咱们想要显示的地位:

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"
}

运行:

分页

因为一次返回了很多 GitHub 仓库,咱们想对其进行分页显示,每页显示 5 条,能够按 pageuppagedown翻页。首先在模型中减少两个字段,当前页和总页数:

const (CountPerPage = 5)

type model struct {
  // ...
  curPage   int
  totalPage int
}

拉取到仓库时,计算总页数:

case []*Repo:
  m.repos = msg
  m.totalPage = (len(msg) + CountPerPage - 1) / CountPerPage
  return m, nil

另外须要监听翻页按键:

case "pgdown":
  if m.curPage < m.totalPage-1 {m.curPage++}
  return m, nil
case "pgup":
  if m.curPage > 0 {m.curPage--}
  return m, nil

View() 办法中,咱们依据当前页计算须要显示哪些仓库:

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("--------------------------------------")

最初,如果总页数大于 1,给出翻页按键的提醒:

if m.totalPage > 1 {s += gray.Render("Pagedown to next page, pageup to prev page.")
  s += "\n"
}

运行:

很棒,咱们只显示了 5 页。试试翻页吧:

总结

bubbletea提供了一个 TUI 程序运行的根本框架。咱们要显示什么,显示的款式,要对哪些事件进行解决都由咱们本人指定。bubbletea仓库的 examples 文件夹中有多个示例程序,对编写 TUI 程序感兴趣的童鞋千万不能错过。另外它的兄弟仓库 bubbles 中也提供了不少组件。

大家如果发现好玩、好用的 Go 语言库,欢送到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. bubbletea GitHub:https://github.com/charmbracelet/bubbletea
  2. bubble GitHub:https://github.com/charmbracelet/bubbles
  3. Go 每日一库 GitHub:https://github.com/darjun/go-daily-lib
  4. issue:https://github.com/darjun/go-daily-lib/issues/22

我的博客:https://darjun.github.io

欢送关注我的微信公众号【GoUpUp】,独特学习,一起提高~

退出移动版