八 改进的菜单(二)

八 改进的菜单(二)

【动机】

上一篇改进的菜单(一), 主要存在的问题是, 如果菜单项和(或)值包含单引号, 得到的结果可能不是预期. 本篇尝试解决.

【意图】

上述问题的根源在于, 菜单项与对应命令组成的描述字符串, 本身已经是单引号嵌套于双引号的形式, 所以在单引号内部, 继续嵌套单引号, 难度加大. 所以, 尝试摒弃描述字符串, 改为传入两个索引数组, 一个用于保存菜单项文本, 一个用于保存菜单项对应的命令(函数). 之所以采用两个索引数组, 而不是关联数组, 在于后者传入函数后, 元素顺序变为不可预知.

【预备函数】

在 data.sh 中, 新建 check_var_name, 用于检查变量名称是否合法.

:<<COMMENT
    $1: 检查可能会作为变量名的字符串是否合法
    判断一个字符串是否可以作为有效的变量名
    返回值: 
        1) y 表示可以作为变量名; 
        2) 空 表示不可以
    用法: check_var_name <v_name>
COMMENT
check_var_name(){
    # [[ "$1" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] && echo y
    [[ "$1" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] && return 0 || return 1
}

【实现】

在 menu.sh 中新建函数 adult_menu 和 adult_assign, 表示相对于 guy_* 有所改进.

:<<COMMENT
    根据提供的选项列表, 生成选择菜单
    guy_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  

    --menu_item_texts   菜单项文本数组名称
    --menu_item_cmds    菜单项对应的命令(函数)数组名称
    
    注意: 菜单项及对应命令分别位于不同的数组, 而不是同时位于一个关联数组, 是为了枚举时按索引(数字)排序, 以便所得与调用时所见相同.
    使用: 
        1. 所有配置全部定制的使用形式:
            local -r item_texts=("执行 funA, 无参数" "执行 funB, 带参数 hello" "示范最简使用方式" 
                                "示范动态生成的菜单项" "...返回上一步"  "...退出程序")
            local -r item_cmds=("funA; choice" "funB \" ' hell' o; choice" mini_choice 
                                dynamic_choic start "")
            adult_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 "请选择操作"
        2. 全部采用默认值, 除了菜单项:
            adult_menu    --menu-item-texts item_texts --menu-item-cmds item_cmds

COMMENT
adult_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   menu_item_texts menu_item_cmds \
            title prompt pads \
            title_align item_align prompt_align \
            title_clrs item_clrs prompt_clrs 
         
    # 所有选项参数
    params  "title 'Available Operation Choices'" \
            "prompt 'welcome, which operation do you want'" \
            pads menu_item_texts menu_item_cmds \
            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 -n __ref_texts__=$menu_item_texts
    local -n __ref_cmds__=$menu_item_cmds

    local count=${#__ref_texts__[@]}
    log_info "----${__ref_texts__[@]}----------"
    #describe_array __ref_cmds__
    for((idx=0;idx<count;idx++)); do
        printf -v line "%2d) %s" $[idx+1] "${__ref_texts__[idx]}"
        aopx $pads "echox -e" $item_align "\e[${item_clrs}m$line\e[0m"
    done

    #describe_array __ref_cmds__


    # 选择, 使用序号
    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=${__ref_cmds__[_choice_-1]}
    [ "$__DEBUG__" ] && log_info "prepare to eval original: <$cmdline>"
   # eval "$cmdline"        # error when including double quote 
   # eval "${cmdline//\"/\\\"}"  # 将内嵌的所有双引号转义 ok
    evalx $cmdline
}

:<<COMMENT
    从指定的列表数据中, 根据用户的选择, 将指定项数据赋值给指定名称的变量
    guy_assign 的升级版本, 菜单项设置上稍稍复杂, 主要是为了应对提供的目标值中可能包含的引号. 所以比前者更健壮
    参数
    --exe-flag: 执行标志字符串, 用来标识某个菜单项对应的值字符串, 是为执行命令
                而提供的命令行(函数及参数)字符串, 而非为了赋值. 这主要是为了
                让客户端除了使用菜单项对应的值给变量赋值外, 还可以提供额外的
                等同于 adult_menu 的实际命令, 例如 "回到上一步", "退出程序" 之类,
                当然也可以是其他命令. 默认为双花括号 {{}}

    --v-name:       选择的结果, 将赋值给该名称指向的变量值

    以下选项参数, 意义与 adult_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  

    --menu_item_texts   菜单项文本数组名称
    --menu_item_values  菜单项对应的值数组. 根据元素是否以 exe-flag 开头, 判断是执行命令, 还是赋值
    
    用法:  
            local choice    # --item-clrs "31;46;4"
            local mygod="yes, I'm an engineer." # ' 无效果

            # 注意有一个为空的菜单项
            local item_texts=(
                ''
                "2010 - 04 - 12"
                "2012- 05-09, 有意试试空格作为 value"
                "使用 2009 年 05 月 30 日的记录"
                "use   2018/23/08 \'  ' record' "  
                "您好, 您还可以直接返回上一步"
                "当然, 您也可以退出程序")
            local item_values=(
                ''
                ''
                ' '
                '2009 - 05 -30'
                "2 0 $mygod \" you are a teacher. \'  '  ''' 18-08-23"
                "  +=+ dynamic_choic      --no-such-parameter xyz "
                " exit ")
            adult_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  --menu-item-texts item_texts --menu-item-values item_values
COMMENT
adult_assign(){
    local  v_name exe_flag  menu_item_values menu_item_texts
    eval $PASX
    params v_name "exe_flag {{}}" menu_item_values menu_item_texts

    local -n values=$menu_item_values
    local -n texts=$menu_item_texts
    local item_cmds=() val
    local flag_len=${#exe_flag} head idx temp target
    local count=${#texts[@]}

    if check_var_name $v_name; then
        for((idx=0;idx<count;idx++)){
            val=${values[$idx]}
            if [ "$val" ]; then
                # 先假设它仅由空格组成, 或者确实是非空格目标值
                #target="$v_name=\"$val\""
                target="$v_name=\${values[$idx]}"       # 必须晚绑定

                # 如果是命令
                if [[ ! "$val" =~ ^[[:space:]]+$ ]]; then
                    # 不全是空格            
                    temp=$(trim "$val")
                    head=${temp:0:$flag_len}
                    if [ "$head" == $exe_flag ]; then
                        # 是命令, 不是赋值
                        target=${temp#*$exe_flag}       # 得到即时值 
                    fi
                fi
            else
                #target="$v_name=\"${texts[$idx]}\""
                target="$v_name=\${texts[$idx]}"        # 必须晚绑定
            fi
            item_cmds+=("$target")         
        }
    else
        log_error "<$v_name> not available for variable"
        return 123
    fi

    #   describe_array values

    #   describe_array texts

    #   describe_array item_cmds

    # 虽然直接使用 $@ 会连同以下参数会一起发送过去: 
    #       --v-name --exe-flag --menu-item-values 
    # 但是, adult_menu 将忽略这三个选项参数, 所以这不是问题.
    # 如果一定要将它们隔离后再发送, 尚需要做很多工作, 以至于成本大于收益, 不划算
    adult_menu --menu-item-cmds item_cmds "$@"
}

【测试】

新建文件 test.sh, 写入以下代码:

    # 测试 adult_menu adult_assign
    funA(){
        tip "funA ......"
    }
    funB(){
        echo "funB args:<$*>"
    }

    # 最简使用
    mini_choice(){
        local -r item_texts=("执行 funA, 无参数" "执行 funB, 带参数 hello" "示范最简使用方式" "示范动态生成的菜单项" "...返回上一步"  "...退出程序")
        local -r item_cmds=("funA; choice" "funB \" ' hell' o; choice" mini_choice dynamic_choic start "")
        adult_menu    --menu-item-texts item_texts --menu-item-cmds item_cmds
    }

    # 模拟动态生成的菜单项
    dynamic_choic(){
        local  item_texts=() item_cmds=()
        item_texts+=("执\" ' \'  \\  \\\\\\\"  ' 行 funA, 无参数, 但是 \"   \"文本\"包含许多无聊的引号"); item_cmds+=("funA; choice" )
        item_texts+=("执行 funB, 带参数 hello"); item_cmds+=("funB  hello ' \" ' you're a teacher \'   \"; choice" )
        item_texts+=("示范多个参数明示的使用方式"); item_cmds+=(choice)
        item_texts+=("示范赋值菜单"); item_cmds+=(assign_by_menu)
        item_texts+=("...返回上一步"); item_cmds+=(start)
        item_texts+=("...退出程序");
        adult_menu --title "动态生成的选择菜单项" --menu-item-texts item_texts --menu-item-cmds item_cmds
    }

    assign_by_menu(){
        local choice    # --item-clrs "31;46;4"
        local mygod="yes, I'm an engineer." 

        # 注意有一个为空的菜单项
        local item_texts=(
            ''
            "2010 - 04 - 12"
            "2012- 05-09, 有意试试空格作为 value"
            "使用 2009 年 05 月 30 日的记录"
            "use   2018/23/08 \'  ' record' "  
            "您好, 您还可以直接返回上一步"
            "当然, 您也可以退出程序")
        local item_values=(
            ''
            ''
            ' '
            '2009 - 05 -30'
            "2 0 $mygod \" you are a teacher. \'  '  ''' 18-08-23"
            "  +=+ dynamic_choic      --no-such-parameter xyz "
            " exit ")
        adult_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  --menu-item-texts item_texts --menu-item-values item_values


        if [ "$choice" == exit ]; then
            success "go out of selection."
            exit 0
        else
            #echo -e "choice:<$choice>"
            if [ "$choice" ]; then
                tip "you will delete <$choice> record."
                dynamic_choic
            else
                tip "you abort!"
            fi
        fi
    }

    choice(){
        # 注意 funB 的参数有意带上引号
        local -r item_texts=("执行 funA, 无参数" "执行 funB, 带参数 hello" "示范最简使用方式" "示范动态生成的菜单项" "...返回上一步"  "...退出程序")
        local -r item_cmds=("funA; choice" "funB \" ' hell' o; choice" mini_choice dynamic_choic start "")

        adult_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 "请选择操作"
    }

    start(){
        # 模拟程序开始
        #read -p "here is mock starter. any key to enter choice..."
        choice
    }

    start

    info "Here is mocking exit programe."  

【效果】

可以看到, I’m an engineer 已经可以正常显示. 当然, 相对于 guy_* 函数, 代价是将菜单文本和相应命令分别保存于两个索引数组(理论上将两者保存到同一个关联数组更优雅, 可惜目前最新的 bash 版本下, 尚无法定制关联数组的顺序, 这一般来说是制作菜单不能容忍的).

谢谢观看!