prompt-toolkit / python-prompt-toolkit

Library for building powerful interactive command line applications in Python
https://python-prompt-toolkit.readthedocs.io/
BSD 3-Clause "New" or "Revised" License
9.29k stars 715 forks source link

[feature-request] Command line input form and MultiSelect Prompt. #1071

Open skywind3000 opened 4 years ago

skywind3000 commented 4 years ago

Some cool features from enquier that I really wish to achieve them in prompt-toolkit, because I don't write javascript / nodejs stuff.

Form Prompt

Prompt that allows the user to enter and submit multiple values on a single terminal screen.

MultiSelect Prompt

Prompt that allows the user to select multiple items from a list of options.

jonathanslenders commented 4 years ago

Hi @skywind3000,

This functionality can be built on top of prompt_toolkit. It does require a new custom layout (see the pages about full screen applications) and custom key bindings. It should not be a lot of work, but it's not yet available out of the box.

skywind3000 commented 4 years ago

Is it possible for full screen apps to use only a portion of current screen ?? Modern CLI tools use this feature to provide better experience:

图片

laixintao commented 4 years ago

How does the completion feature work? Can we implement a non-fullscreen app like this?

image
yajo commented 4 years ago

https://github.com/CITGuru/PyInquirer seems to include most of these controls.

liuyangc3 commented 3 years ago

Thanks for Yajo and PyInquirer. I create a simple prompt for this feature, maybe we can add it into exmaples

from typing import List, Optional, Tuple, Union
from prompt_toolkit.application import Application, get_app
from prompt_toolkit.filters import IsDone
from prompt_toolkit.formatted_text import AnyFormattedText
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout.containers import Window
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.layout import Layout
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.layout.containers import ConditionalContainer, HSplit
from prompt_toolkit.mouse_events import MouseEventType
from prompt_toolkit.styles import Style

OptionValue = Optional[AnyFormattedText]
Option = Union[
    AnyFormattedText,  # name value is same
    Tuple[AnyFormattedText, OptionValue]  # (name, value)
]
IndexedOption = Tuple[
    int,  # index
    AnyFormattedText,  # name
    OptionValue
]

class SelectionControl(FormattedTextControl):
    def __init__(
            self,
            options: List[Option],
            **kwargs
    ) -> None:
        self.options = self._index_options(options)
        self.answered = False
        self.selected_option_index = 0
        super().__init__(**kwargs)

    @property
    def selected_option(self) -> IndexedOption:
        return self.options[self.selected_option_index]

    @property
    def options_count(self) -> int:
        return len(self.options)

    def _index_options(self, options) -> List[IndexedOption]:
        """
        Convert Option to IndexedOption
        """
        indexed_options = []
        for idx, opt in enumerate(options):
            if isinstance(opt, str):
                indexed_options.append((idx, opt, opt))
            if isinstance(opt, tuple):
                if len(opt) != 2:
                    raise ValueError(f'invalid tuple option: {opt}.')
                indexed_options.append((idx, *opt))

        return indexed_options

    def _select_option(self, index):

        def handler(mouse_event):
            if mouse_event.event_type != MouseEventType.MOUSE_DOWN:
                raise NotImplemented

            # bind option with this index to mouse event
            self.selected_option_index = index
            self.answered = True
            get_app().exit(result=self.selected_option)

        return handler

    def format_option(
            self,
            option: IndexedOption,
            *,
            selected_style_class: str = '',
            selected_prefix_char: str = '>',
            indent: int = 1
    ):
        option_prefix: AnyFormattedText = ' ' * indent
        idx, name, value = option
        if self.selected_option_index == idx:
            option_prefix = selected_prefix_char + option_prefix
            return selected_style_class, f'{option_prefix}{name}\n', self._select_option(idx)

        option_prefix += ' '
        return '', f'{option_prefix}{name}\n', self._select_option(idx)

class SelectionPrompt:
    def __init__(
            self,
            message: AnyFormattedText = "",
            *,
            options: List[Option] = None
    ) -> None:
        self.message = message
        self.options = options

        self.control = None
        self.layout = None
        self.key_bindings = None
        self.app = None

    def _create_layout(self) -> Layout:
        """
        Create `Layout` for this prompt.
        """

        def get_option_text():
            return [
                self.control.format_option(
                    opt, selected_style_class='class:reverse'
                ) for opt in self.control.options
            ]

        layout = HSplit([
            Window(
                height=Dimension.exact(1),
                content=FormattedTextControl(
                    lambda: self.message + '\n',
                    show_cursor=False
                ),
            ),
            Window(
                height=Dimension.exact(self.control.options_count),
                content=FormattedTextControl(get_option_text)
            ),
            ConditionalContainer(
                Window(self.control),
                filter=~IsDone()
            )
        ])
        return Layout(layout)

    def _create_key_bindings(self) -> KeyBindings:
        """
        Create `KeyBindings` for this prompt
        """
        control = self.control
        kb = KeyBindings()

        @kb.add('c-q', eager=True)
        @kb.add('c-c', eager=True)
        def _(event):
            raise KeyboardInterrupt()

        @kb.add('down', eager=True)
        def move_cursor_down(event):
            control.selected_option_index = (control.selected_option_index + 1) % control.options_count

        @kb.add('up', eager=True)
        def move_cursor_up(event):
            control.selected_option_index = (control.selected_option_index - 1) % control.options_count

        @kb.add('enter', eager=True)
        def set_answer(event):
            control.answered = True
            _, _, selected_option_value = control.selected_option
            event.app.exit(result=selected_option_value)

        return kb

    def _create_application(self) -> Application:
        """
        Create `Application` for this prompt.
        """
        style = Style.from_dict(
            {
                "status": "reverse",
            }
        )
        app = Application(
            layout=self.layout,
            key_bindings=self.key_bindings,
            style=style,
            full_screen=False
        )
        return app

    def prompt(
            self,
            message: Optional[AnyFormattedText] = None,
            *,
            options: List[Option],
    ):
        # all arguments are overwritten the init arguments in SelectionPrompt.
        if message is not None:
            self.message = message
        if options is not None:
            self.options = options

        if self.app is None:
            self.control = SelectionControl(self.options)
            self.layout = self._create_layout()
            self.key_bindings = self._create_key_bindings()
            self.app = self._create_application()

        return self.app.run()

if __name__ == '__main__':
    p = SelectionPrompt()
    v = p.prompt('choose one', options=['v1', 'v2'])
    print(f'you choose: {v}')

image