From 04353140436e4027a71eaf9d2de7bdc7865fead6 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 4 May 2026 18:38:03 -0400 Subject: [PATCH] fix(menu): plain numbered menu by default with aligned key columns - Switch default from simple-term-menu (arrow-key) to plain numbered input so all keys including 10+ can be selected by typing a number; arrow-key mode is now opt-in via HATE_CRACK_ARROW_MENU=1 - Right-align key numbers within brackets so single- and multi-digit items line up: [ 1] through [99] - Use [key] format consistently in both plain and arrow-key modes Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/menu.py | 23 ++++++++++------------- tests/test_menu.py | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/hate_crack/menu.py b/hate_crack/menu.py index 2f5504c..f99b81d 100644 --- a/hate_crack/menu.py +++ b/hate_crack/menu.py @@ -1,10 +1,10 @@ """Reusable interactive menu with optional arrow-key navigation. -When ``simple-term-menu`` is installed AND stdout is a TTY, renders an -arrow-key navigable menu. Otherwise falls back to classic numbered -``print()`` + ``input()`` selection. +Default: classic numbered ``print()`` + ``input()`` selection (full number +entry for all keys). -Set ``HATE_CRACK_PLAIN_MENU=1`` to force the plain numbered menu. +Set ``HATE_CRACK_ARROW_MENU=1`` to enable arrow-key navigation via +``simple-term-menu`` (single-digit shortcut keys only; 10+ require arrows). """ from __future__ import annotations @@ -21,7 +21,7 @@ except ImportError: def _use_arrow_menu() -> bool: - if os.environ.get("HATE_CRACK_PLAIN_MENU", "") == "1": + if os.environ.get("HATE_CRACK_ARROW_MENU", "") != "1": return False if not _HAS_TERM_MENU: return False @@ -34,15 +34,11 @@ def _arrow_menu( items: list[tuple[str, str]], title: str | None, ) -> str | None: - menu_entries = [f"[{key}] {label}" for key, label in items] + w = max(len(key) for key, _ in items) + menu_entries = [f"[{key:>{w}}] {label}" for key, label in items] shortcuts = [key for key, _ in items] - # Build shortcut_key_highlight_style so pressing a number jumps there - menu = TerminalMenu( - menu_entries, - title=title, - shortcut_key_highlight_style=("standout",), - ) + menu = TerminalMenu(menu_entries, title=title) idx = menu.show() if idx is None: return None @@ -53,8 +49,9 @@ def _numbered_menu( items: list[tuple[str, str]], prompt: str, ) -> str | None: + w = max(len(key) for key, _ in items) for key, label in items: - print(f"\t({key}) {label}") + print(f"\t[{key:>{w}}] {label}") choice = input(prompt).strip() if not choice: return None diff --git a/tests/test_menu.py b/tests/test_menu.py index 31ebc6c..3ef46bc 100644 --- a/tests/test_menu.py +++ b/tests/test_menu.py @@ -19,29 +19,30 @@ class TestUseArrowMenu: import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", False) - monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False) + monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1") assert _use_arrow_menu() is False def test_falls_back_on_non_tty(self, monkeypatch): import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", True) - monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False) + monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1") monkeypatch.setattr("sys.stdout.isatty", lambda: False) assert _use_arrow_menu() is False - def test_falls_back_with_env_var(self, monkeypatch): + def test_falls_back_without_env_var(self, monkeypatch): import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", True) - monkeypatch.setenv("HATE_CRACK_PLAIN_MENU", "1") + monkeypatch.delenv("HATE_CRACK_ARROW_MENU", raising=False) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) assert _use_arrow_menu() is False def test_enabled_when_all_conditions_met(self, monkeypatch): import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", True) - monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False) + monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1") monkeypatch.setattr("sys.stdout.isatty", lambda: True) assert _use_arrow_menu() is True @@ -56,8 +57,9 @@ class TestNumberedMenu: monkeypatch.setattr("builtins.input", lambda _: "1") _numbered_menu(SAMPLE_ITEMS, "\nSelect: ") captured = capsys.readouterr().out + w = max(len(key) for key, _ in SAMPLE_ITEMS) for key, label in SAMPLE_ITEMS: - assert f"({key}) {label}" in captured + assert f"[{key:>{w}}] {label}" in captured def test_returns_none_on_empty_input(self, monkeypatch): monkeypatch.setattr("builtins.input", lambda _: "") @@ -94,7 +96,7 @@ class TestInteractiveMenu: import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", False) - monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False) + monkeypatch.delenv("HATE_CRACK_ARROW_MENU", raising=False) monkeypatch.setattr("builtins.input", lambda _: "99") result = interactive_menu(SAMPLE_ITEMS) assert result == "99" @@ -103,7 +105,7 @@ class TestInteractiveMenu: import hate_crack.menu as mod monkeypatch.setattr(mod, "_HAS_TERM_MENU", True) - monkeypatch.delenv("HATE_CRACK_PLAIN_MENU", raising=False) + monkeypatch.setenv("HATE_CRACK_ARROW_MENU", "1") monkeypatch.setattr("sys.stdout.isatty", lambda: True) mock_menu_instance = MagicMock() mock_menu_instance.show.return_value = 0