""" 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