- 简介[1]
- 保持事件循环快速[2]
- 将消息转储到文件[3]
- 实时重载代码更改[4]
- 谨慎使用模型的接收器方法[5]
- 消息接收顺序不一定与发送顺序相同[6]
- 构建模型树[7]
- 布局计算容易出错[8]
- 恢复终端[9]
- 使用 teatest 进行端到端测试[10]
- 在 VHS 上录制演示和截图[11]
- 更多...[12]
- 简介
用其作者的话说, Bubble Tea[13] 是一个"强大的小型 TUI[14] 框架",适用于 Go。它可能很小,但我发现在真正掌握其威力之前,它有一个陡峭的学习曲线。我花了很多个深夜与破碎的布局和无响应的按键搏斗,才艰难地学会了我哪里出了错。我努力的结果是 PUG[15] ,一个用于驱动 terraform 的全屏终端界面:
我在 reddit 上宣布了 PUG,并回应一位用户 提供了一些使用 Bubble Tea 的建议[16] 。
这篇博文是对那些建议的更全面阐述;它不是教程,而是一系列提示(和技巧),以帮助任何人开发、调试和测试他们的 TUI。以下大部分内容来自 Bubble Tea github 项目上的各种问题和讨论,其中用户和作者都提供了宝贵的专业知识和指导。
- 保持事件循环快速
Bubble Tea 在事件循环中处理消息:
for { select { case msg := <-msgs: model, cmd = model.Update(msg) cmds <- cmd view := model.View() renderer.Render(view) } }
从通道接收消息并发送到模型的 Update()
方法。返回的命令被发送到一个通道,在其他地方的 go 例程中调用。然后调用模型的 View()
方法,然后重复循环并处理下一条消息。
因此,Bubble Tea 只能以与 Update()
和 View()
方法一样快的速度处理消息。你希望这些方法快速,否则你的程序可能会出现延迟,导致 UI 无响应。如果你的程序生成大量消息,它们可能会堆积,程序可能会出现停滞:用户按下一个键,但在不确定的时间内什么都没有发生。
编写快速模型的关键是将昂贵的操作卸载到 tea.Cmd
:
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": return m, tea.Quit case "f": return m, func() tea.Msg { // do something expensive time.Sleep(5 * time.Second) return someMsg{} } } } return m, nil }
- 将消息转储到文件
在调试时,查看模型接收的消息可能非常有价值。为此,将每条消息转储到一个文件中,并在另一个终端中跟踪该文件。我推荐使用 spew[17] 来美化打印消息。以下代码在设置 DEBUG
时转储消息:
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if os.Getenv("DEBUG") != "" { f, _ := os.OpenFile("messages.log", os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) defer f.Close() spew.Fdump(f, msg) } // ... }
运行程序,然后在另一个终端中跟踪 messages.log
。回到程序并输入 messages
,按回车,然后用 Ctrl-C 退出程序,你应该看到另一个终端输出:
(tea.WindowSizeMsg) { Width: (int) 213, Height: (int) 60 } (tea.KeyMsg) { Type: (tea.KeyType) 1, Runes: ([]int32) (len=1 cap=1) { (int32) 109 }, Alt: (bool) false, String: (string) (len=1) "m" } (tea.KeyMsg) { Type: (tea.KeyType) 1, Runes: ([]int32) (len=1 cap=1) { (int32) 101 }, Alt: (bool) false, String: (string) (len=1) "e" } ...
注意,收到的第一条消息是窗口调整大小消息,Bubble Tea 在启动后不久就会发送给你的程序。
- 实时重载代码更改
Web 应用程序开发人员使用 livereload[18] 在浏览器中近乎实时地查看代码更改的效果。你应该为你的 TUI 做同样的事情。我为 PUG 拼凑了几个脚本:
#!/bin/bash # rebuild.sh while true; do inotifywait -e modify -r . && go build -o pug done
#!/bin/bash # run.sh while true; do ./pug sleep 1 done
我在不同的终端中运行这些脚本。每当我保存代码更改时,由于 Go 的快速编译,更改几乎立即可见。这个解决方案可以改进以处理几个缺点:
- 如果我搞砸了,PUG 在启动时出现恐慌,那么它会进入死循环,每秒重启无数次,直到错误被修复。这里需要一个退避机制。
- 启动和停止这些脚本有点麻烦。要停止它们,我首先需要停止重建脚本,然后使用
kill
终止另一个脚本。
有几个工具如 air[19] 可以为 CLI 提供"实时重载"。但是 我发现它们不适用于[20] 期望标准输入是 TTY 的程序,包括 TUI,但在同一个问题中的用户 报告使用 watchexec 成功[21] 。
- 谨慎使用模型的接收器方法
在 Go 中,方法接收器可以作为值或指针传递。当有疑问时,通常使用指针接收器,而值接收器保留用于 特定用例[22] 。
这可能会让 Go 程序员感到困惑,因为文档中的所有 Bubble Tea 模型都有值接收器。这可能是因为 Bubble Tea 基于 Elm 架构[23] ,这是一种纯函数模式,其中函数不能改变其内部状态,而在 Go 中,具有值接收器的方法不能修改其接收器。
然而,你可以自由设置任何你喜欢的接收器类型。如果你为你的模型使用指针接收器,并且你在 Init()
中对模型进行了更改,那么这个更改会被保留:
type model struct { content string } func (m *model) Init() tea.Cmd { m.content = "initialized" return nil } 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": return m, tea.Quit } } return m, nil } func (m model) View() string { return m.content }
返回:
initialized
然而,不要犯在事件循环之外进行更改而引入竞态条件的错误:
func (m *model) Init() tea.Cmd { go func() { time.Sleep(time.Millisecond) m.content = "initialized" }() return nil }
如果你反复运行这个程序,你会发现它有时返回 initialized
,有时返回 uninitialized
。在后一种情况下,事件循环已经在 go 例程将内容设置为 initialized
之前调用了 View()
(参见 上面的事件循环代码[1] )。
除非有充分的理由这样做,否则坚持正常的消息流程:对模型的任何更改都应该在 Update()
中进行,并立即在第一个返回值中返回。偏离这个方向不仅违背了 Bubbletea 的自然顺序,还有可能使其变慢(参见 保持事件循环快速[1] )。
使用指针接收器并不一定与这个建议相矛盾。例如,指针接收器对于辅助方法很有用:
func (m *model) setContent(s string) { m.content = s } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "i": m.setContent("initialized") return m, nil } } return m, nil }
关于这个主题有一个 Github 讨论[24] ,其中对何时使用指针接收器有不同的意见。
- 消息接收顺序不一定与发送顺序相同
在 Go 中,如果你有多个 go 例程向一个通道发送消息,发送和接收的顺序是未指定的:
https://go.dev/play/p/G4-o8F6PsvU[25]
package main import ( "fmt" "sync" ) func main() { ch := make(chan int) var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() ch <- 1 ch <- 2 }() go func() { defer wg.Done() ch <- 3 ch <- 4 }() go func() { wg.Wait() close(ch) }() for i := range ch { fmt.Println(i) } }
运行上面的代码可能会返回类似这样的结果:
3 1 4 2
现在,在 Bubble Tea 中,消息来自多个来源,包括:
- 用户输入,按键,鼠标移动等。
- 来自 tea 命令的消息 (
tea.Cmd
)。 - 使用
Send(msg)
显式发送的消息。 - 信号,如窗口调整大小、挂起等。
用户输入消息在单个例程中发送:
for { msg := p.input.Read() p.msgs <- msg }
因此,用户输入消息确实按顺序发送。这很好,否则在文本输入中输入单词最终会变成乱码。
然而,Bubble Tea 命令在单独的 go 例程中并发执行:
go func() { p.msgs <- cmd() }()
因此,它们产生的消息以未指定的顺序发送。即使一个命令在另一个命令之前完成,也不能保证它的消息会先发送和接收。
以下通过使用 Send(msg)
并发显式发送消息来演示这一点:
func (m model) Init() tea.Cmd { return tea.Batch( func() tea.Msg { return msg{1} }, func() tea.Msg { return msg{2} }, func() tea.Msg { return msg{3} }, ) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case msg: fmt.Println(msg.i) case tea.KeyMsg: return m, tea.Quit } return m, nil }
运行程序会产生:
2 3 1
你不能依赖并发发送消息的顺序。如果顺序很重要,你有几种解决方法:
- 直接在
Update()
中更新模型:这可能与 保持 Update 快速[1] 相矛盾。func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "f": // do something expensive time.Sleep(5 * time.Second) m.content = "done" return m, nil } } return m, nil }
- 使用
tea.Sequence
按顺序一次运行一个命令:参见 文档[26] 。return m, tea.Sequence(cmd1, cmd2, cmd3)
最后,如果顺序很重要,那么尝试重构你的程序,使其不再重要。拥抱并发的混沌。
- 构建模型树
任何非平凡的 Bubble Tea 程序都会超出单个模型的范围。很有可能你正在使用 Charm 的 bubbles[27] ,它们本身就是模型,每个都有 Init()
、Update()
和 View()
。你将这些模型嵌入到你自己的模型中。同样适用于你自己的代码:你可能想把你自己的组件推到单独的模型中。然后原始模型成为"顶级"模型,其角色仅仅是消息路由器和屏幕合成器,负责将消息路由到正确的"子"模型,并用子模型的 View()
方法的内容填充布局。
反过来,子模型也可能嵌入模型,形成一个模型树:根模型接收所有消息,这些消息沿树向下传递到相关的子模型,结果模型和命令被传回树上,由根模型的 Update()
方法返回。然后渲染发生相同的遍历:调用根模型的 View()
方法,它反过来调用子模型的 View()
方法,结果字符串被传回树上,连接在一起并返回给渲染器。
这是 PUG 中实现的模型树,箭头说明了消息从模型到模型的路由:
根模型维护一个子模型列表或映射。根据你的程序,你可能会指定一个子模型为"当前"模型,这是当前可见的模型,也是用户与之交互的模型。你可能会维护一个以前访问过的模型栈:当用户按下一个键时,你的程序将另一个模型推到栈上,然后栈顶就成为当前模型。当用户按键"返回"时,模型从栈中"弹出",前一个模型成为当前模型。
你可以选择在程序启动时预先创建你的子模型。或者你可以根据需求动态创建它们,如果概念上它们在启动时不存在或者可能数量达到数千个,这是有意义的。在 PUG 的情况下,只有当用户"深入"到单个日志消息时才创建 LogMessage
模型。如果你选择动态方法,维护模型缓存以避免不必要地重新创建模型是有意义的。
根模型接收所有消息。有三个主要的路由决策路径:
- 直接在根模型中处理消息。例如,"全局键",如映射到退出、帮助等的键。
- 将消息路由到当前模型(如果你有一个)。例如,除全局键以外的所有键,如 PageUp 和 PageDown 用于上下滚动某些内容。
- 将消息路由到所有子模型,例如
tea.WindowSizeMsg
,它包含当前终端尺寸,所有子模型可能都想用它来计算渲染内容的高度和宽度。
以上都不是铁律。它可能不适合你的特定程序。然而,Bubble Tea 将架构决策留给你,一旦你的程序达到一定规模,你需要对如何管理不可避免出现的复杂性做出有意识的决定。
- 布局计算容易出错
您负责确保您的程序适合终端。终端的尺寸通过 tea.WindowSizeMsg
消息传递,该消息在程序启动后不久发送,并在终端调整大小时再次发送。您的模型记录这些尺寸,并在渲染时使用它们来计算小部件的大小。
在这个应用程序中,有三个小部件:标题、内容和页脚:
type model struct { width, height int } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC: return m, tea.Quit } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height } return m, nil } func (m model) View() string { header := lipgloss.NewStyle(). Align(lipgloss.Center). Width(m.width). Render("header") footer := lipgloss.NewStyle(). Align(lipgloss.Center). Width(m.width). Render("footer") content := lipgloss.NewStyle(). Width(m.width). // accommodate header and footer Height(m.height-1-1). Align(lipgloss.Center, lipgloss.Center). Render("content") return lipgloss.JoinVertical(lipgloss.Top, header, content, footer) } func main() { p := tea.NewProgram(model{}, tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Println("could not start program:", err) os.Exit(1) } }
这会产生:
标题和页脚的大小是固定的,内容小部件占用剩余的空间。
然后修改代码,在标题底部添加边框:
func (m model) View() string { header := lipgloss.NewStyle(). Align(lipgloss.Center). Width(m.width). Border(lipgloss.NormalBorder(), false, false, true, false). Render("header") footer := lipgloss.NewStyle(). Align(lipgloss.Center). Width(m.width). Render("footer") content := lipgloss.NewStyle(). Width(m.width). // accommodate header and footer Height(m.height-1-1). Align(lipgloss.Center, lipgloss.Center). Render("content") return lipgloss.JoinVertical(lipgloss.Top, header, content, footer) }
但这会破坏布局,迫使标题超出终端:
问题在于算术计算没有更新以适应边框。代码很脆弱,使用硬编码的高度,在更新代码时很容易被忘记。解决方法是使用 lipgloss 的 Height()
和 Width()
方法来引用小部件的高度和宽度:
func (m model) View() string { header := lipgloss.NewStyle(). Align(lipgloss.Center). Width(m.width). Border(lipgloss.NormalBorder(), false, false, true, false). Render("header") footer := lipgloss.NewStyle(). Align(lipgloss.Center). Width(m.width). Render("footer") content := lipgloss.NewStyle(). Width(m.width). Height(m.height-lipgloss.Height(header)-lipgloss.Height(footer)). Align(lipgloss.Center, lipgloss.Center). Render("content") return lipgloss.JoinVertical(lipgloss.Top, header, content, footer) }
这修复了布局:
现在,当对小部件大小进行更改时,布局会相应地进行调整。
随着程序变得更加复杂,拥有更多的小部件和模型,在设置尺寸时保持严谨很重要,以避免令人沮丧地试图追踪导致布局破坏的原因。
- 恢复您的终端
Bubble Tea 可以优雅地从事件循环中发生的崩溃中恢复,但如果崩溃发生在命令中则无法恢复:
type model struct{} func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { panic("error") } } func (m model) View() string { return "" } func main() { p := tea.NewProgram(model{}, tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Println("could not start program:", err) os.Exit(1) } }
当调用时会导致这种情况:
注意堆栈跟踪是畸形的,并且没有光标。由于崩溃没有被恢复,终端没有重置为之前的设置,禁用了原始模式等,您输入的任何字符都不再回显。
要恢复终端,请重置它:
之后您应该重新获得光标和终端的先前设置。
注意:这个 bug 有一个 未解决的问题[28] 。
- 使用 teatest 进行端到端测试
对于 TUI 的端到端测试,Charm 开发了 teatest[29] ,他们去年在 博客文章[30] 中介绍了它。
这是一个运行然后在用户确认后退出的程序:
type model struct { quitting bool } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyCtrlC: m.quitting = true return m, nil } if m.quitting { switch { case msg.String() == "y": return m, tea.Quit default: m.quitting = false } } } return m, nil } func (m model) View() string { if m.quitting { return "Quit? (y/N)" } else { return "Running." } }
这是测试:
func TestQuit(t *testing.T) { m := model{} tm := teatest.NewTestModel(t, m) waitForString(t, tm, "Running.") tm.Send(tea.KeyMsg{Type: tea.KeyCtrlC}) waitForString(t, tm, "Quit? (y/N)") tm.Type("y") tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) } func waitForString(t *testing.T, tm *teatest.TestModel, s string) { teatest.WaitFor( t, tm.Output(), func(b []byte) bool { return strings.Contains(string(b), s) }, teatest.WithCheckInterval(time.Millisecond*100), teatest.WithDuration(time.Second*10), ) }
如您所见,测试模拟用户按键并检查程序做出相应响应,然后检查程序是否已完成。
虽然这个特定的测试只检查子字符串,但上面链接的博客文章展示了 teatest 如何支持使用"黄金文件",在第一次运行测试时捕获整个输出,然后后续测试检查内容是否与捕获的输出匹配。这对于内容的回归测试很有用,但意味着每次对程序内容进行微小更改时都需要重新生成黄金文件。
注意:截至撰写本文时,teatest 是 Charm 的 实验性[31] 仓库的一部分,这意味着不保证向后兼容性。
- 在 VHS 上录制演示和截图
这一点并不是关于构建程序本身,而是关于测试、记录和向受众展示程序。Charm 为终端制作了许多不错的工具,而 VHS[32] 正是满足这一需求的工具。
使用一个声明性脚本,您可以同时生成动画 gif 和 png 截图。这是 PUG 脚本的一个片段:
Output demo/demo.gif Set Shell "bash" Set FontSize 14 Set Width 1200 Set Height 800 Set Framerate 24 Set Padding 5 Hide Type `go run main.go` Enter Sleep 1s Show # init all modules Ctrl+a Sleep 0.5s Type "i" # we're taken to the init task group page Sleep 0.5s # preview output for several tasks Down Sleep 0.5s Down Sleep 0.5s Down Sleep 0.5s ... # go back to modules Type "m" Sleep 0.5s # take screen shot of modules (sleep to ensure page doesn't switch too soon) Screenshot demo/modules.png Sleep 0.5s
VHS 将脚本称为"磁带",您可以通过运行以下命令来录制生成的 gif:
vhs demo.tape
然后输出磁带中指定的动画 gif。这是 PUG 的完整动画 gif:
将您的磁带与代码一起提交。您可以选择在构建流程中录制新视频。同一个磁带可以生成截图,这些截图同样构成了您的(手动)测试和文档的一部分。
事实上,本文中的截图和动画 gif 都是在 VHS 上录制的,可以在 博客的仓库[33] 中找到磁带。
- 更多...
我会努力继续添加更多技巧,但没有什么能替代阅读 Bubble Tea 和基于 Bubble Tea 的项目的代码。我邀请您阅读 PUG[14] 的代码,它实现了几个可能对您自己的项目有用的组件:
- 表格小部件,具有选择、排序、过滤和自定义行渲染功能。
- 拆分模型:带有表格和预览窗格的分屏;可调整/可切换的分割。
- 导航器:创建和缓存模型;历史跟踪器。
- 集成测试:使用 teatest 进行端到端测试。
注意:如果发现任何错误、拼写错误等,请在博客的 仓库[32] 中提出问题。
参考链接
- 简介: https://leg100.github.io/en/posts/building-bubbletea-programs//#0-intro
- 保持事件循环快速: https://leg100.github.io/en/posts/building-bubbletea-programs//#keepfast
- 将消息转储到文件: https://leg100.github.io/en/posts/building-bubbletea-programs//#2-dump-messages-to-a-file
- 实时重载代码更改: https://leg100.github.io/en/posts/building-bubbletea-programs//#3-live-reload-code-changes
- 谨慎使用模型的接收器方法: https://leg100.github.io/en/posts/building-bubbletea-programs//#4-use-receiver-methods-on-your-model-judiciously
- 消息接收顺序不一定与发送顺序相同: https://leg100.github.io/en/posts/building-bubbletea-programs//#5-messages-are-not-necessarily-received-in-the-order-they-are-sent
- 构建模型树: https://leg100.github.io/en/posts/building-bubbletea-programs//#6-build-a-tree-of-models
- 布局计算容易出错: https://leg100.github.io/en/posts/building-bubbletea-programs//#7-layout-arithmetic-is-error-prone
- 恢复终端: https://leg100.github.io/en/posts/building-bubbletea-programs//#8-recovering-your-terminal
- 使用 teatest 进行端到端测试: https://leg100.github.io/en/posts/building-bubbletea-programs//#9-use-teatest-for-end-to-end-tests
- 在 VHS 上录制演示和截图: https://leg100.github.io/en/posts/building-bubbletea-programs//#10-record-demos-and-screenshots-on-vhs
- 更多...: https://leg100.github.io/en/posts/building-bubbletea-programs//#11-and-more
- Bubble Tea: https://github.com/charmbracelet/bubbletea
- TUI: https://en.wikipedia.org/wiki/Text-based_user_interface
- PUG: https://github.com/leg100/pug
- 提供了一些使用 Bubble Tea 的建议: https://www.reddit.com/r/golang/comments/1duart8/comment/lbhwwoj/
- spew: https://github.com/davecgh/go-spew
- livereload: https://github.com/livereload/livereload-js
- air: https://github.com/air-verse/air
- 我发现它们不适用于: https://github.com/charmbracelet/bubbletea/issues/150
- 报告使用 watchexec 成功: https://github.com/charmbracelet/bubbletea/issues/150#issuecomment-988857894
- 特定用例: https://google.github.io/styleguide/go/decisions#receiver-type
- Elm 架构: https://guide.elm-lang.org/architecture/
- Github 讨论: https://github.com/charmbracelet/bubbletea/discussions/434
- https://go.dev/play/p/G4-o8F6PsvU
- 文档: https://pkg.go.dev/github.com/charmbracelet/bubbletea#Sequence
- bubbles: https://github.com/charmbracelet/bubbles
- 未解决的问题: https://github.com/charmbracelet/bubbletea/issues/234
- teatest: https://github.com/charmbracelet/x/tree/main/exp/teatest
- 博客文章: https://charm.sh/blog/teatest/
- 实验性: https://github.com/charmbracelet/x
- VHS: https://github.com/charmbracelet/vhs
- 博客的仓库: https://github.com/leg100/leg100.github.io