From c308b7e286e72129e5c54f8aac03446890200848 Mon Sep 17 00:00:00 2001 From: Felitendo Date: Sat, 8 Aug 2026 16:18:59 +0200 Subject: [PATCH] Add a settings screen so nothing needs a text editor All eighteen options are now reachable from the menu as a cursor list: arrows select, Space or Right cycles a value, q goes back, changes are written immediately. A numbered menu would have run out of digits. Three real bugs surfaced while building it, each found by testing against an actual pty rather than a pipe: - Labels went through printf as format strings, so the percent sign in "Minimum battery level (%)" was an invalid conversion. cau_msg_in now only treats a message as a format string when arguments were actually passed - otherwise any literal % a translator writes is a trap. - Mixing bash's line-mode read into a single-key interface left the following read -sn1 receiving nothing at all, reproducibly, so the screen froze after editing the package list. Replaced with a small line editor built on the same single-character reader. - Backspace was being swallowed: in canonical mode DEL is the ERASE character and the line discipline consumes it, and bash returns to canonical mode between each read -sn1. The interface now holds non-canonical mode for its whole lifetime and hands the terminal back only around actions that print or prompt, with a trap restoring it on Ctrl-C. Translations and config reads are memoized; the screen redraws every label on every keypress and a fork per lookup was the reason the redraw was slow enough to matter. --- Makefile | 2 +- README.md | 5 + doc/cachy-auto-update.1.scd | 5 +- po/cachy-auto-update.pot | 66 ++++++++ po/de.po | 66 ++++++++ src/lib/common.sh | 27 ++- src/lib/config.sh | 26 ++- src/lib/menu.sh | 320 ++++++++++++++++++++++++++++++++++-- 8 files changed, 491 insertions(+), 26 deletions(-) diff --git a/Makefile b/Makefile index 2f1ff79..989a406 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ # Overridable so a packager can pass the version it is actually building # (`make VERSION=$pkgver`). The literal below is the fallback for builds # straight from a checkout, and is what a release tag has to carry. -VERSION ?= 1.0.9 +VERSION ?= 1.1.0 PREFIX ?= /usr DESTDIR ?= diff --git a/README.md b/README.md index 26aa4fa..12af6a6 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,14 @@ That opens a menu with the two switches there are: [3] Update now [4] Show log [5] Show current conditions + [6] Settings [q] Quit ``` +Everything is configurable from **[6] Settings** — a cursor list covering all +eighteen options, so nothing needs a text editor. Arrow keys select, Space or +Right changes a value, `q` goes back; changes are written immediately. + The interface is fully translated; on a German system everything above appears in German. diff --git a/doc/cachy-auto-update.1.scd b/doc/cachy-auto-update.1.scd index d5d7482..79f9502 100644 --- a/doc/cachy-auto-update.1.scd +++ b/doc/cachy-auto-update.1.scd @@ -16,7 +16,10 @@ updated in the background, with no password prompt and no terminal. Run without a command it opens a small interactive menu with the two switches that matter - automatic updates on/off and notifications on/off - plus the -current status. +current status, and a settings screen covering every remaining option so the +configuration file never has to be edited by hand. In the settings screen the +arrow keys select, Space or Right changes a value, and _q_ goes back; every +change is written out immediately. The actual work is done by a systemd system service. The timer ticks hourly; whether a tick does anything is decided by _UpdateInterval_ (daily by default). diff --git a/po/cachy-auto-update.pot b/po/cachy-auto-update.pot index 0af3bc9..6fb9f79 100644 --- a/po/cachy-auto-update.pot +++ b/po/cachy-auto-update.pot @@ -269,3 +269,69 @@ msgstr "" msgid "%d packages are being updated. Please leave the computer switched on until this is done." msgstr "" + +msgid "Settings" +msgstr "" + +msgid "(none)" +msgstr "" + +msgid "Up/Down select - Space or Right changes - q goes back" +msgstr "" + +msgid "Package names separated by spaces, empty to clear:" +msgstr "" + +msgid "Notify when an update starts" +msgstr "" + +msgid "Notify after a successful update" +msgstr "" + +msgid "Notify when something goes wrong" +msgstr "" + +msgid "Notify when a restart is needed" +msgstr "" + +msgid "Time between update runs" +msgstr "" + +msgid "Postpone while a game is running" +msgstr "" + +msgid "Only update on mains power" +msgstr "" + +msgid "Minimum battery level (%)" +msgstr "" + +msgid "Update AUR packages" +msgstr "" + +msgid "Update Flatpaks" +msgstr "" + +msgid "Update AppImages" +msgstr "" + +msgid "Also rebuild -git packages" +msgstr "" + +msgid "AUR helper" +msgstr "" + +msgid "Resolve package conflicts automatically" +msgstr "" + +msgid "Trim the package cache" +msgstr "" + +msgid "Cached versions to keep" +msgstr "" + +msgid "Remove packages nothing needs any more" +msgstr "" + +msgid "Never update these packages" +msgstr "" diff --git a/po/de.po b/po/de.po index f416f00..fa3c566 100644 --- a/po/de.po +++ b/po/de.po @@ -271,3 +271,69 @@ msgstr "Updates werden installiert" #, c-format msgid "%d packages are being updated. Please leave the computer switched on until this is done." msgstr "%d Pakete werden gerade aktualisiert. Bitte den Rechner so lange eingeschaltet lassen." + +msgid "Settings" +msgstr "Einstellungen" + +msgid "(none)" +msgstr "(keine)" + +msgid "Up/Down select - Space or Right changes - q goes back" +msgstr "Hoch/Runter wählen – Leertaste oder Rechts ändert – q zurück" + +msgid "Package names separated by spaces, empty to clear:" +msgstr "Paketnamen mit Leerzeichen getrennt, leer zum Löschen:" + +msgid "Notify when an update starts" +msgstr "Melden, wenn ein Update beginnt" + +msgid "Notify after a successful update" +msgstr "Melden nach erfolgreichem Update" + +msgid "Notify when something goes wrong" +msgstr "Melden, wenn etwas schiefgeht" + +msgid "Notify when a restart is needed" +msgstr "Melden, wenn ein Neustart nötig ist" + +msgid "Time between update runs" +msgstr "Abstand zwischen Update-Läufen" + +msgid "Postpone while a game is running" +msgstr "Verschieben, solange ein Spiel läuft" + +msgid "Only update on mains power" +msgstr "Nur am Stromnetz aktualisieren" + +msgid "Minimum battery level (%)" +msgstr "Mindest-Akkustand (%)" + +msgid "Update AUR packages" +msgstr "AUR-Pakete aktualisieren" + +msgid "Update Flatpaks" +msgstr "Flatpaks aktualisieren" + +msgid "Update AppImages" +msgstr "AppImages aktualisieren" + +msgid "Also rebuild -git packages" +msgstr "Auch -git-Pakete neu bauen" + +msgid "AUR helper" +msgstr "AUR-Helfer" + +msgid "Resolve package conflicts automatically" +msgstr "Paketkonflikte automatisch auflösen" + +msgid "Trim the package cache" +msgstr "Paket-Cache beschneiden" + +msgid "Cached versions to keep" +msgstr "Behaltene Versionen im Cache" + +msgid "Remove packages nothing needs any more" +msgstr "Nicht mehr benötigte Pakete entfernen" + +msgid "Never update these packages" +msgstr "Diese Pakete nie aktualisieren" diff --git a/src/lib/common.sh b/src/lib/common.sh index d64358d..6f6f8fd 100644 --- a/src/lib/common.sh +++ b/src/lib/common.sh @@ -75,13 +75,34 @@ cau_msg() { cau_msg_in "$(cau_ui_locale)" "$@" } +# Translations are memoized. Every gettext lookup is a fork, and the settings +# screen redraws forty-odd labels per keypress; without this the redraw takes +# long enough that a keystroke arriving during it is lost when the terminal +# switches back to single-character mode. +declare -A CAU_MSG_CACHE=() + # cau_msg_in [printf args...] cau_msg_in() { - local locale="$1" msgid="$2" translated + local locale="$1" msgid="$2" translated cachekey shift 2 - translated="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)" - [[ -n $translated ]] || translated="$msgid" + cachekey="${locale}"$'\x1f'"${msgid}" + if [[ -n ${CAU_MSG_CACHE[$cachekey]+set} ]]; then + translated="${CAU_MSG_CACHE[$cachekey]}" + else + translated="$(LC_ALL="$locale" LANGUAGE="${locale%%.*}" gettext -- "$msgid" 2>/dev/null)" + [[ -n $translated ]] || translated="$msgid" + CAU_MSG_CACHE[$cachekey]="$translated" + fi + + # With no arguments the message is plain text, not a format string. Feeding + # it to printf anyway turns any literal percent sign in it - "Battery (%)", + # "100 % done" - into an invalid conversion, and that is a trap every + # translator would eventually walk into. + if (( $# == 0 )); then + printf '%s' "$translated" + return + fi # shellcheck disable=SC2059 # the format string is the translated message printf -- "$translated" "$@" diff --git a/src/lib/config.sh b/src/lib/config.sh index 6070ffa..91f6ba6 100644 --- a/src/lib/config.sh +++ b/src/lib/config.sh @@ -6,14 +6,33 @@ # root-run daemon, and sourcing it would turn a stray line into arbitrary code # execution. The format is one "Key=Value" per line, '#' starts a comment. +# The file is cached and parsed in-process rather than shelled out to sed on +# every lookup. The settings screen reads every key on every redraw, and a fork +# per key made the redraw slow enough to swallow keystrokes. +CAU_CONFIG_CACHE='' +CAU_CONFIG_CACHED=0 + +_cau_config_slurp() { + (( CAU_CONFIG_CACHED )) && return 0 + CAU_CONFIG_CACHE='' + [[ -r $CAU_CONFIG ]] && CAU_CONFIG_CACHE="$(< "$CAU_CONFIG")" + CAU_CONFIG_CACHED=1 + return 0 +} + # cau_config_get [default] cau_config_get() { - local key="$1" default="${2:-}" val + local key="$1" default="${2:-}" val='' line [[ -r $CAU_CONFIG ]] || { printf '%s\n' "$default"; return; } + _cau_config_slurp - val="$(sed -nE "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*(.*)$/\\1/p" \ - "$CAU_CONFIG" 2>/dev/null | tail -n1)" + # last assignment wins, matching the previous sed|tail behaviour + while IFS= read -r line; do + [[ $line == *"$key"* ]] || continue + [[ $line =~ ^[[:space:]]*"$key"[[:space:]]*=(.*)$ ]] || continue + val="${BASH_REMATCH[1]}" + done <<< "$CAU_CONFIG_CACHE" # strip a trailing comment and surrounding whitespace/quotes val="${val%%#*}" @@ -78,6 +97,7 @@ cau_config_set() { fi mv -f "$tmp" "$CAU_CONFIG" + CAU_CONFIG_CACHED=0 } # --------------------------------------------------------------------------- diff --git a/src/lib/menu.sh b/src/lib/menu.sh index 42812f6..25be2db 100644 --- a/src/lib/menu.sh +++ b/src/lib/menu.sh @@ -7,6 +7,279 @@ CAU_UNIT="cachy-auto-update.timer" +# 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 vanished. +# Holding non-canonical mode for the whole interface removes the gap. +CAU_TERM_SAVED='' + +cau_ui_term_raw() { + cau_have stty || return 0 + [[ -t 0 ]] || return 0 + [[ -n $CAU_TERM_SAVED ]] && return 0 + + CAU_TERM_SAVED="$(stty -g 2>/dev/null)" || { CAU_TERM_SAVED=''; return 0; } + stty -icanon -echo min 1 time 0 2>/dev/null || true +} + +cau_ui_term_restore() { + [[ -n $CAU_TERM_SAVED ]] || return 0 + stty "$CAU_TERM_SAVED" 2>/dev/null || true + CAU_TERM_SAVED='' +} + +# Runs an action with the terminal handed back to normal line mode, so anything +# it prints - or prompts for - behaves the way a program expects. +cau_ui_cooked() { + cau_ui_term_restore + "$@" + local rc=$? + cau_ui_term_raw + return $rc +} + +# cau_read_key +# One keypress, resolved to a symbolic name: a literal character, or one of +# up/down/left/right/enter/space/escape. Arrow keys arrive as ESC [ A, so the +# tail of the sequence is consumed here rather than being mistaken for three +# separate presses. +cau_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 + ;; + # Enter is an empty read in cooked mode and a carriage return in raw + # mode, depending on whether the terminal is translating it. + ''|$'\r') printf 'enter\n' ;; + $'\x7f'|$'\b') printf 'backspace\n' ;; + ' ') printf 'space\n' ;; + *) printf '%s\n' "$k" ;; + esac +} + +# cau_ui_read_line +# A minimal line editor built on cau_read_key, with the result in +# CAU_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, reproducibly, on a real pty. +# Never leaving single-character mode side-steps that completely. +CAU_LINE_RESULT='' + +cau_ui_read_line() { + local buf="${1:-}" key + + CAU_LINE_RESULT='' + printf '%s' "$buf" + + while true; do + key="$(cau_read_key)" || { printf '\n'; return 1; } + + case "$key" in + enter) + printf '\n' + CAU_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) ;; # no cursor movement in this editor + *) + # a single printable character; control keys arrive as names + [[ ${#key} -eq 1 ]] || continue + buf+="$key" + printf '%s' "$key" + ;; + esac + done +} + +# Everything that can be changed without opening a text editor. +# Format: Key|type|default|label-msgid +# type is bool, choice:, or text. +CAU_SETTINGS=( + "NotifyOnStart|bool|yes|Notify when an update starts" + "NotifyOnSuccess|bool|yes|Notify after a successful update" + "NotifyOnError|bool|yes|Notify when something goes wrong" + "NotifyReboot|bool|yes|Notify when a restart is needed" + "UpdateInterval|choice:6h 12h 1d 2d 1w|1d|Time between update runs" + "SkipWhenGaming|bool|yes|Postpone while a game is running" + "RequireAC|bool|no|Only update on mains power" + "MinBatteryPercent|choice:0 20 30 40 50 60 70 80|40|Minimum battery level (%)" + "UpdateAUR|bool|yes|Update AUR packages" + "UpdateFlatpak|bool|yes|Update Flatpaks" + "UpdateAppImages|bool|yes|Update AppImages" + "UpdateDevel|bool|no|Also rebuild -git packages" + "AURHelper|choice:auto paru yay pikaur|auto|AUR helper" + "AutoResolveConflicts|bool|yes|Resolve package conflicts automatically" + "CleanCache|bool|yes|Trim the package cache" + "KeepOldPackages|choice:0 1 2 3 5|3|Cached versions to keep" + "RemoveOrphans|bool|no|Remove packages nothing needs any more" + "IgnorePkg|text||Never update these packages" +) + +_cau_is_true() { + case "${1,,}" in + yes|y|true|1|on|enabled) return 0 ;; + *) return 1 ;; + esac +} + +# _cau_setting_display +_cau_setting_display() { + local type="$1" value="$2" + + case "$type" in + bool) + if _cau_is_true "$value"; then + printf '%s%s%s' "$CAU_C_GREEN" "$(cau_msg "ON")" "$CAU_C_RESET" + else + printf '%s%s%s' "$CAU_C_DIM" "$(cau_msg "OFF")" "$CAU_C_RESET" + fi + ;; + text) + if [[ -n $value ]]; then + printf '%s' "$value" + else + printf '%s%s%s' "$CAU_C_DIM" "$(cau_msg "(none)")" "$CAU_C_RESET" + fi + ;; + *) printf '%s' "$value" ;; + esac +} + +# _cau_setting_cycle +# The next value for this setting. Choices wrap around, so one key is enough to +# reach everything without needing a second one for the other direction. +_cau_setting_cycle() { + local type="$1" value="$2" dir="$3" + + if [[ $type == bool ]]; then + _cau_is_true "$value" && printf 'no\n' || printf 'yes\n' + return + fi + + local -a choices + read -r -a choices <<< "${type#choice:}" + (( ${#choices[@]} )) || { printf '%s\n' "$value"; return; } + + local i idx=0 + for i in "${!choices[@]}"; do + [[ ${choices[i]} == "$value" ]] && { idx=$i; break; } + done + + idx=$(( (idx + dir + ${#choices[@]}) % ${#choices[@]} )) + printf '%s\n' "${choices[idx]}" +} + +# cau_ui_settings +# A cursor list rather than a numbered menu: there are eighteen settings, and +# numbering them would run out of digits and force paging. +cau_ui_settings() { + local cursor=0 key spec name type default label value line pad + local count=${#CAU_SETTINGS[@]} + + while true; do + clear 2>/dev/null || true + cau_head " $(cau_msg "Settings")" + + local i=0 + for spec in "${CAU_SETTINGS[@]}"; do + IFS='|' read -r name type default label <<< "$spec" + value="$(cau_config_get "$name" "$default")" + + if (( i == cursor )); then + line="${CAU_C_BLUE}▸${CAU_C_RESET} " + else + line=" " + fi + + local text + text="$(cau_msg "$label")" + pad=$(( 42 - ${#text} )) + (( pad < 0 )) && pad=0 + + printf ' %s%s%*s %s\n' "$line" "$text" "$pad" '' \ + "$(_cau_setting_display "$type" "$value")" + i=$(( i + 1 )) + done + + printf '\n %s%s%s\n' "$CAU_C_DIM" \ + "$(cau_msg "Up/Down select - Space or Right changes - q goes back")" "$CAU_C_RESET" + + key="$(cau_read_key)" || return 0 + + IFS='|' read -r name type default label <<< "${CAU_SETTINGS[cursor]}" + value="$(cau_config_get "$name" "$default")" + + case "$key" in + up|k) cursor=$(( (cursor - 1 + count) % count )) ;; + down|j) cursor=$(( (cursor + 1) % count )) ;; + space|enter|right|l) + if [[ $type == text ]]; then + cau_ui_edit_text "$name" "$value" + else + cau_config_set "$name" "$(_cau_setting_cycle "$type" "$value" 1)" \ + || { cau_bad "$(cau_msg "Could not write the configuration file.")"; cau_pause; } + fi + ;; + left|h) + [[ $type == text ]] || cau_config_set "$name" \ + "$(_cau_setting_cycle "$type" "$value" -1)" \ + || { cau_bad "$(cau_msg "Could not write the configuration file.")"; cau_pause; } + ;; + q|Q|escape) return 0 ;; + *) ;; + esac + done +} + +# cau_ui_edit_text +# The one setting that is a free-text list rather than a choice. +cau_ui_edit_text() { + local name="$1" current="$2" + + printf '\n %s\n' "$(cau_msg "Package names separated by spaces, empty to clear:")" + printf ' > ' + + # pre-filled with the current value so it can be corrected rather than + # retyped; Escape leaves it unchanged + if cau_ui_read_line "$current"; then + cau_config_set "$name" "$CAU_LINE_RESULT" \ + || { cau_bad "$(cau_msg "Could not write the configuration file.")"; cau_pause; } + fi +} + # _cau_row