"""Numbered interactive menus (Enter = default index).""" from __future__ import annotations from collections.abc import Callable, Sequence from dataclasses import dataclass from rich.markup import escape Ask = Callable[[str, str], str] Show = Callable[[str], None] @dataclass(frozen=True) class MenuItem: key: str label: str def resolve_menu_choice( raw: str, items: Sequence[MenuItem], *, default_key: str, ) -> str: """Accept 1-based index or exact key (case-insensitive). Empty → default.""" text = (raw or "").strip().lower() if not text: return default_key if text.isdigit(): idx = int(text) if 1 <= idx <= len(items): return items[idx - 1].key raise ValueError(f"выбор: жду 1…{len(items)}, получили {idx}") for item in items: if item.key.lower() == text: return item.key keys = ", ".join(f"{i}:{it.key}" for i, it in enumerate(items, 1)) raise ValueError(f"выбор: жду номер или ключ ({keys}), получили {raw!r}") def prompt_menu( title: str, items: Sequence[MenuItem], *, default: str, ask: Ask, show: Show | None = None, ) -> str: """Print numbered list, ask for index (default = current recommendation).""" if not items: raise ValueError(f"{title}: пустое меню") default_idx = 1 for i, item in enumerate(items, 1): if item.key == default: default_idx = i break safe_title = escape(title) lines = [f"[bold cyan]{safe_title}[/bold cyan]"] for i, item in enumerate(items, 1): label = escape(item.label) if i == default_idx: lines.append(f" [cyan]{i}.[/cyan] {label} [green]←[/green]") else: lines.append(f" [dim]{i}.[/dim] {label}") emit = show if emit is None: from gpu_rent.term import log as emit # type: ignore[assignment] for line in lines: emit(line) raw = ask(f"{title} [1-{len(items)}]", str(default_idx)) return resolve_menu_choice(raw, items, default_key=items[default_idx - 1].key)