七 改进的菜单(一)

七 改进的菜单(一)

【动机】

上一篇的简易菜单, 至少存在以下两个明显缺点:

  1. 即使行分隔符和列分隔符均采用默认, 调用 kid_menu / kid_assign 时, 仍然需要键入分隔符, 比较繁琐;
  2. 调用代码的视觉(所见即所得)较差.

【意图】

摒弃分隔符, 使用菜单项与对应命令的描述字符串的形式, 代表每个菜单项, 以期尽量接近所见即所得; 同时, 添加标题, 菜单项,提示用语的样式(对齐和色彩)定制.

【预备函数】

在 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_* 函数, 因为改进的菜单(二)采用两个数组来回避该问题, 难免在菜单的所见即所得上打了折扣.

【继续】

如上所述, 若需继续优化, 详见下一篇改进的菜单(二).