Wren Linux

wren-linux / lib / checklist.sh

# shellcheck shell=bash
# lib/checklist.sh: a list of check boxes on the whole terminal, for
# install-extras.sh (Install apps, Remove apps). Drawn by bash itself, as the
# Wren menu is, so it needs no other program:
#   arrows or j/k move, wrapping round at both ends; Page Up/Down and Home/End
#   jump; Space checks a box; Enter (or a click on the first button) goes on;
#   Esc or q (or a click on the second) cancels; the mouse wheel scrolls; a
#   click on a line checks its box. / or Tab (or a click) goes to the search box
#   at the bottom: what you type there narrows the list, by name and description, keeping
#   what's checked. Tab or an arrow goes back to the list, and Esc clears the search first.
# Sourced after lib.sh.
#
# checklist: shows ITEMS (an array of TAG DESCRIPTION ON|OFF triples; a TAG
# starting with ~ is a heading, which can't be checked) with CL_TITLE, CL_TEXT
# (lines, joined by \n), and the buttons CL_OK and CL_CANCEL. Sets chosen to
# the checked tags, in list order. Returns 1 if cancelled. CL_ALONE, if set,
# is the tag of a box that is only ever checked on its own ("none of these"):
# checking it unchecks the others, and checking another unchecks it. CL_TAGW, if set, is how wide
# the tags' column is (17 otherwise).
CL_ESC_WAIT=${WREN_ESC_WAIT:-0.05}

# cl_key: the next key on the terminal, into CL_KEY (up down pgup pgdn home end
# space enter esc click wheelup wheeldown, or the character), with the click's
# column and row in CL_X and CL_Y
cl_key() {
    local c k2 k3 k4 report btn
    CL_KEY=other
    IFS= read -rsn1 c </dev/tty || { CL_KEY=esc; return; }
    case $c in
        '') CL_KEY=enter ;;
        ' ') CL_KEY=space ;;
        $'\t') CL_KEY=tab ;;
        $'\e')
            CL_KEY=esc   # a plain Esc has nothing after it; an arrow or a mouse report does, at once
            IFS= read -rsn1 -t "$CL_ESC_WAIT" k2 </dev/tty || return 0
            [ "$k2" = '[' ] || [ "$k2" = O ] || return 0
            IFS= read -rsn1 -t "$CL_ESC_WAIT" k3 </dev/tty || return 0
            case $k3 in
                A) CL_KEY=up ;;
                B) CL_KEY=down ;;
                H) CL_KEY=home ;;
                F) CL_KEY=end ;;
                [0-9])
                    IFS= read -rsn1 -t "$CL_ESC_WAIT" k4 </dev/tty || return 0   # the ~ that ends it
                    case $k3 in
                        1 | 7) CL_KEY=home ;;
                        4 | 8) CL_KEY=end ;;
                        5) CL_KEY=pgup ;;
                        6) CL_KEY=pgdn ;;
                        *) CL_KEY=other ;;
                    esac ;;
                '<')
                    # mouse: button;column;row then M (press) or m (release)
                    report=
                    while IFS= read -rsn1 -t "$CL_ESC_WAIT" c </dev/tty; do
                        if [ "$c" = M ] || [ "$c" = m ]; then break; fi
                        report+=$c
                    done
                    CL_KEY=other
                    # three numbers, or nothing: bash arithmetic on CL_Y would
                    # run whatever else came in (pasted text can look like this)
                    [[ $report =~ ^[0-9]{1,4}\;[0-9]{1,4}\;[0-9]{1,4}$ ]] || return 0
                    IFS=';' read -r btn CL_X CL_Y <<<"$report"
                    if [ "$c" = M ]; then
                        case $btn in
                            0) CL_KEY=click ;;
                            64) CL_KEY=wheelup ;;
                            65) CL_KEY=wheeldown ;;
                        esac
                    fi ;;
                *) CL_KEY=other ;;
            esac ;;
        *) CL_KEY=$c ;;
    esac
}

checklist() {
    local tags=() descs=() on=() n i j cur=-1 top=0 saved rows cols size
    local textlines=() head listtop height btnrow okfrom okto cfrom cto out line mark rc
    local view=() filter='' searching=false searchrow
    for ((i = 0; i < ${#ITEMS[@]}; i += 3)); do
        tags+=("${ITEMS[i]}") descs+=("${ITEMS[i + 1]}")
        if [ "${ITEMS[i + 2]}" = ON ]; then on+=(1); else on+=(0); fi
    done
    n=${#tags[@]}
    for ((i = 0; i < n; i++)); do [[ ${tags[i]} == "~"* ]] || { cur=$i; break; }; done
    mapfile -t textlines < <(printf '%b\n' "$CL_TEXT")
    # cl_toggle: the box on line cur checked or unchecked (and CL_ALONE kept alone)
    cl_toggle() {
        local k
        [ "$cur" -ge 0 ] || return 0
        on[cur]=$((1 - on[cur]))
        [ -n "${CL_ALONE:-}" ] && [ "${on[cur]}" = 1 ] || return 0
        for ((k = 0; k < n; k++)); do
            [ "$k" != "$cur" ] || continue
            if [ "${tags[cur]}" = "$CL_ALONE" ] || [ "${tags[k]}" = "$CL_ALONE" ]; then on[k]=0; fi
        done
    }
    # cl_view: which lines show: all, or those the search finds, each under its heading.
    # cur stays on its line if that still shows, else moves to the first that can be checked
    cl_view() {
        local f=${filter,,} k heading=-1 keep=false
        view=()
        for ((k = 0; k < n; k++)); do
            if [ -z "$f" ]; then view+=("$k"); continue; fi
            if [[ ${tags[k]} == "~"* ]]; then heading=$k; continue; fi
            if [[ ${tags[k],,} == *"$f"* || ${descs[k],,} == *"$f"* ]]; then
                if [ "$heading" -ge 0 ]; then view+=("$heading"); heading=-1; fi
                view+=("$k")
            fi
        done
        for k in "${view[@]}"; do [ "$k" != "$cur" ] || keep=true; done
        $keep && [ "$cur" -ge 0 ] && [[ ${tags[cur]} != "~"* ]] && return 0
        cur=-1
        for k in "${view[@]}"; do [[ ${tags[k]} == "~"* ]] || { cur=$k; break; }; done
        top=0
    }
    # cl_pos: where cur is in the view (-1: nowhere)
    cl_pos() {
        local k
        pos=-1
        for ((k = 0; k < ${#view[@]}; k++)); do [ "${view[k]}" != "$cur" ] || { pos=$k; return; }; done
    }
    # cl_step D: the next line that can be checked, D (1 or -1) lines on, round
    # the ends; cl_near D: the same, but stopping at the ends (the wheel)
    cl_step() {
        local pos m count=${#view[@]}
        [ "$count" -gt 0 ] || return 0
        cl_pos
        for ((m = 0; m < count; m++)); do
            pos=$(((pos + $1 + count) % count))
            [[ ${tags[view[pos]]} == "~"* ]] || { cur=${view[pos]}; return; }
        done
    }
    cl_near() {
        local pos
        cl_pos
        [ "$pos" -ge 0 ] || return 0
        while :; do
            pos=$((pos + $1))
            [ "$pos" -ge 0 ] && [ "$pos" -lt ${#view[@]} ] || return 0
            [[ ${tags[view[pos]]} == "~"* ]] || { cur=${view[pos]}; return; }
        done
    }
    # blockw: the widest line (title, text, a box line, the hint at the
    # bottom), worked out once so the block stays put while you search
    local blockw=${#CL_TITLE} left w
    for ((j = 0; j < ${#textlines[@]}; j++)); do [ ${#textlines[j]} -le "$blockw" ] || blockw=${#textlines[j]}; done
    for ((i = 0; i < n; i++)); do
        if [[ ${tags[i]} == "~"* ]]; then w=$((2 + ${#descs[i]})); else
            w=${#tags[i]}
            [ "$w" -ge 17 ] || w=17
            w=$((w + ${#descs[i]} + 7))
        fi
        [ "$w" -le "$blockw" ] || blockw=$w
    done
    w="Space or a click checks a box. Enter: $CL_OK. Esc: $CL_CANCEL."
    [ ${#w} -le "$blockw" ] || blockw=${#w}
    cl_draw() {
        size=$(stty size </dev/tty 2>/dev/null || echo "24 80")
        read -r rows cols <<<"$size"
        # the block centered across the window, as the Wren menu is (at least
        # two columns in from the left; the full width when it is narrower)
        left=$(((cols - blockw) / 2 + 1))
        [ "$left" -ge 3 ] || left=3
        head=$((2 + ${#textlines[@]}))
        listtop=$((head + 2))
        btnrow=$((rows - 1))
        searchrow=$((btnrow - 1))
        height=$((searchrow - listtop))
        [ "$height" -ge 1 ] || height=1
        local pos
        cl_pos
        if [ "$pos" -ge 0 ]; then
            [ "$pos" -ge "$top" ] || top=$pos
            [ "$pos" -lt $((top + height)) ] || top=$((pos - height + 1))
            # the heading just above the first line stays in sight
            if [ "$top" -gt 0 ] && [ "$top" -eq "$pos" ] && [[ ${tags[view[top - 1]]} == "~"* ]]; then top=$((top - 1)); fi
        fi
        # (E is the escape character; every code below starts with it)
        local E=$'\e'
        out="${E}[H${E}[2J${E}[1;${left}H${E}[1m${CL_TITLE:0:cols-left-1}${E}[0m"
        for ((j = 0; j < ${#textlines[@]}; j++)); do
            out+="${E}[$((3 + j));${left}H${textlines[j]:0:cols-left-1}"
        done
        for ((j = 0; j < height; j++)); do
            [ $((top + j)) -lt ${#view[@]} ] || break
            i=${view[top + j]}
            if [[ ${tags[i]} == "~"* ]]; then
                line="  ${descs[i]}"
                out+="${E}[$((listtop + j));${left}H${E}[1m${line:0:cols-left-1}${E}[0m"
            else
                if [ "${on[i]}" = 1 ]; then mark='[x]'; else mark='[ ]'; fi
                printf -v line " %s %-${CL_TAGW:-17}s %s " "$mark" "${tags[i]}" "${descs[i]}"
                if [ "$i" -eq "$cur" ]; then
                    out+="${E}[$((listtop + j));${left}H${E}[7m${line:0:cols-left-1}${E}[0m"
                else
                    out+="${E}[$((listtop + j));${left}H${line:0:cols-left-1}"
                fi
            fi
        done
        [ ${#view[@]} -gt 0 ] || out+="${E}[$listtop;${left}HNothing matches \"${filter:0:cols-24}\"."
        if $searching; then
            out+="${E}[$searchrow;${left}H${E}[7m Search: ${filter:0:cols-16}_ ${E}[0m"
        else
            out+="${E}[$searchrow;${left}H Search: ${filter:0:cols-16} ${E}[2m(/ or Tab)${E}[0m"
        fi
        # the two buttons centred under the block, three columns apart; each ends where it is
        # drawn ("< TEXT >"), which a click on it is measured against
        okfrom=$((left + (blockw - (${#CL_OK} + 4 + 3 + ${#CL_CANCEL} + 4)) / 2))
        [ "$okfrom" -ge "$left" ] || okfrom=$left
        okto=$((okfrom + ${#CL_OK} + 4))
        cfrom=$((okto + 3)) cto=$((cfrom + ${#CL_CANCEL} + 4))
        out+="${E}[$btnrow;${okfrom}H${E}[7m< $CL_OK >${E}[0m"
        out+="${E}[$btnrow;${cfrom}H< $CL_CANCEL >"
        out+="${E}[$rows;${left}H${E}[2mSpace or a click checks a box. Enter: $CL_OK. Esc: $CL_CANCEL.${E}[0m"
        printf '%s' "$out" >/dev/tty
    }
    saved=$(stty -g </dev/tty 2>/dev/null)
    printf '\e[?1049h\e[?25l\e[?1000h\e[?1006h' >/dev/tty
    stty -echo -icanon </dev/tty 2>/dev/null
    rc=
    cl_view
    while [ -z "$rc" ]; do
        cl_draw
        cl_key
        # In the search box, keys are letters. Only what prints goes in, so nothing typed
        # or pasted can reach the screen as a control code.
        if $searching; then
            case $CL_KEY in
                tab | enter) searching=false; continue ;;
                esc) filter='' searching=false; cl_view; continue ;;
                space) CL_KEY=' ' ;;
                up | down) searching=false ;;
            esac
            if [ ${#CL_KEY} -eq 1 ]; then
                if [ "$CL_KEY" = $'\x7f' ] || [ "$CL_KEY" = $'\b' ]; then
                    filter=${filter%?}
                elif [[ $CL_KEY == [[:print:]] ]] && [ ${#filter} -lt 40 ]; then
                    filter+=$CL_KEY
                fi
                cl_view
                continue
            fi
            [ "$CL_KEY" != click ] || searching=false
        fi
        case $CL_KEY in
            / | tab) searching=true ;;
            down | j) cl_step 1 ;;
            up | k) cl_step -1 ;;
            wheeldown) cl_near 1 ;;
            wheelup) cl_near -1 ;;
            pgdn) for ((j = 0; j < height; j++)); do cl_near 1; done ;;
            pgup) for ((j = 0; j < height; j++)); do cl_near -1; done ;;
            home) cur=-1; cl_step 1 ;;
            end) cur=-1; cl_step -1 ;;
            space | x)
                cl_toggle ;;
            enter) rc=0 ;;
            esc) if [ -n "$filter" ]; then filter=''; cl_view; else rc=1; fi ;;
            q) rc=1 ;;
            click)
                if [ "$CL_Y" -eq "$btnrow" ] && [ "$CL_X" -ge "$okfrom" ] && [ "$CL_X" -lt "$okto" ]; then
                    rc=0
                elif [ "$CL_Y" -eq "$btnrow" ] && [ "$CL_X" -ge "$cfrom" ] && [ "$CL_X" -lt "$cto" ]; then
                    rc=1
                elif [ "$CL_Y" -eq "$searchrow" ]; then
                    searching=true
                elif [ "$CL_Y" -ge "$listtop" ] && [ "$CL_Y" -lt $((listtop + height)) ]; then
                    j=$((top + CL_Y - listtop))
                    if [ "$j" -lt ${#view[@]} ] && [[ ${tags[view[j]]} != "~"* ]]; then
                        cur=${view[j]}
                        cl_toggle
                    fi
                fi ;;
        esac
    done
    printf '\e[?1000l\e[?1006l\e[?25h\e[?1049l' >/dev/tty
    [ -z "$saved" ] || stty "$saved" </dev/tty 2>/dev/null
    chosen=()
    if [ "$rc" = 0 ]; then
        for ((i = 0; i < n; i++)); do
            [ "${on[i]}" = 1 ] && [[ ${tags[i]} != "~"* ]] && chosen+=("${tags[i]}")
        done
    fi
    return "$rc"
}