构建 Bubble Tea 程序的技巧

文摘   2024-09-18 13:45   美国  
    1. 简介[1]
    1. 保持事件循环快速[2]
    1. 将消息转储到文件[3]
    1. 实时重载代码更改[4]
    1. 谨慎使用模型的接收器方法[5]
    1. 消息接收顺序不一定与发送顺序相同[6]
    1. 构建模型树[7]
    1. 布局计算容易出错[8]
    1. 恢复终端[9]
    1. 使用 teatest 进行端到端测试[10]
    1. 在 VHS 上录制演示和截图[11]
    1. 更多...[12]

  1. 简介


用其作者的话说, Bubble Tea[13]  是一个"强大的小型  TUI[14]  框架",适用于 Go。它可能很小,但我发现在真正掌握其威力之前,它有一个陡峭的学习曲线。我花了很多个深夜与破碎的布局和无响应的按键搏斗,才艰难地学会了我哪里出了错。我努力的结果是  PUG[15] ,一个用于驱动 terraform 的全屏终端界面:

我在 reddit 上宣布了 PUG,并回应一位用户 提供了一些使用 Bubble Tea 的建议[16]

这篇博文是对那些建议的更全面阐述;它不是教程,而是一系列提示(和技巧),以帮助任何人开发、调试和测试他们的 TUI。以下大部分内容来自 Bubble Tea github 项目上的各种问题和讨论,其中用户和作者都提供了宝贵的专业知识和指导。

  1. 保持事件循环快速


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
}

  1. 将消息转储到文件


在调试时,查看模型接收的消息可能非常有价值。为此,将每条消息转储到一个文件中,并在另一个终端中跟踪该文件。我推荐使用  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 在启动后不久就会发送给你的程序。

  1. 实时重载代码更改


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]

  1. 谨慎使用模型的接收器方法


在 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] ,其中对何时使用指针接收器有不同的意见。

  1. 消息接收顺序不一定与发送顺序相同


在 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 中,消息来自多个来源,包括:

  1. 用户输入,按键,鼠标移动等。
  2. 来自 tea 命令的消息 (tea.Cmd)。
  3. 使用 Send(msg) 显式发送的消息。
  4. 信号,如窗口调整大小、挂起等。

用户输入消息在单个例程中发送:

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

你不能依赖并发发送消息的顺序。如果顺序很重要,你有几种解决方法:

  1. 直接在 Update() 中更新模型:
    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
    }
    
    这可能与 保持 Update 快速[1] 相矛盾。
  2. 使用 tea.Sequence 按顺序一次运行一个命令:
    return m, tea.Sequence(cmd1, cmd2, cmd3)
    
    参见 文档[26]

最后,如果顺序很重要,那么尝试重构你的程序,使其不再重要。拥抱并发的混沌。

  1. 构建模型树


任何非平凡的 Bubble Tea 程序都会超出单个模型的范围。很有可能你正在使用 Charm 的  bubbles[27] ,它们本身就是模型,每个都有 Init()Update()View()。你将这些模型嵌入到你自己的模型中。同样适用于你自己的代码:你可能想把你自己的组件推到单独的模型中。然后原始模型成为"顶级"模型,其角色仅仅是消息路由器和屏幕合成器,负责将消息路由到正确的"子"模型,并用子模型的 View() 方法的内容填充布局。

反过来,子模型也可能嵌入模型,形成一个模型树:根模型接收所有消息,这些消息沿树向下传递到相关的子模型,结果模型和命令被传回树上,由根模型的 Update() 方法返回。然后渲染发生相同的遍历:调用根模型的 View() 方法,它反过来调用子模型的 View() 方法,结果字符串被传回树上,连接在一起并返回给渲染器。

这是 PUG 中实现的模型树,箭头说明了消息从模型到模型的路由:

根模型维护一个子模型列表或映射。根据你的程序,你可能会指定一个子模型为"当前"模型,这是当前可见的模型,也是用户与之交互的模型。你可能会维护一个以前访问过的模型栈:当用户按下一个键时,你的程序将另一个模型推到栈上,然后栈顶就成为当前模型。当用户按键"返回"时,模型从栈中"弹出",前一个模型成为当前模型。

你可以选择在程序启动时预先创建你的子模型。或者你可以根据需求动态创建它们,如果概念上它们在启动时不存在或者可能数量达到数千个,这是有意义的。在 PUG 的情况下,只有当用户"深入"到单个日志消息时才创建 LogMessage 模型。如果你选择动态方法,维护模型缓存以避免不必要地重新创建模型是有意义的。

根模型接收所有消息。有三个主要的路由决策路径:

  1. 直接在根模型中处理消息。例如,"全局键",如映射到退出、帮助等的键。
  2. 将消息路由到当前模型(如果你有一个)。例如,除全局键以外的所有键,如 PageUp 和 PageDown 用于上下滚动某些内容。
  3. 将消息路由到所有子模型,例如 tea.WindowSizeMsg,它包含当前终端尺寸,所有子模型可能都想用它来计算渲染内容的高度和宽度。

以上都不是铁律。它可能不适合你的特定程序。然而,Bubble Tea 将架构决策留给你,一旦你的程序达到一定规模,你需要对如何管理不可避免出现的复杂性做出有意识的决定。

  1. 布局计算容易出错


您负责确保您的程序适合终端。终端的尺寸通过 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)
}

这修复了布局:

现在,当对小部件大小进行更改时,布局会相应地进行调整。

随着程序变得更加复杂,拥有更多的小部件和模型,在设置尺寸时保持严谨很重要,以避免令人沮丧地试图追踪导致布局破坏的原因。

  1. 恢复您的终端


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]

  1. 使用 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] 仓库的一部分,这意味着不保证向后兼容性。

  1. 在 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] 中找到磁带。

  1. 更多...


我会努力继续添加更多技巧,但没有什么能替代阅读 Bubble Tea 和基于 Bubble Tea 的项目的代码。我邀请您阅读  PUG[14]  的代码,它实现了几个可能对您自己的项目有用的组件:

  • 表格小部件,具有选择、排序、过滤和自定义行渲染功能。
  • 拆分模型:带有表格和预览窗格的分屏;可调整/可切换的分割。
  • 导航器:创建和缓存模型;历史跟踪器。
  • 集成测试:使用 teatest 进行端到端测试。

注意:如果发现任何错误、拼写错误等,请在博客的 仓库[32] 中提出问题。

参考链接

    1. 简介: https://leg100.github.io/en/posts/building-bubbletea-programs//#0-intro
    1. 保持事件循环快速: https://leg100.github.io/en/posts/building-bubbletea-programs//#keepfast
    1. 将消息转储到文件: https://leg100.github.io/en/posts/building-bubbletea-programs//#2-dump-messages-to-a-file
    1. 实时重载代码更改: https://leg100.github.io/en/posts/building-bubbletea-programs//#3-live-reload-code-changes
    1. 谨慎使用模型的接收器方法: https://leg100.github.io/en/posts/building-bubbletea-programs//#4-use-receiver-methods-on-your-model-judiciously
    1. 消息接收顺序不一定与发送顺序相同: https://leg100.github.io/en/posts/building-bubbletea-programs//#5-messages-are-not-necessarily-received-in-the-order-they-are-sent
    1. 构建模型树: https://leg100.github.io/en/posts/building-bubbletea-programs//#6-build-a-tree-of-models
    1. 布局计算容易出错: https://leg100.github.io/en/posts/building-bubbletea-programs//#7-layout-arithmetic-is-error-prone
    1. 恢复终端: https://leg100.github.io/en/posts/building-bubbletea-programs//#8-recovering-your-terminal
    1. 使用 teatest 进行端到端测试: https://leg100.github.io/en/posts/building-bubbletea-programs//#9-use-teatest-for-end-to-end-tests
    1. 在 VHS 上录制演示和截图: https://leg100.github.io/en/posts/building-bubbletea-programs//#10-record-demos-and-screenshots-on-vhs
    1. 更多...: https://leg100.github.io/en/posts/building-bubbletea-programs//#11-and-more
  1. Bubble Tea: https://github.com/charmbracelet/bubbletea
  2. TUI: https://en.wikipedia.org/wiki/Text-based_user_interface
  3. PUG: https://github.com/leg100/pug
  4. 提供了一些使用 Bubble Tea 的建议: https://www.reddit.com/r/golang/comments/1duart8/comment/lbhwwoj/
  5. spew: https://github.com/davecgh/go-spew
  6. livereload: https://github.com/livereload/livereload-js
  7. air: https://github.com/air-verse/air
  8. 我发现它们不适用于: https://github.com/charmbracelet/bubbletea/issues/150
  9. 报告使用 watchexec 成功: https://github.com/charmbracelet/bubbletea/issues/150#issuecomment-988857894
  10. 特定用例: https://google.github.io/styleguide/go/decisions#receiver-type
  11. Elm 架构: https://guide.elm-lang.org/architecture/
  12. Github 讨论: https://github.com/charmbracelet/bubbletea/discussions/434
  13. https://go.dev/play/p/G4-o8F6PsvU
  14. 文档: https://pkg.go.dev/github.com/charmbracelet/bubbletea#Sequence
  15. bubbles: https://github.com/charmbracelet/bubbles
  16. 未解决的问题: https://github.com/charmbracelet/bubbletea/issues/234
  17. teatest: https://github.com/charmbracelet/x/tree/main/exp/teatest
  18. 博客文章: https://charm.sh/blog/teatest/
  19. 实验性: https://github.com/charmbracelet/x
  20. VHS: https://github.com/charmbracelet/vhs
  21. 博客的仓库: https://github.com/leg100/leg100.github.io

幻想发生器
图解技术本质
 最新文章