diff --git a/examples/input_selection/color.py b/examples/input_selection/color.py
new file mode 100644
index 000000000..b66c1ba74
--- /dev/null
+++ b/examples/input_selection/color.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from prompt_toolkit.formatted_text import HTML
+from prompt_toolkit.shortcuts.input_selection import select_input
+from prompt_toolkit.styles import Style
+
+
+def main() -> None:
+ style = Style.from_dict(
+ {
+ "input-selection": "fg:#ff0000",
+ "number": "fg:#884444 bold",
+ "selected-option": "underline",
+ "frame.border": "#884444",
+ }
+ )
+
+ result = select_input(
+ message=HTML("Please select a dish:"),
+ options=[
+ ("pizza", "Pizza with mushrooms"),
+ (
+ "salad",
+ HTML("Salad with tomatoes"),
+ ),
+ ("sushi", "Sushi"),
+ ],
+ style=style,
+ )
+ print(result)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/input_selection/many-options.py b/examples/input_selection/many-options.py
new file mode 100644
index 000000000..03b2df9b7
--- /dev/null
+++ b/examples/input_selection/many-options.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from prompt_toolkit.shortcuts.input_selection import select_input
+
+
+def main() -> None:
+ result = select_input(
+ message="Please select an option:",
+ options=[(i, f"Option {i}") for i in range(1, 100)],
+ )
+ print(result)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/input_selection/simple-selection.py b/examples/input_selection/simple-selection.py
new file mode 100644
index 000000000..6a3bd6ec6
--- /dev/null
+++ b/examples/input_selection/simple-selection.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+from prompt_toolkit.shortcuts.input_selection import select_input
+
+
+def main() -> None:
+ result = select_input(
+ message="Please select a dish:",
+ options=[
+ ("pizza", "Pizza with mushrooms"),
+ ("salad", "Salad with tomatoes"),
+ ("sushi", "Sushi"),
+ ],
+ )
+ print(result)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/input_selection/with-frame.py b/examples/input_selection/with-frame.py
new file mode 100644
index 000000000..4d6ccf53c
--- /dev/null
+++ b/examples/input_selection/with-frame.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from prompt_toolkit.formatted_text import HTML
+from prompt_toolkit.shortcuts.input_selection import select_input
+from prompt_toolkit.styles import Style
+
+
+def main() -> None:
+ style = Style.from_dict(
+ {
+ "frame.border": "#884444",
+ }
+ )
+
+ result = select_input(
+ message=HTML("Please select a dish:"),
+ options=[
+ ("pizza", "Pizza with mushrooms"),
+ ("salad", "Salad with tomatoes"),
+ ("sushi", "Sushi"),
+ ],
+ style=style,
+ show_frame=1,
+ )
+ print(result)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/prompt_toolkit/shortcuts/input_selection.py b/src/prompt_toolkit/shortcuts/input_selection.py
new file mode 100644
index 000000000..9a945b757
--- /dev/null
+++ b/src/prompt_toolkit/shortcuts/input_selection.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+from typing import Generic, Sequence, TypeVar
+
+from prompt_toolkit.application import Application
+from prompt_toolkit.filters import Condition, FilterOrBool, to_filter
+from prompt_toolkit.formatted_text import AnyFormattedText
+from prompt_toolkit.key_binding.key_bindings import KeyBindings
+from prompt_toolkit.key_binding.key_processor import KeyPressEvent
+from prompt_toolkit.layout import AnyContainer, HSplit, Layout
+from prompt_toolkit.styles import BaseStyle
+from prompt_toolkit.utils import suspend_to_background_supported
+from prompt_toolkit.widgets import Box, Frame, Label, RadioList
+
+_T = TypeVar("_T")
+E = KeyPressEvent
+
+
+class InputSelection(Generic[_T]):
+ def __init__(
+ self,
+ *,
+ message: AnyFormattedText,
+ options: Sequence[tuple[_T, AnyFormattedText]],
+ default: _T | None = None,
+ mouse_support: bool = True,
+ style: BaseStyle | None = None,
+ symbol: str = ">",
+ show_frame: bool = False,
+ enable_suspend: FilterOrBool = False,
+ enable_abort: FilterOrBool = True,
+ interrupt_exception: type[BaseException] = KeyboardInterrupt,
+ ) -> None:
+ self.message = message
+ self.default = default
+ self.options = options
+ self.mouse_support = mouse_support
+ self.style = style
+ self.symbol = symbol
+ self.show_frame = show_frame
+ self.enable_suspend = enable_suspend
+ self.interrupt_exception = interrupt_exception
+ self.enable_abort = enable_abort
+
+ def _create_application(self) -> Application[_T]:
+ radio_list = RadioList(
+ values=self.options,
+ default=self.default,
+ select_on_focus=True,
+ open_character="",
+ select_character=self.symbol,
+ close_character="",
+ show_cursor=False,
+ show_numbers=True,
+ container_style="class:input-selection",
+ default_style="class:option",
+ selected_style="",
+ checked_style="class:selected-option",
+ number_style="class:number",
+ show_scrollbar=False,
+ )
+ container: AnyContainer = HSplit(
+ [
+ Box(
+ Label(text=self.message, dont_extend_height=True),
+ padding_top=0,
+ padding_left=1,
+ padding_right=1,
+ padding_bottom=0,
+ ),
+ Box(
+ radio_list,
+ padding_top=0,
+ padding_left=3,
+ padding_right=1,
+ padding_bottom=0,
+ ),
+ ]
+ )
+ if self.show_frame:
+ container = Frame(container)
+ layout = Layout(container, radio_list)
+
+ kb = KeyBindings()
+
+ @kb.add("enter", eager=True)
+ def _accept_input(event: E) -> None:
+ "Accept input when enter has been pressed."
+ event.app.exit(result=radio_list.current_value)
+
+ @Condition
+ def enable_abort() -> bool:
+ return to_filter(self.enable_abort)()
+
+ @kb.add("c-c", filter=enable_abort)
+ @kb.add("", filter=enable_abort)
+ def _keyboard_interrupt(event: E) -> None:
+ "Abort when Control-C has been pressed."
+ event.app.exit(exception=self.interrupt_exception(), style="class:aborting")
+
+ suspend_supported = Condition(suspend_to_background_supported)
+
+ @Condition
+ def enable_suspend() -> bool:
+ return to_filter(self.enable_suspend)()
+
+ @kb.add("c-z", filter=suspend_supported & enable_suspend)
+ def _suspend(event: E) -> None:
+ """
+ Suspend process to background.
+ """
+ event.app.suspend_to_background()
+
+ return Application(
+ layout=layout,
+ full_screen=False,
+ mouse_support=self.mouse_support,
+ key_bindings=kb,
+ style=self.style,
+ )
+
+ def prompt(self) -> _T:
+ return self._create_application().run()
+
+ async def prompt_async(self) -> _T:
+ return await self._create_application().run_async()
+
+
+def select_input(
+ message: AnyFormattedText,
+ options: Sequence[tuple[_T, AnyFormattedText]],
+ default: _T | None = None,
+ mouse_support: bool = True,
+ style: BaseStyle | None = None,
+ symbol: str = ">",
+ show_frame: bool = False,
+ enable_suspend: FilterOrBool = False,
+ enable_abort: FilterOrBool = True,
+) -> _T:
+ return InputSelection[_T](
+ message=message,
+ options=options,
+ default=default,
+ mouse_support=mouse_support,
+ show_frame=show_frame,
+ symbol=symbol,
+ style=style,
+ enable_suspend=enable_suspend,
+ enable_abort=enable_abort,
+ ).prompt()
diff --git a/src/prompt_toolkit/widgets/base.py b/src/prompt_toolkit/widgets/base.py
index 51cfaa27f..701d06864 100644
--- a/src/prompt_toolkit/widgets/base.py
+++ b/src/prompt_toolkit/widgets/base.py
@@ -696,24 +696,41 @@ class _DialogList(Generic[_T]):
Common code for `RadioList` and `CheckboxList`.
"""
- open_character: str = ""
- close_character: str = ""
- container_style: str = ""
- default_style: str = ""
- selected_style: str = ""
- checked_style: str = ""
- multiple_selection: bool = False
- show_scrollbar: bool = True
-
def __init__(
self,
values: Sequence[tuple[_T, AnyFormattedText]],
default_values: Sequence[_T] | None = None,
+ select_on_focus: bool = False,
+ open_character: str = "",
+ select_character: str = "*",
+ close_character: str = "",
+ container_style: str = "",
+ default_style: str = "",
+ number_style: str = "",
+ selected_style: str = "",
+ checked_style: str = "",
+ multiple_selection: bool = False,
+ show_scrollbar: bool = True,
+ show_cursor: bool = True,
+ show_numbers: bool = False,
) -> None:
assert len(values) > 0
default_values = default_values or []
self.values = values
+ self.show_numbers = show_numbers
+
+ self.open_character = open_character
+ self.select_character = select_character
+ self.close_character = close_character
+ self.container_style = container_style
+ self.default_style = default_style
+ self.number_style = number_style
+ self.selected_style = selected_style
+ self.checked_style = checked_style
+ self.multiple_selection = multiple_selection
+ self.show_scrollbar = show_scrollbar
+
# current_values will be used in multiple_selection,
# current_value will be used otherwise.
keys: list[_T] = [value for (value, _) in values]
@@ -736,12 +753,18 @@ def __init__(
kb = KeyBindings()
@kb.add("up")
+ @kb.add("k") # Vi-like.
def _up(event: E) -> None:
self._selected_index = max(0, self._selected_index - 1)
+ if select_on_focus:
+ self._handle_enter()
@kb.add("down")
+ @kb.add("j") # Vi-like.
def _down(event: E) -> None:
self._selected_index = min(len(self.values) - 1, self._selected_index + 1)
+ if select_on_focus:
+ self._handle_enter()
@kb.add("pageup")
def _pageup(event: E) -> None:
@@ -776,9 +799,22 @@ def _find(event: E) -> None:
self._selected_index = self.values.index(value)
return
+ numbers_visible = Condition(lambda: self.show_numbers)
+
+ for i in range(1, 10):
+
+ @kb.add(str(i), filter=numbers_visible)
+ def _select_i(event: E, index: int = i) -> None:
+ self._selected_index = min(len(self.values) - 1, index - 1)
+ if select_on_focus:
+ self._handle_enter()
+
# Control and window.
self.control = FormattedTextControl(
- self._get_text_fragments, key_bindings=kb, focusable=True
+ self._get_text_fragments,
+ key_bindings=kb,
+ focusable=True,
+ show_cursor=show_cursor,
)
self.window = Window(
@@ -833,13 +869,19 @@ def mouse_handler(mouse_event: MouseEvent) -> None:
result.append(("[SetCursorPosition]", ""))
if checked:
- result.append((style, "*"))
+ result.append((style, self.select_character))
else:
result.append((style, " "))
result.append((style, self.close_character))
- result.append((self.default_style, " "))
- result.extend(to_formatted_text(value[1], style=self.default_style))
+ result.append((f"{style} {self.default_style}", " "))
+
+ if self.show_numbers:
+ result.append((f"{style} {self.number_style}", f"{i + 1:2d}. "))
+
+ result.extend(
+ to_formatted_text(value[1], style=f"{style} {self.default_style}")
+ )
result.append(("", "\n"))
# Add mouse handler to all fragments.
@@ -860,25 +902,46 @@ class RadioList(_DialogList[_T]):
:param values: List of (value, label) tuples.
"""
- open_character = "("
- close_character = ")"
- container_style = "class:radio-list"
- default_style = "class:radio"
- selected_style = "class:radio-selected"
- checked_style = "class:radio-checked"
- multiple_selection = False
-
def __init__(
self,
values: Sequence[tuple[_T, AnyFormattedText]],
default: _T | None = None,
+ show_numbers: bool = False,
+ select_on_focus: bool = False,
+ open_character: str = "(",
+ select_character: str = "*",
+ close_character: str = ")",
+ container_style: str = "class:radio-list",
+ default_style: str = "class:radio",
+ selected_style: str = "class:radio-selected",
+ checked_style: str = "class:radio-checked",
+ number_style: str = "class:radio-number",
+ multiple_selection: bool = False,
+ show_cursor: bool = True,
+ show_scrollbar: bool = True,
) -> None:
if default is None:
default_values = None
else:
default_values = [default]
- super().__init__(values, default_values=default_values)
+ super().__init__(
+ values,
+ default_values=default_values,
+ select_on_focus=select_on_focus,
+ show_numbers=show_numbers,
+ open_character=open_character,
+ select_character=select_character,
+ close_character=close_character,
+ container_style=container_style,
+ default_style=default_style,
+ selected_style=selected_style,
+ checked_style=checked_style,
+ number_style=number_style,
+ multiple_selection=False,
+ show_cursor=show_cursor,
+ show_scrollbar=show_scrollbar,
+ )
class CheckboxList(_DialogList[_T]):
@@ -888,13 +951,30 @@ class CheckboxList(_DialogList[_T]):
:param values: List of (value, label) tuples.
"""
- open_character = "["
- close_character = "]"
- container_style = "class:checkbox-list"
- default_style = "class:checkbox"
- selected_style = "class:checkbox-selected"
- checked_style = "class:checkbox-checked"
- multiple_selection = True
+ def __init__(
+ self,
+ values: Sequence[tuple[_T, AnyFormattedText]],
+ default_values: Sequence[_T] | None = None,
+ open_character: str = "[",
+ select_character: str = "*",
+ close_character: str = "]",
+ container_style: str = "class:checkbox-list",
+ default_style: str = "class:checkbox",
+ selected_style: str = "class:checkbox-selected",
+ checked_style: str = "class:checkbox-checked",
+ ) -> None:
+ super().__init__(
+ values,
+ default_values=default_values,
+ open_character=open_character,
+ select_character=select_character,
+ close_character=close_character,
+ container_style=container_style,
+ default_style=default_style,
+ selected_style=selected_style,
+ checked_style=checked_style,
+ multiple_selection=True,
+ )
class Checkbox(CheckboxList[str]):