Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
skywind3000 opened this issue Feb 2, 2020 · 5 comments · May be fixed by #1331
Open

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

skywind3000 opened this issue Feb 2, 2020 · 5 comments · May be fixed by #1331

Comments

@skywind3000
Copy link

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
Copy link
Member

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
Copy link
Author

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
Copy link

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

image

@yajo
Copy link

yajo commented Sep 20, 2020

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

@liuyangc3
Copy link

liuyangc3 commented Nov 23, 2020

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

@liuyangc3 liuyangc3 linked a pull request Jan 21, 2021 that will close this issue
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants