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 <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-05-04 18:38:03 -04:00
co-authored by Claude Sonnet 4.6
parent 14c6edf27a
commit 0435314043
2 changed files with 20 additions and 21 deletions
+10 -13
View File
@@ -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
+10 -8
View File
@@ -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