用 VHS 为 Ruby cli-ui 录制终端演示:从 Tape 脚本到 GIF 的完整实战
用 VHS 为 Ruby cli-ui 录制终端演示:从 Tape 脚本到 GIF 的完整实战
本指南以 examples/cli-ui/README.md 为核心,系统讲解如何用 VHS 为 Ruby 的 cli-ui 命令行组件库录制高质量终端演示 GIF。你将掌握 VHS .tape 脚本的完整书写方法,包括 IRB 会话编排、隐藏输入、清屏、延时控制,以及如何录制 CLI::UI 的彩色文本、嵌套 Frame、进度条、Spinner、状态组件、符号渲染和交互式提问等典型 UI 场景,并理解其背后的源码级实现原理。
前置准备:依赖与 Gemfile
VHS 是一个把"终端操作脚本"渲染成 GIF/MP4/WebM 的命令行工具,它运行在虚拟终端(由 ttyd 提供,页面渲染依赖 headless Chrome)之上,再通过 ffmpeg 合成输出。运行 tape 文件前,需要确保系统中已安装 ttyd 和 ffmpeg 并加入 PATH(参见 README.md 的安装说明)。
本示例要演示的是 Ruby 的 cli-ui 库,因此还需要一个 Ruby 环境。仓库在 examples/cli-ui/Gemfile 中给出了最简单的依赖声明:
source "https://rubygems.org"
gem 'cli-ui'
本地可按如下方式准备环境:
# 在 examples/cli-ui 目录下安装依赖
bundle install
# 打开一个不自动补全的 IRB 会话(--noautocomplete 可避免补全干扰录制画面)
irb --noautocomplete
cli-ui 的所有组件都要求先执行 CLI::UI::StdoutRouter.enable 来接管标准输出路由,否则组件无法正确渲染。
Tape 脚本的公共骨架
examples/cli-ui 目录下的 7 个演示脚本(format.tape、nested-frames.tape、progress.tape、spinner.tape、status-widget.tape、symbols.tape、text-prompt.tape)共享同一套录制骨架。以 format.tape 为例:
Output examples/cli-ui/format.gif
Set FontSize 32
Set Width 2200
Set Height 400
Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show
Type 'puts CLI::UI.fmt "{{red:Red}} {{green:Green}}"'
Sleep .5
Enter
Sleep 5
这套骨架可以拆解为四个阶段:
-
输出与画布设置:
Output指定渲染产物路径;Set FontSize 32、Set Width 2200、Set Height 400设定终端字号与画布尺寸。注意Set类设置必须放在 tape 文件顶部(除TypingSpeed外),放在非设置命令之后会被忽略(见 README.md 的 Settings 说明)。 -
隐藏的启动准备:
Hide让 VHS 暂停捕获帧(ExecuteHide 调用vhs.PauseRecording(),见 vhs.go),随后在"幕后"依次键入irb --noautocomplete、require 'cli/ui'、CLI::UI::StdoutRouter.enable并回车——这些准备动作不会出现在最终成品里。 -
清屏并恢复录制:
Ctrl+L清空终端屏幕,Show恢复帧捕获(ExecuteShow 调用vhs.ResumeRecording()),从这一刻起才是观众能看到的画面。 -
表演与定格:用
Type输入演示命令,Sleep控制节奏,最后保留足够时长展示结果。
从源码看,Hide/Show 的底层是录制循环中的一个布尔开关:在 vhs.go 的帧采集循环里,每次到点都会先检查 vhs.recording,为 false 时直接 continue 跳过帧写入。也就是说,Hide 期间虚拟终端上的所有操作都不会产出任何帧,非常适合做环境准备与清理。
运行方式很简单:
vhs examples/cli-ui/format.tape
Format:彩色格式化文本
CLI::UI.fmt 是 cli-ui 的文本格式化入口,用 {{red:...}}、{{green:...}} 这类内联标记渲染颜色。完整演示脚本见 format.tape:
Output examples/cli-ui/format.gif
Set FontSize 32
Set Width 2200
Set Height 400
Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show
Type 'puts CLI::UI.fmt "{{red:Red}} {{green:Green}}"'
Sleep .5
Enter
Sleep 5
关键细节:
- 命令字符串使用了单引号包裹,因为内容本身包含双引号;VHS 的
Type还支持用反引号转义(如Type `VAR="Escaped"`,见 README.md 的 Type 章节)。 Sleep .5让"输入命令"这个动作在画面上稍作停顿,增加演示的呼吸感;Enter执行后Sleep 5让红/绿两种颜色有充足的展示时间。- 宽高取 2200×400 的横向比例,能完整容纳 IRB 提示符与输出行。
Nested Frames:嵌套框架
CLI::UI::Frame.open 可以在终端中绘制带标题的框架区块,且支持嵌套。完整脚本见 nested-frames.tape:
Output examples/cli-ui/nested-frames.gif
Set FontSize 32
Set Width 2000
Set Height 750
Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show
Type "CLI::UI::Frame.open('Frame 1') do"
Enter
Type " CLI::UI::Frame.open('Frame 2') { puts 'inside frame 2' }"
Enter
Type " puts 'inside frame 1'"
Enter
Type "end"
Sleep 1
Enter
Sleep 3
这段演示展示了多行代码的输入节奏控制:
- 四行
Type逐行输入一个完整的 Ruby block;每行结尾用Enter换行(注意此时 block 尚未闭合,IRB 会显示继续输入提示符,这正是演示"框架结构如何在交互式会话中逐步展开"的好素材)。 - 最后一行
Type "end"之后,先用Sleep 1让观众看清收尾输入,再Enter触发 block 求值,随后Sleep 3展示两层框架同时渲染的结果。 - 由于嵌套框架画面较高,这里把
Set Height提高到 750,确保内外两层 Frame 都在画布内。
Progress:进度条
CLI::UI::Progress 提供一个基于块的进度条 API。完整脚本见 progress.tape:
Output examples/cli-ui/progress.gif
Set FontSize 32
Set Width 2200
Set Height 400
Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show
Type "CLI::UI::Progress.progress { |bar| 100.times { sleep 0.02; bar.tick } }"
Sleep .5
Enter
Sleep 5
这段代码在块内循环 100 次,每次 sleep 0.02(总耗时约 2 秒)并调用 bar.tick 推进一格。Sleep 5 保证进度条完整走完并停留展示最终 100% 状态。progress.gif 中可以看到进度条从 0 到满格的动画过程——这正是用 VHS 录制"随时间变化的 UI"的典型场景:进程类组件不需要额外人工输入,只要用 Sleep 把渲染过程完整留在画面里。
Spinner:旋转加载指示器
CLI::UI::Spinner.spin 用于展示耗时任务的加载状态。完整脚本见 spinner.tape:
Output examples/cli-ui/spinner.gif
Set FontSize 32
Set Width 2200
Set Height 400
Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show
Type "CLI::UI::Spinner.spin('Spinning...') { sleep 3 }"
Sleep .5
Enter
Sleep 5
spin 方法接收标题字符串和任务块,块内 sleep 3 模拟 3 秒的耗时任务。录制时 Sleep 5 覆盖了整个 3 秒旋转过程,让观众完整看到 spinner 转圈、任务结束、显示完成状态的全过程。与 Progress 一样,这类"异步等待型"组件是 Sleep + Wait 类命令发挥价值的地方(Wait 命令可等待屏幕出现指定正则内容,默认超时 15s,见 README.md 的 Wait 章节)。
Status Widget:行内状态组件
cli-ui 还支持在 spinner 标题中嵌入行内状态组件 {{@widget/status:...}}。完整脚本见 status-widget.tape:
Output examples/cli-ui/status-widget.gif
Set FontSize 32
Set Width 2200
Set Height 400
Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show
Type 'CLI::UI::Spinner.spin("Building: {{@widget/status:1:2:3:4}}") { |spinner| sleep 3 }'
Sleep .5
Enter
Sleep 5
{{@widget/status:1:2:3:4}} 依次定义状态组件在四个阶段各显示什么内容(例如"进行中/成功/失败/跳过"四种形态),spinner 推进时组件会随阶段切换渲染形态。这里同样用单引号包裹含双引号的命令字符串。这类"组件状态枚举"是演示 CLI 构建工具时非常出彩的画面,status-widget.gif 直观呈现了标题行内状态图标的动态变化。
Symbols:符号渲染
CLI::UI.fmt 内置了对符号标记的支持。完整脚本见 symbols.tape:
Output examples/cli-ui/symbols.gif
Set FontSize 32
Set Width 2200
Set Height 400
Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show
Type 'puts CLI::UI.fmt "{{*}} {{v}} {{?}} {{x}}"'
Sleep .5
Enter
Sleep 5
{{*}}、{{v}}、{{?}}、{{x}} 分别渲染为 cli-ui 内置的星号、对勾、问号、叉号等终端符号(实际形状取决于当前字体与主题)。这段演示是展示"一行命令、多种符号输出"最轻量的方式,也为后续介绍字体/主题设置(如 examples/settings 中的 Set Theme、Set FontFamily)埋下伏笔——同一符号在不同主题下观感不同,录制前建议先用小尺寸脚本试跑确认观感。
Text Prompt:交互式提问
最后是交互场景:CLI::UI.ask 会向用户提出一个问题并等待输入。完整脚本见 text-prompt.tape,它同时声明了两个输出:
Output examples/cli-ui/text-prompt.ascii
Output examples/cli-ui/text-prompt.gif
Set FontSize 32
Set Width 2200
Set Height 500
Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show
Type "CLI::UI.ask('Is CLI UI Awesome?', default: 'It is great!')"
Sleep .5
Enter
Sleep 1
Type "I love it!"
Sleep 1
Enter
Sleep 3
这段脚本演示了"提问 → 展示默认值 → 输入回答 → 提交"的完整交互链路:
Type输入ask调用(带default:参数),Enter触发。Sleep 1让"问题已显示、等待输入"的提示画面(? Is CLI UI Awesome? (empty = It is great!)+>输入行)停留足够时间。Type "I love it!"模拟用户输入回答,Sleep 1后再Enter提交。Sleep 3展示返回结果=> "I love it!"。
该脚本同时输出 .gif 和 .ascii 两种格式。.ascii 输出是纯文本帧序列(可对照仓库中的 text-prompt.ascii,其中记录了每一帧的 IRB 提示符、问题文本、输入行与返回值),这正是 VHS 集成测试能力的体现:.txt/.ascii 输出可作为 golden file 存入仓库,CI 中比对每次运行结果是否一致(见 README.md 的 Continuous Integration 章节)。
延伸:带选项的交互提示
同一个目录下还有一个未被 README 收录的补充示例 interactive-prompt.tape,展示 ask 的选项模式:
Output examples/cli-ui/interactive-prompt.gif
Set FontSize 32
Set Width 2200
Set Height 500
Hide
Type "irb --noautocomplete"
Enter
Type "require 'cli/ui'"
Enter
Type "CLI::UI::StdoutRouter.enable"
Enter
Ctrl+L
Show
Type "CLI::UI.ask('What do you use?', options: %w(rails go ruby python))"
Sleep .5
Enter
Sleep 1
Down@.5 3
Sleep 1
Up@.5 2
Sleep 1
Enter
Sleep 3
这段脚本引入了两个新的 VHS 能力:方向键与节拍控制。Down@.5 3 表示以 0.5 秒为间隔按下方向键 3 次,Up@.5 2 同理。这里的 @<time> 是"每个按键之间间隔多久"的节拍参数,适用于所有可重复按键命令(Key[@<time>] [count] 语法,见 README.md 的 Keys 章节)。interactive-prompt.gif 中可以看到光标在 rails → go → ruby → python 选项间上下移动,最终停在选定项上的完整过程。
命令与设置速查(结合源码)
下表汇总了 cli-ui 演示脚本中用到的 VHS 命令及它们在 command.go 中的实现位置:
| 命令 | 作用 | 源码实现 |
|---|---|---|
Output <path> |
指定输出(GIF/MP4/WebM/PNG 帧序列/ascii) | ExecuteOutput,按扩展名分发到 Video.Output 或 Test.Output |
Set FontSize N |
设置字号并调用 term.fit 重排终端 |
ExecuteSetFontSize |
Set Width/Height N |
设置输出画布宽高 | ExecuteSetWidth/ExecuteSetHeight,写入 Video.Style |
Type "..." |
逐字符模拟键入,可带 @time 覆盖打字速度 |
ExecuteType,按 keymap 命中按键、否则写入 textarea |
Enter / Ctrl+X |
回车 / 组合键(按住 Ctrl 依次按下其余键) | ExecuteKey / ExecuteCtrl |
Hide / Show |
暂停 / 恢复帧捕获 | ExecuteHide/ExecuteShow → PauseRecording/ResumeRecording(vhs.go) |
Sleep <dur> |
静置等待(支持 0.5、100ms、1s) |
ExecuteSleep,time.ParseDuration 后 time.Sleep |
值得注意的实现细节:
- Type 的逐字符机制:
ExecuteType对参数中的每个字符,先在keymap中查找对应按键(特殊键走v.Page.Keyboard.Type),普通字符则直接写入页面上的 textarea,每键入一个字符后Sleep(typingSpeed)一次——这就是为什么Type天然具备"打字动画"效果。 - 组合键的按下顺序:
ExecuteCtrl先Press(ControlLeft)再依次按下参数中的键,最后一个键用Type触发(保持其余键按住直到组合结束),因此Ctrl+L这类序列才能被终端正确识别为控制字符。 - 设置必须在顶部:
Settings映射表(command.go)涵盖 FontSize、Width、Height、Theme、TypingSpeed 等全部设置项;除TypingSpeed外,设置命令放在非设置/非输出命令之后会被忽略,所以所有演示脚本都把Output和Set写在开头。
扩展技巧
基于以上 7 个演示,可以进一步组合 VHS 能力产出更专业的成品:
- 隐藏阶段做环境准备:
Hide期间可执行bundle install、构建二进制等耗时操作,配合Show只展示最终效果(参考 README.md 的 Hide/Show 章节)。 - 同一脚本多格式输出:像 text-prompt.tape 那样同时声明
.gif与.ascii,一份脚本既能出演示物料,又能生成 golden file 用于集成测试(SaveOutput会将缓冲区逐行写入并以分隔线收尾,见 testing.go)。 - 用
Require提前校验依赖:在 tape 顶部用Require irb、Require bundle等命令确保$PATH中有对应程序,缺失时提前失败(见 ExecuteRequire)。
完整的 7 个演示脚本、生成的 GIF 产物与 Gemfile 均位于 examples/cli-ui 目录,可对照本指南逐一运行验证。


