picker.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. """Interactive device picker using questionary or CLI fallback."""
  2. import sys
  3. from typing import Iterable
  4. from .pulse import Device
  5. def _build_choices(devices: list[Device], preselected: set[str]) -> list:
  6. """Build questionary checkbox choices.
  7. questionary preselects via ``Choice(..., checked=True)``; passing a
  8. ``default`` list to ``checkbox`` is rejected by its validator.
  9. """
  10. import questionary
  11. return [
  12. questionary.Choice(
  13. f"[{'OUT' if d.kind == 'output' else 'IN'}] {d.description} ({d.name})",
  14. value=d.name,
  15. checked=d.name in preselected,
  16. )
  17. for d in devices
  18. ]
  19. def select_sources(devices: list[Device], preselected: Iterable[str] = ()) -> list[str]:
  20. """Interactively select audio sources.
  21. If questionary is available and stdin is a TTY, show a checkbox UI.
  22. Otherwise, show a numbered menu.
  23. Args:
  24. devices: List of available Device objects.
  25. preselected: Source names that should be pre-checked.
  26. Returns:
  27. List of selected source names in device order, deduplicated.
  28. Raises:
  29. RuntimeError: If non-TTY and user provides invalid input with no valid context.
  30. """
  31. preselected_set = set(preselected)
  32. # Try questionary UI first
  33. if sys.stdin.isatty():
  34. try:
  35. import questionary
  36. except ImportError:
  37. questionary = None
  38. if questionary is not None:
  39. try:
  40. selected = questionary.checkbox(
  41. "Select audio sources to capture:",
  42. choices=_build_choices(devices, preselected_set),
  43. ).ask()
  44. except KeyboardInterrupt:
  45. return list(preselected)
  46. if selected is None:
  47. # User aborted (Ctrl+C)
  48. return list(preselected)
  49. # Preserve device order
  50. result = []
  51. seen = set()
  52. for device in devices:
  53. if device.name in selected and device.name not in seen:
  54. result.append(device.name)
  55. seen.add(device.name)
  56. return result
  57. # CLI fallback: show numbered menu and read input
  58. print("\nAvailable audio sources:")
  59. for i, device in enumerate(devices, 1):
  60. kind_label = "OUT" if device.kind == "output" else "IN"
  61. marker = "✓" if device.name in preselected_set else " "
  62. print(f" {marker} {i:2}. [{kind_label}] {device.description}")
  63. while True:
  64. prompt = (
  65. "\nSelect sources by number (space/comma-separated), "
  66. "'all', 'none', or blank to keep current:"
  67. )
  68. print(prompt)
  69. try:
  70. line = input("> ").strip()
  71. except EOFError:
  72. raise RuntimeError(
  73. "No TTY and EOF reached. Please provide --sources with comma-separated names "
  74. "(e.g. --sources 'alsa_input.usb-...,alsa_output.usb-...')"
  75. )
  76. if not line:
  77. # Keep preselected
  78. result = []
  79. seen = set()
  80. for device in devices:
  81. if device.name in preselected_set and device.name not in seen:
  82. result.append(device.name)
  83. seen.add(device.name)
  84. return result
  85. if line.lower() == "all":
  86. return [d.name for d in devices]
  87. if line.lower() == "none":
  88. return []
  89. # Parse indices
  90. try:
  91. # Accept space or comma as separator
  92. indices_str = line.replace(",", " ")
  93. indices = [int(x) for x in indices_str.split()]
  94. # Validate all indices first
  95. selected_indices = set()
  96. for idx in indices:
  97. if 1 <= idx <= len(devices):
  98. selected_indices.add(idx)
  99. else:
  100. print(
  101. f"Invalid index: {idx}. "
  102. f"Please enter numbers between 1 and {len(devices)}."
  103. )
  104. break
  105. else:
  106. # All indices were valid; return devices in device list order
  107. result = []
  108. seen = set()
  109. for i, device in enumerate(devices, 1):
  110. if i in selected_indices and device.name not in seen:
  111. result.append(device.name)
  112. seen.add(device.name)
  113. return result
  114. except ValueError:
  115. print(
  116. "Invalid input. Please enter space/comma-separated numbers, "
  117. "'all', 'none', or blank."
  118. )