1from __future__ import annotations
2
3from collections.abc import Sequence
4from typing import Generic, TypeVar
5
6from prompt_toolkit.application import Application
7from prompt_toolkit.filters import (
8 Condition,
9 FilterOrBool,
10 is_done,
11 renderer_height_is_known,
12 to_filter,
13)
14from prompt_toolkit.formatted_text import AnyFormattedText
15from prompt_toolkit.key_binding.key_bindings import (
16 DynamicKeyBindings,
17 KeyBindings,
18 KeyBindingsBase,
19 merge_key_bindings,
20)
21from prompt_toolkit.key_binding.key_processor import KeyPressEvent
22from prompt_toolkit.layout import (
23 AnyContainer,
24 ConditionalContainer,
25 HSplit,
26 Layout,
27 Window,
28)
29from prompt_toolkit.layout.controls import FormattedTextControl
30from prompt_toolkit.layout.dimension import Dimension
31from prompt_toolkit.styles import BaseStyle, Style
32from prompt_toolkit.utils import suspend_to_background_supported
33from prompt_toolkit.widgets import Box, Frame, Label, RadioList
34
35__all__ = [
36 "ChoiceInput",
37 "choice",
38]
39
40_T = TypeVar("_T")
41E = KeyPressEvent
42
43
44def create_default_choice_input_style() -> BaseStyle:
45 return Style.from_dict(
46 {
47 "frame.border": "#884444",
48 "selected-option": "bold",
49 }
50 )
51
52
53class ChoiceInput(Generic[_T]):
54 """
55 Input selection prompt. Ask the user to choose among a set of options.
56
57 Example usage::
58
59 input_selection = ChoiceInput(
60 message="Please select a dish:",
61 options=[
62 ("pizza", "Pizza with mushrooms"),
63 ("salad", "Salad with tomatoes"),
64 ("sushi", "Sushi"),
65 ],
66 default="pizza",
67 )
68 result = input_selection.prompt()
69
70 :param message: Plain text or formatted text to be shown before the options.
71 :param options: Sequence of ``(value, label)`` tuples. The labels can be
72 formatted text.
73 :param default: Default value. If none is given, the first option is
74 considered the default.
75 :param mouse_support: Enable mouse support.
76 :param style: :class:`.Style` instance for the color scheme.
77 :param symbol: Symbol to be displayed in front of the selected choice.
78 :param bottom_toolbar: Formatted text or callable that returns formatted
79 text to be displayed at the bottom of the screen.
80 :param show_frame: `bool` or
81 :class:`~prompt_toolkit.filters.Filter`. When True, surround the input
82 with a frame.
83 :param enable_interrupt: `bool` or
84 :class:`~prompt_toolkit.filters.Filter`. When True, raise
85 the ``interrupt_exception`` (``KeyboardInterrupt`` by default) when
86 control-c has been pressed.
87 :param interrupt_exception: The exception type that will be raised when
88 there is a keyboard interrupt (control-c keypress).
89 """
90
91 def __init__(
92 self,
93 *,
94 message: AnyFormattedText,
95 options: Sequence[tuple[_T, AnyFormattedText]],
96 default: _T | None = None,
97 mouse_support: bool = False,
98 style: BaseStyle | None = None,
99 symbol: str = ">",
100 bottom_toolbar: AnyFormattedText = None,
101 show_frame: FilterOrBool = False,
102 enable_suspend: FilterOrBool = False,
103 enable_interrupt: FilterOrBool = True,
104 interrupt_exception: type[BaseException] = KeyboardInterrupt,
105 key_bindings: KeyBindingsBase | None = None,
106 ) -> None:
107 if style is None:
108 style = create_default_choice_input_style()
109
110 self.message = message
111 self.default = default
112 self.options = options
113 self.mouse_support = mouse_support
114 self.style = style
115 self.symbol = symbol
116 self.show_frame = show_frame
117 self.enable_suspend = enable_suspend
118 self.interrupt_exception = interrupt_exception
119 self.enable_interrupt = enable_interrupt
120 self.bottom_toolbar = bottom_toolbar
121 self.key_bindings = key_bindings
122
123 def _create_application(self) -> Application[_T]:
124 radio_list = RadioList(
125 values=self.options,
126 default=self.default,
127 select_on_focus=True,
128 open_character="",
129 select_character=self.symbol,
130 close_character="",
131 show_cursor=False,
132 show_numbers=True,
133 container_style="class:input-selection",
134 default_style="class:option",
135 selected_style="",
136 checked_style="class:selected-option",
137 number_style="class:number",
138 show_scrollbar=False,
139 )
140 container: AnyContainer = HSplit(
141 [
142 Box(
143 Label(text=self.message, dont_extend_height=True),
144 padding_top=0,
145 padding_left=1,
146 padding_right=1,
147 padding_bottom=0,
148 ),
149 Box(
150 radio_list,
151 padding_top=0,
152 padding_left=3,
153 padding_right=1,
154 padding_bottom=0,
155 ),
156 ]
157 )
158
159 @Condition
160 def show_frame_filter() -> bool:
161 return to_filter(self.show_frame)()
162
163 show_bottom_toolbar = (
164 Condition(lambda: self.bottom_toolbar is not None)
165 & ~is_done
166 & renderer_height_is_known
167 )
168
169 container = ConditionalContainer(
170 Frame(container),
171 alternative_content=container,
172 filter=show_frame_filter,
173 )
174
175 bottom_toolbar = ConditionalContainer(
176 Window(
177 FormattedTextControl(
178 lambda: self.bottom_toolbar, style="class:bottom-toolbar.text"
179 ),
180 style="class:bottom-toolbar",
181 dont_extend_height=True,
182 height=Dimension(min=1),
183 ),
184 filter=show_bottom_toolbar,
185 )
186
187 layout = Layout(
188 HSplit(
189 [
190 container,
191 # Add an empty window between the selection input and the
192 # bottom toolbar, if the bottom toolbar is visible, in
193 # order to allow the bottom toolbar to be displayed at the
194 # bottom of the screen.
195 ConditionalContainer(Window(), filter=show_bottom_toolbar),
196 bottom_toolbar,
197 ]
198 ),
199 focused_element=radio_list,
200 )
201
202 kb = KeyBindings()
203
204 @kb.add("enter", eager=True)
205 def _accept_input(event: E) -> None:
206 "Accept input when enter has been pressed."
207 event.app.exit(result=radio_list.current_value, style="class:accepted")
208
209 @Condition
210 def enable_interrupt() -> bool:
211 return to_filter(self.enable_interrupt)()
212
213 @kb.add("c-c", filter=enable_interrupt)
214 @kb.add("<sigint>", filter=enable_interrupt)
215 def _keyboard_interrupt(event: E) -> None:
216 "Abort when Control-C has been pressed."
217 event.app.exit(exception=self.interrupt_exception(), style="class:aborting")
218
219 suspend_supported = Condition(suspend_to_background_supported)
220
221 @Condition
222 def enable_suspend() -> bool:
223 return to_filter(self.enable_suspend)()
224
225 @kb.add("c-z", filter=suspend_supported & enable_suspend)
226 def _suspend(event: E) -> None:
227 """
228 Suspend process to background.
229 """
230 event.app.suspend_to_background()
231
232 return Application(
233 layout=layout,
234 full_screen=False,
235 mouse_support=self.mouse_support,
236 key_bindings=merge_key_bindings(
237 [kb, DynamicKeyBindings(lambda: self.key_bindings)]
238 ),
239 style=self.style,
240 )
241
242 def prompt(self) -> _T:
243 return self._create_application().run()
244
245 async def prompt_async(self) -> _T:
246 return await self._create_application().run_async()
247
248
249def choice(
250 message: AnyFormattedText,
251 *,
252 options: Sequence[tuple[_T, AnyFormattedText]],
253 default: _T | None = None,
254 mouse_support: bool = False,
255 style: BaseStyle | None = None,
256 symbol: str = ">",
257 bottom_toolbar: AnyFormattedText = None,
258 show_frame: bool = False,
259 enable_suspend: FilterOrBool = False,
260 enable_interrupt: FilterOrBool = True,
261 interrupt_exception: type[BaseException] = KeyboardInterrupt,
262 key_bindings: KeyBindingsBase | None = None,
263) -> _T:
264 """
265 Choice selection prompt. Ask the user to choose among a set of options.
266
267 Example usage::
268
269 result = choice(
270 message="Please select a dish:",
271 options=[
272 ("pizza", "Pizza with mushrooms"),
273 ("salad", "Salad with tomatoes"),
274 ("sushi", "Sushi"),
275 ],
276 default="pizza",
277 )
278
279 :param message: Plain text or formatted text to be shown before the options.
280 :param options: Sequence of ``(value, label)`` tuples. The labels can be
281 formatted text.
282 :param default: Default value. If none is given, the first option is
283 considered the default.
284 :param mouse_support: Enable mouse support.
285 :param style: :class:`.Style` instance for the color scheme.
286 :param symbol: Symbol to be displayed in front of the selected choice.
287 :param bottom_toolbar: Formatted text or callable that returns formatted
288 text to be displayed at the bottom of the screen.
289 :param show_frame: `bool` or
290 :class:`~prompt_toolkit.filters.Filter`. When True, surround the input
291 with a frame.
292 :param enable_interrupt: `bool` or
293 :class:`~prompt_toolkit.filters.Filter`. When True, raise
294 the ``interrupt_exception`` (``KeyboardInterrupt`` by default) when
295 control-c has been pressed.
296 :param interrupt_exception: The exception type that will be raised when
297 there is a keyboard interrupt (control-c keypress).
298 """
299 return ChoiceInput[_T](
300 message=message,
301 options=options,
302 default=default,
303 mouse_support=mouse_support,
304 style=style,
305 symbol=symbol,
306 bottom_toolbar=bottom_toolbar,
307 show_frame=show_frame,
308 enable_suspend=enable_suspend,
309 enable_interrupt=enable_interrupt,
310 interrupt_exception=interrupt_exception,
311 key_bindings=key_bindings,
312 ).prompt()