bubbletea
是一個簡單、小巧、能夠很是方便地用來編寫 TUI(terminal User Interface,控制檯界面程序)程序的框架。內置簡單的事件處理機制,能夠對外部事件作出響應,如鍵盤按鍵。一塊兒來看下吧。先看看bubbletea
能作出什麼效果:git
感謝kiyonlin推薦。github
本文代碼使用 Go Modules。golang
建立目錄並初始化:編程
$ 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+c
或q
:退出程序;up
或k
:向上移動光標;down
或j
:向下移動光標;enter
或
:切換光標處事項的完成狀態。處理ctrl+c
或q
按鍵時,返回一個特殊的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) } }
運行:
一個簡單的 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.Cmd
。tea
後臺會執行這個函數,最終將返回的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
的組件,它只是顯示一些字符,一直在變化,給咱們形成一種任務正在處理中的感受。spinner
在github.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()
方法中返回觸發Tick
的Cmd
。可是又須要返回fetchTrending
。bubbletea
提供了Batch
能夠將兩個Cmd
合併在一塊兒返回:
func (m model) Init() tea.Cmd { return tea.Batch( spinner.Tick, fetchTrending, ) }
而後Update()
方法中咱們須要更新spinner
。Init()
方法返回的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 條,能夠按pageup
和pagedown
翻頁。首先在模型中增長兩個字段,當前頁和總頁數:
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😄
歡迎關注個人微信公衆號【GoUpUp】,共同窗習,一塊兒進步~