1"""
2Creation of the `Layout` instance for the Python input/REPL.
3"""
4import platform
5import sys
6from enum import Enum
7from inspect import _ParameterKind as ParameterKind
8from typing import TYPE_CHECKING, Optional
9
10from prompt_toolkit.application import get_app
11from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
12from prompt_toolkit.filters import (
13    Condition,
14    has_focus,
15    is_done,
16    renderer_height_is_known,
17)
18from prompt_toolkit.formatted_text import fragment_list_width, to_formatted_text
19from prompt_toolkit.formatted_text.base import StyleAndTextTuples
20from prompt_toolkit.key_binding.vi_state import InputMode
21from prompt_toolkit.layout.containers import (
22    ConditionalContainer,
23    Container,
24    Float,
25    FloatContainer,
26    HSplit,
27    ScrollOffsets,
28    VSplit,
29    Window,
30)
31from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
32from prompt_toolkit.layout.dimension import AnyDimension, Dimension
33from prompt_toolkit.layout.layout import Layout
34from prompt_toolkit.layout.margins import PromptMargin
35from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu
36from prompt_toolkit.layout.processors import (
37    AppendAutoSuggestion,
38    ConditionalProcessor,
39    DisplayMultipleCursors,
40    HighlightIncrementalSearchProcessor,
41    HighlightMatchingBracketProcessor,
42    HighlightSelectionProcessor,
43    TabsProcessor,
44)
45from prompt_toolkit.lexers import SimpleLexer
46from prompt_toolkit.mouse_events import MouseEvent
47from prompt_toolkit.selection import SelectionType
48from prompt_toolkit.widgets.toolbars import (
49    ArgToolbar,
50    CompletionsToolbar,
51    SearchToolbar,
52    SystemToolbar,
53    ValidationToolbar,
54)
55from pygments.lexers import PythonLexer
56
57from .filters import HasSignature, ShowDocstring, ShowSidebar, ShowSignature
58from .utils import if_mousedown
59
60if TYPE_CHECKING:
61    from .python_input import OptionCategory, PythonInput
62
63__all__ = ["PtPythonLayout", "CompletionVisualisation"]
64
65
66class CompletionVisualisation(Enum):
67    "Visualisation method for the completions."
68    NONE = "none"
69    POP_UP = "pop-up"
70    MULTI_COLUMN = "multi-column"
71    TOOLBAR = "toolbar"
72
73
74def show_completions_toolbar(python_input: "PythonInput") -> Condition:
75    return Condition(
76        lambda: python_input.completion_visualisation == CompletionVisualisation.TOOLBAR
77    )
78
79
80def show_completions_menu(python_input: "PythonInput") -> Condition:
81    return Condition(
82        lambda: python_input.completion_visualisation == CompletionVisualisation.POP_UP
83    )
84
85
86def show_multi_column_completions_menu(python_input: "PythonInput") -> Condition:
87    return Condition(
88        lambda: python_input.completion_visualisation
89        == CompletionVisualisation.MULTI_COLUMN
90    )
91
92
93def python_sidebar(python_input: "PythonInput") -> Window:
94    """
95    Create the `Layout` for the sidebar with the configurable options.
96    """
97
98    def get_text_fragments() -> StyleAndTextTuples:
99        tokens: StyleAndTextTuples = []
100
101        def append_category(category: "OptionCategory") -> None:
102            tokens.extend(
103                [
104                    ("class:sidebar", "  "),
105                    ("class:sidebar.title", "   %-36s" % category.title),
106                    ("class:sidebar", "\n"),
107                ]
108            )
109
110        def append(index: int, label: str, status: str) -> None:
111            selected = index == python_input.selected_option_index
112
113            @if_mousedown
114            def select_item(mouse_event: MouseEvent) -> None:
115                python_input.selected_option_index = index
116
117            @if_mousedown
118            def goto_next(mouse_event: MouseEvent) -> None:
119                "Select item and go to next value."
120                python_input.selected_option_index = index
121                option = python_input.selected_option
122                option.activate_next()
123
124            sel = ",selected" if selected else ""
125
126            tokens.append(("class:sidebar" + sel, " >" if selected else "  "))
127            tokens.append(("class:sidebar.label" + sel, "%-24s" % label, select_item))
128            tokens.append(("class:sidebar.status" + sel, " ", select_item))
129            tokens.append(("class:sidebar.status" + sel, "%s" % status, goto_next))
130
131            if selected:
132                tokens.append(("[SetCursorPosition]", ""))
133
134            tokens.append(
135                ("class:sidebar.status" + sel, " " * (13 - len(status)), goto_next)
136            )
137            tokens.append(("class:sidebar", "<" if selected else ""))
138            tokens.append(("class:sidebar", "\n"))
139
140        i = 0
141        for category in python_input.options:
142            append_category(category)
143
144            for option in category.options:
145                append(i, option.title, "%s" % option.get_current_value())
146                i += 1
147
148        tokens.pop()  # Remove last newline.
149
150        return tokens
151
152    class Control(FormattedTextControl):
153        def move_cursor_down(self):
154            python_input.selected_option_index += 1
155
156        def move_cursor_up(self):
157            python_input.selected_option_index -= 1
158
159    return Window(
160        Control(get_text_fragments),
161        style="class:sidebar",
162        width=Dimension.exact(43),
163        height=Dimension(min=3),
164        scroll_offsets=ScrollOffsets(top=1, bottom=1),
165    )
166
167
168def python_sidebar_navigation(python_input):
169    """
170    Create the `Layout` showing the navigation information for the sidebar.
171    """
172
173    def get_text_fragments():
174        # Show navigation info.
175        return [
176            ("class:sidebar", "    "),
177            ("class:sidebar.key", "[Arrows]"),
178            ("class:sidebar", " "),
179            ("class:sidebar.description", "Navigate"),
180            ("class:sidebar", " "),
181            ("class:sidebar.key", "[Enter]"),
182            ("class:sidebar", " "),
183            ("class:sidebar.description", "Hide menu"),
184        ]
185
186    return Window(
187        FormattedTextControl(get_text_fragments),
188        style="class:sidebar",
189        width=Dimension.exact(43),
190        height=Dimension.exact(1),
191    )
192
193
194def python_sidebar_help(python_input):
195    """
196    Create the `Layout` for the help text for the current item in the sidebar.
197    """
198    token = "class:sidebar.helptext"
199
200    def get_current_description():
201        """
202        Return the description of the selected option.
203        """
204        i = 0
205        for category in python_input.options:
206            for option in category.options:
207                if i == python_input.selected_option_index:
208                    return option.description
209                i += 1
210        return ""
211
212    def get_help_text():
213        return [(token, get_current_description())]
214
215    return ConditionalContainer(
216        content=Window(
217            FormattedTextControl(get_help_text),
218            style=token,
219            height=Dimension(min=3),
220            wrap_lines=True,
221        ),
222        filter=ShowSidebar(python_input)
223        & Condition(lambda: python_input.show_sidebar_help)
224        & ~is_done,
225    )
226
227
228def signature_toolbar(python_input):
229    """
230    Return the `Layout` for the signature.
231    """
232
233    def get_text_fragments() -> StyleAndTextTuples:
234        result: StyleAndTextTuples = []
235        append = result.append
236        Signature = "class:signature-toolbar"
237
238        if python_input.signatures:
239            sig = python_input.signatures[0]  # Always take the first one.
240
241            append((Signature, " "))
242            try:
243                append((Signature, sig.name))
244            except IndexError:
245                # Workaround for #37: https://github.com/jonathanslenders/python-prompt-toolkit/issues/37
246                # See also: https://github.com/davidhalter/jedi/issues/490
247                return []
248
249            append((Signature + ",operator", "("))
250
251            got_positional_only = False
252            got_keyword_only = False
253
254            for i, p in enumerate(sig.parameters):
255                # Detect transition between positional-only and not positional-only.
256                if p.kind == ParameterKind.POSITIONAL_ONLY:
257                    got_positional_only = True
258                if got_positional_only and p.kind != ParameterKind.POSITIONAL_ONLY:
259                    got_positional_only = False
260                    append((Signature, "/"))
261                    append((Signature + ",operator", ", "))
262
263                if not got_keyword_only and p.kind == ParameterKind.KEYWORD_ONLY:
264                    got_keyword_only = True
265                    append((Signature, "*"))
266                    append((Signature + ",operator", ", "))
267
268                sig_index = getattr(sig, "index", 0)
269
270                if i == sig_index:
271                    # Note: we use `_Param.description` instead of
272                    #       `_Param.name`, that way we also get the '*' before args.
273                    append((Signature + ",current-name", p.description))
274                else:
275                    append((Signature, p.description))
276
277                if p.default:
278                    # NOTE: For the jedi-based completion, the default is
279                    #       currently still part of the name.
280                    append((Signature, f"={p.default}"))
281
282                append((Signature + ",operator", ", "))
283
284            if sig.parameters:
285                # Pop last comma
286                result.pop()
287
288            append((Signature + ",operator", ")"))
289            append((Signature, " "))
290        return result
291
292    return ConditionalContainer(
293        content=Window(
294            FormattedTextControl(get_text_fragments), height=Dimension.exact(1)
295        ),
296        filter=
297        # Show only when there is a signature
298        HasSignature(python_input) &
299        # Signature needs to be shown.
300        ShowSignature(python_input) &
301        # And no sidebar is visible.
302        ~ShowSidebar(python_input) &
303        # Not done yet.
304        ~is_done,
305    )
306
307
308class PythonPromptMargin(PromptMargin):
309    """
310    Create margin that displays the prompt.
311    It shows something like "In [1]:".
312    """
313
314    def __init__(self, python_input) -> None:
315        self.python_input = python_input
316
317        def get_prompt_style():
318            return python_input.all_prompt_styles[python_input.prompt_style]
319
320        def get_prompt() -> StyleAndTextTuples:
321            return to_formatted_text(get_prompt_style().in_prompt())
322
323        def get_continuation(width, line_number, is_soft_wrap):
324            if python_input.show_line_numbers and not is_soft_wrap:
325                text = ("%i " % (line_number + 1)).rjust(width)
326                return [("class:line-number", text)]
327            else:
328                return get_prompt_style().in2_prompt(width)
329
330        super().__init__(get_prompt, get_continuation)
331
332
333def status_bar(python_input: "PythonInput") -> Container:
334    """
335    Create the `Layout` for the status bar.
336    """
337    TB = "class:status-toolbar"
338
339    @if_mousedown
340    def toggle_paste_mode(mouse_event: MouseEvent) -> None:
341        python_input.paste_mode = not python_input.paste_mode
342
343    @if_mousedown
344    def enter_history(mouse_event: MouseEvent) -> None:
345        python_input.enter_history()
346
347    def get_text_fragments() -> StyleAndTextTuples:
348        python_buffer = python_input.default_buffer
349
350        result: StyleAndTextTuples = []
351        append = result.append
352
353        append((TB, " "))
354        result.extend(get_inputmode_fragments(python_input))
355        append((TB, " "))
356
357        # Position in history.
358        append(
359            (
360                TB,
361                "%i/%i "
362                % (python_buffer.working_index + 1, len(python_buffer._working_lines)),
363            )
364        )
365
366        # Shortcuts.
367        app = get_app()
368        if (
369            not python_input.vi_mode
370            and app.current_buffer == python_input.search_buffer
371        ):
372            append((TB, "[Ctrl-G] Cancel search [Enter] Go to this position."))
373        elif bool(app.current_buffer.selection_state) and not python_input.vi_mode:
374            # Emacs cut/copy keys.
375            append((TB, "[Ctrl-W] Cut [Meta-W] Copy [Ctrl-Y] Paste [Ctrl-G] Cancel"))
376        else:
377            result.extend(
378                [
379                    (TB + " class:status-toolbar.key", "[F3]", enter_history),
380                    (TB, " History ", enter_history),
381                    (TB + " class:status-toolbar.key", "[F6]", toggle_paste_mode),
382                    (TB, " ", toggle_paste_mode),
383                ]
384            )
385
386            if python_input.paste_mode:
387                append(
388                    (TB + " class:paste-mode-on", "Paste mode (on)", toggle_paste_mode)
389                )
390            else:
391                append((TB, "Paste mode", toggle_paste_mode))
392
393        return result
394
395    return ConditionalContainer(
396        content=Window(content=FormattedTextControl(get_text_fragments), style=TB),
397        filter=~is_done
398        & renderer_height_is_known
399        & Condition(
400            lambda: python_input.show_status_bar
401            and not python_input.show_exit_confirmation
402        ),
403    )
404
405
406def get_inputmode_fragments(python_input: "PythonInput") -> StyleAndTextTuples:
407    """
408    Return current input mode as a list of (token, text) tuples for use in a
409    toolbar.
410    """
411    app = get_app()
412
413    @if_mousedown
414    def toggle_vi_mode(mouse_event: MouseEvent) -> None:
415        python_input.vi_mode = not python_input.vi_mode
416
417    token = "class:status-toolbar"
418    input_mode_t = "class:status-toolbar.input-mode"
419
420    mode = app.vi_state.input_mode
421    result: StyleAndTextTuples = []
422    append = result.append
423
424    if python_input.title:
425        result.extend(to_formatted_text(python_input.title))
426
427    append((input_mode_t, "[F4] ", toggle_vi_mode))
428
429    # InputMode
430    if python_input.vi_mode:
431        recording_register = app.vi_state.recording_register
432        if recording_register:
433            append((token, " "))
434            append((token + " class:record", "RECORD({})".format(recording_register)))
435            append((token, " - "))
436
437        if app.current_buffer.selection_state is not None:
438            if app.current_buffer.selection_state.type == SelectionType.LINES:
439                append((input_mode_t, "Vi (VISUAL LINE)", toggle_vi_mode))
440            elif app.current_buffer.selection_state.type == SelectionType.CHARACTERS:
441                append((input_mode_t, "Vi (VISUAL)", toggle_vi_mode))
442                append((token, " "))
443            elif app.current_buffer.selection_state.type == SelectionType.BLOCK:
444                append((input_mode_t, "Vi (VISUAL BLOCK)", toggle_vi_mode))
445                append((token, " "))
446        elif mode in (InputMode.INSERT, "vi-insert-multiple"):
447            append((input_mode_t, "Vi (INSERT)", toggle_vi_mode))
448            append((token, "  "))
449        elif mode == InputMode.NAVIGATION:
450            append((input_mode_t, "Vi (NAV)", toggle_vi_mode))
451            append((token, "     "))
452        elif mode == InputMode.REPLACE:
453            append((input_mode_t, "Vi (REPLACE)", toggle_vi_mode))
454            append((token, " "))
455    else:
456        if app.emacs_state.is_recording:
457            append((token, " "))
458            append((token + " class:record", "RECORD"))
459            append((token, " - "))
460
461        append((input_mode_t, "Emacs", toggle_vi_mode))
462        append((token, " "))
463
464    return result
465
466
467def show_sidebar_button_info(python_input: "PythonInput") -> Container:
468    """
469    Create `Layout` for the information in the right-bottom corner.
470    (The right part of the status bar.)
471    """
472
473    @if_mousedown
474    def toggle_sidebar(mouse_event: MouseEvent) -> None:
475        "Click handler for the menu."
476        python_input.show_sidebar = not python_input.show_sidebar
477
478    version = sys.version_info
479    tokens: StyleAndTextTuples = [
480        ("class:status-toolbar.key", "[F2]", toggle_sidebar),
481        ("class:status-toolbar", " Menu", toggle_sidebar),
482        ("class:status-toolbar", " - "),
483        (
484            "class:status-toolbar.python-version",
485            "%s %i.%i.%i"
486            % (platform.python_implementation(), version[0], version[1], version[2]),
487        ),
488        ("class:status-toolbar", " "),
489    ]
490    width = fragment_list_width(tokens)
491
492    def get_text_fragments() -> StyleAndTextTuples:
493        # Python version
494        return tokens
495
496    return ConditionalContainer(
497        content=Window(
498            FormattedTextControl(get_text_fragments),
499            style="class:status-toolbar",
500            height=Dimension.exact(1),
501            width=Dimension.exact(width),
502        ),
503        filter=~is_done
504        & renderer_height_is_known
505        & Condition(
506            lambda: python_input.show_status_bar
507            and not python_input.show_exit_confirmation
508        ),
509    )
510
511
512def create_exit_confirmation(
513    python_input: "PythonInput", style="class:exit-confirmation"
514) -> Container:
515    """
516    Create `Layout` for the exit message.
517    """
518
519    def get_text_fragments() -> StyleAndTextTuples:
520        # Show "Do you really want to exit?"
521        return [
522            (style, "\n %s ([y]/n) " % python_input.exit_message),
523            ("[SetCursorPosition]", ""),
524            (style, "  \n"),
525        ]
526
527    visible = ~is_done & Condition(lambda: python_input.show_exit_confirmation)
528
529    return ConditionalContainer(
530        content=Window(
531            FormattedTextControl(get_text_fragments, focusable=True), style=style
532        ),
533        filter=visible,
534    )
535
536
537def meta_enter_message(python_input: "PythonInput") -> Container:
538    """
539    Create the `Layout` for the 'Meta+Enter` message.
540    """
541
542    def get_text_fragments() -> StyleAndTextTuples:
543        return [("class:accept-message", " [Meta+Enter] Execute ")]
544
545    @Condition
546    def extra_condition() -> bool:
547        "Only show when..."
548        b = python_input.default_buffer
549
550        return (
551            python_input.show_meta_enter_message
552            and (
553                not b.document.is_cursor_at_the_end
554                or python_input.accept_input_on_enter is None
555            )
556            and "\n" in b.text
557        )
558
559    visible = ~is_done & has_focus(DEFAULT_BUFFER) & extra_condition
560
561    return ConditionalContainer(
562        content=Window(FormattedTextControl(get_text_fragments)), filter=visible
563    )
564
565
566class PtPythonLayout:
567    def __init__(
568        self,
569        python_input: "PythonInput",
570        lexer=PythonLexer,
571        extra_body=None,
572        extra_toolbars=None,
573        extra_buffer_processors=None,
574        input_buffer_height: Optional[AnyDimension] = None,
575    ) -> None:
576        D = Dimension
577        extra_body = [extra_body] if extra_body else []
578        extra_toolbars = extra_toolbars or []
579        extra_buffer_processors = extra_buffer_processors or []
580        input_buffer_height = input_buffer_height or D(min=6)
581
582        search_toolbar = SearchToolbar(python_input.search_buffer)
583
584        def create_python_input_window():
585            def menu_position():
586                """
587                When there is no autocompletion menu to be shown, and we have a
588                signature, set the pop-up position at `bracket_start`.
589                """
590                b = python_input.default_buffer
591
592                if python_input.signatures:
593                    row, col = python_input.signatures[0].bracket_start
594                    index = b.document.translate_row_col_to_index(row - 1, col)
595                    return index
596
597            return Window(
598                BufferControl(
599                    buffer=python_input.default_buffer,
600                    search_buffer_control=search_toolbar.control,
601                    lexer=lexer,
602                    include_default_input_processors=False,
603                    input_processors=[
604                        ConditionalProcessor(
605                            processor=HighlightIncrementalSearchProcessor(),
606                            filter=has_focus(SEARCH_BUFFER)
607                            | has_focus(search_toolbar.control),
608                        ),
609                        HighlightSelectionProcessor(),
610                        DisplayMultipleCursors(),
611                        TabsProcessor(),
612                        # Show matching parentheses, but only while editing.
613                        ConditionalProcessor(
614                            processor=HighlightMatchingBracketProcessor(chars="[](){}"),
615                            filter=has_focus(DEFAULT_BUFFER)
616                            & ~is_done
617                            & Condition(
618                                lambda: python_input.highlight_matching_parenthesis
619                            ),
620                        ),
621                        ConditionalProcessor(
622                            processor=AppendAutoSuggestion(), filter=~is_done
623                        ),
624                    ]
625                    + extra_buffer_processors,
626                    menu_position=menu_position,
627                    # Make sure that we always see the result of an reverse-i-search:
628                    preview_search=True,
629                ),
630                left_margins=[PythonPromptMargin(python_input)],
631                # Scroll offsets. The 1 at the bottom is important to make sure
632                # the cursor is never below the "Press [Meta+Enter]" message
633                # which is a float.
634                scroll_offsets=ScrollOffsets(bottom=1, left=4, right=4),
635                # As long as we're editing, prefer a minimal height of 6.
636                height=(
637                    lambda: (
638                        None
639                        if get_app().is_done or python_input.show_exit_confirmation
640                        else input_buffer_height
641                    )
642                ),
643                wrap_lines=Condition(lambda: python_input.wrap_lines),
644            )
645
646        sidebar = python_sidebar(python_input)
647        self.exit_confirmation = create_exit_confirmation(python_input)
648
649        self.root_container = HSplit(
650            [
651                VSplit(
652                    [
653                        HSplit(
654                            [
655                                FloatContainer(
656                                    content=HSplit(
657                                        [create_python_input_window()] + extra_body
658                                    ),
659                                    floats=[
660                                        Float(
661                                            xcursor=True,
662                                            ycursor=True,
663                                            content=HSplit(
664                                                [
665                                                    signature_toolbar(python_input),
666                                                    ConditionalContainer(
667                                                        content=CompletionsMenu(
668                                                            scroll_offset=(
669                                                                lambda: python_input.completion_menu_scroll_offset
670                                                            ),
671                                                            max_height=12,
672                                                        ),
673                                                        filter=show_completions_menu(
674                                                            python_input
675                                                        ),
676                                                    ),
677                                                    ConditionalContainer(
678                                                        content=MultiColumnCompletionsMenu(),
679                                                        filter=show_multi_column_completions_menu(
680                                                            python_input
681                                                        ),
682                                                    ),
683                                                ]
684                                            ),
685                                        ),
686                                        Float(
687                                            left=2,
688                                            bottom=1,
689                                            content=self.exit_confirmation,
690                                        ),
691                                        Float(
692                                            bottom=0,
693                                            right=0,
694                                            height=1,
695                                            content=meta_enter_message(python_input),
696                                            hide_when_covering_content=True,
697                                        ),
698                                        Float(
699                                            bottom=1,
700                                            left=1,
701                                            right=0,
702                                            content=python_sidebar_help(python_input),
703                                        ),
704                                    ],
705                                ),
706                                ArgToolbar(),
707                                search_toolbar,
708                                SystemToolbar(),
709                                ValidationToolbar(),
710                                ConditionalContainer(
711                                    content=CompletionsToolbar(),
712                                    filter=show_completions_toolbar(python_input)
713                                    & ~is_done,
714                                ),
715                                # Docstring region.
716                                ConditionalContainer(
717                                    content=Window(
718                                        height=D.exact(1),
719                                        char="\u2500",
720                                        style="class:separator",
721                                    ),
722                                    filter=HasSignature(python_input)
723                                    & ShowDocstring(python_input)
724                                    & ~is_done,
725                                ),
726                                ConditionalContainer(
727                                    content=Window(
728                                        BufferControl(
729                                            buffer=python_input.docstring_buffer,
730                                            lexer=SimpleLexer(style="class:docstring"),
731                                            # lexer=PythonLexer,
732                                        ),
733                                        height=D(max=12),
734                                    ),
735                                    filter=HasSignature(python_input)
736                                    & ShowDocstring(python_input)
737                                    & ~is_done,
738                                ),
739                            ]
740                        ),
741                        ConditionalContainer(
742                            content=HSplit(
743                                [
744                                    sidebar,
745                                    Window(style="class:sidebar,separator", height=1),
746                                    python_sidebar_navigation(python_input),
747                                ]
748                            ),
749                            filter=ShowSidebar(python_input) & ~is_done,
750                        ),
751                    ]
752                )
753            ]
754            + extra_toolbars
755            + [
756                VSplit(
757                    [status_bar(python_input), show_sidebar_button_info(python_input)]
758                )
759            ]
760        )
761
762        self.layout = Layout(self.root_container)
763        self.sidebar = sidebar
764