Replace whiptail TUI with gum (charmbracelet/gum)

- Rewrite ui_common.sh wrappers to use gum choose, gum input,
  gum confirm, gum style, gum pager
- Menu items display as "TAG  Description" for reliable tag extraction
  (handles duplicate descriptions safely)
- Replace whiptail --gauge with text-based progress bar
- Use printf %b instead of echo -e to avoid escape injection
- Read msgbox keypress from /dev/tty to work in piped contexts
- Update bin/gniza, install.sh, README.md references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-05 21:45:15 +02:00
parent 0004dbed9f
commit 6c16f65c3c
4 changed files with 140 additions and 86 deletions

View File

@@ -1,6 +1,6 @@
# gniza - Linux Backup Manager # gniza - Linux Backup Manager
A generic Linux backup tool with a Whiptail TUI and CLI interface. Define named backup targets (sets of directories), configure remote destinations (SSH, local, S3, Google Drive), and run incremental backups with rsync `--link-dest` deduplication. A generic Linux backup tool with a Gum TUI and CLI interface. Define named backup targets (sets of directories), configure remote destinations (SSH, local, S3, Google Drive), and run incremental backups with rsync `--link-dest` deduplication.
``` ```
▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
@@ -30,7 +30,7 @@ A generic Linux backup tool with a Whiptail TUI and CLI interface. Define named
- **Target-based backups** - Define named profiles with sets of directories to back up - **Target-based backups** - Define named profiles with sets of directories to back up
- **Multiple remote types** - SSH, local (USB/NFS), S3, Google Drive - **Multiple remote types** - SSH, local (USB/NFS), S3, Google Drive
- **Incremental snapshots** - rsync `--link-dest` for space-efficient deduplication - **Incremental snapshots** - rsync `--link-dest` for space-efficient deduplication
- **Whiptail TUI** - Full terminal UI for interactive management - **Gum TUI** - Beautiful terminal UI powered by [gum](https://github.com/charmbracelet/gum)
- **CLI interface** - Scriptable commands for automation and cron - **CLI interface** - Scriptable commands for automation and cron
- **Atomic snapshots** - `.partial` directory during backup, renamed on success - **Atomic snapshots** - `.partial` directory during backup, renamed on success
- **Retention policies** - Automatic pruning of old snapshots - **Retention policies** - Automatic pruning of old snapshots
@@ -68,7 +68,7 @@ Root mode installs to `/usr/local/gniza`. User mode installs to `~/.local/share/
### Dependencies ### Dependencies
- **Required**: bash 4+, rsync - **Required**: bash 4+, rsync
- **Optional**: ssh, whiptail (TUI), curl (SMTP notifications), rclone (S3/GDrive) - **Optional**: ssh, [gum](https://github.com/charmbracelet/gum) (TUI), curl (SMTP notifications), rclone (S3/GDrive)
## Quick Start ## Quick Start

View File

@@ -94,7 +94,7 @@ Commands:
logs [--last] [--tail=N] logs [--last] [--tail=N]
version version
If no command is given and whiptail is available, the TUI is launched. If no command is given and gum is available, the TUI is launched.
EOF EOF
} }
@@ -436,7 +436,7 @@ if [[ -n "$SUBCOMMAND" ]]; then
run_cli run_cli
elif [[ "$FORCE_CLI" == "true" ]]; then elif [[ "$FORCE_CLI" == "true" ]]; then
run_cli run_cli
elif command -v whiptail &>/dev/null && [[ -t 1 ]]; then elif command -v gum &>/dev/null && [[ -t 1 ]]; then
# TUI mode # TUI mode
show_logo show_logo
if ! has_remotes && ! has_targets; then if ! has_remotes && ! has_targets; then

View File

@@ -1,133 +1,187 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# gniza4linux/lib/ui_common.sh — Whiptail TUI wrappers with consistent sizing # gniza4linux/lib/ui_common.sh — Gum TUI wrappers
[[ -n "${_GNIZA4LINUX_UI_COMMON_LOADED:-}" ]] && return 0 [[ -n "${_GNIZA4LINUX_UI_COMMON_LOADED:-}" ]] && return 0
_GNIZA4LINUX_UI_COMMON_LOADED=1 _GNIZA4LINUX_UI_COMMON_LOADED=1
readonly WHIPTAIL_TITLE="gniza Backup Manager" # Theme
readonly _GUM_ACCENT="212"
readonly _GUM_CURSOR="▸ "
ui_calc_size() { # ── Internal: tag-description mapping ─────────────────────────
local term_h="${LINES:-24}" # gum returns the selected display text, not a separate tag.
local term_w="${COLUMNS:-80}" # To handle duplicate descriptions, we append a zero-width space
local h w # marker per item (U+200B repeated i times) to guarantee uniqueness,
h=$(( term_h - 4 )) # then strip the markers from the result to find the index.
w=$(( term_w - 4 )) #
(( h > 20 )) && h=20 # Alternatively we use a simpler approach: display "tag description"
(( w > 76 )) && w=76 # and split on the double-space to extract the tag.
echo "$h" "$w" # Tags in gniza are always short identifiers without spaces.
_gum_fmt_items() {
local -n _tags=$1 _descs=$2
local i
for i in "${!_tags[@]}"; do
printf '%s\n' "${_tags[$i]} ${_descs[$i]}"
done
} }
_ui_backtitle() { _gum_extract_tag() {
echo "gniza v${GNIZA4LINUX_VERSION}" local line="$1"
echo "${line%% *}"
} }
# ── Menu (tag/description pairs) ─────────────────────────────
# Usage: ui_menu "Title" "TAG1" "Desc 1" "TAG2" "Desc 2" ...
# Returns the selected TAG.
ui_menu() { ui_menu() {
local title="$1"; shift local title="$1"; shift
local -a items=("$@") local -a tags=() descs=()
local size; size=$(ui_calc_size) while [[ $# -ge 2 ]]; do
local h w tags+=("$1")
read -r h w <<< "$size" descs+=("$2")
local menu_h=$(( h - 7 )) shift 2
(( menu_h < 3 )) && menu_h=3 done
local result local result
result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ result=$(_gum_fmt_items tags descs | gum choose \
--menu "$title" "$h" "$w" "$menu_h" "${items[@]}" 3>&1 1>&2 2>&3) || return 1 --header "$title" \
echo "$result" --header.foreground "$_GUM_ACCENT" \
--cursor.foreground "$_GUM_ACCENT" \
--cursor "$_GUM_CURSOR") || return 1
_gum_extract_tag "$result"
} }
# ── Checklist (tag/description/status triplets, multi-select) ─
# Usage: ui_checklist "Title" "TAG1" "Desc 1" "ON" "TAG2" "Desc 2" "OFF" ...
# Returns space-separated quoted tags: "TAG1" "TAG2"
ui_checklist() { ui_checklist() {
local title="$1"; shift local title="$1"; shift
local -a items=("$@") local -a tags=() descs=() selected=()
local size; size=$(ui_calc_size) while [[ $# -ge 3 ]]; do
local h w tags+=("$1")
read -r h w <<< "$size" descs+=("$2")
local list_h=$(( h - 7 )) [[ "$3" == "ON" ]] && selected+=("${1} ${2}")
(( list_h < 3 )) && list_h=3 shift 3
done
local -a sel_args=()
local s
for s in "${selected[@]}"; do
sel_args+=(--selected "$s")
done
local result local result
result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ result=$(_gum_fmt_items tags descs | gum choose --no-limit \
--checklist "$title" "$h" "$w" "$list_h" "${items[@]}" 3>&1 1>&2 2>&3) || return 1 --header "$title" \
echo "$result" --header.foreground "$_GUM_ACCENT" \
--cursor.foreground "$_GUM_ACCENT" \
--cursor "$_GUM_CURSOR" \
"${sel_args[@]}") || return 1
local output=""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local tag
tag=$(_gum_extract_tag "$line")
[[ -n "$output" ]] && output+=" "
output+="\"$tag\""
done <<< "$result"
echo "$output"
} }
# ── Radiolist (tag/description/status triplets, single select) ─
# Usage: ui_radiolist "Title" "TAG1" "Desc 1" "ON" "TAG2" "Desc 2" "OFF" ...
# Returns the selected TAG.
ui_radiolist() { ui_radiolist() {
local title="$1"; shift local title="$1"; shift
local -a items=("$@") local -a tags=() descs=()
local size; size=$(ui_calc_size) local preselected=""
local h w while [[ $# -ge 3 ]]; do
read -r h w <<< "$size" tags+=("$1")
local list_h=$(( h - 7 )) descs+=("$2")
(( list_h < 3 )) && list_h=3 [[ "$3" == "ON" ]] && preselected="${1} ${2}"
shift 3
done
local -a sel_args=()
[[ -n "$preselected" ]] && sel_args=(--selected "$preselected")
local result local result
result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ result=$(_gum_fmt_items tags descs | gum choose \
--radiolist "$title" "$h" "$w" "$list_h" "${items[@]}" 3>&1 1>&2 2>&3) || return 1 --header "$title" \
echo "$result" --header.foreground "$_GUM_ACCENT" \
--cursor.foreground "$_GUM_ACCENT" \
--cursor "$_GUM_CURSOR" \
"${sel_args[@]}") || return 1
_gum_extract_tag "$result"
} }
# ── Input box ─────────────────────────────────────────────────
ui_inputbox() { ui_inputbox() {
local title="$1" local title="$1"
local prompt="$2" local prompt="$2"
local default="${3:-}" local default="${3:-}"
local size; size=$(ui_calc_size)
local h w
read -r h w <<< "$size"
local result gum input \
result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ --header "$prompt" \
--inputbox "$prompt" "$h" "$w" "$default" 3>&1 1>&2 2>&3) || return 1 --header.foreground "$_GUM_ACCENT" \
echo "$result" --value "$default" \
--width 60 || return 1
} }
# ── Yes/No confirmation ──────────────────────────────────────
ui_yesno() { ui_yesno() {
local prompt="$1" local prompt="$1"
local size; size=$(ui_calc_size) gum confirm "$(printf '%b' "$prompt")" \
local h w --affirmative "Yes" \
read -r h w <<< "$size" --negative "No"
whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \
--yesno "$prompt" "$h" "$w" 3>&1 1>&2 2>&3
} }
# ── Message box ───────────────────────────────────────────────
ui_msgbox() { ui_msgbox() {
local msg="$1" local msg="$1"
local size; size=$(ui_calc_size) echo ""
local h w printf '%b' "$msg" | gum style \
read -r h w <<< "$size" --border rounded \
--border-foreground "$_GUM_ACCENT" \
whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ --padding "1 2" \
--msgbox "$msg" "$h" "$w" 3>&1 1>&2 2>&3 --margin "0 2"
echo ""
read -rsp " Press any key to continue..." -n1 < /dev/tty
echo ""
} }
# ── Progress gauge (reads percentage lines from stdin) ────────
ui_gauge() { ui_gauge() {
local prompt="$1" local prompt="$1"
local size; size=$(ui_calc_size) while IFS= read -r pct; do
local h w [[ "$pct" =~ ^[0-9]+$ ]] || continue
read -r h w <<< "$size" local filled=$(( pct / 5 ))
local empty=$(( 20 - filled ))
whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ local bar=""
--gauge "$prompt" "$h" "$w" 0 3>&1 1>&2 2>&3 local i
for ((i=0; i<filled; i++)); do bar+="█"; done
for ((i=0; i<empty; i++)); do bar+="░"; done
printf "\r %s [%s] %s%%" "$prompt" "$bar" "$pct"
done
printf "\033[2K\r %s [████████████████████] done\n" "$prompt"
} }
# ── Text file viewer ─────────────────────────────────────────
ui_textbox() { ui_textbox() {
local filepath="$1" local filepath="$1"
local size; size=$(ui_calc_size) gum pager < "$filepath"
local h w
read -r h w <<< "$size"
whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \
--textbox "$filepath" "$h" "$w" 3>&1 1>&2 2>&3
} }
# ── Password input ────────────────────────────────────────────
ui_password() { ui_password() {
local prompt="$1" local prompt="$1"
local size; size=$(ui_calc_size) gum input \
local h w --password \
read -r h w <<< "$size" --header "$prompt" \
--header.foreground "$_GUM_ACCENT" \
local result --width 60 || return 1
result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \
--passwordbox "$prompt" "$h" "$w" 3>&1 1>&2 2>&3) || return 1
echo "$result"
} }

View File

@@ -68,7 +68,7 @@ for cmd in bash rsync; do
fi fi
done done
for cmd in ssh whiptail curl; do for cmd in ssh gum curl; do
if ! command -v "$cmd" &>/dev/null; then if ! command -v "$cmd" &>/dev/null; then
warn "Optional dependency not found: $cmd" warn "Optional dependency not found: $cmd"
fi fi
@@ -140,5 +140,5 @@ echo " Logs: $LOG_DIR"
echo "" echo ""
echo "Get started:" echo "Get started:"
echo " gniza --help Show CLI help" echo " gniza --help Show CLI help"
echo " gniza Launch TUI (requires whiptail)" echo " gniza Launch TUI (requires gum)"
echo "" echo ""