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