Skip to content

Commit 5396c70

Browse files
authored
Add questionary-based ui helper (#204)
* Fix issue with infinite loop during rig picking * Add ability to skip using the cache * Remove type checking * Add questionary-based ui helper * Add docstrings
1 parent a375593 commit 5396c70

File tree

5 files changed

+153
-3
lines changed

5 files changed

+153
-3
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
"semver",
3030
"rich",
3131
"aind_behavior_services < 1",
32+
"questionary",
3233
]
3334

3435
[project.urls]

src/clabe/ui/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1-
from .ui_helper import DefaultUIHelper, UiHelper, prompt_field_from_input
1+
from .questionary_ui_helper import QuestionaryUIHelper
2+
from .ui_helper import NativeUiHelper, UiHelper, prompt_field_from_input
23

3-
__all__ = ["DefaultUIHelper", "UiHelper", "prompt_field_from_input"]
4+
DefaultUIHelper = QuestionaryUIHelper
5+
6+
__all__ = ["DefaultUIHelper", "UiHelper", "prompt_field_from_input", "NativeUiHelper", "QuestionaryUIHelper"]
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import asyncio
2+
import logging
3+
from typing import List, Optional
4+
5+
import questionary
6+
from questionary import Style
7+
8+
from .ui_helper import _UiHelperBase
9+
10+
logger = logging.getLogger(__name__)
11+
12+
custom_style = Style(
13+
[
14+
("qmark", "fg:#5f87ff bold"), # Question mark - blue
15+
("question", "fg:#ffffff bold"), # Question text - white bold
16+
("answer", "fg:#5f87ff bold"), # Selected answer - blue
17+
("pointer", "fg:#5f87ff bold"), # Pointer - blue arrow
18+
("highlighted", "fg:#000000 bg:#5f87ff bold"), # INVERTED: black text on blue background
19+
("selected", "fg:#5f87ff"), # After selection
20+
("separator", "fg:#666666"), # Separator
21+
("instruction", "fg:#888888"), # Instructions
22+
("text", ""), # Plain text
23+
("disabled", "fg:#858585 italic"), # Disabled
24+
]
25+
)
26+
27+
28+
def _ask_sync(question):
29+
"""Ask question, handling both sync and async contexts.
30+
31+
When in an async context, runs the questionary prompt in a thread pool
32+
to avoid the "asyncio.run() cannot be called from a running event loop" error.
33+
"""
34+
try:
35+
# Check if we're in an async context
36+
loop = asyncio.get_running_loop()
37+
# We are in an async context - use thread pool to avoid nested event loop
38+
import concurrent.futures
39+
40+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
41+
future = executor.submit(question.ask)
42+
return future.result()
43+
except RuntimeError:
44+
# No running loop - use normal ask()
45+
return question.ask()
46+
47+
48+
class QuestionaryUIHelper(_UiHelperBase):
49+
"""UI helper implementation using Questionary for interactive prompts."""
50+
def __init__(self, style: Optional[questionary.Style] = None) -> None:
51+
"""Initializes the QuestionaryUIHelper with an optional custom style."""
52+
self.style = style or custom_style
53+
54+
def print(self, message: str) -> None:
55+
"""Prints a message with custom styling."""
56+
questionary.print(message, "bold italic")
57+
58+
def input(self, prompt: str) -> str:
59+
"""Prompts the user for input with custom styling."""
60+
return _ask_sync(questionary.text(prompt, style=self.style)) or ""
61+
62+
def prompt_pick_from_list(self, value: List[str], prompt: str, **kwargs) -> Optional[str]:
63+
"""Interactive list selection with visual highlighting using arrow keys or number shortcuts."""
64+
allow_0_as_none = kwargs.get("allow_0_as_none", True)
65+
zero_label = kwargs.get("zero_label", "None")
66+
67+
choices = []
68+
69+
if allow_0_as_none:
70+
choices.append(zero_label)
71+
72+
choices.extend(value)
73+
74+
result = _ask_sync(
75+
questionary.select(
76+
prompt,
77+
choices=choices,
78+
style=self.style,
79+
use_arrow_keys=True,
80+
use_indicator=True,
81+
use_shortcuts=True,
82+
)
83+
)
84+
85+
if result is None:
86+
return None
87+
88+
if result == zero_label and allow_0_as_none:
89+
return None
90+
91+
return result
92+
93+
def prompt_yes_no_question(self, prompt: str) -> bool:
94+
"""Prompts the user with a yes/no question using custom styling."""
95+
return _ask_sync(questionary.confirm(prompt, style=self.style)) or False
96+
97+
def prompt_text(self, prompt: str) -> str:
98+
"""Prompts the user for generic text input using custom styling."""
99+
return _ask_sync(questionary.text(prompt, style=self.style)) or ""
100+
101+
def prompt_float(self, prompt: str) -> float:
102+
"""Prompts the user for a float input using custom styling."""
103+
while True:
104+
try:
105+
value_str = _ask_sync(questionary.text(prompt, style=self.style))
106+
if value_str:
107+
return float(value_str)
108+
except ValueError:
109+
self.print("Invalid input. Please enter a valid float.")

src/clabe/ui/ui_helper.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def prompt_float(self, prompt: str) -> float:
137137
UiHelper: TypeAlias = _UiHelperBase
138138

139139

140-
class DefaultUIHelper(_UiHelperBase):
140+
class NativeUiHelper(_UiHelperBase):
141141
"""
142142
Default implementation of the UI helper for user interaction.
143143

uv.lock

Lines changed: 37 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)