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