【动机】
上一篇的简易菜单, 至少存在以下两个明显缺点:
- 即使行分隔符和列分隔符均采用默认, 调用 kid_menu / kid_assign 时, 仍然需要键入分隔符, 比较繁琐;
- 调用代码的视觉(所见即所得)较差.
【意图】
摒弃分隔符, 使用菜单项与对应命令的描述字符串的形式, 代表每个菜单项, 以期尽量接近所见即所得; 同时, 添加标题, 菜单项,提示用语的样式(对齐和色彩)定制.
【预备函数】
在 system.sh 中, 新建 filter_args, assign_value, array_add_value, evalx 函数:
:<<COMMENT
将给定参数集合, 过滤为位置参数和选项参数两部分, 分别存放在指定名称的数组内
$1: options 用来存储所有选项参数的名称及其值的数组名称, 必填. 如要忽略该数组, 可填 ''
$2: positions 用来存储所有位置参数的数组名称, 必填. 如要忽略该数组, 可填 ''
后续其余: 要解析的参数字符串集合, 为空则忽略整个操作
用法: filter_args <options> <positions> <"$@">
COMMENT
filter_args(){
local options=$1 positions=$2
shift 2
# 1 到多个 - 之后, 最少要有一个非空格字符, 才是选项参数名称
local reg_opt_name="^-+[^ ]+$"
local i j
for((i=1;i<=$#;i++)){
log_info "checking:<${!i}>"
# 当前或者前面一个符合选项正则, 则判定为选项参数的一部分; 否则判定为位置参数
j=$[i-1]
if [[ "${!i}" =~ $reg_opt_name || "${!j}" =~ $reg_opt_name ]]; then
[ "$options" ] && array_add_value $options "${!i}"
else
[ "$positions" ] && array_add_value $positions "${!i}"
fi
}
}
:<<COMMENT
将可能包含引号的值, 赋给指定名称的变量
$1: 变量名称
$2 及后续所有参数: 值
使用: assign_value <variable_name> <value_part1> <value_part2> ...
COMMENT
assign_value(){
local ___v_name___=$1
shift
temp=${@//\"/\\\"}
eval "$___v_name___=\$temp"
}
:<<COMMENT
将给定的值, 作为新的元素, 添加到给定名称的索引数组.
$1: 索引数组名称
$2 及后续所有参数: 值
使用: array_add_value <array_name> <value_part1> <value_part2> ...
COMMENT
array_add_value(){
local ___arr_name___=$1
shift
eval "$___arr_name___+=(\"\$@\")" # 不需要 emb_quote
}
:<<COMMENT
补充 eval 命令在参数中包含独立双引号时, 可能会引发错误
参数:
$@: 要传递给 eval 的所有参数
用法: evalx <...> <...> ...
说明: 仅在 eval 因内嵌的双引号引发错误时尝试使用.
COMMENT
evalx(){
local temp=${@//\"/\\\"}
eval "$temp"
}
测试略.
【实现】
在 menu.sh 中, 新建函数 guy_menu 和 guy_assign, 分别是 kid_* 的改进版:
:<<COMMENT
根据提供的选项列表, 生成选择菜单
kid_menu 的简明版本, 但设置也更具语意, 也更开放
参数
--title: 标题, 默认值 Available Operation Choices
--title-align: 标题的对齐方式, 可用值 left right center, 默认值 center
--title-clrs: 标题的颜色字符串, 默认 "33;1"
--item-align: 菜单项的对齐方式, 可用值 left right center, 默认值 left
--item-clrs: 菜单项的颜色字符串, 默认为 "36;1"
--prompt: 选择的提示用语, 默认是 welcome, which operation do you want
--prompt-align: 选择的提示用语的对齐方式
--prompt-clrs: 选择的提示用语采用的颜色字符串, 默认为 "34;1"
--pads: 标题/菜单项/选择提示用语对齐方式为 left 或 right 时, 添加的先导(对于left)或后缀(对于right)空格数量. 默认为 4
所有位置参数, 均为菜单项及对应命令的描述字符串, 例如 "'执行...., 且...' 'funA; funB amen; funC'"
使用: guy_menu --title --title-align --title-clrs \
--item-align --item-clrs \
--prompt --prompt-align --prompt-clrs \
--pads \
<item-cmdline-description1> \
<item-cmdline-description2> ......
注意:
由于描述字符串采用引号嵌套的形式, 使得如果菜单项和(或)命令行参数包含单引号, 得到的结果可能不是预期
COMMENT
guy_menu(){
local -r reg_clrs="^[;0-9]+$"
local -Ar clrs_defaults=([title]="33;1" [item]="36;1" [prompt]="34;1")
local -Ar align_defaults=([title]=center [item]=left [prompt]=left)
eval $PASX
local items \
title prompt pads \
title_align item_align prompt_align \
title_clrs item_clrs prompt_clrs
# 所有位置参数: 菜单项文本及对应的执行命令(函数及参数)
readarray -t items <<< $(echox -e "$(params)")
# 所有选项参数
params "title 'Available Operation Choices'" \
"prompt 'welcome, which operation do you want'" \
pads \
title-align item-align prompt-align \
title-clrs item-clrs prompt-clrs
# 修正
pads=$(ensure_integer 4 "$pads")
local key v_n
for key in ${!align_defaults[@]}; do
v_n=${key}_align
# eval 后的字串, 标志函数调用的 $ 必须转义
eval "$v_n=\$(available_range \"${!v_n}\" ${align_defaults[$key]} left center right)"
done
for key in ${!clrs_defaults[@]}; do
v_n=${key}_clrs
[[ ! "${!v_n}" =~ $reg_clrs ]] && eval "$v_n=\"${clrs_defaults[$key]}\""
done
# 打印标题
aopx $pads "echox -e" $title_align "\e[${title_clrs}m$title\e[0m"
# 打印菜单项
local pair line idx
local count=${#items[@]} cmdlines=()
log_info "----${items[@]}----------"
for((idx=0;idx<count;idx++)); do
readarrayx "${items[idx]}" pair
printf -v line "%2d) %s" $[idx+1] "${pair[0]}"
aopx $pads "echox -e" $item_align "\e[${item_clrs}m$line\e[0m"
cmdlines+=("${pair[1]}") # "" is essential
log_info "cmdlines=<${pair[1]}>"
done
# 选择, 使用序号
local _choice_=-1 hint
until [[ "$_choice_" =~ ^[0-9]+$ && $_choice_ -ge 1 && $_choice_ -le $count ]] ; do
printf -v hint "%s(input index: 1-%d, then press enter): " "$prompt" $count
aopx $pads "echox -ne" $prompt_align "\e[${prompt_clrs}m$hint\e[0m"
read _choice_
done
# 对应的命令行(函数)
local cmdline=${cmdlines[_choice_-1]}
[ "$__DEBUG__" ] && log_info "prepare to eval <$cmdline>"
# eval "$cmdline" # error when including double quote
# eval "${cmdline//\"/\\\"}" # ok
evalx $cmdline
}
:<<COMMENT
从指定的列表数据中, 根据用户的选择, 将指定项数据赋值给指定名称的变量
kid_assign 的简明版本, 但设置也更具语意, 也更开放
参数
--exe-flag: 执行标志字符串, 用来标识某个菜单项对应的描述字符串中, 下一个字串是为执行命令
而提供的命令行(函数及参数)字符串, 而非为了赋值. 这主要是为了
让客户端除了使用菜单项对应的值给变量赋值外, 还可以提供额外的
等同于 guy_menu 的实际命令, 例如 "回到上一步", "退出程序" 之类,
当然也可以是其他命令. 默认为双花括号 {{}}
--v-name: 选择的结果, 将赋值给该名称指向的变量值
以下选项参数, 意义与 guy_menu 一致, 意味着它们将被原封不动传入其中
--title: 选择菜单的标题, 默认为 "choose a value from list"
--title-align: 标题的对齐方式, 可用值 left right center, 默认值 center
--title-clrs: 标题的颜色字符串, 默认 "33;1"
--item-align: 菜单项的对齐方式, 可用值 left right center, 默认值 left
--item-clrs: 菜单项的颜色字符串, 默认为 "36;1"
--prompt: 选择的提示用语, 默认是 enter your choice
--prompt-align: 选择的提示用语的对齐方式
--prompt-clrs: 选择的提示用语采用的颜色字符串, 默认为 "34;1"
--pads: 标题/菜单项/选择提示用语对齐方式为 left 或 right 时, 添加的先导(对于left)或后缀(对于right)空格数量. 默认为 4
所有位置参数, 均为菜单项及对应值的描述字符串, 或者与对应命令的描述字符串(需要提供 --exe-flag 字符串).
描述字符串的情况如下:
1) 只有一个子串, 则取其自身为值, 例如
"2010-04-12"
2) 两个或两个以上子串, 且第二个不是 --exe-flag, 则以第二个为值, 例如
"'使用2009年5月30日的记录' 2009-05-30"
4) 两个或两个以上子串, 且第二个是 --exe-flag, 则执行第三个字串表示的命令行(当然也可能是空命令), 例如
"'您好, 您还可以返回上一步' {{}} 'prev_func arg1 arg2 ...'"
"'当然, 您也可以退出程序' {{}}"
用法: guy_assign --exe-flag --v-name --title --title-align --title-clrs --item-align --item-clrs \
--prompt --prompt-align --prompt-clrs \
--pads \
"2010-04-12" "'2012-05-09'" \
"'使用 2009 年 05 月 30 日的记录' 2009-05-30" "'use 2018/23/08 record' 2018-08-23" \
"'您好, 您还可以直接返回上一步' +=+ 'dynamic_choic --no-such-parameter xyz'" \
"'当然, 您也可以退出程序' exit"
注意:
由于描述字符串采用引号嵌套的形式, 使得如果菜单项和(或)值包含单引号, 得到的结果可能不是预期
COMMENT
guy_assign(){
local opts poses v_name exe_flag
filter_args opts poses "$@"
eval $PASX
params v_name "exe_flag {{}}"
# 构造位置参数, 即由菜单项及对应的命令行作为元素的列表 items
if check_var_name $v_name; then
local pos arr count a0 a1 rest items=()
for pos in "${poses[@]}";do
readarrayx "$pos" arr
count=${#arr[@]}
a0=${arr[0]}
if [ $count -eq 1 ]; then
items+=("'$a0' 'assign_value $v_name $a0'")
elif [ $count -gt 1 ]; then
a1=${arr[1]}
if [ "$a1" == "$exe_flag" ]; then
items+=("'$a0' '${arr[@]:2}'")
else
rest=${arr[@]:1}
items+=("'$a0' 'assign_value $v_name $rest'")
fi
fi
done
else
log_error "<$v_name> not available for variable"
return 123
fi
# describe_array opts
# describe_array poses
# describe_array items
guy_menu "${opts[@]}" "${items[@]}"
}
代码还是比较简单, 且附有比较完整的注释, 在此不做过多解释. 下面看看如何使用.
【测试】
新建 test.sh 文件, 粘贴以下代码:
funA(){
tip "funA ......"
}
funB(){
echo "funB args:<$*>"
}
# 最简使用
mini_choice(){
guy_menu "'执行 funA, 无参数' 'funA; choice'" \
"'执行 funB, 带参数 hello' 'funB hello; choice'" \
"'示范多个参数明示的使用方式' choice" \
"'...返回上一步' start " \
"'...退出程序' "
}
assign_by_menu(){
# guy_ 版本, 体现在命令参数或者赋值当中, 很难自如的嵌入引号.
local choice # --item-clrs "31;46;4"
local mygod="yes, I'm an engineer." # ' 无效果
guy_assign --exe-flag '+=+' --v-name choice \
--title "当前存在的可以删除的记录列表, 操作完成自动返回上一步" --title-align right --title-clrs "35;42;2" \
--item-align center \
--prompt "输入要删除 的记录 的编号 " --prompt-align center --prompt-clrs "33;44;1" \
--pads 8 \
"'2010 - 04 - 12'" "'2012- 05-09'" \
"'使用 2009 年 05 月 30 日的记录' '2009 - 05 -30'" "'use 2018/23/08 record' '2 0 \" $mygod \" you ' are a teacher. 18-08-23'" \
"'您好, 您还可以直接返回上一步' +=+ 'dynamic_choic --no-such-parameter xyz'" \
"'当然, 您也可以退出程序' exit"
if [ "$choice" == exit ]; then
success "go out of selection."
exit 0
else
if [ "$choice" ]; then
tip "you will delete <$choice> record."
dynamic_choic
else
tip "you abort!"
fi
fi
}
# 模拟动态生成的菜单项
dynamic_choic(){
local items=(
"'执行 funA, 无参数' 'funA; choice'"
"'执行 funB, 带参数 hello' 'funB hello; choice'"
"'示范多个参数明示的使用方式' choice"
"'示范赋值菜单' assign_by_menu"
"'...返回上一步' start "
"'...退出程序' ")
guy_menu --title "动态生成的选择菜单项" "${items[@]}"
}
choice(){
guy_menu --title "please select a choice" \
--menu-item-texts item_texts --menu-item-cmds item_cmds \
--title-align left --item-align center --prompt-align right \
--title-clrs "35;42;4" -item-clrs "33;46;2" --prompt-clrs "31;44;5" \
--prompt "请选择操作" \
"'执行 funA, 无参数' 'funA; choice'" \
"'执行 funB, 带参数 hello' 'funB hello \" ; choice'" \
"'示范最简使用方式' mini_choice" \
"'示范动态生成的菜单项' dynamic_choic" \
"'...返回上一步' start" \
"'...退出程序' "
}
start(){
# 模拟程序开始
read -p "here is mock starter. any key to enter choice..."
choice
}
start
info "Here is mocking exit programe."
注意, assign_by_menu 测试函数, 其中进行了 guy_assign 函数的一些引号及插值的压力测试, 可以看到, 由于描述字符串采用引号嵌套的形式, 使得如果菜单项和(或)值包含单引号, 得到的结果可能不是预期. 类似问题, 在 guy_menu 中也有体现. 当然这些嵌套引号的需求, 只存在于一些极端情况下.
【效果】
如上所述, 虽然 mygod=”yes, I’m an engineer.”, 但是赋值选择的结果中, 这一部分却是: yes, I am an engineer. 即单引号丢失, 而如果将单引号以转义形式出现, 则结果更糟, 读者不妨一试.
【结论】
一句话, 如果菜单项和(或)对应命令行参数和(或)目标赋值包含单引号, 请考虑使用下一篇改进的菜单(二), 否则可以大胆使用 guy_* 函数, 因为改进的菜单(二)采用两个数组来回避该问题, 难免在菜单的所见即所得上打了折扣.
【继续】
如上所述, 若需继续优化, 详见下一篇改进的菜单(二).