六 菜单的简易生成方式

六 菜单的简易生成方式

【动机】

百度一下, 目前的方法大约是以下几种方式:

  1. select 命令结合 case:
#!/bin/bash
 
PS3="请选择一个选项: "
 
select opt in "选项1" "选项2" "选项3" "退出"; do
    case $opt in
        "选项1")
            echo "你选择了选项1"
            ;;
        "选项2")
            echo "你选择了选项2"
            ;;
        "选项3")
            echo "你选择了选项3"
            ;;
        "退出")
            break
            ;;
        *)
            echo "无效的输入"
            ;;
    esac
done

2. read 命令结合 case:

#!/bin/bash
 
while true; do
    echo "请选择一个选项:"
    echo "1) 选项1"
    echo "2) 选项2"
    echo "3) 选项3"
    echo "4) 退出"
    read -p "请输入选项编号: " choice
    case $choice in
        1) echo "你选择了选项1";;
        2) echo "你选择了选项2";;
        3) echo "你选择了选项3";;
        4) echo "退出"; break;;
        *) echo "无效的输入";;
    esac
done

3. dialog 工具:

#!/bin/bash
 
dialog --title "菜单示例" --menu "请选择一个选项" 0 0 0 \
    1 "选项1" \
    2 "选项2" \
    3 "选项3" \
    4 "退出" 2>&1 >/dev/tty
choice=$?
case $choice in
    1) echo "你选择了选项1";;
    2) echo "你选择了选项2";;
    3) echo "你选择了选项3";;
    4) echo "退出"; exit;;  # 注意这里使用了exit来结束脚本执行,而不是break,因为我们已经离开了循环结构。
esac

意味着每次生成菜单, 都需要先来这么一大段循环/case, 而且同样的菜单项, 对于 select 格式的菜单, 还需要在两个位置重复书写, 味道不好.

如何通过指定以下内容, 生成菜单:

  1. 标题
  2. 菜单项文本, 对应菜单项描述, 选择该菜单项出发的命令(函数)调用, 还可以配置其他相关数据
  3. 可选的回到上一步菜单项
  4. 可选的退出程序菜单项

从而避免随时出现的冗长的 case 语句?

【意图】

将菜单的生成和对应的命令执行封装到函数, 需要菜单时配置好函数的各个选项, 调用即可.

【实现】

先创建 constant.sh 文件, 用于声明分隔符常量:

# 多行字符串的  行 / 列  分隔符(主要用于菜单及其对应的描述和命令行的生成)
# 注意并不是任何字符均适合此用途, 例如 * $ | 等
declare -r _DEF_ROW_SEP='}}'
declare -r _DEF_COL_SEP='}'

它们将被用于后面的 kid_menu 函数

新建文件 menu.sh, 写入函数 kid_menu:

:<<COMMENT
    根据提供的选项列表, 结合 back_fun, 生成选择菜单
    -t: title 标题文本, 默认 Available Operation Choices
    -p: prompt 选择的提示用语, 默认是 welcome, which operation do you want
    -s: options 由多个选项/命令行对, 构成的字符串, 字符串格式说明见后
    -c: cs (即 col seperator) 每个选项与对应选项描述及其命令行分隔符, 默认为  $_DEF_COL_SEP
    -r: rs (即 row seperator) 相邻的选项/命令行对之间的分隔符, 默认为  $_DEF_ROW_SEP
    -b: back_cmdl 返回上一步 选项对应的命令行. 默认空, 表示无 返回上一步 选项, 或者需要自定义  
    -u: unNeed_exit 标志不附加 退出程序 选项, 默认空, 表示附加, 任意非空值, 表示不附加
    返回值, 无 
    用法: kid_menu -t <title> -p <prompt> -s <options> -c <col-sep=> -r <row-sep=> -b <back_cmdl?> -u <unNeed_exit?>
    注意:
        1. options 表达的选项/命令行对: 每个 pair 之间, 使用 $rs 分隔符隔开; 
        2. 每个 pair 内部, 依次是 item 文本, description 文本, 以及 cmdl 命令行(可包括参数), 
            相邻两者以 $cs 分隔符隔开
        3. 附加的退出程序选项, 执行的是空操作. 而不是 exit. 以保证父函数的扫尾工作正常进行
        4. $rc $cs 应避开 options 中存在的字符, 例如存在组合命令时,  应避免采用分号(;)
COMMENT
kid_menu(){

    eval $PAS
    local title=$(para t "Available Operation Choices");
    local prompt=$(para p "welcome, which operation do you want");
    local options=$(para s)
    local cs=$(para c "$_DEF_COL_SEP") 
    local rs=$(para r "$_DEF_ROW_SEP")
    local back_cmdl=$(para b)
    local unNeed_exit=$(para u)

    [ "$back_cmdl" ] && options+="...<<返回上一步 $cs prepare to go back, please wait a while...! $cs $back_cmdl $rs"
    [ -z "$unNeed_exit" ] && options+="...<<退出程序 $cs prepare to exit, byebye! $cs : $rs"
    [ "$__DEBUG__" ] && log_info "options:<$options>" 

    local arrItem arrDesc arrCmdl
    local arr_names=(arrItem arrDesc arrCmdl)
    for idx in ${!arr_names[@]}; do
        local arr_name=${arr_names[idx]}
        # awk 列索引从 1 开始, 而 shell 数组索引从 0 开始
        readarray -t $arr_name <<< $(echo "$options" | awk -v FS="$cs" -v RS="$rs" -v col=$[idx+1] \
        '{
            # 忽略空行
            if ($0 !~ /^\s*$/){
                # 去掉首尾空格(但保留中间)
                sub(/^\s+/, "", $col)
                sub(/\s+$/, "", $col)
                print $col            
            }
        }')            
    done

    # log_info "items=<${arrItem[@]}>"
    # log_info "descs=<${arrDesc[@]}>"
    # log_info "cmdls=<${arrCmdl[@]}>"
    
    # 标题
    aop caption center $title

    # 菜单项
    local count=${#arrItem[@]} msg i
    for((i=0;i<count;i++));do
        msg=$(printf "%d) %s" $[i+1] "${arrItem[$i]}")
        aop 'menu -e' left "\t$msg"
    done

    # 选择, 使用序号
    local choice=-1
    until [[ $choice =~ ^[0-9]+$ && $choice -ge 1 && $choice -le $count ]] ; do
        msg=$(printf "%s(input index: 1-$count, then press enter): " "$prompt")
        aop "notice -ne" left "\t$msg"
        read choice
    done

    # 对应的描述以及命令行(函数)
    aop tip right "${arrDesc[choice-1]}"
    [ "$__DEBUG__" ] && log_info "prepare to eval <${arrCmdl[choice-1]}>"
    eval "${arrCmdl[choice-1]}"
}

注意, kid_menu, 用到 parameter.sh, console.sh, log.sh 中的相关函数

大致的原理是: 确定三个数组 –> 写菜单项 –> 等待选择 –> 根据选择出发相应命令(函数)执行.

【测试】

从本节起, 省略 source 其他脚本文件的代码行, 读者可自由选择逐行 source , 或者使用 find 命令批量 source的形式.

新建 test.sh, 第一个测试: 默认值, 菜单项仅执行单独命令:

    way1(){
        funA(){
            tip "funA ......"
        }
        funB(){
            echo "funB args:<$*>"
        }

        local pairs
        for mod in $modules; do 
            fun="${mod}_menu_status" 
            pairs+=$($fun -e $ent -r $rs -c $cs)
        done
        pairs+="执行 funA, 无参数 } 准备调用 funA... } funA }}"
        pairs+="执行 funB, 带参数 hello } 准备调用 funB...} funB hello }}"

        echo "除了菜单项, 其余全部采用默认生成菜单如下, 同时, 每个菜单项, 执行完毕也退出程序:"
        kid_menu -s "$pairs"
    }
    way1
    unset -f way1

运行结果:

第二个测试, 所有选项全部设置, 菜单项执行组合命令:

    way2(){
        funA(){
            tip "funA ......"
        }
        funB(){
            echo "funB args:<$*>"
        }

        local pairs
        for mod in $modules; do 
            fun="${mod}_menu_status" 
            pairs+=$($fun -e $ent -r $rs -c $cs)
        done
        pairs+="执行 funA, 无参数 @ 准备调用 funA... @ funA; aop success center \"funA is over \"; way2  %%"
        pairs+="执行 funB, 带参数 hello @ 准备调用 funB...@ funB hello; aop info right \"end of funB\"; way2 %%"

        kid_menu -t "参数全部配置,包括分隔符" -s "$pairs" \
            -p "请选择. 请勿尝试非法输入:" \
            -c '@' -r '%%' -b "way1" -u y
    }
    way2
    unset -f way2

运行结果:

可以看到, 默认的 “退出程序” 被屏蔽, 选项 1 / 2 末尾均加入执行 way2, 而返回上一步则执行 way1, 符合一般常见用法. 当然, 生成环境中使用时, way1 中还应该配置进入 way2 的菜单项, 同时 way2 中一般也应该包含退出程序选项. 这里仅仅是示范菜单的生成, 这些工作就免了.

【赋值菜单】

上一种菜单姑且称之为, 命令菜单; 实际应用中,赋值菜单也可能会用到, 理论上讲, 可以将赋值语句写成命令的形式, 然后套用前面的 kid_menu. 但为了使用更简洁, 还有新的方式, 这就是重新封装的 kid_assign 函数:

:<<COMMENT
    从指定的列表数据中, 根据用户的选择, 将指定项数据赋值给指定名称的变量
    -t: caption: 选择菜单的标题, 默认为 "choose a value from list"
    -p: prompt: 选择提示用语, 默认为 "enter your choice"
    -m: cmdl_to_prev: 返回到上一步的命令行, 默认为空, 即无此选项
    -v: v_name: 选择的结果, 将赋值给该名称指向的变量值
    -d: datas: 以下面的 -s 参数指定的分隔符相隔的多个数据, 
    -s: datas 以一个字符串表示的多个数据使用的分隔符, 当数据本身包含空格时需要修改.  默认为单个空格 ' '.
    -c: cs (即 col seperator) 每个选项与对应选项描述及其命令行分隔符, 默认为  $_DEF_COL_SEP
    -r: rs (即 row seperator) 相邻的选项/命令行对之间的分隔符, 默认为  $_DEF_ROW_SEP
    用法: 
        local v_name
        assign_from_menu -t <caption?> -p <prompt?> -m <cmdl_to_prev> -v <v_name> -d <datas> -c <col-sep=> -r <row-sep=>
COMMENT
kid_assign(){
    eval $PAS
    local caption=$(para t "choose a value from list") 
    local prompt=$(para p "enter your choice")
    local cmdl_to_prev=$(para m)
    local v_name=$(para v) 
    local datas=$(para d)
    local sep=$(para s ' ')
    local cs=$(para c "$_DEF_COL_SEP") 
    local rs=$(para r "$_DEF_ROW_SEP") 

    local pairs item title desc cmdl;
    push IFS
    IFS=$sep
    for item in $datas;do
        title=$item
        desc="$item selected"
        cmdl="$v_name=\"$item\""
        pairs+="$title $cs $desc $cs $cmdl $rs"
        # log_info "option created:<$title $cs $desc $cs $cmdl $rs>"
    done
    pop IFS

    [ "$cmdl_to_prev" ] && \
        pairs+="...<<返回上一步 $cs 正在返回到上一步, 请稍候! $cs $cmdl_to_prev $rs"

    kid_menu -t "$caption" \
        -p "$prompt" \
        -s "$pairs" -c "$cs" -r "$rs" 
}

下面测试一下.

    way3(){
        aop info right "........................测试不包含空格的选项"
        local datas="one two three four five"  
        local choice
        kid_assign  -d "$datas" -v choice
        echo "your selection: <$choice>"

        aop info right ".................测试包含空格的选项"
        local datas="one first 1 |second two 2 |three | four   fourth  4 |"
        kid_assign  -d "$datas" -v choice -s '|'
        echo "your selection: <$choice>"        
    }
    way3

测试结果如下:

【继续】

但这种菜单的生成方式还可以继续改进, 详情见下一篇改进的菜单(一).