Files
middleclick-autoscroll/src/lib/menu.sh
T

670 lines
20 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# shellcheck shell=bash
#
# The interactive front end.
#
# This is the whole configuration interface. There is a file behind it, but no
# part of the program ever asks anyone to open it: one switch on the front
# screen, a settings list for the categories, and a list of the applications
# themselves for the cases where the automatic answer is not the wanted one.
# Terminal mode.
#
# bash flips the terminal into non-canonical mode for each `read -sn1` and back
# out again in between. That gap matters: in canonical mode DEL is the ERASE
# character, so the line discipline eats it instead of delivering it, and a
# backspace typed while the interface was between reads simply vanishes.
# Holding non-canonical mode for the whole interface removes the gap.
MCA_TERM_SAVED=''
mca_ui_term_raw() {
mca_have stty || return 0
[[ -t 0 ]] || return 0
[[ -n $MCA_TERM_SAVED ]] && return 0
MCA_TERM_SAVED="$(stty -g 2>/dev/null)" || { MCA_TERM_SAVED=''; return 0; }
stty -icanon -echo min 1 time 0 2>/dev/null || true
}
mca_ui_term_restore() {
[[ -n $MCA_TERM_SAVED ]] || return 0
stty "$MCA_TERM_SAVED" 2>/dev/null || true
MCA_TERM_SAVED=''
}
# What the last action printed. The menu shows it under the status until the
# next key, so nothing has to wait for a key on a screen of its own.
MCA_UI_MSG=''
# Set while the menu runs. Its status block already says that running
# applications need a restart, so the actions leave that line out there.
MCA_IN_MENU=''
# mca_ui_run <command...>
# Runs an action in this shell (so what it scans and loads stays) with its
# output kept for the menu. None of them asks anything.
mca_ui_run() {
local tmp
printf ' %s%s%s\n' "$MCA_C_DIM" "$(mca_msg "One moment...")" "$MCA_C_RESET"
tmp="$(mktemp)" || { "$@"; return; }
"$@" > "$tmp" 2>&1
MCA_UI_MSG="$(< "$tmp")"
rm -f -- "$tmp"
}
# mca_read_key
# One keypress, resolved to a symbolic name. Arrow keys arrive as ESC [ A, so
# the tail of the sequence is consumed here rather than being mistaken for
# three separate presses.
mca_read_key() {
local k rest
IFS= read -rsn1 k || return 1
case "$k" in
$'\e')
if IFS= read -rsn2 -t 0.05 rest; then
case "$rest" in
'[A') printf 'up\n' ;;
'[B') printf 'down\n' ;;
'[C') printf 'right\n' ;;
'[D') printf 'left\n' ;;
*) printf 'escape\n' ;;
esac
else
printf 'escape\n'
fi
;;
''|$'\r') printf 'enter\n' ;;
$'\x7f'|$'\b') printf 'backspace\n' ;;
' ') printf 'space\n' ;;
*) printf '%s\n' "$k" ;;
esac
}
# mca_ui_read_line <initial>
# A minimal line editor built on mca_read_key, with the result in
# MCA_LINE_RESULT. This exists instead of bash's own `read -r` because mixing
# line mode into a single-key interface breaks it: after one cooked-mode read
# the following `read -sn1` stops receiving keystrokes entirely.
MCA_LINE_RESULT=''
mca_ui_read_line() {
local buf="${1:-}" key
MCA_LINE_RESULT=''
printf '%s' "$buf"
while true; do
key="$(mca_read_key)" || { printf '\n'; return 1; }
case "$key" in
enter)
printf '\n'
MCA_LINE_RESULT="$buf"
return 0
;;
escape)
printf '\n'
return 1
;;
backspace)
if [[ -n $buf ]]; then
buf="${buf%?}"
printf '\b \b'
fi
;;
space)
buf+=' '
printf ' '
;;
up|down|left|right) ;;
*)
[[ ${#key} -eq 1 ]] || continue
buf+="$key"
printf '%s' "$key"
;;
esac
done
}
mca_pause() {
printf '\n %s' "$(mca_msg "Press any key to continue...")"
read -rsn1 _ || true
printf '\n'
}
# _mca_row <label> <value>
# printf's %-28s pads by bytes, so a label containing "ü" comes out one column
# short. ${#s} counts characters in a UTF-8 locale, so the padding is computed
# here instead. It is applied inline, because command substitution would eat
# the trailing spaces again.
_mca_row() {
local label="$1" value="$2" pad
pad=$(( 30 - ${#label} ))
(( pad < 0 )) && pad=0
printf ' %s%*s %s\n' "$label" "$pad" '' "$value"
}
_mca_onoff() {
if [[ $1 == yes ]]; then
printf '%s%s%s' "$MCA_C_GREEN" "$(mca_msg "ON")" "$MCA_C_RESET"
else
printf '%s%s%s' "$MCA_C_DIM" "$(mca_msg "OFF")" "$MCA_C_RESET"
fi
}
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
# mca_ui_status
# Shared by the `status` subcommand and the menu header. Scans, so the numbers
# describe what is on disk right now rather than what was true at the last run.
mca_ui_status() {
local last covered
mca_scan_once
mca_count_routes
last="$(mca_state_read last_apply '')"
_mca_row "$(mca_msg "Autoscroll")" "$(_mca_onoff "$CFG_ENABLED")"
printf '\n'
if [[ $CFG_ENABLED == yes ]]; then
covered="$MCA_N_ON"
else
covered=0
fi
_mca_row "$(mca_msg "Applications covered")" \
"$(mca_msg "%d of %d" "$covered" "$(( MCA_N_ON + MCA_N_OFF + MCA_N_UNKNOWN ))")"
if (( MCA_N_UNKNOWN )); then
_mca_row "$(mca_msg "Not identified")" \
"${MCA_C_DIM}$(mca_msg "%d (see the applications list)" "$MCA_N_UNKNOWN")${MCA_C_RESET}"
fi
if mca_steam_installed; then
if mca_steam_patched; then
_mca_row "$(mca_msg "Steam")" "$(_mca_onoff yes)"
elif [[ $CFG_ENABLED == yes ]] && mca_steam_wanted; then
_mca_row "$(mca_msg "Steam")" \
"${MCA_C_YELLOW}$(mca_msg "not patched yet")${MCA_C_RESET}"
else
_mca_row "$(mca_msg "Steam")" "$(_mca_onoff no)"
fi
fi
if mca_watch_available; then
if mca_watch_enabled; then
_mca_row "$(mca_msg "New applications")" "$(_mca_onoff yes)"
else
_mca_row "$(mca_msg "New applications")" "$(_mca_onoff no)"
fi
fi
# Not an ON/OFF like the rows around it: what is being reported is the state
# of the paste, not of a switch, and off is the state this program is after.
if mca_kde_available; then
if [[ "$(mca_kde_read)" == false ]]; then
_mca_row "$(mca_msg "Middle-click paste")" \
"${MCA_C_GREEN}$(mca_msg "off")${MCA_C_RESET}"
else
_mca_row "$(mca_msg "Middle-click paste")" \
"${MCA_C_DIM}$(mca_msg "on")${MCA_C_RESET}"
fi
fi
_mca_row "$(mca_msg "Last applied")" "$(mca_time_ago "$last")"
if [[ $CFG_ENABLED == yes ]]; then
printf '\n %s%s%s\n' "$MCA_C_DIM" \
"$(mca_msg "Applications pick this up the next time they are started.")" \
"$MCA_C_RESET"
fi
}
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
# Only what is not about one application. Which applications get autoscroll is
# decided in the applications list.
#
# Format: Key|type|default|check|label-msgid|help-msgid
# type is bool or text. The row is only shown when the check passes: a switch
# for something this system does not have is only noise.
MCA_SETTINGS=(
"DisablePaste|bool|yes|mca_kde_available|Turn off middle-click paste|Stops middle click from pasting anywhere else. Starts with the next login."
"WatchNewApps|bool|yes|mca_watch_available|Cover new applications|Applications installed later get autoscroll on their own."
"ExtraFlags|text|||More Chromium arguments|Passed to every application along with the autoscroll one."
)
_mca_is_true() {
case "${1,,}" in
yes|y|true|1|on|enabled) return 0 ;;
*) return 1 ;;
esac
}
MCA_SETTING_SHOWN=''
MCA_LBL_ON=''
MCA_LBL_OFF=''
MCA_LBL_NONE=''
_mca_setting_display() {
local type="$1" value="$2"
case "$type" in
bool)
if _mca_is_true "$value"; then
MCA_SETTING_SHOWN="${MCA_C_GREEN}${MCA_LBL_ON}${MCA_C_RESET}"
else
MCA_SETTING_SHOWN="${MCA_C_DIM}${MCA_LBL_OFF}${MCA_C_RESET}"
fi
;;
text)
if [[ -n $value ]]; then
MCA_SETTING_SHOWN="$value"
else
MCA_SETTING_SHOWN="${MCA_C_DIM}${MCA_LBL_NONE}${MCA_C_RESET}"
fi
;;
*) MCA_SETTING_SHOWN="$value" ;;
esac
}
# mca_ui_settings
# A cursor list rather than a numbered menu. The frame is assembled in memory
# and written once, and everything constant is resolved before the loop:
# drawing the naive way costs a command substitution per label per frame, which
# is slow enough that arrow keys feel like the console is reloading.
#
# Returns 0 when something was changed, so the caller knows to re-apply.
mca_ui_settings() {
local -a names=() types=() defaults=() labels=() helps=() values=()
local spec name type default check label help locale i key frame row pad
local count width=0 dirty=1 cursor=0 touched=0
locale="$(mca_ui_locale)"
mca_msg_into "$locale" "ON"; MCA_LBL_ON="$MCA_MSG_RESULT"
mca_msg_into "$locale" "OFF"; MCA_LBL_OFF="$MCA_MSG_RESULT"
mca_msg_into "$locale" "(none)"; MCA_LBL_NONE="$MCA_MSG_RESULT"
for spec in "${MCA_SETTINGS[@]}"; do
IFS='|' read -r name type default check label help <<< "$spec"
[[ -z $check ]] || "$check" || continue
names+=("$name"); types+=("$type"); defaults+=("$default")
mca_msg_into "$locale" "$label"
labels+=("$MCA_MSG_RESULT")
(( ${#MCA_MSG_RESULT} > width )) && width=${#MCA_MSG_RESULT}
mca_msg_into "$locale" "$help"
helps+=("$MCA_MSG_RESULT")
done
count=${#names[@]}
local title hint
mca_msg_into "$locale" "Settings"; title="$MCA_MSG_RESULT"
mca_msg_into "$locale" "Up/Down: select, Space: change, q: back"
hint="$MCA_MSG_RESULT"
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
while true; do
if (( dirty )); then
for i in "${!names[@]}"; do
_mca_config_lookup "${names[i]}" "${defaults[i]}"
values[i]="$MCA_CONFIG_VALUE"
done
dirty=0
fi
frame="$clearseq"$'\n'"${MCA_C_BOLD}${MCA_C_BLUE} ${title}${MCA_C_RESET}"$'\n\n'
local marker selected="${MCA_C_BLUE}▸${MCA_C_RESET} "
for i in "${!names[@]}"; do
_mca_setting_display "${types[i]}" "${values[i]}"
pad=$(( width + 3 - ${#labels[i]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' \
"$marker" "${labels[i]}" "$pad" '' "$MCA_SETTING_SHOWN"
frame+="$row"$'\n'
done
frame+=$'\n'" ${helps[cursor]}"$'\n'
frame+=$'\n'" ${MCA_C_DIM}${hint}${MCA_C_RESET}"$'\n'
printf '%s' "$frame"
key="$(mca_read_key)" || return "$(( ! touched ))"
type="${types[cursor]}"
name="${names[cursor]}"
case "$key" in
up|k) cursor=$(( (cursor - 1 + count) % count )) ;;
down|j) cursor=$(( (cursor + 1) % count )) ;;
space|enter|right|l|left|h)
if [[ $type == text ]]; then
[[ $key == left || $key == h ]] && continue
mca_ui_edit_text "$name" "${values[cursor]}" && touched=1
else
if _mca_is_true "${values[cursor]}"; then
mca_config_set "$name" no
else
mca_config_set "$name" yes
fi || { mca_bad "$(mca_msg "Could not save the setting.")"; mca_pause; }
touched=1
fi
dirty=1
;;
q|Q|escape) return "$(( ! touched ))" ;;
*) ;;
esac
done
}
# mca_ui_edit_text <key> <current>
mca_ui_edit_text() {
local name="$1" current="$2"
printf '\n %s\n' "$(mca_msg "Arguments separated by spaces, empty for none:")"
printf ' > '
if mca_ui_read_line "$current"; then
mca_config_set "$name" "$MCA_LINE_RESULT" \
|| { mca_bad "$(mca_msg "Could not save the setting.")"; mca_pause; }
return 0
fi
return 1
}
# ---------------------------------------------------------------------------
# Applications
# ---------------------------------------------------------------------------
# mca_ui_apps
# Every application that was found, what will happen to it, and a key to
# change that. This is where an AppImage that could not be identified gets
# turned on, and where a single application gets left out without having to
# switch off its whole category.
#
# The screen opens on a search field above the list, so typing filters it
# right away. The field is row -1: Down leaves it, Up from the top returns.
#
# Returns 0 when something was changed.
mca_ui_apps() {
local count key frame row pad i idx cursor=-1 dirty=1 touched=0 locale query='' needle
local footer flines size rows height top=0
local -a labels=() lower=() states=() matches=()
locale="$(mca_ui_locale)"
local title hint legend warn
mca_msg_into "$locale" "Applications"; title="$MCA_MSG_RESULT"
mca_msg_into "$locale" "Up/Down: select, Space: turn on or off, q: back"
hint="$MCA_MSG_RESULT"
local l_search hint_search nomatch
mca_msg_into "$locale" "Search:"; l_search="$MCA_MSG_RESULT"
mca_msg_into "$locale" "Type to search, Down: go to the list, Esc: back"
hint_search="$MCA_MSG_RESULT"
mca_msg_into "$locale" "No matches."; nomatch="$MCA_MSG_RESULT"
mca_msg_into "$locale" 'Anything marked "cannot tell" stays off until it is turned on here.'
legend="$MCA_MSG_RESULT"
mca_msg_into "$locale" "Autoscroll is off. This is what would be covered."
warn="$MCA_MSG_RESULT"
# The per-row labels are resolved once, here. Looking them up inside the
# drawing loop is a fork per row per keypress, and that is enough to make
# the arrow keys feel like the screen is reloading.
local l_off l_cannot l_on l_steam l_flagfile l_launcher l_profile
mca_msg_into "$locale" "off"; l_off="$MCA_MSG_RESULT"
mca_msg_into "$locale" "cannot tell"; l_cannot="$MCA_MSG_RESULT"
mca_msg_into "$locale" "on"; l_on="$MCA_MSG_RESULT"
mca_msg_into "$locale" "Steam"; l_steam="$MCA_MSG_RESULT"
mca_msg_into "$locale" "flag file"; l_flagfile="$MCA_MSG_RESULT"
mca_msg_into "$locale" "launcher"; l_launcher="$MCA_MSG_RESULT"
mca_msg_into "$locale" "profile"; l_profile="$MCA_MSG_RESULT"
local s_off="${MCA_C_DIM}${l_off}${MCA_C_RESET}"
local s_cannot="${MCA_C_DIM}${l_cannot}${MCA_C_RESET}"
local s_on="${MCA_C_GREEN}${l_on}${MCA_C_RESET}"
local s_steam="${s_on} ${MCA_C_DIM}(${l_steam})${MCA_C_RESET}"
local s_flags="${s_on} ${MCA_C_DIM}(${l_flagfile})${MCA_C_RESET}"
local s_desktop="${s_on} ${MCA_C_DIM}(${l_launcher})${MCA_C_RESET}"
local s_spotify="${s_on} ${MCA_C_DIM}(spotify-launcher)${MCA_C_RESET}"
local s_gecko="${s_on} ${MCA_C_DIM}(${l_profile})${MCA_C_RESET}"
local clearseq
clearseq="$(clear 2>/dev/null)" || clearseq=$'\033[H\033[2J'
printf '%s\n %s\n' "$clearseq" "$(mca_msg "Looking at the installed applications...")"
mca_config_load
mca_scan_once
count=${#MCA_IDS[@]}
if (( count == 0 )); then
MCA_UI_MSG="$(mca_msg "No supported applications found.")"
return 1
fi
while true; do
if (( dirty )); then
mca_config_load
mca_count_routes
labels=(); lower=(); states=()
for i in "${!MCA_IDS[@]}"; do
labels+=("${MCA_NAMES[i]}")
row="${MCA_NAMES[i],,}"
lower+=("${row//ı/i}")
states+=("${MCA_ROUTES[i]}")
done
dirty=0
fi
# In a Turkish locale I lowercases to a dotless ı, so "office" would
# not find ONLYOFFICE. Both sides fold it back to i.
needle="${query,,}"
needle="${needle//ı/i}"
matches=()
for i in "${!labels[@]}"; do
[[ ${lower[i]} == *"$needle"* ]] && matches+=("$i")
done
count=${#matches[@]}
(( cursor >= count )) && cursor=$(( count - 1 ))
# The footer is built first: the list gets the rows it leaves over.
footer='' flines=1
# Without this the list reads as a list of what is switched on, which
# it is not while the whole thing is off.
if [[ $CFG_ENABLED != yes ]]; then
footer+=" ${MCA_C_YELLOW}${warn}${MCA_C_RESET}"$'\n'
flines=$(( flines + 1 ))
fi
# Only needed while there is something it explains.
if (( MCA_N_UNKNOWN )); then
footer+=" ${MCA_C_DIM}${legend}${MCA_C_RESET}"$'\n'
flines=$(( flines + 1 ))
fi
if (( cursor < 0 )); then
footer+=" ${MCA_C_DIM}${hint_search}${MCA_C_RESET}"$'\n'
# Puts the terminal cursor at the end of the query (row 4).
printf -v row '\033[4;%dH' $(( 6 + ${#l_search} + ${#query} ))
footer+="$row"
else
footer+=" ${MCA_C_DIM}${hint}${MCA_C_RESET}"$'\n'
fi
# A list longer than the terminal would scroll the search field off
# the top. So only a window of it is drawn, and it follows the cursor.
# Five rows above the list, one below it, the footer, and the row the
# terminal cursor ends up on.
# A terminal that does not know its size says 0; then all of it is drawn.
size="$(stty size 2>/dev/null)"
rows="${size%% *}"
[[ $rows =~ ^[1-9][0-9]*$ ]] || rows=10000
height=$(( rows - 7 - flines ))
(( height < 1 )) && height=1
(( top > count - height )) && top=$(( count - height ))
(( top < 0 )) && top=0
if (( cursor >= 0 )); then
(( cursor < top )) && top=$cursor
(( cursor >= top + height )) && top=$(( cursor - height + 1 ))
fi
frame="$clearseq"$'\n'"${MCA_C_BOLD}${MCA_C_BLUE} ${title}${MCA_C_RESET}"$'\n\n'
local marker selected="${MCA_C_BLUE}▸${MCA_C_RESET} " shown
if (( cursor < 0 )); then marker="$selected"; else marker=' '; fi
frame+=" ${marker}${l_search} ${query}"$'\n\n'
for (( i = top; i < count && i < top + height; i++ )); do
idx=${matches[i]}
case "${states[idx]}" in
off) shown="$s_off" ;;
unknown) shown="$s_cannot" ;;
steam) shown="$s_steam" ;;
flags) shown="$s_flags" ;;
spotify) shown="$s_spotify" ;;
gecko) shown="$s_gecko" ;;
*) shown="$s_desktop" ;;
esac
pad=$(( 34 - ${#labels[idx]} ))
(( pad < 0 )) && pad=0
if (( i == cursor )); then marker="$selected"; else marker=' '; fi
printf -v row ' %s%s%*s %s' "$marker" "${labels[idx]}" "$pad" '' "$shown"
frame+="$row"$'\n'
done
(( count )) || frame+=" ${MCA_C_DIM}${nomatch}${MCA_C_RESET}"$'\n'
# Where the window is, in the row under it, when there is more.
if (( count > height )); then
frame+=" ${MCA_C_DIM}$(( top + 1 ))–$(( i )) / ${count}${MCA_C_RESET}"$'\n'
else
frame+=$'\n'
fi
frame+="$footer"
printf '%s' "$frame"
key="$(mca_read_key)" || return "$(( ! touched ))"
# In the search field every key that can be typed goes into the query,
# q and the vi keys too.
if (( cursor < 0 )); then
case "$key" in
down|enter) (( count )) && cursor=0 ;;
up) cursor=$(( count - 1 )) ;;
backspace) query="${query%?}" ;;
space) query+=' ' ;;
escape)
[[ -z $query ]] && return "$(( ! touched ))"
query=''
;;
left|right) ;;
*) [[ $key == [[:print:]] ]] && query+="$key" ;;
esac
continue
fi
idx=${matches[cursor]}
case "$key" in
up|k) cursor=$(( cursor - 1 )) ;;
down|j) cursor=$(( cursor + 1 < count ? cursor + 1 : -1 )) ;;
space|enter|right|left|l|h)
case "${states[idx]}" in
off|unknown)
mca_config_list_del Skip "${MCA_IDS[idx]}"
mca_config_list_add Include "${MCA_IDS[idx]}"
;;
*)
mca_config_list_del Include "${MCA_IDS[idx]}"
mca_config_list_add Skip "${MCA_IDS[idx]}"
;;
esac
touched=1
dirty=1
;;
q|Q|escape) return "$(( ! touched ))" ;;
*) ;;
esac
done
}
# ---------------------------------------------------------------------------
# The menu
# ---------------------------------------------------------------------------
mca_ui_menu() {
local choice line
MCA_IN_MENU=1
mca_ui_term_raw
trap 'mca_ui_term_restore' EXIT INT TERM
while true; do
mca_config_load
clear 2>/dev/null || true
mca_head " $MCA_PRETTY"
mca_ui_status
printf '\n'
if [[ -n $MCA_UI_MSG ]]; then
while IFS= read -r line; do
printf ' %s\n' "$line"
done <<< "$MCA_UI_MSG"
printf '\n'
MCA_UI_MSG=''
fi
printf ' [1] %s\n' "$(mca_msg "Turn autoscroll on or off")"
printf ' [2] %s\n' "$(mca_msg "Re-apply everything")"
printf ' [3] %s\n' "$(mca_msg "Applications")"
printf ' [4] %s\n' "$(mca_msg "Settings")"
printf ' [q] %s\n' "$(mca_msg "Quit")"
printf '\n > '
choice="$(mca_read_key)" || {
printf '\n'; mca_ui_term_restore; trap - EXIT INT TERM; return 0
}
case "$choice" in
enter|space|up|down|left|right|escape) choice='' ;;
esac
printf '%s\n' "$choice"
case "$choice" in
1)
if [[ $CFG_ENABLED == yes ]]; then
mca_ui_run mca_do_disable
else
mca_ui_run mca_do_enable
fi
;;
# Not a plain apply: with the watcher running there is never
# anything left for one to do, and a menu entry that answers
# "already done" every time is not an action. This is the repair -
# everything is taken back and written again from scratch, which is
# what fixes an application that drifted, a flag file somebody
# edited, or Steam after it restored its own script.
2)
mca_ui_run mca_do_apply --rebuild
;;
3)
if mca_ui_apps; then
[[ $CFG_ENABLED == yes ]] && mca_ui_run mca_do_apply --rebuild
fi
;;
4)
if mca_ui_settings; then
[[ $CFG_ENABLED == yes ]] && mca_ui_run mca_do_apply --rebuild
fi
;;
q|Q) mca_ui_term_restore; trap - EXIT INT TERM; return 0 ;;
# Anything else (Enter, arrow keys, stray characters) just
# redraws. Escape is deliberately not a quit key, so a mistyped
# arrow key cannot close the menu.
*) ;;
esac
done
}