From d0f3a7e77bb51f922d8be1e6b6711a0f33d84d3f Mon Sep 17 00:00:00 2001 From: scott Date: Wed, 4 Mar 2026 21:24:21 -0500 Subject: [PATCH] Add interactive checkbox share selector with arrow keys and spacebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the numbered-input share picker with a full TTY checkbox UI: ↑/↓ to move cursor, Space to toggle, A to select/deselect all, Enter to confirm. Falls back to the original numbered text input when stdin is not a TTY. Co-Authored-By: Claude Sonnet 4.6 --- truenas_migrate.py | 132 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 117 insertions(+), 15 deletions(-) diff --git a/truenas_migrate.py b/truenas_migrate.py index b678f48..9af9680 100644 --- a/truenas_migrate.py +++ b/truenas_migrate.py @@ -63,6 +63,13 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional +try: + import tty + import termios + _TTY_SUPPORTED = True +except ImportError: + _TTY_SUPPORTED = False # Windows + # ───────────────────────────────────────────────────────────────────────────── # Color helpers (ANSI; auto-disabled when stderr is not a TTY) # ───────────────────────────────────────────────────────────────────────────── @@ -1110,33 +1117,130 @@ def _confirm(label: str) -> bool: return False +def _checkbox_select(items: list[str], title: str) -> list[int]: + """ + Full-screen interactive checkbox selector. Returns the indices of selected + items. All items start selected. + + Keys: + ↑ / k move cursor up + ↓ / j move cursor down + Space toggle item under cursor + a toggle all (select all if any unselected, else deselect all) + Enter confirm + q / Esc confirm with current selection + + Falls back to simple numbered input if the terminal doesn't support raw mode. + """ + if not _TTY_SUPPORTED or not sys.stdin.isatty(): + return list(range(len(items))) # caller will use fallback + + selected = [True] * len(items) + cursor = 0 + n = len(items) + + def _render(): + # Move cursor up to redraw in-place after first render + lines = [ + "", + f" {_bold(title)}", + f" {_dim('↑/↓ move · Space toggle · a all/none · Enter confirm')}", + "", + ] + for i, label in enumerate(items): + arrow = _cyan("▶") if i == cursor else " " + box = _bold_green("✓") if selected[i] else _dim("·") + if i == cursor: + lines.append(f" {arrow} [{box}] {_bold(label)}") + else: + lines.append(f" {arrow} [{box}] {label}") + lines.append("") + return "\n".join(lines) + + def _read_key(fd: int) -> str: + ch = os.read(fd, 1) + if ch == b"\x1b": + rest = os.read(fd, 2) + return "\x1b" + rest.decode("latin-1", errors="replace") + return ch.decode("latin-1", errors="replace") + + # Print initial render + output = _render() + print(output, end="", flush=True) + line_count = output.count("\n") + + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + while True: + key = _read_key(fd) + + if key in ("\r", "\n", "q", "\x1b"): + break + elif key in ("\x1b[A", "k"): # up + cursor = (cursor - 1) % n + elif key in ("\x1b[B", "j"): # down + cursor = (cursor + 1) % n + elif key == " ": + selected[cursor] = not selected[cursor] + elif key in ("a", "A"): + # Select all if any are unselected, otherwise deselect all + new_state = not all(selected) + selected[:] = [new_state] * n + + # Redraw in-place + sys.stdout.write(f"\033[{line_count}A\033[J") + output = _render() + print(output, end="", flush=True) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + return [i for i, s in enumerate(selected) if s] + + def _select_shares(shares: list[dict], share_type: str) -> list[dict]: """ - Display a numbered list of *shares* and return only those the user selects. - Enter (or 'all') returns all shares unchanged. 'n' / 'none' returns []. + Interactive share selector. Uses arrow-key/spacebar UI when stdin is a TTY, + falls back to numbered text input otherwise. """ if not shares: return shares - print(f"\n {_bold(f'{share_type} shares in archive ({len(shares)}):')} \n") - for i, share in enumerate(shares, 1): + # Build display labels + labels: list[str] = [] + for share in shares: if share_type == "SMB": name = share.get("name", "") path = share.get("path", "") - print(f" {_cyan(str(i) + '.')} {name:<22} {_dim(path)}") + labels.append(f"{name:<22} {_dim(path)}") else: # NFS - pl = share.get("paths") or [] + pl = share.get("paths") or [] path = share.get("path") or (pl[0] if pl else "") extra = f" {_dim('+ ' + str(len(pl) - 1) + ' more')}" if len(pl) > 1 else "" - print(f" {_cyan(str(i) + '.')} {path}{extra}") + labels.append(f"{path}{extra}") + title = f"{share_type} shares to migrate ({len(shares)} found)" + + if _TTY_SUPPORTED and sys.stdin.isatty(): + indices = _checkbox_select(labels, title) + selected = [shares[i] for i in indices] + if selected: + print(f" {_green('✓')} {len(selected)} of {len(shares)} {share_type} share(s) selected.\n") + else: + print(f" {_yellow('–')} No {share_type} shares selected.\n") + return selected + + # ── Fallback: numbered text input ──────────────────────────────────────── + print(f"\n {_bold(title)}\n") + for i, label in enumerate(labels, 1): + print(f" {_cyan(str(i) + '.')} {label}") print() raw = _prompt( f" Select {share_type} shares to migrate " "(e.g. '1 3', Enter = all, 'n' = none)", default="all", ) - low = raw.strip().lower() if low in ("", "all"): print(f" {_green('✓')} All {len(shares)} {share_type} share(s) selected.") @@ -1144,21 +1248,19 @@ def _select_shares(shares: list[dict], share_type: str) -> list[dict]: if low in ("n", "none", "0"): print(f" {_yellow('–')} No {share_type} shares selected.") return [] - seen: set[int] = set() - selected: list[dict] = [] + result: list[dict] = [] for tok in raw.split(): if tok.isdigit(): idx = int(tok) - 1 if 0 <= idx < len(shares) and idx not in seen: seen.add(idx) - selected.append(shares[idx]) - - if selected: - print(f" {_green('✓')} {len(selected)} of {len(shares)} {share_type} share(s) selected.") + result.append(shares[idx]) + if result: + print(f" {_green('✓')} {len(result)} of {len(shares)} {share_type} share(s) selected.") else: print(f" {_yellow('–')} No valid selections; skipping {share_type} shares.") - return selected + return result def interactive_mode() -> None: