1""" 2Utility to easily select lines from the history and execute them again. 3 4`create_history_application` creates an `Application` instance that runs will 5run as a sub application of the Repl/PythonInput. 6""" 7from functools import partial 8 9from prompt_toolkit.application import Application 10from prompt_toolkit.application.current import get_app 11from prompt_toolkit.buffer import Buffer 12from prompt_toolkit.document import Document 13from prompt_toolkit.enums import DEFAULT_BUFFER 14from prompt_toolkit.filters import Condition, has_focus 15from prompt_toolkit.formatted_text.utils import fragment_list_to_text 16from prompt_toolkit.key_binding import KeyBindings 17from prompt_toolkit.layout.containers import ( 18 ConditionalContainer, 19 Container, 20 Float, 21 FloatContainer, 22 HSplit, 23 ScrollOffsets, 24 VSplit, 25 Window, 26 WindowAlign, 27) 28from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl 29from prompt_toolkit.layout.dimension import Dimension as D 30from prompt_toolkit.layout.layout import Layout 31from prompt_toolkit.layout.margins import Margin, ScrollbarMargin 32from prompt_toolkit.layout.processors import Processor, Transformation 33from prompt_toolkit.lexers import PygmentsLexer 34from prompt_toolkit.widgets import Frame 35from prompt_toolkit.widgets.toolbars import ArgToolbar, SearchToolbar 36from pygments.lexers import Python3Lexer as PythonLexer 37from pygments.lexers import RstLexer 38 39from ptpython.layout import get_inputmode_fragments 40 41from .utils import if_mousedown 42 43HISTORY_COUNT = 2000 44 45__all__ = ["HistoryLayout", "PythonHistory"] 46 47HELP_TEXT = """ 48This interface is meant to select multiple lines from the 49history and execute them together. 50 51Typical usage 52------------- 53 541. Move the ``cursor up`` in the history pane, until the 55 cursor is on the first desired line. 562. Hold down the ``space bar``, or press it multiple 57 times. Each time it will select one line and move to 58 the next one. Each selected line will appear on the 59 right side. 603. When all the required lines are displayed on the right 61 side, press ``Enter``. This will go back to the Python 62 REPL and show these lines as the current input. They 63 can still be edited from there. 64 65Key bindings 66------------ 67 68Many Emacs and Vi navigation key bindings should work. 69Press ``F4`` to switch between Emacs and Vi mode. 70 71Additional bindings: 72 73- ``Space``: Select or delect a line. 74- ``Tab``: Move the focus between the history and input 75 pane. (Alternative: ``Ctrl-W``) 76- ``Ctrl-C``: Cancel. Ignore the result and go back to 77 the REPL. (Alternatives: ``q`` and ``Control-G``.) 78- ``Enter``: Accept the result and go back to the REPL. 79- ``F1``: Show/hide help. Press ``Enter`` to quit this 80 help message. 81 82Further, remember that searching works like in Emacs 83(using ``Ctrl-R``) or Vi (using ``/``). 84""" 85 86 87class BORDER: 88 "Box drawing characters." 89 HORIZONTAL = "\u2501" 90 VERTICAL = "\u2503" 91 TOP_LEFT = "\u250f" 92 TOP_RIGHT = "\u2513" 93 BOTTOM_LEFT = "\u2517" 94 BOTTOM_RIGHT = "\u251b" 95 LIGHT_VERTICAL = "\u2502" 96 97 98def _create_popup_window(title: str, body: Container) -> Frame: 99 """ 100 Return the layout for a pop-up window. It consists of a title bar showing 101 the `title` text, and a body layout. The window is surrounded by borders. 102 """ 103 return Frame(body=body, title=title) 104 105 106class HistoryLayout: 107 """ 108 Create and return a `Container` instance for the history 109 application. 110 """ 111 112 def __init__(self, history): 113 search_toolbar = SearchToolbar() 114 115 self.help_buffer_control = BufferControl( 116 buffer=history.help_buffer, lexer=PygmentsLexer(RstLexer) 117 ) 118 119 help_window = _create_popup_window( 120 title="History Help", 121 body=Window( 122 content=self.help_buffer_control, 123 right_margins=[ScrollbarMargin(display_arrows=True)], 124 scroll_offsets=ScrollOffsets(top=2, bottom=2), 125 ), 126 ) 127 128 self.default_buffer_control = BufferControl( 129 buffer=history.default_buffer, 130 input_processors=[GrayExistingText(history.history_mapping)], 131 lexer=PygmentsLexer(PythonLexer), 132 ) 133 134 self.history_buffer_control = BufferControl( 135 buffer=history.history_buffer, 136 lexer=PygmentsLexer(PythonLexer), 137 search_buffer_control=search_toolbar.control, 138 preview_search=True, 139 ) 140 141 history_window = Window( 142 content=self.history_buffer_control, 143 wrap_lines=False, 144 left_margins=[HistoryMargin(history)], 145 scroll_offsets=ScrollOffsets(top=2, bottom=2), 146 ) 147 148 self.root_container = HSplit( 149 [ 150 # Top title bar. 151 Window( 152 content=FormattedTextControl(_get_top_toolbar_fragments), 153 align=WindowAlign.CENTER, 154 style="class:status-toolbar", 155 ), 156 FloatContainer( 157 content=VSplit( 158 [ 159 # Left side: history. 160 history_window, 161 # Separator. 162 Window( 163 width=D.exact(1), 164 char=BORDER.LIGHT_VERTICAL, 165 style="class:separator", 166 ), 167 # Right side: result. 168 Window( 169 content=self.default_buffer_control, 170 wrap_lines=False, 171 left_margins=[ResultMargin(history)], 172 scroll_offsets=ScrollOffsets(top=2, bottom=2), 173 ), 174 ] 175 ), 176 floats=[ 177 # Help text as a float. 178 Float( 179 width=60, 180 top=3, 181 bottom=2, 182 content=ConditionalContainer( 183 content=help_window, 184 filter=has_focus(history.help_buffer), 185 ), 186 ) 187 ], 188 ), 189 # Bottom toolbars. 190 ArgToolbar(), 191 search_toolbar, 192 Window( 193 content=FormattedTextControl( 194 partial(_get_bottom_toolbar_fragments, history=history) 195 ), 196 style="class:status-toolbar", 197 ), 198 ] 199 ) 200 201 self.layout = Layout(self.root_container, history_window) 202 203 204def _get_top_toolbar_fragments(): 205 return [("class:status-bar.title", "History browser - Insert from history")] 206 207 208def _get_bottom_toolbar_fragments(history): 209 python_input = history.python_input 210 211 @if_mousedown 212 def f1(mouse_event): 213 _toggle_help(history) 214 215 @if_mousedown 216 def tab(mouse_event): 217 _select_other_window(history) 218 219 return ( 220 [("class:status-toolbar", " ")] 221 + get_inputmode_fragments(python_input) 222 + [ 223 ("class:status-toolbar", " "), 224 ("class:status-toolbar.key", "[Space]"), 225 ("class:status-toolbar", " Toggle "), 226 ("class:status-toolbar.key", "[Tab]", tab), 227 ("class:status-toolbar", " Focus ", tab), 228 ("class:status-toolbar.key", "[Enter]"), 229 ("class:status-toolbar", " Accept "), 230 ("class:status-toolbar.key", "[F1]", f1), 231 ("class:status-toolbar", " Help ", f1), 232 ] 233 ) 234 235 236class HistoryMargin(Margin): 237 """ 238 Margin for the history buffer. 239 This displays a green bar for the selected entries. 240 """ 241 242 def __init__(self, history): 243 self.history_buffer = history.history_buffer 244 self.history_mapping = history.history_mapping 245 246 def get_width(self, ui_content): 247 return 2 248 249 def create_margin(self, window_render_info, width, height): 250 document = self.history_buffer.document 251 252 lines_starting_new_entries = self.history_mapping.lines_starting_new_entries 253 selected_lines = self.history_mapping.selected_lines 254 255 current_lineno = document.cursor_position_row 256 257 visible_line_to_input_line = window_render_info.visible_line_to_input_line 258 result = [] 259 260 for y in range(height): 261 line_number = visible_line_to_input_line.get(y) 262 263 # Show stars at the start of each entry. 264 # (Visualises multiline entries.) 265 if line_number in lines_starting_new_entries: 266 char = "*" 267 else: 268 char = " " 269 270 if line_number in selected_lines: 271 t = "class:history-line,selected" 272 else: 273 t = "class:history-line" 274 275 if line_number == current_lineno: 276 t = t + ",current" 277 278 result.append((t, char)) 279 result.append(("", "\n")) 280 281 return result 282 283 284class ResultMargin(Margin): 285 """ 286 The margin to be shown in the result pane. 287 """ 288 289 def __init__(self, history): 290 self.history_mapping = history.history_mapping 291 self.history_buffer = history.history_buffer 292 293 def get_width(self, ui_content): 294 return 2 295 296 def create_margin(self, window_render_info, width, height): 297 document = self.history_buffer.document 298 299 current_lineno = document.cursor_position_row 300 offset = ( 301 self.history_mapping.result_line_offset 302 ) # original_document.cursor_position_row 303 304 visible_line_to_input_line = window_render_info.visible_line_to_input_line 305 306 result = [] 307 308 for y in range(height): 309 line_number = visible_line_to_input_line.get(y) 310 311 if ( 312 line_number is None 313 or line_number < offset 314 or line_number >= offset + len(self.history_mapping.selected_lines) 315 ): 316 t = "" 317 elif line_number == current_lineno: 318 t = "class:history-line,selected,current" 319 else: 320 t = "class:history-line,selected" 321 322 result.append((t, " ")) 323 result.append(("", "\n")) 324 325 return result 326 327 def invalidation_hash(self, document): 328 return document.cursor_position_row 329 330 331class GrayExistingText(Processor): 332 """ 333 Turn the existing input, before and after the inserted code gray. 334 """ 335 336 def __init__(self, history_mapping): 337 self.history_mapping = history_mapping 338 self._lines_before = len( 339 history_mapping.original_document.text_before_cursor.splitlines() 340 ) 341 342 def apply_transformation(self, transformation_input): 343 lineno = transformation_input.lineno 344 fragments = transformation_input.fragments 345 346 if lineno < self._lines_before or lineno >= self._lines_before + len( 347 self.history_mapping.selected_lines 348 ): 349 text = fragment_list_to_text(fragments) 350 return Transformation(fragments=[("class:history.existing-input", text)]) 351 else: 352 return Transformation(fragments=fragments) 353 354 355class HistoryMapping: 356 """ 357 Keep a list of all the lines from the history and the selected lines. 358 """ 359 360 def __init__(self, history, python_history, original_document): 361 self.history = history 362 self.python_history = python_history 363 self.original_document = original_document 364 365 self.lines_starting_new_entries = set() 366 self.selected_lines = set() 367 368 # Process history. 369 history_strings = python_history.get_strings() 370 history_lines = [] 371 372 for entry_nr, entry in list(enumerate(history_strings))[-HISTORY_COUNT:]: 373 self.lines_starting_new_entries.add(len(history_lines)) 374 375 for line in entry.splitlines(): 376 history_lines.append(line) 377 378 if len(history_strings) > HISTORY_COUNT: 379 history_lines[0] = ( 380 "# *** History has been truncated to %s lines ***" % HISTORY_COUNT 381 ) 382 383 self.history_lines = history_lines 384 self.concatenated_history = "\n".join(history_lines) 385 386 # Line offset. 387 if self.original_document.text_before_cursor: 388 self.result_line_offset = self.original_document.cursor_position_row + 1 389 else: 390 self.result_line_offset = 0 391 392 def get_new_document(self, cursor_pos=None): 393 """ 394 Create a `Document` instance that contains the resulting text. 395 """ 396 lines = [] 397 398 # Original text, before cursor. 399 if self.original_document.text_before_cursor: 400 lines.append(self.original_document.text_before_cursor) 401 402 # Selected entries from the history. 403 for line_no in sorted(self.selected_lines): 404 lines.append(self.history_lines[line_no]) 405 406 # Original text, after cursor. 407 if self.original_document.text_after_cursor: 408 lines.append(self.original_document.text_after_cursor) 409 410 # Create `Document` with cursor at the right position. 411 text = "\n".join(lines) 412 if cursor_pos is not None and cursor_pos > len(text): 413 cursor_pos = len(text) 414 return Document(text, cursor_pos) 415 416 def update_default_buffer(self): 417 b = self.history.default_buffer 418 419 b.set_document(self.get_new_document(b.cursor_position), bypass_readonly=True) 420 421 422def _toggle_help(history): 423 "Display/hide help." 424 help_buffer_control = history.history_layout.help_buffer_control 425 426 if history.app.layout.current_control == help_buffer_control: 427 history.app.layout.focus_previous() 428 else: 429 history.app.layout.current_control = help_buffer_control 430 431 432def _select_other_window(history): 433 "Toggle focus between left/right window." 434 current_buffer = history.app.current_buffer 435 layout = history.history_layout.layout 436 437 if current_buffer == history.history_buffer: 438 layout.current_control = history.history_layout.default_buffer_control 439 440 elif current_buffer == history.default_buffer: 441 layout.current_control = history.history_layout.history_buffer_control 442 443 444def create_key_bindings(history, python_input, history_mapping): 445 """ 446 Key bindings. 447 """ 448 bindings = KeyBindings() 449 handle = bindings.add 450 451 @handle(" ", filter=has_focus(history.history_buffer)) 452 def _(event): 453 """ 454 Space: select/deselect line from history pane. 455 """ 456 b = event.current_buffer 457 line_no = b.document.cursor_position_row 458 459 if not history_mapping.history_lines: 460 # If we've no history, then nothing to do 461 return 462 463 if line_no in history_mapping.selected_lines: 464 # Remove line. 465 history_mapping.selected_lines.remove(line_no) 466 history_mapping.update_default_buffer() 467 else: 468 # Add line. 469 history_mapping.selected_lines.add(line_no) 470 history_mapping.update_default_buffer() 471 472 # Update cursor position 473 default_buffer = history.default_buffer 474 default_lineno = ( 475 sorted(history_mapping.selected_lines).index(line_no) 476 + history_mapping.result_line_offset 477 ) 478 default_buffer.cursor_position = ( 479 default_buffer.document.translate_row_col_to_index(default_lineno, 0) 480 ) 481 482 # Also move the cursor to the next line. (This way they can hold 483 # space to select a region.) 484 b.cursor_position = b.document.translate_row_col_to_index(line_no + 1, 0) 485 486 @handle(" ", filter=has_focus(DEFAULT_BUFFER)) 487 @handle("delete", filter=has_focus(DEFAULT_BUFFER)) 488 @handle("c-h", filter=has_focus(DEFAULT_BUFFER)) 489 def _(event): 490 """ 491 Space: remove line from default pane. 492 """ 493 b = event.current_buffer 494 line_no = b.document.cursor_position_row - history_mapping.result_line_offset 495 496 if line_no >= 0: 497 try: 498 history_lineno = sorted(history_mapping.selected_lines)[line_no] 499 except IndexError: 500 pass # When `selected_lines` is an empty set. 501 else: 502 history_mapping.selected_lines.remove(history_lineno) 503 504 history_mapping.update_default_buffer() 505 506 help_focussed = has_focus(history.help_buffer) 507 main_buffer_focussed = has_focus(history.history_buffer) | has_focus( 508 history.default_buffer 509 ) 510 511 @handle("tab", filter=main_buffer_focussed) 512 @handle("c-x", filter=main_buffer_focussed, eager=True) 513 # Eager: ignore the Emacs [Ctrl-X Ctrl-X] binding. 514 @handle("c-w", filter=main_buffer_focussed) 515 def _(event): 516 "Select other window." 517 _select_other_window(history) 518 519 @handle("f4") 520 def _(event): 521 "Switch between Emacs/Vi mode." 522 python_input.vi_mode = not python_input.vi_mode 523 524 @handle("f1") 525 def _(event): 526 "Display/hide help." 527 _toggle_help(history) 528 529 @handle("enter", filter=help_focussed) 530 @handle("c-c", filter=help_focussed) 531 @handle("c-g", filter=help_focussed) 532 @handle("escape", filter=help_focussed) 533 def _(event): 534 "Leave help." 535 event.app.layout.focus_previous() 536 537 @handle("q", filter=main_buffer_focussed) 538 @handle("f3", filter=main_buffer_focussed) 539 @handle("c-c", filter=main_buffer_focussed) 540 @handle("c-g", filter=main_buffer_focussed) 541 def _(event): 542 "Cancel and go back." 543 event.app.exit(result=None) 544 545 @handle("enter", filter=main_buffer_focussed) 546 def _(event): 547 "Accept input." 548 event.app.exit(result=history.default_buffer.text) 549 550 enable_system_bindings = Condition(lambda: python_input.enable_system_bindings) 551 552 @handle("c-z", filter=enable_system_bindings) 553 def _(event): 554 "Suspend to background." 555 event.app.suspend_to_background() 556 557 return bindings 558 559 560class PythonHistory: 561 def __init__(self, python_input, original_document): 562 """ 563 Create an `Application` for the history screen. 564 This has to be run as a sub application of `python_input`. 565 566 When this application runs and returns, it retuns the selected lines. 567 """ 568 self.python_input = python_input 569 570 history_mapping = HistoryMapping(self, python_input.history, original_document) 571 self.history_mapping = history_mapping 572 573 document = Document(history_mapping.concatenated_history) 574 document = Document( 575 document.text, 576 cursor_position=document.cursor_position 577 + document.get_start_of_line_position(), 578 ) 579 580 self.history_buffer = Buffer( 581 document=document, 582 on_cursor_position_changed=self._history_buffer_pos_changed, 583 accept_handler=( 584 lambda buff: get_app().exit(result=self.default_buffer.text) 585 ), 586 read_only=True, 587 ) 588 589 self.default_buffer = Buffer( 590 name=DEFAULT_BUFFER, 591 document=history_mapping.get_new_document(), 592 on_cursor_position_changed=self._default_buffer_pos_changed, 593 read_only=True, 594 ) 595 596 self.help_buffer = Buffer(document=Document(HELP_TEXT, 0), read_only=True) 597 598 self.history_layout = HistoryLayout(self) 599 600 self.app = Application( 601 layout=self.history_layout.layout, 602 full_screen=True, 603 style=python_input._current_style, 604 mouse_support=Condition(lambda: python_input.enable_mouse_support), 605 key_bindings=create_key_bindings(self, python_input, history_mapping), 606 ) 607 608 def _default_buffer_pos_changed(self, _): 609 """When the cursor changes in the default buffer. Synchronize with 610 history buffer.""" 611 # Only when this buffer has the focus. 612 if self.app.current_buffer == self.default_buffer: 613 try: 614 line_no = ( 615 self.default_buffer.document.cursor_position_row 616 - self.history_mapping.result_line_offset 617 ) 618 619 if line_no < 0: # When the cursor is above the inserted region. 620 raise IndexError 621 622 history_lineno = sorted(self.history_mapping.selected_lines)[line_no] 623 except IndexError: 624 pass 625 else: 626 self.history_buffer.cursor_position = ( 627 self.history_buffer.document.translate_row_col_to_index( 628 history_lineno, 0 629 ) 630 ) 631 632 def _history_buffer_pos_changed(self, _): 633 """When the cursor changes in the history buffer. Synchronize.""" 634 # Only when this buffer has the focus. 635 if self.app.current_buffer == self.history_buffer: 636 line_no = self.history_buffer.document.cursor_position_row 637 638 if line_no in self.history_mapping.selected_lines: 639 default_lineno = ( 640 sorted(self.history_mapping.selected_lines).index(line_no) 641 + self.history_mapping.result_line_offset 642 ) 643 644 self.default_buffer.cursor_position = ( 645 self.default_buffer.document.translate_row_col_to_index( 646 default_lineno, 0 647 ) 648 ) 649