|
"""Contains the interface class :class:`.BaseComplexPrompt` for more complex prompts and the mocked document class :class:`.FakeDocument`.""" |
|
import shutil |
|
from dataclasses import dataclass |
|
from typing import Any, Callable, List, Optional, Tuple, Union |
|
|
|
from prompt_toolkit.application import Application |
|
from prompt_toolkit.enums import EditingMode |
|
from prompt_toolkit.filters.base import Condition, FilterOrBool |
|
from prompt_toolkit.key_binding.key_bindings import KeyHandlerCallable |
|
from prompt_toolkit.keys import Keys |
|
|
|
from InquirerPy.base.simple import BaseSimplePrompt |
|
from InquirerPy.enum import INQUIRERPY_KEYBOARD_INTERRUPT |
|
from InquirerPy.utils import ( |
|
InquirerPySessionResult, |
|
InquirerPyStyle, |
|
InquirerPyValidate, |
|
) |
|
|
|
|
|
@dataclass |
|
class FakeDocument: |
|
"""A fake `prompt_toolkit` document class. |
|
|
|
Work around to allow non-buffer type :class:`~prompt_toolkit.layout.UIControl` to use |
|
:class:`~prompt_toolkit.validation.Validator`. |
|
|
|
Args: |
|
text: Content to be validated. |
|
cursor_position: Fake cursor position. |
|
""" |
|
|
|
text: str |
|
cursor_position: int = 0 |
|
|
|
|
|
class BaseComplexPrompt(BaseSimplePrompt): |
|
"""A base class to create a more complex prompt that will involve :class:`~prompt_toolkit.application.Application`. |
|
|
|
Note: |
|
This class does not create :class:`~prompt_toolkit.layout.Layout` nor :class:`~prompt_toolkit.application.Application`, |
|
it only contains the necessary attributes and helper functions to be consumed. |
|
|
|
Note: |
|
Use :class:`~InquirerPy.base.BaseListPrompt` to create a complex list prompt which involves multiple choices. It has |
|
more methods and helper function implemented. |
|
|
|
See Also: |
|
:class:`~InquirerPy.base.BaseListPrompt` |
|
:class:`~InquirerPy.prompts.fuzzy.FuzzyPrompt` |
|
""" |
|
|
|
def __init__( |
|
self, |
|
message: Union[str, Callable[[InquirerPySessionResult], str]], |
|
style: Optional[InquirerPyStyle] = None, |
|
border: bool = False, |
|
vi_mode: bool = False, |
|
qmark: str = "?", |
|
amark: str = "?", |
|
instruction: str = "", |
|
long_instruction: str = "", |
|
transformer: Optional[Callable[[Any], Any]] = None, |
|
filter: Optional[Callable[[Any], Any]] = None, |
|
validate: Optional[InquirerPyValidate] = None, |
|
invalid_message: str = "Invalid input", |
|
wrap_lines: bool = True, |
|
raise_keyboard_interrupt: bool = True, |
|
mandatory: bool = True, |
|
mandatory_message: str = "Mandatory prompt", |
|
session_result: Optional[InquirerPySessionResult] = None, |
|
) -> None: |
|
super().__init__( |
|
message=message, |
|
style=style, |
|
vi_mode=vi_mode, |
|
qmark=qmark, |
|
amark=amark, |
|
instruction=instruction, |
|
transformer=transformer, |
|
filter=filter, |
|
invalid_message=invalid_message, |
|
validate=validate, |
|
wrap_lines=wrap_lines, |
|
raise_keyboard_interrupt=raise_keyboard_interrupt, |
|
mandatory=mandatory, |
|
mandatory_message=mandatory_message, |
|
session_result=session_result, |
|
) |
|
self._invalid_message = invalid_message |
|
self._rendered = False |
|
self._invalid = False |
|
self._loading = False |
|
self._application: Application |
|
self._long_instruction = long_instruction |
|
self._border = border |
|
self._height_offset = 2 |
|
if self._border: |
|
self._height_offset += 2 |
|
if self._long_instruction: |
|
self._height_offset += 1 |
|
self._validation_window_bottom_offset = 0 if not self._long_instruction else 1 |
|
if self._wrap_lines: |
|
self._validation_window_bottom_offset += ( |
|
self.extra_long_instruction_line_count |
|
) |
|
|
|
self._is_vim_edit = Condition(lambda: self._editing_mode == EditingMode.VI) |
|
self._is_invalid = Condition(lambda: self._invalid) |
|
self._is_displaying_long_instruction = Condition( |
|
lambda: self._long_instruction != "" |
|
) |
|
|
|
def _redraw(self) -> None: |
|
"""Redraw the application UI.""" |
|
self._application.invalidate() |
|
|
|
def register_kb( |
|
self, *keys: Union[Keys, str], filter: FilterOrBool = True |
|
) -> Callable[[KeyHandlerCallable], KeyHandlerCallable]: |
|
"""Decorate keybinding registration function. |
|
|
|
Ensure that the `invalid` state is cleared on next keybinding entered. |
|
""" |
|
kb_dec = super().register_kb(*keys, filter=filter) |
|
|
|
def decorator(func: KeyHandlerCallable) -> KeyHandlerCallable: |
|
@kb_dec |
|
def executable(event): |
|
if self._invalid: |
|
self._invalid = False |
|
func(event) |
|
|
|
return executable |
|
|
|
return decorator |
|
|
|
def _exception_handler(self, _, context) -> None: |
|
"""Set exception handler for the event loop. |
|
|
|
Skip the question and raise exception. |
|
|
|
Args: |
|
loop: Current event loop. |
|
context: Exception context. |
|
""" |
|
self._status["answered"] = True |
|
self._status["result"] = INQUIRERPY_KEYBOARD_INTERRUPT |
|
self._status["skipped"] = True |
|
self._application.exit(exception=context["exception"]) |
|
|
|
def _after_render(self, app: Optional[Application]) -> None: |
|
"""Run after the :class:`~prompt_toolkit.application.Application` is rendered/updated. |
|
|
|
Since this function is fired up on each render, adding a check on `self._rendered` to |
|
process logics that should only run once. |
|
|
|
Set event loop exception handler here, since its guaranteed that the event loop is running |
|
in `_after_render`. |
|
""" |
|
if not self._rendered: |
|
self._rendered = True |
|
|
|
self._keybinding_factory() |
|
self._on_rendered(app) |
|
|
|
def _set_error(self, message: str) -> None: |
|
"""Set error message and set invalid state. |
|
|
|
Args: |
|
message: Error message to display. |
|
""" |
|
self._invalid_message = message |
|
self._invalid = True |
|
|
|
def _get_error_message(self) -> List[Tuple[str, str]]: |
|
"""Obtain the error message dynamically. |
|
|
|
Returns: |
|
FormattedText in list of tuple format. |
|
""" |
|
return [ |
|
( |
|
"class:validation-toolbar", |
|
self._invalid_message, |
|
) |
|
] |
|
|
|
def _on_rendered(self, _: Optional[Application]) -> None: |
|
"""Run once after the UI is rendered. Acts like `ComponentDidMount`.""" |
|
pass |
|
|
|
def _get_prompt_message(self) -> List[Tuple[str, str]]: |
|
"""Get the prompt message to display. |
|
|
|
Returns: |
|
Formatted text in list of tuple format. |
|
""" |
|
pre_answer = ( |
|
"class:instruction", |
|
" %s " % self.instruction if self.instruction else " ", |
|
) |
|
post_answer = ("class:answer", " %s" % self.status["result"]) |
|
return super()._get_prompt_message(pre_answer, post_answer) |
|
|
|
def _run(self) -> Any: |
|
"""Run the application.""" |
|
return self.application.run() |
|
|
|
async def _run_async(self) -> None: |
|
"""Run the application asynchronously.""" |
|
return await self.application.run_async() |
|
|
|
@property |
|
def application(self) -> Application: |
|
"""Get the application. |
|
|
|
:class:`.BaseComplexPrompt` requires :attr:`.BaseComplexPrompt._application` to be defined since this class |
|
doesn't implement :class:`~prompt_toolkit.layout.Layout` and :class:`~prompt_toolkit.application.Application`. |
|
|
|
Raises: |
|
NotImplementedError: When `self._application` is not defined. |
|
""" |
|
if not self._application: |
|
raise NotImplementedError |
|
return self._application |
|
|
|
@application.setter |
|
def application(self, value: Application) -> None: |
|
self._application = value |
|
|
|
@property |
|
def height_offset(self) -> int: |
|
"""int: Height offset to apply.""" |
|
if not self._wrap_lines: |
|
return self._height_offset |
|
return self.extra_line_count + self._height_offset |
|
|
|
@property |
|
def total_message_length(self) -> int: |
|
"""int: Total length of the message.""" |
|
total_message_length = 0 |
|
if self._qmark: |
|
total_message_length += len(self._qmark) |
|
total_message_length += 1 |
|
total_message_length += len(str(self._message)) |
|
total_message_length += 1 |
|
total_message_length += len(str(self._instruction)) |
|
if self._instruction: |
|
total_message_length += 1 |
|
return total_message_length |
|
|
|
@property |
|
def extra_message_line_count(self) -> int: |
|
"""int: Get the extra lines created caused by line wrapping. |
|
|
|
Minus 1 on the totoal message length as we only want the extra line. |
|
24 // 24 will equal to 1 however we only want the value to be 1 when we have 25 char |
|
which will create an extra line. |
|
""" |
|
term_width, _ = shutil.get_terminal_size() |
|
return (self.total_message_length - 1) // term_width |
|
|
|
@property |
|
def extra_long_instruction_line_count(self) -> int: |
|
"""int: Get the extra lines created caused by line wrapping. |
|
|
|
See Also: |
|
:attr:`.BaseComplexPrompt.extra_message_line_count` |
|
""" |
|
if self._long_instruction: |
|
term_width, _ = shutil.get_terminal_size() |
|
return (len(self._long_instruction) - 1) // term_width |
|
else: |
|
return 0 |
|
|
|
@property |
|
def extra_line_count(self) -> int: |
|
"""Get the extra lines created caused by line wrapping. |
|
|
|
Used mainly to calculate how much additional offset should be applied when getting |
|
the height. |
|
|
|
Returns: |
|
Total extra lines created due to line wrapping. |
|
""" |
|
result = 0 |
|
|
|
|
|
result += self.extra_message_line_count |
|
|
|
result += self.extra_long_instruction_line_count |
|
|
|
return result |
|
|