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]):