From 46e342ceb3a2757554685853597e12a9e70427e0 Mon Sep 17 00:00:00 2001 From: Donald Williams <129223418+dwilliam62@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:47:42 -0500 Subject: Copy menu - 1st step in menu driven dot files install / upgrade process (#917) * Phase 1 of creating TUI for copy.sh for installs and upgrades On branch copy-menu Changes to be committed: modified: copy.sh new file: scripts/copy_menu.sh * Fixing formatting in copy-menu On branch copy-menu Your branch is up to date with 'origin/copy-menu'. Changes to be committed: modified: scripts/copy_menu.sh * More formatting and color first letter as selectable option On branch copy-menu Your branch is up to date with 'origin/copy-menu'. Changes to be committed: modified: copy.sh modified: scripts/copy_menu.sh * Fixing syntax error * Some whiptail versions don't support color Added check before displaying colors On branch copy-menu Your branch is up to date with 'origin/copy-menu'. Changes to be committed: modified: scripts/copy_menu.sh * fixing formatting On branch copy-menu Your branch is up to date with 'origin/copy-menu'. Changes to be committed: modified: scripts/copy_menu.sh * More formatting for whiptail * Formatting whiptail is so much fun * Changed descrption to two lines * Whiltail 4, human 0 * Whiltail 5, human 0 - shortened text * Whiltail 6, human 1 - remove dup items * Whiltail 6, human 2 - removed color highlight --- scripts/copy_menu.sh | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100755 scripts/copy_menu.sh (limited to 'scripts/copy_menu.sh') diff --git a/scripts/copy_menu.sh b/scripts/copy_menu.sh new file mode 100755 index 00000000..18482b18 --- /dev/null +++ b/scripts/copy_menu.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +# show_copy_menu +# Arguments: +# $1 - express_supported flag (1 if available, 0 otherwise) +# Sets global COPY_MENU_CHOICE to one of: install, upgrade, express, quit +show_copy_menu() { + local express_supported="${1:-0}" + local menu_title=" KooL's Hyprland Dotfiles " + local prompt="Select what you would like to do:" + + local install_tag="Install" + local upgrade_tag="Upgrade" + local express_tag="Express" + local quit_tag="Quit" + + local install_desc="Fresh copy" + local upgrade_desc="Backups + prompts" + local express_desc="Skips restores & wallpapers" + local quit_desc="Exit without changes" + if [ "$express_supported" -ne 1 ]; then + express_body="xpress - Requires dots >= ${MIN_EXPRESS_VERSION}" + fi + + local choice="" + + if command -v whiptail >/dev/null 2>&1; then + if ! choice=$(whiptail --title "$menu_title" --menu "$prompt" 17 60 8 \ + "$install_tag" "$install_desc" \ + "$upgrade_tag" "$upgrade_desc" \ + "$express_tag" "$express_desc" \ + "$quit_tag" "$quit_desc" 3>&1 1>&2 2>&3); then + COPY_MENU_CHOICE="quit" + return 1 + fi + else + while true; do + printf "\n%s\n" "$menu_title" + printf "%s\n" "$prompt" + printf " 1) Install - %s\n" "$install_desc" + printf " 2) Upgrade - %s\n" "$upgrade_desc" + printf " 3) Express - %s\n" "$express_desc" + printf " 4) Quit - %s\n" "$quit_desc" + printf "Enter choice [1-4]: " + read -r text_choice + case "$text_choice" in + 1) choice="$install_tag"; break ;; + 2) choice="$upgrade_tag"; break ;; + 3) choice="$express_tag"; break ;; + 4) choice="$quit_tag"; break ;; + *) echo "Invalid selection. Please choose 1-4." ;; + esac + done + fi + + # shellcheck disable=SC2034 # used by parent script after sourcing this file + COPY_MENU_CHOICE="$choice" +} -- cgit v1.2.3 From a988f706e0080cde8aad3966948ac68ea0075da7 Mon Sep 17 00:00:00 2001 From: Don Williams Date: Mon, 12 Jan 2026 10:12:20 -0500 Subject: Added update dotfiles option to men On branch development Your branch is up to date with 'origin/development'. Changes to be committed: modified: copy.sh modified: scripts/copy_menu.sh new file: scripts/lib_update.sh --- copy.sh | 14 +++++++++ scripts/copy_menu.sh | 13 +++++--- scripts/lib_update.sh | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 scripts/lib_update.sh (limited to 'scripts/copy_menu.sh') diff --git a/copy.sh b/copy.sh index 8df2532d..def8b209 100755 --- a/copy.sh +++ b/copy.sh @@ -6,6 +6,7 @@ # # Layout (high-level; future modularization targets): # - Constants/colors, helper sourcing (copy_menu.sh, lib_backup.sh, lib_detect.sh, lib_prompts.sh, lib_apps.sh, lib_copy.sh). +# - New update helper (lib_update.sh) provides menu-driven repo update: verifies Hyprland-Dots root, stashes changes, git pull, logs, summarizes, waits for keypress. # - Version helpers and CLI parsing (install/upgrade/express). # - Safety checks (non-root), banners/notices. # - Environment/distro checks and warnings. @@ -57,6 +58,7 @@ DETECT_HELPER="$SCRIPT_DIR/scripts/lib_detect.sh" PROMPTS_HELPER="$SCRIPT_DIR/scripts/lib_prompts.sh" APPS_HELPER="$SCRIPT_DIR/scripts/lib_apps.sh" COPY_HELPER="$SCRIPT_DIR/scripts/lib_copy.sh" +UPDATE_HELPER="$SCRIPT_DIR/scripts/lib_update.sh" if [ -f "$MENU_HELPER" ]; then # shellcheck source=./scripts/copy_menu.sh . "$MENU_HELPER" @@ -96,6 +98,13 @@ else echo "${ERROR} Copy helper not found at $COPY_HELPER. Exiting." exit 1 fi +if [ -f "$UPDATE_HELPER" ]; then + # shellcheck source=./scripts/lib_update.sh + . "$UPDATE_HELPER" +else + echo "${ERROR} Update helper not found at $UPDATE_HELPER. Exiting." + exit 1 +fi version_gte() { [ "$1" = "$(echo -e "$1\n$2" | sort -V | tail -n1)" ] @@ -193,6 +202,11 @@ if [ -z "$RUN_MODE" ]; then UPGRADE_MODE=1 EXPRESS_MODE=1 ;; + update) + run_repo_update "$SCRIPT_DIR" + # After update, continue showing the menu without exiting + continue + ;; quit) echo "${NOTE} Exiting per user selection." exit 0 diff --git a/scripts/copy_menu.sh b/scripts/copy_menu.sh index 18482b18..78fb4070 100755 --- a/scripts/copy_menu.sh +++ b/scripts/copy_menu.sh @@ -12,11 +12,13 @@ show_copy_menu() { local install_tag="Install" local upgrade_tag="Upgrade" local express_tag="Express" + local update_tag="Update" local quit_tag="Quit" local install_desc="Fresh copy" local upgrade_desc="Backups + prompts" local express_desc="Skips restores & wallpapers" + local update_desc="Stash + git pull" local quit_desc="Exit without changes" if [ "$express_supported" -ne 1 ]; then express_body="xpress - Requires dots >= ${MIN_EXPRESS_VERSION}" @@ -29,6 +31,7 @@ show_copy_menu() { "$install_tag" "$install_desc" \ "$upgrade_tag" "$upgrade_desc" \ "$express_tag" "$express_desc" \ + "$update_tag" "$update_desc" \ "$quit_tag" "$quit_desc" 3>&1 1>&2 2>&3); then COPY_MENU_CHOICE="quit" return 1 @@ -40,15 +43,17 @@ show_copy_menu() { printf " 1) Install - %s\n" "$install_desc" printf " 2) Upgrade - %s\n" "$upgrade_desc" printf " 3) Express - %s\n" "$express_desc" - printf " 4) Quit - %s\n" "$quit_desc" - printf "Enter choice [1-4]: " + printf " 4) Update - %s\n" "$update_desc" + printf " 5) Quit - %s\n" "$quit_desc" + printf "Enter choice [1-5]: " read -r text_choice case "$text_choice" in 1) choice="$install_tag"; break ;; 2) choice="$upgrade_tag"; break ;; 3) choice="$express_tag"; break ;; - 4) choice="$quit_tag"; break ;; - *) echo "Invalid selection. Please choose 1-4." ;; + 4) choice="$update_tag"; break ;; + 5) choice="$quit_tag"; break ;; + *) echo "Invalid selection. Please choose 1-5." ;; esac done fi diff --git a/scripts/lib_update.sh b/scripts/lib_update.sh new file mode 100644 index 00000000..0a70dff0 --- /dev/null +++ b/scripts/lib_update.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +# run_repo_update +# Arguments: +# $1 - expected repository root (typically SCRIPT_DIR from copy.sh) +# Behavior: +# * Verifies the script is executed from Hyprland-Dots root. +# * Stashes local changes (including untracked), pulls latest changes. +# * Shows progress, reports errors, and summarizes results. +# * Waits for user input before returning control to caller. +run_repo_update() { + local repo_dir="${1:-$(pwd)}" + local expected_name="Hyprland-Dots" + local log_dir="$repo_dir/Copy-Logs" + local log_file="$log_dir/update-$(date +%d-%H%M%S)_git.log" + + mkdir -p "$log_dir" + + echo "${INFO} Starting repository update..." | tee -a "$log_file" + + if [ ! -d "$repo_dir" ] || [ "$(basename "$repo_dir")" != "$expected_name" ]; then + echo "${ERROR} This helper must be run from the $expected_name directory. Current: $(pwd)" | tee -a "$log_file" + read -n1 -s -r -p "Press any key to return to the menu..." + echo + return 1 + fi + + if [ "$PWD" != "$repo_dir" ]; then + echo "${INFO} Changing directory to $repo_dir" | tee -a "$log_file" + cd "$repo_dir" || { + echo "${ERROR} Failed to change directory to $repo_dir" | tee -a "$log_file" + read -n1 -s -r -p "Press any key to return to the menu..." + echo + return 1 + } + fi + + local head_before stash_msg pull_status=0 + head_before=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + + echo "${INFO} Checking working tree..." | tee -a "$log_file" + if git diff --quiet && git diff --cached --quiet; then + stash_msg="No local changes; no stash created." + echo "${NOTE} $stash_msg" | tee -a "$log_file" + else + echo "${INFO} Stashing local changes (tracked + untracked)..." | tee -a "$log_file" + if stash_output=$(git stash push -u 2>&1); then + stash_msg="Created stash: $(echo "$stash_output" | head -n1)" + echo "${OK} $stash_msg" | tee -a "$log_file" + else + echo "${ERROR} git stash failed. Details:" | tee -a "$log_file" + echo "$stash_output" | tee -a "$log_file" + read -n1 -s -r -p "Press any key to return to the menu..." + echo + return 1 + fi + fi + + echo "${INFO} Pulling latest changes..." | tee -a "$log_file" + if git pull --ff-only 2>&1 | tee -a "$log_file"; then + pull_status=0 + echo "${OK} Repository updated successfully." | tee -a "$log_file" + else + pull_status=$? + echo "${ERROR} git pull failed (exit $pull_status)." | tee -a "$log_file" + fi + + local head_after + head_after=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + + echo "----------------------------------------" | tee -a "$log_file" + echo "Summary:" | tee -a "$log_file" + echo " Repo : $repo_dir" | tee -a "$log_file" + echo " HEAD before : $head_before" | tee -a "$log_file" + echo " HEAD after : $head_after" | tee -a "$log_file" + echo " Stash : $stash_msg" | tee -a "$log_file" + echo " Pull status : $( [ $pull_status -eq 0 ] && echo success || echo failure )" | tee -a "$log_file" + echo "----------------------------------------" | tee -a "$log_file" + + read -n1 -s -r -p "Press any key to return to the main menu..." + echo + + return $pull_status +} -- cgit v1.2.3 From 1451c8f90cab6a28216872f017083a77dad54be1 Mon Sep 17 00:00:00 2001 From: Don Williams Date: Thu, 15 Jan 2026 01:46:51 -0500 Subject: Adding python based tui to replace whiptail On branch development Your branch is up to date with 'origin/development'. Changes to be committed: modified: scripts/copy_menu.sh new file: scripts/tui_menu.py --- scripts/copy_menu.sh | 19 ++- scripts/tui_menu.py | 328 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 3 deletions(-) create mode 100755 scripts/tui_menu.py (limited to 'scripts/copy_menu.sh') diff --git a/scripts/copy_menu.sh b/scripts/copy_menu.sh index 78fb4070..212cab84 100755 --- a/scripts/copy_menu.sh +++ b/scripts/copy_menu.sh @@ -20,12 +20,24 @@ show_copy_menu() { local express_desc="Skips restores & wallpapers" local update_desc="Stash + git pull" local quit_desc="Exit without changes" - if [ "$express_supported" -ne 1 ]; then - express_body="xpress - Requires dots >= ${MIN_EXPRESS_VERSION}" - fi local choice="" + # Prefer Python TUI if available (mouse + keyboard, styled hotkeys, help) + # Determine repo dir robustly: prefer SCRIPT_DIR if set by caller (copy.sh); fallback to this file's parent + local __self_dir __repo_dir + __self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + __repo_dir="${SCRIPT_DIR:-$(cd "${__self_dir}/.." 2>/dev/null && pwd)}" + local py_menu="${__repo_dir}/scripts/tui_menu.py" + if command -v python3 >/dev/null 2>&1 && [ -f "$py_menu" ]; then + if choice=$(python3 "$py_menu" --express-supported "$express_supported"); then + # shellcheck disable=SC2034 # used by parent script after sourcing this file + COPY_MENU_CHOICE="$choice" + return 0 + fi + fi + + # Fallback to whiptail if present if command -v whiptail >/dev/null 2>&1; then if ! choice=$(whiptail --title "$menu_title" --menu "$prompt" 17 60 8 \ "$install_tag" "$install_desc" \ @@ -37,6 +49,7 @@ show_copy_menu() { return 1 fi else + # Plain-text fallback while true; do printf "\n%s\n" "$menu_title" printf "%s\n" "$prompt" diff --git a/scripts/tui_menu.py b/scripts/tui_menu.py new file mode 100755 index 00000000..abf35751 --- /dev/null +++ b/scripts/tui_menu.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +# Simple TUI menu for Hyprland-Dots copy workflow +# - Prefers Textual (rich) for a nicer UI with mouse + keyboard +# - Falls back to curses if Textual is unavailable +# - Prints the chosen action (Install|Upgrade|Express|Update|Quit) to stdout + +from __future__ import annotations + +import argparse +import os +import sys + +CHOICES = [ + ("Install", "Fresh copy of the dotfiles into ~/.config"), + ("Upgrade", "Backups + interactive prompts"), + ("Express", "Skips restore prompts and large wallpaper download"), + ("Update", "Update this repo: stash local changes, git pull"), + ("Help", "Explain the options shown here"), + ("Quit", "Exit without making changes"), +] + +HELP_TEXT = ( + "Install: Perform a fresh copy of configs into ~/.config.\n" + "Upgrade: Back up existing configs and prompt to restore what you want.\n" + "Express: Faster upgrade (requires installed dots >= the minimum version).\n" + "Update: Safely update this Git repo (stash local changes, then git pull).\n" + "Quit: Exit without making changes.\n\n" + "Tips:\n" + "- Use Up/Down or mouse to select, Enter to confirm.\n" + "- Press the highlighted first letter (I/U/E/U/Q/H) as a shortcut.\n" + "- Press h or ? at any time to view this help.\n" +) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--express-supported", default="0", choices=["0", "1"], help="Whether Express is allowed (1) or not (0)") + args = parser.parse_args() + express_supported = args.express_supported == "1" + + # Try Textual first + try: + return run_textual(express_supported) + except Exception: # noqa: BLE001 - silently fall back + pass + + # Then try curses + try: + return run_curses(express_supported) + except Exception: + # Final fallback to a very simple stdin prompt to avoid breaking workflows + return run_basic(express_supported) + + +def stylize_first_letter(label: str) -> tuple[str, str]: + # returns (styled_label_for_ui, hotkey) + hotkey = label[0].upper() + rest = label[1:] + # Textual Rich markup: bold + accent color for the first letter + styled = f"[b][cyan]{hotkey}[/cyan][/b]{rest}" + return styled, hotkey + + +def run_textual(express_supported: bool) -> int: + from textual.app import App, ComposeResult + from textual.widgets import Header, Footer, Static, Button + from textual.containers import Vertical + from textual.reactive import reactive + from textual import events + + class MenuButton(Button): + def __init__(self, label: str, choice_key: str, disabled: bool = False) -> None: + super().__init__(label, disabled=disabled) + self.choice_key = choice_key + + class MenuApp(App): + CSS = """ + Screen { background: black; } + #title { content-align: center middle; height: 3; color: white; } + Vertical { width: 80; max-width: 90; margin: 1 auto; } + Button { margin: 1 0; padding: 1 2; border: round cornflowerblue; } + Button:hover { background: rgba(100,100,255,0.1); } + Button.-disabled { color: grey50; border: round grey35; } + #help { padding: 1 2; border: round grey42; height: auto; } + """ + + selected: reactive[str | None] = reactive(None) + + def compose(self) -> ComposeResult: # type: ignore[override] + yield Header(show_clock=False) + yield Static("KooL's Hyprland Dotfiles", id="title") + with Vertical(): + # Build buttons + for (name, _desc) in CHOICES: + if name == "Express" and not express_supported: + styled, _hk = stylize_first_letter(name) + yield MenuButton(f"{styled} [grey62](requires newer installed dots)\n[/grey62]", name, disabled=True) + else: + styled, _hk = stylize_first_letter(name) + yield MenuButton(styled, name) + yield Static("Press h or ? for help", id="help") + yield Footer() + + def on_button_pressed(self, event: Button.Pressed) -> None: # type: ignore[override] + btn = event.button + if isinstance(btn, MenuButton): + if btn.choice_key == "Help": + self.show_help() + return + if btn.choice_key == "Express" and not express_supported: + return + self.selected = btn.choice_key + self.exit_app() + + def action_quit(self) -> None: # Esc + self.exit_app("Quit") + + BINDINGS = [ + ("escape", "quit", "Quit"), + ("i", "select('Install')", "Install"), + ("u", "select('Upgrade')", "Upgrade"), + ("e", "select('Express')", "Express"), + ("d", "select('Update')", "Update"), + ("q", "select('Quit')", "Quit"), + ("h", "help", "Help"), + ("?", "help", "Help"), + ("enter", "activate", "Select"), + ] + + def action_select(self, name: str) -> None: + if name == "Express" and not express_supported: + return + if name == "Help": + self.show_help() + return + self.selected = name + self.exit_app() + + def action_help(self) -> None: + self.show_help() + + def action_activate(self) -> None: + # Activate focused button + focused = self.focused + if isinstance(focused, MenuButton): + self.on_button_pressed(Button.Pressed(focused)) + + def show_help(self) -> None: + self.push_screen(HelpScreen()) + + def exit_app(self, fallback: str | None = None) -> None: + result = self.selected or fallback + if result: + print(result) + self.exit(0) + + from textual.screen import ModalScreen + + class HelpScreen(ModalScreen[None]): + def compose(self) -> ComposeResult: # type: ignore[override] + yield Static("[b]Help[/b]\n\n" + HELP_TEXT, id="help") + + BINDINGS = [("escape", "dismiss", "Close"), ("q", "dismiss", "Close")] + + def action_dismiss(self) -> None: + self.dismiss(None) + + app = MenuApp() + app.run() + return 0 + + +def run_curses(express_supported: bool) -> int: + import curses + + labels = [name for (name, _d) in CHOICES] + + def draw_menu(stdscr, idx: int, show_help: bool) -> None: + stdscr.clear() + h, w = stdscr.getmaxyx() + title = "KooL's Hyprland Dotfiles" + stdscr.attron(curses.A_BOLD) + stdscr.addstr(1, (w - len(title)) // 2, title) + stdscr.attroff(curses.A_BOLD) + + y = 4 + for i, name in enumerate(labels): + disabled = (name == "Express" and not express_supported) + hk = name[0].upper() + rest = name[1:] + if i == idx: + stdscr.attron(curses.A_REVERSE) + if disabled: + color = curses.color_pair(2) + else: + color = curses.color_pair(1) + # First letter styled + stdscr.attron(color | curses.A_BOLD) + stdscr.addstr(y, 4, hk) + stdscr.attroff(color | curses.A_BOLD) + stdscr.addstr(y, 5, rest) + if disabled: + msg = " (requires newer installed dots)" + stdscr.attron(curses.color_pair(2)) + stdscr.addstr(y, 5 + len(rest), msg) + stdscr.attroff(curses.color_pair(2)) + if i == idx: + stdscr.attroff(curses.A_REVERSE) + y += 2 + + info = "Enter=Select ↑/↓=Move Mouse=Click h/?=Help q=Quit" + stdscr.addstr(h - 2, 2, info) + + if show_help: + box_w = min(w - 6, 76) + box_h = min(12, h - 6) + bx = (w - box_w) // 2 + by = (h - box_h) // 2 + # simple box + for yy in range(by, by + box_h): + stdscr.addstr(yy, bx, " " * box_w, curses.color_pair(3)) + stdscr.attron(curses.A_BOLD) + stdscr.addstr(by, bx + 2, "Help") + stdscr.attroff(curses.A_BOLD) + for i, line in enumerate(HELP_TEXT.splitlines()[: box_h - 3]): + stdscr.addstr(by + 2 + i, bx + 2, line) + stdscr.addstr(by + box_h - 2, bx + 2, "Press q or Esc to close help") + + stdscr.refresh() + + def loop(stdscr) -> str: + curses.curs_set(0) + curses.mousemask(1) + curses.start_color() + curses.init_pair(1, curses.COLOR_CYAN, -1) + curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_BLACK) + curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE) + idx = 0 + showing_help = False + + while True: + draw_menu(stdscr, idx, showing_help) + ch = stdscr.getch() + if showing_help: + if ch in (ord('q'), ord('Q'), 27): + showing_help = False + continue + + if ch in (curses.KEY_UP, ord('k')): + idx = (idx - 1) % len(labels) + elif ch in (curses.KEY_DOWN, ord('j')): + idx = (idx + 1) % len(labels) + elif ch in (ord('h'), ord('?')): + showing_help = True + elif ch in (ord('q'), 27): + return "Quit" + elif ch == curses.KEY_MOUSE: + try: + _, mx, my, _, _ = curses.getmouse() + # map click row to item + base_y = 4 + if my >= base_y: + clicked = (my - base_y) // 2 + if 0 <= clicked < len(labels): + name = labels[clicked] + if name == "Help": + showing_help = True + elif name == "Express" and not express_supported: + pass + else: + return name + except Exception: + pass + elif ch in (curses.KEY_ENTER, 10, 13): + name = labels[idx] + if name == "Help": + showing_help = True + elif name == "Express" and not express_supported: + pass + else: + return name + else: + # hotkeys by first letter + key = chr(ch).lower() if 0 <= ch < 256 else "" + mapping = {"i": "Install", "u": "Upgrade", "e": "Express", "d": "Update", "q": "Quit", "h": "Help"} + if key in mapping: + name = mapping[key] + if name == "Help": + showing_help = True + elif name == "Express" and not express_supported: + pass + else: + return name + + choice = curses.wrapper(loop) + print(choice) + return 0 + + +def run_basic(express_supported: bool) -> int: + # Minimal stdin-only fallback + options = [n for (n, _d) in CHOICES] + while True: + print("Select:") + for i, name in enumerate(options, 1): + if name == "Express" and not express_supported: + print(f" {i}) {name} (disabled)") + else: + print(f" {i}) {name}") + try: + sel = input("> ").strip() + except EOFError: + print("Quit") + return 0 + if sel.isdigit(): + idx = int(sel) - 1 + if 0 <= idx < len(options): + choice = options[idx] + if choice == "Express" and not express_supported: + continue + if choice == "Help": + print(HELP_TEXT) + continue + print(choice) + return 0 + +if __name__ == "__main__": + sys.exit(main()) -- cgit v1.2.3 From 0886304d0fe31f88343391f5405465f90d2ac8fe Mon Sep 17 00:00:00 2001 From: Don Williams Date: Thu, 15 Jan 2026 01:56:42 -0500 Subject: Fixing menu and version detection code for express upgrade On branch development Your branch is up to date with 'origin/development'. Changes to be committed: modified: copy.sh modified: scripts/copy_menu.sh modified: scripts/tui_menu.py --- copy.sh | 10 ++++---- scripts/copy_menu.sh | 17 +++++++++---- scripts/tui_menu.py | 70 ++++++++++++++++++++++++++++++---------------------- 3 files changed, 58 insertions(+), 39 deletions(-) (limited to 'scripts/copy_menu.sh') diff --git a/copy.sh b/copy.sh index def8b209..31c66a0b 100755 --- a/copy.sh +++ b/copy.sh @@ -112,12 +112,12 @@ version_gte() { get_installed_dotfiles_version() { local hypr_dir="$HOME/.config/hypr" - local version_file if [ -d "$hypr_dir" ]; then - version_file=$(find "$hypr_dir" -maxdepth 1 -name "v*.*.*" | head -n 1) - if [ -n "$version_file" ]; then - basename "$version_file" | sed 's/^v//' - fi + # Pick the highest semantic version among files named vX.Y.Z + find "$hypr_dir" -maxdepth 1 -type f -name 'v*.*.*' -printf '%f\n' 2>/dev/null \ + | sed 's/^v//' \ + | sort -V \ + | tail -n1 fi } diff --git a/scripts/copy_menu.sh b/scripts/copy_menu.sh index 212cab84..258c2fae 100755 --- a/scripts/copy_menu.sh +++ b/scripts/copy_menu.sh @@ -29,11 +29,18 @@ show_copy_menu() { __self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" __repo_dir="${SCRIPT_DIR:-$(cd "${__self_dir}/.." 2>/dev/null && pwd)}" local py_menu="${__repo_dir}/scripts/tui_menu.py" - if command -v python3 >/dev/null 2>&1 && [ -f "$py_menu" ]; then - if choice=$(python3 "$py_menu" --express-supported "$express_supported"); then - # shellcheck disable=SC2034 # used by parent script after sourcing this file - COPY_MENU_CHOICE="$choice" - return 0 +if command -v python3 >/dev/null 2>&1 && [ -f "$py_menu" ]; then + # Allow forcing backend via COPY_TUI_BACKEND=auto|textual|curses|basic + if [ -n "$COPY_TUI_BACKEND" ]; then + if choice=$(python3 "$py_menu" --express-supported "$express_supported" --backend "$COPY_TUI_BACKEND"); then + COPY_MENU_CHOICE="$choice" + return 0 + fi + else + if choice=$(python3 "$py_menu" --express-supported "$express_supported"); then + COPY_MENU_CHOICE="$choice" + return 0 + fi fi fi diff --git a/scripts/tui_menu.py b/scripts/tui_menu.py index abf35751..d57ba1ac 100755 --- a/scripts/tui_menu.py +++ b/scripts/tui_menu.py @@ -35,20 +35,26 @@ HELP_TEXT = ( def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--express-supported", default="0", choices=["0", "1"], help="Whether Express is allowed (1) or not (0)") + parser.add_argument("--backend", default=os.environ.get("COPY_TUI_BACKEND", "auto"), choices=["auto", "textual", "curses", "basic"], help="Choose UI backend") args = parser.parse_args() express_supported = args.express_supported == "1" + backend = args.backend - # Try Textual first + if backend == "textual": + return run_textual(express_supported) + if backend == "curses": + return run_curses(express_supported) + if backend == "basic": + return run_basic(express_supported) + + # auto: Try Textual, then curses, then basic try: return run_textual(express_supported) - except Exception: # noqa: BLE001 - silently fall back + except Exception: pass - - # Then try curses try: return run_curses(express_supported) except Exception: - # Final fallback to a very simple stdin prompt to avoid breaking workflows return run_basic(express_supported) @@ -180,7 +186,7 @@ def run_curses(express_supported: bool) -> int: h, w = stdscr.getmaxyx() title = "KooL's Hyprland Dotfiles" stdscr.attron(curses.A_BOLD) - stdscr.addstr(1, (w - len(title)) // 2, title) + stdscr.addstr(1, max(2, (w - len(title)) // 2), title) stdscr.attroff(curses.A_BOLD) y = 4 @@ -190,40 +196,36 @@ def run_curses(express_supported: bool) -> int: rest = name[1:] if i == idx: stdscr.attron(curses.A_REVERSE) - if disabled: - color = curses.color_pair(2) - else: - color = curses.color_pair(1) # First letter styled - stdscr.attron(color | curses.A_BOLD) + stdscr.attron(curses.color_pair(1) | curses.A_BOLD) stdscr.addstr(y, 4, hk) - stdscr.attroff(color | curses.A_BOLD) + stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) + if disabled: + stdscr.attron(curses.A_DIM) stdscr.addstr(y, 5, rest) if disabled: msg = " (requires newer installed dots)" - stdscr.attron(curses.color_pair(2)) - stdscr.addstr(y, 5 + len(rest), msg) - stdscr.attroff(curses.color_pair(2)) + stdscr.addstr(y, 5 + len(rest), msg, curses.A_DIM) + stdscr.attroff(curses.A_DIM) if i == idx: stdscr.attroff(curses.A_REVERSE) y += 2 - info = "Enter=Select ↑/↓=Move Mouse=Click h/?=Help q=Quit" - stdscr.addstr(h - 2, 2, info) + info = "Enter=Select Up/Down=Move Mouse=Click h/?=Help q=Quit" + stdscr.addstr(h - 2, 2, info[: max(0, w - 4)]) if show_help: box_w = min(w - 6, 76) box_h = min(12, h - 6) - bx = (w - box_w) // 2 - by = (h - box_h) // 2 - # simple box + bx = max(2, (w - box_w) // 2) + by = max(2, (h - box_h) // 2) for yy in range(by, by + box_h): - stdscr.addstr(yy, bx, " " * box_w, curses.color_pair(3)) + stdscr.addstr(yy, bx, " " * max(0, box_w), curses.color_pair(3)) stdscr.attron(curses.A_BOLD) stdscr.addstr(by, bx + 2, "Help") stdscr.attroff(curses.A_BOLD) - for i, line in enumerate(HELP_TEXT.splitlines()[: box_h - 3]): - stdscr.addstr(by + 2 + i, bx + 2, line) + for i, line in enumerate(HELP_TEXT.splitlines()[: max(0, box_h - 3)]): + stdscr.addstr(by + 2 + i, bx + 2, line[: max(0, box_w - 4)]) stdscr.addstr(by + box_h - 2, bx + 2, "Press q or Esc to close help") stdscr.refresh() @@ -232,9 +234,18 @@ def run_curses(express_supported: bool) -> int: curses.curs_set(0) curses.mousemask(1) curses.start_color() - curses.init_pair(1, curses.COLOR_CYAN, -1) - curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_BLACK) - curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE) + try: + curses.use_default_colors() + except Exception: + pass + try: + curses.init_pair(1, curses.COLOR_CYAN, -1) + except Exception: + curses.init_pair(1, curses.COLOR_CYAN, 0) + try: + curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE) + except Exception: + curses.init_pair(3, curses.COLOR_WHITE, 0) idx = 0 showing_help = False @@ -257,7 +268,6 @@ def run_curses(express_supported: bool) -> int: elif ch == curses.KEY_MOUSE: try: _, mx, my, _, _ = curses.getmouse() - # map click row to item base_y = 4 if my >= base_y: clicked = (my - base_y) // 2 @@ -280,8 +290,10 @@ def run_curses(express_supported: bool) -> int: else: return name else: - # hotkeys by first letter - key = chr(ch).lower() if 0 <= ch < 256 else "" + try: + key = chr(ch).lower() if 0 <= ch < 256 else "" + except Exception: + key = "" mapping = {"i": "Install", "u": "Upgrade", "e": "Express", "d": "Update", "q": "Quit", "h": "Help"} if key in mapping: name = mapping[key] -- cgit v1.2.3 From 122f607047b16488c95ea25e842084d88b394e1d Mon Sep 17 00:00:00 2001 From: Don Williams Date: Thu, 15 Jan 2026 02:25:36 -0500 Subject: Removed python script didn't work On branch development Your branch is up to date with 'origin/development'. Changes to be committed: deleted: copy-menu-issue.txt modified: scripts/copy_menu.sh deleted: scripts/tui_menu.py --- copy-menu-issue.txt | 52 -------- scripts/copy_menu.sh | 73 ++++++----- scripts/tui_menu.py | 340 --------------------------------------------------- 3 files changed, 36 insertions(+), 429 deletions(-) delete mode 100644 copy-menu-issue.txt delete mode 100755 scripts/tui_menu.py (limited to 'scripts/copy_menu.sh') diff --git a/copy-menu-issue.txt b/copy-menu-issue.txt deleted file mode 100644 index 94672040..00000000 --- a/copy-menu-issue.txt +++ /dev/null @@ -1,52 +0,0 @@ -Summary: copy.sh menu issues on Debian host - -Symptoms -- Running copy.sh shows a black screen with a cursor; no visible menu. -- Running scripts/tui_menu.py directly renders an ncurses menu, but Express appears disabled. -- The host has ~/.config/hypr/v2.3.18 present. - -Changes already made (in repo) -- Added Python TUI (scripts/tui_menu.py) with backends: Textual -> curses -> basic; mouse + keyboard; hotkeys; Help panel. -- Updated scripts/copy_menu.sh to prefer Python TUI and to accept COPY_TUI_BACKEND=auto|textual|curses|basic, passing --express-supported accordingly. -- Hardened curses drawing to avoid blank screens (use_default_colors, safer bounds). -- Fixed Express detection in copy.sh to pick the highest version marker (sort -V) under ~/.config/hypr (vX.Y.Z). - -Why Express shows disabled when running the TUI directly -- scripts/tui_menu.py defaults to --express-supported=0 unless told otherwise. When launched by copy.sh, it is passed the correct flag. Running it manually without the flag will show Express as disabled. - -Immediate workaround on the Debian host -1) Ensure you have the latest repo changes - git --no-pager status - git pull --ff-only - -2) Force the curses backend to avoid the black Textual screen - # one-shot - COPY_TUI_BACKEND=curses ./copy.sh - # or set permanently for the session - export COPY_TUI_BACKEND=curses - ./copy.sh - -3) If curses ever fails to render, use the basic fallback - COPY_TUI_BACKEND=basic ./copy.sh - -4) To test the TUI directly with Express allowed - python3 scripts/tui_menu.py --express-supported 1 --backend curses - -Verify Express detection on the host -- Confirm the installed version marker and what copy.sh will see: - find "$HOME/.config/hypr" -maxdepth 1 -type f -name 'v*.*.*' -printf '%f\n' | sed 's/^v//' | sort -V | tail -n1 -- Expected output: 2.3.18 (or newer). If empty, create the marker: - : > "$HOME/.config/hypr/v2.3.18" - -If black screen persists with copy.sh -- Dump debug logs for the menu selection phase: - set -x; COPY_TUI_BACKEND=curses bash -c 'source scripts/copy_menu.sh; show_copy_menu 1; echo "CHOICE=$COPY_MENU_CHOICE"'; set +x -- Check TERM and Python TUI dependency situations: - echo "$TERM" - command -v python3 || true - python3 -c "import curses; print('curses OK')" || true - -Notes for future session -- copy_menu.sh already honors COPY_TUI_BACKEND and falls back to whiptail/text if Python fails. -- copy.sh now uses highest version marker to enable Express; the marker must be a file named exactly vX.Y.Z under ~/.config/hypr. -- If this host lacks Textual (rich) and shows black screen in Textual, forcing curses via COPY_TUI_BACKEND=curses is the recommended run mode on Debian for now. diff --git a/scripts/copy_menu.sh b/scripts/copy_menu.sh index 258c2fae..87f9301f 100755 --- a/scripts/copy_menu.sh +++ b/scripts/copy_menu.sh @@ -22,47 +22,17 @@ show_copy_menu() { local quit_desc="Exit without changes" local choice="" - - # Prefer Python TUI if available (mouse + keyboard, styled hotkeys, help) - # Determine repo dir robustly: prefer SCRIPT_DIR if set by caller (copy.sh); fallback to this file's parent - local __self_dir __repo_dir - __self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - __repo_dir="${SCRIPT_DIR:-$(cd "${__self_dir}/.." 2>/dev/null && pwd)}" - local py_menu="${__repo_dir}/scripts/tui_menu.py" -if command -v python3 >/dev/null 2>&1 && [ -f "$py_menu" ]; then - # Allow forcing backend via COPY_TUI_BACKEND=auto|textual|curses|basic - if [ -n "$COPY_TUI_BACKEND" ]; then - if choice=$(python3 "$py_menu" --express-supported "$express_supported" --backend "$COPY_TUI_BACKEND"); then - COPY_MENU_CHOICE="$choice" - return 0 - fi - else - if choice=$(python3 "$py_menu" --express-supported "$express_supported"); then - COPY_MENU_CHOICE="$choice" - return 0 - fi - fi - fi - - # Fallback to whiptail if present - if command -v whiptail >/dev/null 2>&1; then - if ! choice=$(whiptail --title "$menu_title" --menu "$prompt" 17 60 8 \ - "$install_tag" "$install_desc" \ - "$upgrade_tag" "$upgrade_desc" \ - "$express_tag" "$express_desc" \ - "$update_tag" "$update_desc" \ - "$quit_tag" "$quit_desc" 3>&1 1>&2 2>&3); then - COPY_MENU_CHOICE="quit" - return 1 - fi - else - # Plain-text fallback + run_basic_menu() { while true; do printf "\n%s\n" "$menu_title" printf "%s\n" "$prompt" printf " 1) Install - %s\n" "$install_desc" printf " 2) Upgrade - %s\n" "$upgrade_desc" - printf " 3) Express - %s\n" "$express_desc" + if [ "$express_supported" -eq 1 ]; then + printf " 3) Express - %s\n" "$express_desc" + else + printf " 3) Express - %s (disabled)\n" "$express_desc" + fi printf " 4) Update - %s\n" "$update_desc" printf " 5) Quit - %s\n" "$quit_desc" printf "Enter choice [1-5]: " @@ -70,12 +40,41 @@ if command -v python3 >/dev/null 2>&1 && [ -f "$py_menu" ]; then case "$text_choice" in 1) choice="$install_tag"; break ;; 2) choice="$upgrade_tag"; break ;; - 3) choice="$express_tag"; break ;; + 3) + if [ "$express_supported" -eq 1 ]; then + choice="$express_tag" + break + else + echo "Express is disabled on this system." + fi + ;; 4) choice="$update_tag"; break ;; 5) choice="$quit_tag"; break ;; *) echo "Invalid selection. Please choose 1-5." ;; esac done + } + + if [ "$COPY_TUI_BACKEND" = "basic" ]; then + run_basic_menu + COPY_MENU_CHOICE="$choice" + return 0 + fi + + # Fallback to whiptail if present + if command -v whiptail >/dev/null 2>&1; then + if ! choice=$(whiptail --title "$menu_title" --menu "$prompt" 17 60 8 \ + "$install_tag" "$install_desc" \ + "$upgrade_tag" "$upgrade_desc" \ + "$express_tag" "$express_desc" \ + "$update_tag" "$update_desc" \ + "$quit_tag" "$quit_desc" 3>&1 1>&2 2>&3); then + COPY_MENU_CHOICE="quit" + return 1 + fi + else + # Plain-text fallback + run_basic_menu fi # shellcheck disable=SC2034 # used by parent script after sourcing this file diff --git a/scripts/tui_menu.py b/scripts/tui_menu.py deleted file mode 100755 index d57ba1ac..00000000 --- a/scripts/tui_menu.py +++ /dev/null @@ -1,340 +0,0 @@ -#!/usr/bin/env python3 -# Simple TUI menu for Hyprland-Dots copy workflow -# - Prefers Textual (rich) for a nicer UI with mouse + keyboard -# - Falls back to curses if Textual is unavailable -# - Prints the chosen action (Install|Upgrade|Express|Update|Quit) to stdout - -from __future__ import annotations - -import argparse -import os -import sys - -CHOICES = [ - ("Install", "Fresh copy of the dotfiles into ~/.config"), - ("Upgrade", "Backups + interactive prompts"), - ("Express", "Skips restore prompts and large wallpaper download"), - ("Update", "Update this repo: stash local changes, git pull"), - ("Help", "Explain the options shown here"), - ("Quit", "Exit without making changes"), -] - -HELP_TEXT = ( - "Install: Perform a fresh copy of configs into ~/.config.\n" - "Upgrade: Back up existing configs and prompt to restore what you want.\n" - "Express: Faster upgrade (requires installed dots >= the minimum version).\n" - "Update: Safely update this Git repo (stash local changes, then git pull).\n" - "Quit: Exit without making changes.\n\n" - "Tips:\n" - "- Use Up/Down or mouse to select, Enter to confirm.\n" - "- Press the highlighted first letter (I/U/E/U/Q/H) as a shortcut.\n" - "- Press h or ? at any time to view this help.\n" -) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--express-supported", default="0", choices=["0", "1"], help="Whether Express is allowed (1) or not (0)") - parser.add_argument("--backend", default=os.environ.get("COPY_TUI_BACKEND", "auto"), choices=["auto", "textual", "curses", "basic"], help="Choose UI backend") - args = parser.parse_args() - express_supported = args.express_supported == "1" - backend = args.backend - - if backend == "textual": - return run_textual(express_supported) - if backend == "curses": - return run_curses(express_supported) - if backend == "basic": - return run_basic(express_supported) - - # auto: Try Textual, then curses, then basic - try: - return run_textual(express_supported) - except Exception: - pass - try: - return run_curses(express_supported) - except Exception: - return run_basic(express_supported) - - -def stylize_first_letter(label: str) -> tuple[str, str]: - # returns (styled_label_for_ui, hotkey) - hotkey = label[0].upper() - rest = label[1:] - # Textual Rich markup: bold + accent color for the first letter - styled = f"[b][cyan]{hotkey}[/cyan][/b]{rest}" - return styled, hotkey - - -def run_textual(express_supported: bool) -> int: - from textual.app import App, ComposeResult - from textual.widgets import Header, Footer, Static, Button - from textual.containers import Vertical - from textual.reactive import reactive - from textual import events - - class MenuButton(Button): - def __init__(self, label: str, choice_key: str, disabled: bool = False) -> None: - super().__init__(label, disabled=disabled) - self.choice_key = choice_key - - class MenuApp(App): - CSS = """ - Screen { background: black; } - #title { content-align: center middle; height: 3; color: white; } - Vertical { width: 80; max-width: 90; margin: 1 auto; } - Button { margin: 1 0; padding: 1 2; border: round cornflowerblue; } - Button:hover { background: rgba(100,100,255,0.1); } - Button.-disabled { color: grey50; border: round grey35; } - #help { padding: 1 2; border: round grey42; height: auto; } - """ - - selected: reactive[str | None] = reactive(None) - - def compose(self) -> ComposeResult: # type: ignore[override] - yield Header(show_clock=False) - yield Static("KooL's Hyprland Dotfiles", id="title") - with Vertical(): - # Build buttons - for (name, _desc) in CHOICES: - if name == "Express" and not express_supported: - styled, _hk = stylize_first_letter(name) - yield MenuButton(f"{styled} [grey62](requires newer installed dots)\n[/grey62]", name, disabled=True) - else: - styled, _hk = stylize_first_letter(name) - yield MenuButton(styled, name) - yield Static("Press h or ? for help", id="help") - yield Footer() - - def on_button_pressed(self, event: Button.Pressed) -> None: # type: ignore[override] - btn = event.button - if isinstance(btn, MenuButton): - if btn.choice_key == "Help": - self.show_help() - return - if btn.choice_key == "Express" and not express_supported: - return - self.selected = btn.choice_key - self.exit_app() - - def action_quit(self) -> None: # Esc - self.exit_app("Quit") - - BINDINGS = [ - ("escape", "quit", "Quit"), - ("i", "select('Install')", "Install"), - ("u", "select('Upgrade')", "Upgrade"), - ("e", "select('Express')", "Express"), - ("d", "select('Update')", "Update"), - ("q", "select('Quit')", "Quit"), - ("h", "help", "Help"), - ("?", "help", "Help"), - ("enter", "activate", "Select"), - ] - - def action_select(self, name: str) -> None: - if name == "Express" and not express_supported: - return - if name == "Help": - self.show_help() - return - self.selected = name - self.exit_app() - - def action_help(self) -> None: - self.show_help() - - def action_activate(self) -> None: - # Activate focused button - focused = self.focused - if isinstance(focused, MenuButton): - self.on_button_pressed(Button.Pressed(focused)) - - def show_help(self) -> None: - self.push_screen(HelpScreen()) - - def exit_app(self, fallback: str | None = None) -> None: - result = self.selected or fallback - if result: - print(result) - self.exit(0) - - from textual.screen import ModalScreen - - class HelpScreen(ModalScreen[None]): - def compose(self) -> ComposeResult: # type: ignore[override] - yield Static("[b]Help[/b]\n\n" + HELP_TEXT, id="help") - - BINDINGS = [("escape", "dismiss", "Close"), ("q", "dismiss", "Close")] - - def action_dismiss(self) -> None: - self.dismiss(None) - - app = MenuApp() - app.run() - return 0 - - -def run_curses(express_supported: bool) -> int: - import curses - - labels = [name for (name, _d) in CHOICES] - - def draw_menu(stdscr, idx: int, show_help: bool) -> None: - stdscr.clear() - h, w = stdscr.getmaxyx() - title = "KooL's Hyprland Dotfiles" - stdscr.attron(curses.A_BOLD) - stdscr.addstr(1, max(2, (w - len(title)) // 2), title) - stdscr.attroff(curses.A_BOLD) - - y = 4 - for i, name in enumerate(labels): - disabled = (name == "Express" and not express_supported) - hk = name[0].upper() - rest = name[1:] - if i == idx: - stdscr.attron(curses.A_REVERSE) - # First letter styled - stdscr.attron(curses.color_pair(1) | curses.A_BOLD) - stdscr.addstr(y, 4, hk) - stdscr.attroff(curses.color_pair(1) | curses.A_BOLD) - if disabled: - stdscr.attron(curses.A_DIM) - stdscr.addstr(y, 5, rest) - if disabled: - msg = " (requires newer installed dots)" - stdscr.addstr(y, 5 + len(rest), msg, curses.A_DIM) - stdscr.attroff(curses.A_DIM) - if i == idx: - stdscr.attroff(curses.A_REVERSE) - y += 2 - - info = "Enter=Select Up/Down=Move Mouse=Click h/?=Help q=Quit" - stdscr.addstr(h - 2, 2, info[: max(0, w - 4)]) - - if show_help: - box_w = min(w - 6, 76) - box_h = min(12, h - 6) - bx = max(2, (w - box_w) // 2) - by = max(2, (h - box_h) // 2) - for yy in range(by, by + box_h): - stdscr.addstr(yy, bx, " " * max(0, box_w), curses.color_pair(3)) - stdscr.attron(curses.A_BOLD) - stdscr.addstr(by, bx + 2, "Help") - stdscr.attroff(curses.A_BOLD) - for i, line in enumerate(HELP_TEXT.splitlines()[: max(0, box_h - 3)]): - stdscr.addstr(by + 2 + i, bx + 2, line[: max(0, box_w - 4)]) - stdscr.addstr(by + box_h - 2, bx + 2, "Press q or Esc to close help") - - stdscr.refresh() - - def loop(stdscr) -> str: - curses.curs_set(0) - curses.mousemask(1) - curses.start_color() - try: - curses.use_default_colors() - except Exception: - pass - try: - curses.init_pair(1, curses.COLOR_CYAN, -1) - except Exception: - curses.init_pair(1, curses.COLOR_CYAN, 0) - try: - curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE) - except Exception: - curses.init_pair(3, curses.COLOR_WHITE, 0) - idx = 0 - showing_help = False - - while True: - draw_menu(stdscr, idx, showing_help) - ch = stdscr.getch() - if showing_help: - if ch in (ord('q'), ord('Q'), 27): - showing_help = False - continue - - if ch in (curses.KEY_UP, ord('k')): - idx = (idx - 1) % len(labels) - elif ch in (curses.KEY_DOWN, ord('j')): - idx = (idx + 1) % len(labels) - elif ch in (ord('h'), ord('?')): - showing_help = True - elif ch in (ord('q'), 27): - return "Quit" - elif ch == curses.KEY_MOUSE: - try: - _, mx, my, _, _ = curses.getmouse() - base_y = 4 - if my >= base_y: - clicked = (my - base_y) // 2 - if 0 <= clicked < len(labels): - name = labels[clicked] - if name == "Help": - showing_help = True - elif name == "Express" and not express_supported: - pass - else: - return name - except Exception: - pass - elif ch in (curses.KEY_ENTER, 10, 13): - name = labels[idx] - if name == "Help": - showing_help = True - elif name == "Express" and not express_supported: - pass - else: - return name - else: - try: - key = chr(ch).lower() if 0 <= ch < 256 else "" - except Exception: - key = "" - mapping = {"i": "Install", "u": "Upgrade", "e": "Express", "d": "Update", "q": "Quit", "h": "Help"} - if key in mapping: - name = mapping[key] - if name == "Help": - showing_help = True - elif name == "Express" and not express_supported: - pass - else: - return name - - choice = curses.wrapper(loop) - print(choice) - return 0 - - -def run_basic(express_supported: bool) -> int: - # Minimal stdin-only fallback - options = [n for (n, _d) in CHOICES] - while True: - print("Select:") - for i, name in enumerate(options, 1): - if name == "Express" and not express_supported: - print(f" {i}) {name} (disabled)") - else: - print(f" {i}) {name}") - try: - sel = input("> ").strip() - except EOFError: - print("Quit") - return 0 - if sel.isdigit(): - idx = int(sel) - 1 - if 0 <= idx < len(options): - choice = options[idx] - if choice == "Express" and not express_supported: - continue - if choice == "Help": - print(HELP_TEXT) - continue - print(choice) - return 0 - -if __name__ == "__main__": - sys.exit(main()) -- cgit v1.2.3