summaryrefslogtreecommitdiffstats
path: root/ptpython/layout.py
diff options
context:
space:
mode:
Diffstat (limited to 'ptpython/layout.py')
-rw-r--r--ptpython/layout.py763
1 files changed, 763 insertions, 0 deletions
diff --git a/ptpython/layout.py b/ptpython/layout.py
new file mode 100644
index 0000000..6482cbd
--- /dev/null
+++ b/ptpython/layout.py
@@ -0,0 +1,763 @@
+"""
+Creation of the `Layout` instance for the Python input/REPL.
+"""
+import platform
+import sys
+from enum import Enum
+from inspect import _ParameterKind as ParameterKind
+from typing import TYPE_CHECKING, Optional
+
+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 (
+ 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,
+ TabsProcessor,
+)
+from prompt_toolkit.lexers import 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 pygments.lexers import PythonLexer
+
+from .filters import HasSignature, ShowDocstring, ShowSidebar, ShowSignature
+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") -> 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, "%s" % option.get_current_value())
+ i += 1
+
+ tokens.pop() # Remove last newline.
+
+ return tokens
+
+ class Control(FormattedTextControl):
+ def move_cursor_down(self):
+ python_input.selected_option_index += 1
+
+ def move_cursor_up(self):
+ 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):
+ """
+ Create the `Layout` showing the navigation information for the sidebar.
+ """
+
+ def get_text_fragments():
+ # 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):
+ """
+ Create the `Layout` for the help text for the current item in the sidebar.
+ """
+ token = "class:sidebar.helptext"
+
+ def get_current_description():
+ """
+ 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():
+ 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):
+ """
+ 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)
+ ),
+ filter=
+ # Show only when there is a signature
+ 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) -> None:
+ self.python_input = python_input
+
+ def get_prompt_style():
+ 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, line_number, is_soft_wrap):
+ 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 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", "RECORD({})".format(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="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=PythonLexer,
+ extra_body=None,
+ extra_toolbars=None,
+ extra_buffer_processors=None,
+ input_buffer_height: Optional[AnyDimension] = None,
+ ) -> None:
+ D = Dimension
+ extra_body = [extra_body] if extra_body else []
+ extra_toolbars = extra_toolbars or []
+ extra_buffer_processors = extra_buffer_processors or []
+ input_buffer_height = input_buffer_height or D(min=6)
+
+ search_toolbar = SearchToolbar(python_input.search_buffer)
+
+ def create_python_input_window():
+ def menu_position():
+ """
+ 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 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,
+ 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)
+
+ root_container = HSplit(
+ [
+ VSplit(
+ [
+ HSplit(
+ [
+ FloatContainer(
+ content=HSplit(
+ [create_python_input_window()] + extra_body
+ ),
+ 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(root_container)
+ self.sidebar = sidebar