Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/pages/asking_for_a_choice.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,70 @@ from a list of options:
.. image:: ../images/choice-input.png


Selecting multiple options
--------------------------

Pass ``multiple_selection=True`` to return a list instead of a single value.
Use the arrow keys, ``j``/``k``, or the displayed option numbers to move focus.
Press space to check or uncheck the focused option, and enter to accept the
selection. Moving focus and pressing enter do not check an option. Mouse clicks
also toggle options when ``mouse_support=True``.

.. code:: python

from prompt_toolkit.shortcuts import choice

dishes = choice(
message="Please choose your dishes:",
options=[
("pizza", "Pizza with mushrooms"),
("salad", "Salad with tomatoes"),
("sushi", "Sushi"),
],
multiple_selection=True,
default_values=["salad"],
)

The result contains selected values in the order they appear in ``options``,
not the order in which they were checked. Equal values occur only once, and
values do not need to be hashable. Accepting without checking anything returns
``[]``.

``default_values`` specifies initially checked options. Unknown values are
ignored and duplicates are removed. When ``default_values`` is omitted,
``default`` is checked if supplied; otherwise nothing is checked. An explicit
``default_values=[]`` checks nothing, even when ``default`` is set. Passing
``default_values`` without multiple selection raises ``ValueError``.

Checked options use ``symbol`` and the ``selected-option`` style. The focused
option has reverse highlighting, independently of whether it is checked. The
``focused-option`` style can override this, for example with
``"underline noreverse"``. Frames, toolbars, custom key bindings, and interrupt
handling work in both selection modes.

For a reusable or asynchronous prompt, use
:class:`~prompt_toolkit.shortcuts.choice_input.ChoiceInput` and pass the selection
arguments to its ``prompt()`` or ``prompt_async()`` method:

.. code:: python

from prompt_toolkit.shortcuts.choice_input import ChoiceInput

selection = ChoiceInput(
message="Please choose your dishes:",
options=[("pizza", "Pizza"), ("salad", "Salad")],
)

async def choose_dishes():
return await selection.prompt_async(
multiple_selection=True, default_values=["salad"]
)

Each invocation starts from its configured defaults; selections are not carried
over from a previous invocation. Without ``multiple_selection=True``, existing
single-choice behavior and return types are unchanged.


Coloring the options
--------------------

Expand Down
25 changes: 25 additions & 0 deletions examples/prompts/choice-multiple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env python
"""Choose several dishes without opening a full-screen dialog."""

from __future__ import annotations

from prompt_toolkit.shortcuts import choice


def main() -> None:
dishes = choice(
message="Please choose your dishes:",
options=[
("pizza", "Pizza with mushrooms"),
("salad", "Salad with tomatoes"),
("sushi", "Sushi"),
],
multiple_selection=True,
default_values=["salad"],
bottom_toolbar=" Up/Down: move Space: toggle Enter: accept",
)
print(f"You have chosen: {dishes}")


if __name__ == "__main__":
main()
220 changes: 210 additions & 10 deletions src/prompt_toolkit/shortcuts/choice_input.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from collections.abc import Sequence
from typing import Generic, TypeVar
from collections.abc import Callable, Sequence
from typing import Generic, Literal, TypeVar, overload

from prompt_toolkit.application import Application
from prompt_toolkit.filters import (
Expand Down Expand Up @@ -30,14 +30,15 @@
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.styles import BaseStyle, Style
from prompt_toolkit.utils import suspend_to_background_supported
from prompt_toolkit.widgets import Box, Frame, Label, RadioList
from prompt_toolkit.widgets import Box, CheckboxList, Frame, Label, RadioList

__all__ = [
"ChoiceInput",
"choice",
]

_T = TypeVar("_T")
_R = TypeVar("_R")
E = KeyPressEvent


Expand Down Expand Up @@ -140,6 +141,55 @@ def _create_application(self) -> Application[_T]:
number_style="class:number",
show_scrollbar=False,
)
return self._create_application_for_list(
radio_list, lambda: radio_list.current_value
)

def _create_multiple_application(
self, default_values: Sequence[_T] | None
) -> Application[list[_T]]:
if default_values is None:
default_values = [] if self.default is None else [self.default]

# Do not mutate the caller's defaults, and ensure that toggling a value
# always removes it in one step, even if it was supplied more than once.
defaults: list[_T] = []
for value in default_values:
if value not in defaults:
defaults.append(value)

checkbox_list = CheckboxList(
values=self.options,
default_values=defaults,
open_character="",
select_character=self.symbol,
close_character="",
show_cursor=False,
show_numbers=self.show_numbers,
container_style="class:input-selection",
default_style="class:option",
selected_style="reverse class:focused-option",
checked_style="class:selected-option",
number_style="class:number",
show_scrollbar=False,
)

def get_result() -> list[_T]:
# Return a fresh list in option order, independent of the order in
# which the user toggled items. Values need not be hashable.
result: list[_T] = []
for value, _ in checkbox_list.values:
if value in checkbox_list.current_values and value not in result:
result.append(value)
return result

return self._create_application_for_list(checkbox_list, get_result)

def _create_application_for_list(
self,
choice_list: RadioList[_T] | CheckboxList[_T],
get_result: Callable[[], _R],
) -> Application[_R]:
container: AnyContainer = HSplit(
[
Box(
Expand All @@ -150,7 +200,7 @@ def _create_application(self) -> Application[_T]:
padding_bottom=0,
),
Box(
radio_list,
choice_list,
padding_top=0,
padding_left=3,
padding_right=1,
Expand Down Expand Up @@ -199,15 +249,15 @@ def show_frame_filter() -> bool:
bottom_toolbar,
]
),
focused_element=radio_list,
focused_element=choice_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, style="class:accepted")
event.app.exit(result=get_result(), style="class:accepted")

@Condition
def enable_interrupt() -> bool:
Expand Down Expand Up @@ -242,18 +292,161 @@ def _suspend(event: E) -> None:
style=self.style,
)

def prompt(self) -> _T:
@overload
def prompt(
self,
*,
multiple_selection: Literal[False] = False,
default_values: None = None,
) -> _T: ...

@overload
def prompt(
self,
*,
multiple_selection: Literal[True],
default_values: Sequence[_T] | None = None,
) -> list[_T]: ...

@overload
def prompt(
self,
*,
multiple_selection: bool,
default_values: Sequence[_T] | None = None,
) -> _T | list[_T]: ...

def prompt(
self,
*,
multiple_selection: bool = False,
default_values: Sequence[_T] | None = None,
) -> _T | list[_T]:
"""
Ask for a choice. With ``multiple_selection=True``, space toggles the
focused option and enter returns a list of selected values in option
order. An empty selection returns an empty list.

``default_values`` specifies initially checked values in multiple
selection mode. When omitted, ``default`` is used as a single checked
value, if given. Unknown defaults are ignored and duplicates are
removed. Passing ``default_values`` in single selection mode raises
``ValueError``.
"""
if multiple_selection:
return self._create_multiple_application(default_values).run()
if default_values is not None:
raise ValueError("default_values requires multiple_selection=True")
return self._create_application().run()

async def prompt_async(self) -> _T:
@overload
async def prompt_async(
self,
*,
multiple_selection: Literal[False] = False,
default_values: None = None,
) -> _T: ...

@overload
async def prompt_async(
self,
*,
multiple_selection: Literal[True],
default_values: Sequence[_T] | None = None,
) -> list[_T]: ...

@overload
async def prompt_async(
self,
*,
multiple_selection: bool,
default_values: Sequence[_T] | None = None,
) -> _T | list[_T]: ...

async def prompt_async(
self,
*,
multiple_selection: bool = False,
default_values: Sequence[_T] | None = None,
) -> _T | list[_T]:
"""
Asynchronous version of :meth:`prompt`, with the same selection and
default-value behavior.
"""
if multiple_selection:
return await self._create_multiple_application(default_values).run_async()
if default_values is not None:
raise ValueError("default_values requires multiple_selection=True")
return await self._create_application().run_async()


@overload
def choice(
message: AnyFormattedText,
*,
options: Sequence[tuple[_T, AnyFormattedText]],
default: _T | None = None,
multiple_selection: Literal[False] = False,
default_values: None = None,
mouse_support: bool = False,
style: BaseStyle | None = None,
symbol: str = ">",
bottom_toolbar: AnyFormattedText = None,
show_frame: bool = False,
enable_suspend: FilterOrBool = False,
enable_interrupt: FilterOrBool = True,
interrupt_exception: type[BaseException] = KeyboardInterrupt,
key_bindings: KeyBindingsBase | None = None,
) -> _T: ...


@overload
def choice(
message: AnyFormattedText,
*,
options: Sequence[tuple[_T, AnyFormattedText]],
default: _T | None = None,
multiple_selection: Literal[True],
default_values: Sequence[_T] | None = None,
mouse_support: bool = False,
style: BaseStyle | None = None,
symbol: str = ">",
bottom_toolbar: AnyFormattedText = None,
show_frame: bool = False,
enable_suspend: FilterOrBool = False,
enable_interrupt: FilterOrBool = True,
interrupt_exception: type[BaseException] = KeyboardInterrupt,
key_bindings: KeyBindingsBase | None = None,
) -> list[_T]: ...


@overload
def choice(
message: AnyFormattedText,
*,
options: Sequence[tuple[_T, AnyFormattedText]],
default: _T | None = None,
multiple_selection: bool,
default_values: Sequence[_T] | None = None,
mouse_support: bool = False,
style: BaseStyle | None = None,
symbol: str = ">",
bottom_toolbar: AnyFormattedText = None,
show_frame: bool = False,
enable_suspend: FilterOrBool = False,
enable_interrupt: FilterOrBool = True,
interrupt_exception: type[BaseException] = KeyboardInterrupt,
key_bindings: KeyBindingsBase | None = None,
) -> _T | list[_T]: ...


def choice(
message: AnyFormattedText,
*,
options: Sequence[tuple[_T, AnyFormattedText]],
default: _T | None = None,
multiple_selection: bool = False,
default_values: Sequence[_T] | None = None,
mouse_support: bool = False,
style: BaseStyle | None = None,
symbol: str = ">",
Expand All @@ -263,7 +456,7 @@ def choice(
enable_interrupt: FilterOrBool = True,
interrupt_exception: type[BaseException] = KeyboardInterrupt,
key_bindings: KeyBindingsBase | None = None,
) -> _T:
) -> _T | list[_T]:
"""
Choice selection prompt. Ask the user to choose among a set of options.

Expand All @@ -284,6 +477,13 @@ def choice(
formatted text.
:param default: Default value. If none is given, the first option is
considered the default.
:param multiple_selection: When True, space toggles the focused option and
enter returns a list of selected values in option order. The list can
be empty. Equal option values occur only once in the result.
:param default_values: Initially checked values for multiple selection.
When omitted, ``default`` is used if given. Unknown values are ignored
and duplicates are removed. An explicit empty sequence checks nothing.
This parameter requires ``multiple_selection=True``.
:param mouse_support: Enable mouse support.
:param style: :class:`.Style` instance for the color scheme.
:param symbol: Symbol to be displayed in front of the selected choice.
Expand Down Expand Up @@ -312,4 +512,4 @@ def choice(
enable_interrupt=enable_interrupt,
interrupt_exception=interrupt_exception,
key_bindings=key_bindings,
).prompt()
).prompt(multiple_selection=multiple_selection, default_values=default_values)
Loading
Loading