本文结合实际工程经验,系统总结 如何用 whiptail 设计一个"可回退"的交互式界面框架,以及几个经常踩的坑。
一、whiptail 是什么?
whiptail 是一个TUI(Terminal UI)工具,它受 dialog (诞生于1990年代)启发、但更加轻量,功能更精简的"兼容替代品",方便开发人员开发出dialog 风格交互UI界面。由redhat开发。他们的区别如下:
| 对比项 | dialog | whiptail |
|---|---|---|
| 底层库 | ncurses | newt |
| 设计目标 | 功能完整 | 轻量、简单 |
| 样式控制 | ✅ 支持颜色 / 粗体 | ❌ 不支持 |
| 可扩展性 | 很强 | 很弱 |
| 兼容性 | POSIX 广 | 基本兼容 dialog 参数 |
| 学习曲线 | 稍高 | 很低 |
二、whiptail 基本语法
参考博客:Linux命令------whiptail交互式shell脚本对话框了解基本的语法。
博客介绍了消息框,yes/no对话框,表单输入框,密码输入框,菜单栏,radiolist对话框,多选对话框,进度条等等功能。我目前只使用了 消息框,yes/no对话框 和单选菜单栏。
三、实现可回退的交互界面
3.1 了解shell基础知识
3.1.1 了解stdin, stdout, stderr和 退出码
退出码是 Shell 脚本实现自动化逻辑判断 的基石。退出码不会被输出到stderr和stdout,只会默认流向 Shell 解释器的内部寄存器(即特殊变量 $?)。你可以手动通过echo $?来查看退出码。父进程会检查子进程的退出码来判断其是否成功。
一般的shell脚本都会增加set -e来让进程检查子进程的退出码是否为0,如果不为0立即退出脚本,确保脚本安全性。
3.1.2 $(...)只返回stdout
先说结论 :$(...)只返回子进程中的stdout,子进程的stderr会被直接显示到当前shell的stderr(也就是屏幕上)。
用下面的例子可很好的证明 $(...)只返回stdout:
shell
#!/bin/bash
choice=$(echo "12345" 1>&2)
echo "Captured choice: $choice"
输出为:
$ ./test.sh
12345
Captured choice:
$(...)创建了一个子进程,子进程完全复制母进程的 FD 表,即继承母 shell 的文件描述符(stdout / stderr)。1>&2则把子进程内原本输出到stdout的内容重定向到了stderr,结果直接显示到了当前shell的屏幕上(stdout自然就空了所以choice被赋空值)。
下面我们稍微修改,去掉 1>&2,如下代码:
shell
#!/bin/bash
choice=$(echo "12345")
echo "Captured choice: $choice"
输出为:
$ ./test.sh
Captured choice: 12345
3.1.3 if result=$(...) then;语句中if在判断什么?
if用于检查退出码是否为0,而赋值语句的退出码 = 内部命令的退出码,也就是说这段代码在判断$(...)中命令退出码是多少。不在乎result的值是多少,不在乎赋值操作是否成功,只在乎退出码。
3.1.4 如何理解3>&1
但在 X>&Y 的语法中,箭头 > 的方向决定了数据的"流向"定义权,而 &Y 是"源头"。
- 语法结构 :
目标描述符 > 源描述符的引用 3>&1- 左边 (3) :是接收者。我们要设置 fd 3,是我们要操作的目标。
- fd1是 "通往屏幕的管子",拿一根新管子(fd 3),把它接成和 fd 1 一模一样(通往屏幕),让 fd 3 指向 fd 1 去的地方,结果是fd 3 和 fd 1 指向同一个地方(通常是屏幕)。
- 如果写成**
3>1**,没了&,1会被识别成文件名,含义变成了打开(或创建)一个名字叫1的普通文件,并将文件描述符 3 指向这个文件。
3.2 如何实现可回退的多界面?
因为多界面我们选择回退信号为用户按下cancel键后(退出码为1)进行回退,所以我们需要判断这个信号。因为set -t需要捕获退出码,whiptail 在退出码为1时不是出错了,所以为了实现回退必须要把whiptail放到判断语句中。为了获取用户输入返回值,需要将whiptail放到$(...)中。而结合whiptail的输出实际情况和$(...)只捕获stdout的实际情况,需要考虑使用 3>&1 1>&2 2>&3才能让UI正确显示,让用户选项被输入到$(...)返回。
多个界面就是单个界面的组合。每个界面封装成一个函数。函数被维护在一个关系数组中形成一个状态机,STEP为关系数组的系数。主函数main中通过while循环判断当前为哪个STEP。在每个状态内部完成STEP切换。
3.2.1 增加set -t后的坑
如果shell脚本增加set -e来检查个个命令的退出码确保安全性,检测到退出码不为0直接退出脚本,防止脚本在错误的路上狂奔。而增加了 whiptail 命令会因选择Cancel后返回1而误认为异常退出。咋办呢?
set -e 确实会检查退出码,但它有一个"豁免规则":如果命令的退出码已经被用于逻辑判断(比如在 if 中),set -e 就会认为这个错误"已经被处理了",从而选择忽略它。
所以我们将 whiptail 命令放到判断语句中就行拉。
3.2.2 whiptail 的输出和退出码
先说结论:
| whiptail | 输出到 |
|---|---|
| UI界面 | stdout |
| 用户选择的菜单 | stderr |
| 退出码 | Yes或其他菜单则为 0 Cancel 则为1(不是异常) 出现错误为其他大于1的值(是异常) |
通过执行下面两个不同的脚本:
shell
whiptail --title "Menu test" \
--menu "Choose one" \
12 50 2 \
AAA "Option A" \
BBB "Option B" \
1>/dev/null
whiptail --title "Menu test" \
--menu "Choose one" \
12 50 2 \
AAA "Option A" \
BBB "Option B" \
2>/dev/null
你可以明显发现,第1个没输出,第2个有输出,所以证明**whiptail的UI界面输出到了 stdout**(文件描述符为1)。但是你想要得到用户反馈就需要用上$(...)了。
然后再实验:
shell
whiptail --title "Menu test" \
--menu "Choose one" \
12 50 2 \
AAA "Option A" \
BBB "Option B" \
2>err.txt
当选择不同的选项时,这个选项就会被写入err.txt文件(文件内容为 AAA 或 BBB)。所以证明用户选项被输出到了stderr(文件描述符为2)。
退出码 ( $ ?)很简单就知道了,就不放例子了:
- 情况 A(用户点了"确定") :
whiptail返回退出码 0。 - 情况 B(用户点了"取消"或按了 ESC) :
whiptail返回退出码 1.
3.2.3 经常看到 3>&1 1>&2 2>&3
在shell脚本中我们通常会写:choice=$(whiptail ... 3>&1 1>&2 2>&3),我们知道$(...) 返回的是命令在 stdout (文件描述符 1)上产生的字节流。但是前面我们讨论了,这个子进程中运行的whiptail的UI屏幕默认输出到子进程的 stdout(fd 1),用户选项 默认输出到子进程的 stderr(fd 2)。我们为了让choice得到用户选项 ,就需要在子进程内实现stdout(fd 1)和 stderr(fd 2)的交换。但不能直接交换。
!WARNING
不能直接交换为啥?
不能直接交换(例如写成
1>&2 2>&1)的原因在于:Shell 的重定向是按顺序从左到右执行的,且直接覆盖会导致原始目标丢失。3>&1 1>&2 2>&3这段看似晦涩的代码,实际上是在说:"先把原本的屏幕(fd 1)藏到 fd 3 里;然后把选择输出(fd 2)挪到 fd 1 给变量捕获;最后把藏起来的屏幕(fd 3)挪给 fd 2 去显示界面。"
这就是 Shell 脚本中经典的"三杯交换法"。
3.2.4 代码框架
shell
# Declare associative array for route table
declare -A ROUTES=(
[1]=step_state1
[2]=step_state2
[3]=step_state3
......
)
......
# Step 1: Initial
step_state1() {
if whiptail --title "Step 1: Preparation Check" --backtitle "$BACK_TITLE" \
--no-button "Back" \
--yesno "Continue?" \
14 60; then
if storage_device_detection; then
STEP=$((STEP+1))
else
STEP=0
fi
else
STEP=0
fi
}
# Step 2: xxx
step_state2() {
local choice
if choice=$(whiptail --title "Step 2: Select Board" --backtitle "$BACK_TITLE" \
--cancel-button "Back" \
--menu "Different versions support different board types.):" \
14 60 4 \
"board1" ": some comments" \
"board2" ": some comments" \
"board3" ": some comments" \
3>&1 1>&2 2>&3
); then
BOARD="$choice" # 更新全局变量
STEP=$((STEP+1))
else
STEP=$((STEP-1))
fi
}
# Step 3: xxx
step_state3() {
local choice
if choice=$(whiptail --title "Step 3: Select sdk" --backtitle "$BACK_TITLE" \
--cancel-button "Back" \
--menu "SDK lists:" \
14 60 4 \
"SDK1" ": some comments" \
"SDK2" ": some comments" \
3>&1 1>&2 2>&3
); then
SDK_VERSION="$choice" # 更新全局变量
STEP=$((STEP+1))
else
STEP=$((STEP-1))
fi
}
main() {
while true; do
# Validate STEP is within valid range
if [ -z "${ROUTES[$STEP]}" ]; then
if [ "$STEP" -eq 0 ]; then # STEP是全局变量,为0退出
exit 0
else
whiptail --title "Error" --backtitle "$BACK_TITLE" --msgbox \
"Invalid step number: $STEP" \
10 50
exit 1
fi
fi
# Execute the current step function
"${ROUTES[$STEP]}"
done
}
# Run main function
main