From e5f7a41d988de298069eda636e823f374b973bd4 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Mon, 15 Apr 2024 18:33:15 +0200 Subject: Adding upstream version 3.0.26. Signed-off-by: Daniel Baumann --- ptpython/layout.py | 773 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 773 insertions(+) create mode 100644 ptpython/layout.py (limited to 'ptpython/layout.py') diff --git a/ptpython/layout.py b/ptpython/layout.py new file mode 100644 index 0000000..2c1ec15 --- /dev/null +++ b/ptpython/layout.py @@ -0,0 +1,773 @@ +""" +Creation of the `Layout` instance for the Python input/REPL. +""" +from __future__ import annotations + +import platform +import sys +from enum import Enum +from inspect import _ParameterKind as ParameterKind +from typing import TYPE_CHECKING, Any + +from prompt_toolkit.application import get_app +from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER +from prompt_toolkit.filters import ( + Condition, + has_focus, + is_done, + renderer_height_is_known, +) +from prompt_toolkit.formatted_text import fragment_list_width, to_formatted_text +from prompt_toolkit.formatted_text.base import StyleAndTextTuples +from prompt_toolkit.key_binding.vi_state import InputMode +from prompt_toolkit.layout.containers import ( + AnyContainer, + ConditionalContainer, + Container, + Float, + FloatContainer, + HSplit, + ScrollOffsets, + VSplit, + Window, +) +from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +from prompt_toolkit.layout.dimension import AnyDimension, Dimension +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.layout.margins import PromptMargin +from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu +from prompt_toolkit.layout.processors import ( + AppendAutoSuggestion, + ConditionalProcessor, + DisplayMultipleCursors, + HighlightIncrementalSearchProcessor, + HighlightMatchingBracketProcessor, + HighlightSelectionProcessor, + Processor, + TabsProcessor, +) +from prompt_toolkit.lexers import Lexer, SimpleLexer +from prompt_toolkit.mouse_events import MouseEvent +from prompt_toolkit.selection import SelectionType +from prompt_toolkit.widgets.toolbars import ( + ArgToolbar, + CompletionsToolbar, + SearchToolbar, + SystemToolbar, + ValidationToolbar, +) + +from .filters import HasSignature, ShowDocstring, ShowSidebar, ShowSignature +from .prompt_style import PromptStyle +from .utils import if_mousedown + +if TYPE_CHECKING: + from .python_input import OptionCategory, PythonInput + +__all__ = ["PtPythonLayout", "CompletionVisualisation"] + + +class CompletionVisualisation(Enum): + "Visualisation method for the completions." + + NONE = "none" + POP_UP = "pop-up" + MULTI_COLUMN = "multi-column" + TOOLBAR = "toolbar" + + +def show_completions_toolbar(python_input: PythonInput) -> Condition: + return Condition( + lambda: python_input.completion_visualisation == CompletionVisualisation.TOOLBAR + ) + + +def show_completions_menu(python_input: PythonInput) -> Condition: + return Condition( + lambda: python_input.completion_visualisation == CompletionVisualisation.POP_UP + ) + + +def show_multi_column_completions_menu(python_input: PythonInput) -> Condition: + return Condition( + lambda: python_input.completion_visualisation + == CompletionVisualisation.MULTI_COLUMN + ) + + +def python_sidebar(python_input: PythonInput) -> Window: + """ + Create the `Layout` for the sidebar with the configurable options. + """ + + def get_text_fragments() -> StyleAndTextTuples: + tokens: StyleAndTextTuples = [] + + def append_category(category: OptionCategory[Any]) -> None: + tokens.extend( + [ + ("class:sidebar", " "), + ("class:sidebar.title", " %-36s" % category.title), + ("class:sidebar", "\n"), + ] + ) + + def append(index: int, label: str, status: str) -> None: + selected = index == python_input.selected_option_index + + @if_mousedown + def select_item(mouse_event: MouseEvent) -> None: + python_input.selected_option_index = index + + @if_mousedown + def goto_next(mouse_event: MouseEvent) -> None: + "Select item and go to next value." + python_input.selected_option_index = index + option = python_input.selected_option + option.activate_next() + + sel = ",selected" if selected else "" + + tokens.append(("class:sidebar" + sel, " >" if selected else " ")) + tokens.append(("class:sidebar.label" + sel, "%-24s" % label, select_item)) + tokens.append(("class:sidebar.status" + sel, " ", select_item)) + tokens.append(("class:sidebar.status" + sel, "%s" % status, goto_next)) + + if selected: + tokens.append(("[SetCursorPosition]", "")) + + tokens.append( + ("class:sidebar.status" + sel, " " * (13 - len(status)), goto_next) + ) + tokens.append(("class:sidebar", "<" if selected else "")) + tokens.append(("class:sidebar", "\n")) + + i = 0 + for category in python_input.options: + append_category(category) + + for option in category.options: + append(i, option.title, str(option.get_current_value())) + i += 1 + + tokens.pop() # Remove last newline. + + return tokens + + class Control(FormattedTextControl): + def move_cursor_down(self) -> None: + python_input.selected_option_index += 1 + + def move_cursor_up(self) -> None: + python_input.selected_option_index -= 1 + + return Window( + Control(get_text_fragments), + style="class:sidebar", + width=Dimension.exact(43), + height=Dimension(min=3), + scroll_offsets=ScrollOffsets(top=1, bottom=1), + ) + + +def python_sidebar_navigation(python_input: PythonInput) -> Window: + """ + Create the `Layout` showing the navigation information for the sidebar. + """ + + def get_text_fragments() -> StyleAndTextTuples: + # Show navigation info. + return [ + ("class:sidebar", " "), + ("class:sidebar.key", "[Arrows]"), + ("class:sidebar", " "), + ("class:sidebar.description", "Navigate"), + ("class:sidebar", " "), + ("class:sidebar.key", "[Enter]"), + ("class:sidebar", " "), + ("class:sidebar.description", "Hide menu"), + ] + + return Window( + FormattedTextControl(get_text_fragments), + style="class:sidebar", + width=Dimension.exact(43), + height=Dimension.exact(1), + ) + + +def python_sidebar_help(python_input: PythonInput) -> Container: + """ + Create the `Layout` for the help text for the current item in the sidebar. + """ + token = "class:sidebar.helptext" + + def get_current_description() -> str: + """ + Return the description of the selected option. + """ + i = 0 + for category in python_input.options: + for option in category.options: + if i == python_input.selected_option_index: + return option.description + i += 1 + return "" + + def get_help_text() -> StyleAndTextTuples: + return [(token, get_current_description())] + + return ConditionalContainer( + content=Window( + FormattedTextControl(get_help_text), + style=token, + height=Dimension(min=3), + wrap_lines=True, + ), + filter=ShowSidebar(python_input) + & Condition(lambda: python_input.show_sidebar_help) + & ~is_done, + ) + + +def signature_toolbar(python_input: PythonInput) -> Container: + """ + Return the `Layout` for the signature. + """ + + def get_text_fragments() -> StyleAndTextTuples: + result: StyleAndTextTuples = [] + append = result.append + Signature = "class:signature-toolbar" + + if python_input.signatures: + sig = python_input.signatures[0] # Always take the first one. + + append((Signature, " ")) + try: + append((Signature, sig.name)) + except IndexError: + # Workaround for #37: https://github.com/jonathanslenders/python-prompt-toolkit/issues/37 + # See also: https://github.com/davidhalter/jedi/issues/490 + return [] + + append((Signature + ",operator", "(")) + + got_positional_only = False + got_keyword_only = False + + for i, p in enumerate(sig.parameters): + # Detect transition between positional-only and not positional-only. + if p.kind == ParameterKind.POSITIONAL_ONLY: + got_positional_only = True + if got_positional_only and p.kind != ParameterKind.POSITIONAL_ONLY: + got_positional_only = False + append((Signature, "/")) + append((Signature + ",operator", ", ")) + + if not got_keyword_only and p.kind == ParameterKind.KEYWORD_ONLY: + got_keyword_only = True + append((Signature, "*")) + append((Signature + ",operator", ", ")) + + sig_index = getattr(sig, "index", 0) + + if i == sig_index: + # Note: we use `_Param.description` instead of + # `_Param.name`, that way we also get the '*' before args. + append((Signature + ",current-name", p.description)) + else: + append((Signature, p.description)) + + if p.default: + # NOTE: For the jedi-based completion, the default is + # currently still part of the name. + append((Signature, f"={p.default}")) + + append((Signature + ",operator", ", ")) + + if sig.parameters: + # Pop last comma + result.pop() + + append((Signature + ",operator", ")")) + append((Signature, " ")) + return result + + return ConditionalContainer( + content=Window( + FormattedTextControl(get_text_fragments), height=Dimension.exact(1) + ), + # Show only when there is a signature + filter=HasSignature(python_input) + & + # Signature needs to be shown. + ShowSignature(python_input) + & + # And no sidebar is visible. + ~ShowSidebar(python_input) + & + # Not done yet. + ~is_done, + ) + + +class PythonPromptMargin(PromptMargin): + """ + Create margin that displays the prompt. + It shows something like "In [1]:". + """ + + def __init__(self, python_input: PythonInput) -> None: + self.python_input = python_input + + def get_prompt_style() -> PromptStyle: + return python_input.all_prompt_styles[python_input.prompt_style] + + def get_prompt() -> StyleAndTextTuples: + return to_formatted_text(get_prompt_style().in_prompt()) + + def get_continuation( + width: int, line_number: int, is_soft_wrap: bool + ) -> StyleAndTextTuples: + if python_input.show_line_numbers and not is_soft_wrap: + text = ("%i " % (line_number + 1)).rjust(width) + return [("class:line-number", text)] + else: + return to_formatted_text(get_prompt_style().in2_prompt(width)) + + super().__init__(get_prompt, get_continuation) + + +def status_bar(python_input: PythonInput) -> Container: + """ + Create the `Layout` for the status bar. + """ + TB = "class:status-toolbar" + + @if_mousedown + def toggle_paste_mode(mouse_event: MouseEvent) -> None: + python_input.paste_mode = not python_input.paste_mode + + @if_mousedown + def enter_history(mouse_event: MouseEvent) -> None: + python_input.enter_history() + + def get_text_fragments() -> StyleAndTextTuples: + python_buffer = python_input.default_buffer + + result: StyleAndTextTuples = [] + append = result.append + + append((TB, " ")) + result.extend(get_inputmode_fragments(python_input)) + append((TB, " ")) + + # Position in history. + append( + ( + TB, + "%i/%i " + % (python_buffer.working_index + 1, len(python_buffer._working_lines)), + ) + ) + + # Shortcuts. + app = get_app() + if ( + not python_input.vi_mode + and app.current_buffer == python_input.search_buffer + ): + append((TB, "[Ctrl-G] Cancel search [Enter] Go to this position.")) + elif bool(app.current_buffer.selection_state) and not python_input.vi_mode: + # Emacs cut/copy keys. + append((TB, "[Ctrl-W] Cut [Meta-W] Copy [Ctrl-Y] Paste [Ctrl-G] Cancel")) + else: + result.extend( + [ + (TB + " class:status-toolbar.key", "[F3]", enter_history), + (TB, " History ", enter_history), + (TB + " class:status-toolbar.key", "[F6]", toggle_paste_mode), + (TB, " ", toggle_paste_mode), + ] + ) + + if python_input.paste_mode: + append( + (TB + " class:paste-mode-on", "Paste mode (on)", toggle_paste_mode) + ) + else: + append((TB, "Paste mode", toggle_paste_mode)) + + return result + + return ConditionalContainer( + content=Window(content=FormattedTextControl(get_text_fragments), style=TB), + filter=~is_done + & renderer_height_is_known + & Condition( + lambda: python_input.show_status_bar + and not python_input.show_exit_confirmation + ), + ) + + +def get_inputmode_fragments(python_input: PythonInput) -> StyleAndTextTuples: + """ + Return current input mode as a list of (token, text) tuples for use in a + toolbar. + """ + app = get_app() + + @if_mousedown + def toggle_vi_mode(mouse_event: MouseEvent) -> None: + python_input.vi_mode = not python_input.vi_mode + + token = "class:status-toolbar" + input_mode_t = "class:status-toolbar.input-mode" + + mode = app.vi_state.input_mode + result: StyleAndTextTuples = [] + append = result.append + + if python_input.title: + result.extend(to_formatted_text(python_input.title)) + + append((input_mode_t, "[F4] ", toggle_vi_mode)) + + # InputMode + if python_input.vi_mode: + recording_register = app.vi_state.recording_register + if recording_register: + append((token, " ")) + append((token + " class:record", f"RECORD({recording_register})")) + append((token, " - ")) + + if app.current_buffer.selection_state is not None: + if app.current_buffer.selection_state.type == SelectionType.LINES: + append((input_mode_t, "Vi (VISUAL LINE)", toggle_vi_mode)) + elif app.current_buffer.selection_state.type == SelectionType.CHARACTERS: + append((input_mode_t, "Vi (VISUAL)", toggle_vi_mode)) + append((token, " ")) + elif app.current_buffer.selection_state.type == SelectionType.BLOCK: + append((input_mode_t, "Vi (VISUAL BLOCK)", toggle_vi_mode)) + append((token, " ")) + elif mode in (InputMode.INSERT, "vi-insert-multiple"): + append((input_mode_t, "Vi (INSERT)", toggle_vi_mode)) + append((token, " ")) + elif mode == InputMode.NAVIGATION: + append((input_mode_t, "Vi (NAV)", toggle_vi_mode)) + append((token, " ")) + elif mode == InputMode.REPLACE: + append((input_mode_t, "Vi (REPLACE)", toggle_vi_mode)) + append((token, " ")) + else: + if app.emacs_state.is_recording: + append((token, " ")) + append((token + " class:record", "RECORD")) + append((token, " - ")) + + append((input_mode_t, "Emacs", toggle_vi_mode)) + append((token, " ")) + + return result + + +def show_sidebar_button_info(python_input: PythonInput) -> Container: + """ + Create `Layout` for the information in the right-bottom corner. + (The right part of the status bar.) + """ + + @if_mousedown + def toggle_sidebar(mouse_event: MouseEvent) -> None: + "Click handler for the menu." + python_input.show_sidebar = not python_input.show_sidebar + + version = sys.version_info + tokens: StyleAndTextTuples = [ + ("class:status-toolbar.key", "[F2]", toggle_sidebar), + ("class:status-toolbar", " Menu", toggle_sidebar), + ("class:status-toolbar", " - "), + ( + "class:status-toolbar.python-version", + "%s %i.%i.%i" + % (platform.python_implementation(), version[0], version[1], version[2]), + ), + ("class:status-toolbar", " "), + ] + width = fragment_list_width(tokens) + + def get_text_fragments() -> StyleAndTextTuples: + # Python version + return tokens + + return ConditionalContainer( + content=Window( + FormattedTextControl(get_text_fragments), + style="class:status-toolbar", + height=Dimension.exact(1), + width=Dimension.exact(width), + ), + filter=~is_done + & renderer_height_is_known + & Condition( + lambda: python_input.show_status_bar + and not python_input.show_exit_confirmation + ), + ) + + +def create_exit_confirmation( + python_input: PythonInput, style: str = "class:exit-confirmation" +) -> Container: + """ + Create `Layout` for the exit message. + """ + + def get_text_fragments() -> StyleAndTextTuples: + # Show "Do you really want to exit?" + return [ + (style, "\n %s ([y]/n) " % python_input.exit_message), + ("[SetCursorPosition]", ""), + (style, " \n"), + ] + + visible = ~is_done & Condition(lambda: python_input.show_exit_confirmation) + + return ConditionalContainer( + content=Window( + FormattedTextControl(get_text_fragments, focusable=True), style=style + ), + filter=visible, + ) + + +def meta_enter_message(python_input: PythonInput) -> Container: + """ + Create the `Layout` for the 'Meta+Enter` message. + """ + + def get_text_fragments() -> StyleAndTextTuples: + return [("class:accept-message", " [Meta+Enter] Execute ")] + + @Condition + def extra_condition() -> bool: + "Only show when..." + b = python_input.default_buffer + + return ( + python_input.show_meta_enter_message + and ( + not b.document.is_cursor_at_the_end + or python_input.accept_input_on_enter is None + ) + and "\n" in b.text + ) + + visible = ~is_done & has_focus(DEFAULT_BUFFER) & extra_condition + + return ConditionalContainer( + content=Window(FormattedTextControl(get_text_fragments)), filter=visible + ) + + +class PtPythonLayout: + def __init__( + self, + python_input: PythonInput, + lexer: Lexer, + extra_body: AnyContainer | None = None, + extra_toolbars: list[AnyContainer] | None = None, + extra_buffer_processors: list[Processor] | None = None, + input_buffer_height: AnyDimension | None = None, + ) -> None: + D = Dimension + extra_body_list: list[AnyContainer] = [extra_body] if extra_body else [] + extra_toolbars = extra_toolbars or [] + + input_buffer_height = input_buffer_height or D(min=6) + + search_toolbar = SearchToolbar(python_input.search_buffer) + + def create_python_input_window() -> Window: + def menu_position() -> int | None: + """ + When there is no autocompletion menu to be shown, and we have a + signature, set the pop-up position at `bracket_start`. + """ + b = python_input.default_buffer + + if python_input.signatures: + row, col = python_input.signatures[0].bracket_start + index = b.document.translate_row_col_to_index(row - 1, col) + return index + return None + + return Window( + BufferControl( + buffer=python_input.default_buffer, + search_buffer_control=search_toolbar.control, + lexer=lexer, + include_default_input_processors=False, + input_processors=[ + ConditionalProcessor( + processor=HighlightIncrementalSearchProcessor(), + filter=has_focus(SEARCH_BUFFER) + | has_focus(search_toolbar.control), + ), + HighlightSelectionProcessor(), + DisplayMultipleCursors(), + TabsProcessor(), + # Show matching parentheses, but only while editing. + ConditionalProcessor( + processor=HighlightMatchingBracketProcessor(chars="[](){}"), + filter=has_focus(DEFAULT_BUFFER) + & ~is_done + & Condition( + lambda: python_input.highlight_matching_parenthesis + ), + ), + ConditionalProcessor( + processor=AppendAutoSuggestion(), filter=~is_done + ), + ] + + (extra_buffer_processors or []), + menu_position=menu_position, + # Make sure that we always see the result of an reverse-i-search: + preview_search=True, + ), + left_margins=[PythonPromptMargin(python_input)], + # Scroll offsets. The 1 at the bottom is important to make sure + # the cursor is never below the "Press [Meta+Enter]" message + # which is a float. + scroll_offsets=ScrollOffsets(bottom=1, left=4, right=4), + # As long as we're editing, prefer a minimal height of 6. + height=( + lambda: ( + None + if get_app().is_done or python_input.show_exit_confirmation + else input_buffer_height + ) + ), + wrap_lines=Condition(lambda: python_input.wrap_lines), + ) + + sidebar = python_sidebar(python_input) + self.exit_confirmation = create_exit_confirmation(python_input) + + self.root_container = HSplit( + [ + VSplit( + [ + HSplit( + [ + FloatContainer( + content=HSplit( + [create_python_input_window()] + extra_body_list + ), + floats=[ + Float( + xcursor=True, + ycursor=True, + content=HSplit( + [ + signature_toolbar(python_input), + ConditionalContainer( + content=CompletionsMenu( + scroll_offset=( + lambda: python_input.completion_menu_scroll_offset + ), + max_height=12, + ), + filter=show_completions_menu( + python_input + ), + ), + ConditionalContainer( + content=MultiColumnCompletionsMenu(), + filter=show_multi_column_completions_menu( + python_input + ), + ), + ] + ), + ), + Float( + left=2, + bottom=1, + content=self.exit_confirmation, + ), + Float( + bottom=0, + right=0, + height=1, + content=meta_enter_message(python_input), + hide_when_covering_content=True, + ), + Float( + bottom=1, + left=1, + right=0, + content=python_sidebar_help(python_input), + ), + ], + ), + ArgToolbar(), + search_toolbar, + SystemToolbar(), + ValidationToolbar(), + ConditionalContainer( + content=CompletionsToolbar(), + filter=show_completions_toolbar(python_input) + & ~is_done, + ), + # Docstring region. + ConditionalContainer( + content=Window( + height=D.exact(1), + char="\u2500", + style="class:separator", + ), + filter=HasSignature(python_input) + & ShowDocstring(python_input) + & ~is_done, + ), + ConditionalContainer( + content=Window( + BufferControl( + buffer=python_input.docstring_buffer, + lexer=SimpleLexer(style="class:docstring"), + # lexer=PythonLexer, + ), + height=D(max=12), + ), + filter=HasSignature(python_input) + & ShowDocstring(python_input) + & ~is_done, + ), + ] + ), + ConditionalContainer( + content=HSplit( + [ + sidebar, + Window(style="class:sidebar,separator", height=1), + python_sidebar_navigation(python_input), + ] + ), + filter=ShowSidebar(python_input) & ~is_done, + ), + ] + ) + ] + + extra_toolbars + + [ + VSplit( + [status_bar(python_input), show_sidebar_button_info(python_input)] + ) + ] + ) + + self.layout = Layout(self.root_container) + self.sidebar = sidebar -- cgit v1.2.3