aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDon Williams <don.e.williams@gmail.com>2026-01-15 01:46:51 -0500
committerDon Williams <don.e.williams@gmail.com>2026-01-15 01:46:51 -0500
commit1451c8f90cab6a28216872f017083a77dad54be1 (patch)
treefdd39c1a9faaba3ae6d342dab756971af9390041
parent39372f3c096ab95cebd2acffec81c55fb6b46851 (diff)
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
-rwxr-xr-xscripts/copy_menu.sh19
-rwxr-xr-xscripts/tui_menu.py328
2 files changed, 344 insertions, 3 deletions
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())
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage