From 6c16f65c3cedf15d59979617ca581426c7911367 Mon Sep 17 00:00:00 2001 From: shuki Date: Thu, 5 Mar 2026 21:45:15 +0200 Subject: [PATCH] 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 --- README.md | 6 +- bin/gniza | 4 +- lib/ui_common.sh | 212 ++++++++++++++++++++++++++++----------------- scripts/install.sh | 4 +- 4 files changed, 140 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index b793926..2c0f52d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 - **Multiple remote types** - SSH, local (USB/NFS), S3, Google Drive - **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 - **Atomic snapshots** - `.partial` directory during backup, renamed on success - **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 - **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 diff --git a/bin/gniza b/bin/gniza index f44b647..a512e7f 100755 --- a/bin/gniza +++ b/bin/gniza @@ -94,7 +94,7 @@ Commands: logs [--last] [--tail=N] 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 } @@ -436,7 +436,7 @@ if [[ -n "$SUBCOMMAND" ]]; then run_cli elif [[ "$FORCE_CLI" == "true" ]]; then run_cli -elif command -v whiptail &>/dev/null && [[ -t 1 ]]; then +elif command -v gum &>/dev/null && [[ -t 1 ]]; then # TUI mode show_logo if ! has_remotes && ! has_targets; then diff --git a/lib/ui_common.sh b/lib/ui_common.sh index c3b6c59..8420fb8 100644 --- a/lib/ui_common.sh +++ b/lib/ui_common.sh @@ -1,133 +1,187 @@ #!/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 _GNIZA4LINUX_UI_COMMON_LOADED=1 -readonly WHIPTAIL_TITLE="gniza Backup Manager" +# Theme +readonly _GUM_ACCENT="212" +readonly _GUM_CURSOR="▸ " -ui_calc_size() { - local term_h="${LINES:-24}" - local term_w="${COLUMNS:-80}" - local h w - h=$(( term_h - 4 )) - w=$(( term_w - 4 )) - (( h > 20 )) && h=20 - (( w > 76 )) && w=76 - echo "$h" "$w" +# ── Internal: tag-description mapping ───────────────────────── +# gum returns the selected display text, not a separate tag. +# To handle duplicate descriptions, we append a zero-width space +# marker per item (U+200B repeated i times) to guarantee uniqueness, +# then strip the markers from the result to find the index. +# +# Alternatively we use a simpler approach: display "tag description" +# and split on the double-space to extract the tag. +# 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() { - echo "gniza v${GNIZA4LINUX_VERSION}" +_gum_extract_tag() { + 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() { local title="$1"; shift - local -a items=("$@") - local size; size=$(ui_calc_size) - local h w - read -r h w <<< "$size" - local menu_h=$(( h - 7 )) - (( menu_h < 3 )) && menu_h=3 + local -a tags=() descs=() + while [[ $# -ge 2 ]]; do + tags+=("$1") + descs+=("$2") + shift 2 + done local result - result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ - --menu "$title" "$h" "$w" "$menu_h" "${items[@]}" 3>&1 1>&2 2>&3) || return 1 - echo "$result" + result=$(_gum_fmt_items tags descs | gum choose \ + --header "$title" \ + --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() { local title="$1"; shift - local -a items=("$@") - local size; size=$(ui_calc_size) - local h w - read -r h w <<< "$size" - local list_h=$(( h - 7 )) - (( list_h < 3 )) && list_h=3 + local -a tags=() descs=() selected=() + while [[ $# -ge 3 ]]; do + tags+=("$1") + descs+=("$2") + [[ "$3" == "ON" ]] && selected+=("${1} ${2}") + shift 3 + done + + local -a sel_args=() + local s + for s in "${selected[@]}"; do + sel_args+=(--selected "$s") + done local result - result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ - --checklist "$title" "$h" "$w" "$list_h" "${items[@]}" 3>&1 1>&2 2>&3) || return 1 - echo "$result" + result=$(_gum_fmt_items tags descs | gum choose --no-limit \ + --header "$title" \ + --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() { local title="$1"; shift - local -a items=("$@") - local size; size=$(ui_calc_size) - local h w - read -r h w <<< "$size" - local list_h=$(( h - 7 )) - (( list_h < 3 )) && list_h=3 + local -a tags=() descs=() + local preselected="" + while [[ $# -ge 3 ]]; do + tags+=("$1") + descs+=("$2") + [[ "$3" == "ON" ]] && preselected="${1} ${2}" + shift 3 + done + + local -a sel_args=() + [[ -n "$preselected" ]] && sel_args=(--selected "$preselected") local result - result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ - --radiolist "$title" "$h" "$w" "$list_h" "${items[@]}" 3>&1 1>&2 2>&3) || return 1 - echo "$result" + result=$(_gum_fmt_items tags descs | gum choose \ + --header "$title" \ + --header.foreground "$_GUM_ACCENT" \ + --cursor.foreground "$_GUM_ACCENT" \ + --cursor "$_GUM_CURSOR" \ + "${sel_args[@]}") || return 1 + + _gum_extract_tag "$result" } +# ── Input box ───────────────────────────────────────────────── ui_inputbox() { local title="$1" local prompt="$2" local default="${3:-}" - local size; size=$(ui_calc_size) - local h w - read -r h w <<< "$size" - local result - result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ - --inputbox "$prompt" "$h" "$w" "$default" 3>&1 1>&2 2>&3) || return 1 - echo "$result" + gum input \ + --header "$prompt" \ + --header.foreground "$_GUM_ACCENT" \ + --value "$default" \ + --width 60 || return 1 } +# ── Yes/No confirmation ────────────────────────────────────── ui_yesno() { local prompt="$1" - local size; size=$(ui_calc_size) - local h w - read -r h w <<< "$size" - - whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ - --yesno "$prompt" "$h" "$w" 3>&1 1>&2 2>&3 + gum confirm "$(printf '%b' "$prompt")" \ + --affirmative "Yes" \ + --negative "No" } +# ── Message box ─────────────────────────────────────────────── ui_msgbox() { local msg="$1" - local size; size=$(ui_calc_size) - local h w - read -r h w <<< "$size" - - whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ - --msgbox "$msg" "$h" "$w" 3>&1 1>&2 2>&3 + echo "" + printf '%b' "$msg" | gum style \ + --border rounded \ + --border-foreground "$_GUM_ACCENT" \ + --padding "1 2" \ + --margin "0 2" + echo "" + read -rsp " Press any key to continue..." -n1 < /dev/tty + echo "" } +# ── Progress gauge (reads percentage lines from stdin) ──────── ui_gauge() { local prompt="$1" - local size; size=$(ui_calc_size) - local h w - read -r h w <<< "$size" - - whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ - --gauge "$prompt" "$h" "$w" 0 3>&1 1>&2 2>&3 + while IFS= read -r pct; do + [[ "$pct" =~ ^[0-9]+$ ]] || continue + local filled=$(( pct / 5 )) + local empty=$(( 20 - filled )) + local bar="" + local i + for ((i=0; i&1 1>&2 2>&3 + gum pager < "$filepath" } +# ── Password input ──────────────────────────────────────────── ui_password() { local prompt="$1" - local size; size=$(ui_calc_size) - local h w - read -r h w <<< "$size" - - local result - result=$(whiptail --title "$WHIPTAIL_TITLE" --backtitle "$(_ui_backtitle)" \ - --passwordbox "$prompt" "$h" "$w" 3>&1 1>&2 2>&3) || return 1 - echo "$result" + gum input \ + --password \ + --header "$prompt" \ + --header.foreground "$_GUM_ACCENT" \ + --width 60 || return 1 } diff --git a/scripts/install.sh b/scripts/install.sh index 05ff534..2919eed 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -68,7 +68,7 @@ for cmd in bash rsync; do fi done -for cmd in ssh whiptail curl; do +for cmd in ssh gum curl; do if ! command -v "$cmd" &>/dev/null; then warn "Optional dependency not found: $cmd" fi @@ -140,5 +140,5 @@ echo " Logs: $LOG_DIR" echo "" echo "Get started:" echo " gniza --help Show CLI help" -echo " gniza Launch TUI (requires whiptail)" +echo " gniza Launch TUI (requires gum)" echo ""