1import code 2import contextlib 3import errno 4import itertools 5import logging 6import os 7import re 8import signal 9import subprocess 10import sys 11import tempfile 12import time 13import unicodedata 14from enum import Enum 15from types import TracebackType 16from typing import Dict, Any, List, Optional, Tuple, Union, cast, Type 17from typing_extensions import Literal 18 19import blessings 20import greenlet 21from curtsies import ( 22 FSArray, 23 fmtstr, 24 FmtStr, 25 Termmode, 26 fmtfuncs, 27 events, 28 __version__ as curtsies_version, 29) 30from curtsies.configfile_keynames import keymap as key_dispatch 31from curtsies.input import is_main_thread 32from cwcwidth import wcswidth 33from pygments import format as pygformat 34from pygments.formatters import TerminalFormatter 35from pygments.lexers import Python3Lexer 36 37from . import events as bpythonevents, sitefix, replpainter as paint 38from ..config import Config 39from .coderunner import ( 40 CodeRunner, 41 FakeOutput, 42) 43from .filewatch import ModuleChangedEventHandler 44from .interaction import StatusBar 45from .interpreter import ( 46 Interp, 47 code_finished_will_parse, 48) 49from .manual_readline import edit_keys 50from .parse import parse as bpythonparse, func_for_letter, color_for_letter 51from .preprocess import preprocess 52from .. import __version__ 53from ..config import getpreferredencoding 54from ..formatter import BPythonFormatter 55from ..pager import get_pager_command 56from ..repl import ( 57 Repl, 58 SourceNotFound, 59) 60from ..translations import _ 61 62logger = logging.getLogger(__name__) 63 64INCONSISTENT_HISTORY_MSG = "#<---History inconsistent with output shown--->" 65CONTIGUITY_BROKEN_MSG = "#<---History contiguity broken by rewind--->" 66EXAMPLE_CONFIG_URL = "https://raw.githubusercontent.com/bpython/bpython/master/bpython/sample-config" 67EDIT_SESSION_HEADER = """### current bpython session - make changes and save to reevaluate session. 68### lines beginning with ### will be ignored. 69### To return to bpython without reevaluating make no changes to this file 70### or save an empty file. 71""" 72 73# more than this many events will be assumed to be a true paste event, 74# i.e. control characters like '<Ctrl-a>' will be stripped 75MAX_EVENTS_POSSIBLY_NOT_PASTE = 20 76 77 78class LineType(Enum): 79 """Used when adding a tuple to all_logical_lines, to get input / output values 80 having to actually type/know the strings""" 81 82 INPUT = "input" 83 OUTPUT = "output" 84 85 86class FakeStdin: 87 """The stdin object user code will reference 88 89 In user code, sys.stdin.read() asks the user for interactive input, 90 so this class returns control to the UI to get that input.""" 91 92 def __init__(self, coderunner, repl, configured_edit_keys=None): 93 self.coderunner = coderunner 94 self.repl = repl 95 self.has_focus = False # whether FakeStdin receives keypress events 96 self.current_line = "" 97 self.cursor_offset = 0 98 self.old_num_lines = 0 99 self.readline_results = [] 100 if configured_edit_keys: 101 self.rl_char_sequences = configured_edit_keys 102 else: 103 self.rl_char_sequences = edit_keys 104 105 def process_event(self, e: Union[events.Event, str]) -> None: 106 assert self.has_focus 107 108 logger.debug("fake input processing event %r", e) 109 if isinstance(e, events.PasteEvent): 110 for ee in e.events: 111 if ee not in self.rl_char_sequences: 112 self.add_input_character(ee) 113 elif e in self.rl_char_sequences: 114 self.cursor_offset, self.current_line = self.rl_char_sequences[e]( 115 self.cursor_offset, self.current_line 116 ) 117 elif isinstance(e, events.SigIntEvent): 118 self.coderunner.sigint_happened_in_main_context = True 119 self.has_focus = False 120 self.current_line = "" 121 self.cursor_offset = 0 122 self.repl.run_code_and_maybe_finish() 123 elif e in ("<ESC>",): 124 pass 125 elif e in ("<Ctrl-d>",): 126 if self.current_line == "": 127 self.repl.send_to_stdin("\n") 128 self.has_focus = False 129 self.current_line = "" 130 self.cursor_offset = 0 131 self.repl.run_code_and_maybe_finish(for_code="") 132 else: 133 pass 134 elif e in ("\n", "\r", "<Ctrl-j>", "<Ctrl-m>"): 135 line = self.current_line 136 self.repl.send_to_stdin(line + "\n") 137 self.has_focus = False 138 self.current_line = "" 139 self.cursor_offset = 0 140 self.repl.run_code_and_maybe_finish(for_code=line + "\n") 141 else: # add normal character 142 self.add_input_character(e) 143 144 if self.current_line.endswith(("\n", "\r")): 145 pass 146 else: 147 self.repl.send_to_stdin(self.current_line) 148 149 def add_input_character(self, e): 150 if e in ("<SPACE>",): 151 e = " " 152 if e.startswith("<") and e.endswith(">"): 153 return 154 assert len(e) == 1, "added multiple characters: %r" % e 155 logger.debug("adding normal char %r to current line", e) 156 157 c = e 158 self.current_line = ( 159 self.current_line[: self.cursor_offset] 160 + c 161 + self.current_line[self.cursor_offset :] 162 ) 163 self.cursor_offset += 1 164 165 def readline(self): 166 self.has_focus = True 167 self.repl.send_to_stdin(self.current_line) 168 value = self.coderunner.request_from_main_context() 169 self.readline_results.append(value) 170 return value 171 172 def readlines(self, size=-1): 173 return list(iter(self.readline, "")) 174 175 def __iter__(self): 176 return iter(self.readlines()) 177 178 def isatty(self): 179 return True 180 181 def flush(self): 182 """Flush the internal buffer. This is a no-op. Flushing stdin 183 doesn't make any sense anyway.""" 184 185 def write(self, value): 186 # XXX IPython expects sys.stdin.write to exist, there will no doubt be 187 # others, so here's a hack to keep them happy 188 raise OSError(errno.EBADF, "sys.stdin is read-only") 189 190 def close(self): 191 # hack to make closing stdin a nop 192 # This is useful for multiprocessing.Process, which does work 193 # for the most part, although output from other processes is 194 # discarded. 195 pass 196 197 @property 198 def encoding(self): 199 return sys.__stdin__.encoding 200 201 # TODO write a read() method? 202 203 204class ReevaluateFakeStdin: 205 """Stdin mock used during reevaluation (undo) so raw_inputs don't have to 206 be reentered""" 207 208 def __init__(self, fakestdin, repl): 209 self.fakestdin = fakestdin 210 self.repl = repl 211 self.readline_results = fakestdin.readline_results[:] 212 213 def readline(self): 214 if self.readline_results: 215 value = self.readline_results.pop(0) 216 else: 217 value = "no saved input available" 218 self.repl.send_to_stdouterr(value) 219 return value 220 221 222class ImportLoader: 223 """Wrapper for module loaders to watch their paths with watchdog.""" 224 225 def __init__(self, watcher, loader): 226 self.watcher = watcher 227 self.loader = loader 228 229 def __getattr__(self, name): 230 if name == "create_module" and hasattr(self.loader, name): 231 return self._create_module 232 if name == "load_module" and hasattr(self.loader, name): 233 return self._load_module 234 return getattr(self.loader, name) 235 236 def _create_module(self, spec): 237 spec = self.loader.create_module(spec) 238 if ( 239 getattr(spec, "origin", None) is not None 240 and spec.origin != "builtin" 241 ): 242 self.watcher.track_module(spec.origin) 243 return spec 244 245 def _load_module(self, name): 246 module = self.loader.load_module(name) 247 if hasattr(module, "__file__"): 248 self.watcher.track_module(module.__file__) 249 return module 250 251 252class ImportFinder: 253 """Wrapper for finders in sys.meta_path to replace wrap all loaders with ImportLoader.""" 254 255 def __init__(self, finder, watcher): 256 self.watcher = watcher 257 self.finder = finder 258 259 def __getattr__(self, name): 260 if name == "find_spec" and hasattr(self.finder, name): 261 return self._find_spec 262 if name == "find_module" and hasattr(self.finder, name): 263 return self._find_module 264 return getattr(self.finder, name) 265 266 def _find_spec(self, fullname, path, target=None): 267 # Attempt to find the spec 268 spec = self.finder.find_spec(fullname, path, target) 269 if spec is not None: 270 if getattr(spec, "__loader__", None) is not None: 271 # Patch the loader to enable reloading 272 spec.__loader__ = ImportLoader(self.watcher, spec.__loader__) 273 return spec 274 275 def _find_module(self, fullname, path=None): 276 loader = self.finder.find_module(fullname, path) 277 if loader is not None: 278 return ImportLoader(self.watcher, loader) 279 280 281def _process_ps(ps, default_ps: str): 282 """Replace ps1/ps2 with the default if the user specified value contains control characters.""" 283 if not isinstance(ps, str): 284 return ps 285 286 return ps if wcswidth(ps) >= 0 else default_ps 287 288 289class BaseRepl(Repl): 290 """Python Repl 291 292 Reacts to events like 293 - terminal dimensions and change events 294 - keystrokes 295 Behavior altered by 296 - number of scroll downs that were necessary to render array after each 297 display 298 - initial cursor position 299 outputs: 300 - 2D array to be rendered 301 302 BaseRepl is mostly view-independent state of Repl - but self.width and 303 self.height are important for figuring out how to wrap lines for example. 304 Usually self.width and self.height should be set by receiving a window 305 resize event, not manually set to anything - as long as the first event 306 received is a window resize event, this works fine. 307 308 Subclasses are responsible for implementing several methods. 309 """ 310 311 def __init__( 312 self, 313 config: Config, 314 locals_: Dict[str, Any] = None, 315 banner: str = None, 316 interp: code.InteractiveInterpreter = None, 317 orig_tcattrs: List[Any] = None, 318 ): 319 """ 320 locals_ is a mapping of locals to pass into the interpreter 321 config is a bpython config.Struct with config attributes 322 banner is a string to display briefly in the status bar 323 interp is an interpreter instance to use 324 original terminal state, useful for shelling out with normal terminal 325 """ 326 327 logger.debug("starting init") 328 329 # If creating a new interpreter on undo would be unsafe because initial 330 # state was passed in 331 self.weak_rewind = bool(locals_ or interp) 332 333 if interp is None: 334 interp = Interp(locals=locals_) 335 interp.write = self.send_to_stdouterr # type: ignore 336 if banner is None: 337 if config.help_key: 338 banner = ( 339 _("Welcome to bpython!") 340 + " " 341 + _("Press <%s> for help.") % config.help_key 342 ) 343 else: 344 banner = None 345 if config.cli_suggestion_width <= 0 or config.cli_suggestion_width > 1: 346 config.cli_suggestion_width = 1 347 348 self.reevaluating = False 349 self.fake_refresh_requested = False 350 351 self.status_bar = StatusBar( 352 config, 353 "", 354 request_refresh=self.request_refresh, 355 schedule_refresh=self.schedule_refresh, 356 ) 357 self.edit_keys = edit_keys.mapping_with_config(config, key_dispatch) 358 logger.debug("starting parent init") 359 super().__init__(interp, config) 360 361 self.formatter = BPythonFormatter(config.color_scheme) 362 363 # overwriting what bpython.Repl put there 364 # interact is called to interact with the status bar, 365 # so we're just using the same object 366 self.interact = self.status_bar 367 368 # logical line currently being edited, without ps1 (usually '>>> ') 369 self._current_line = "" 370 371 # current line of output - stdout and stdin go here 372 self.current_stdouterr_line = "" # Union[str, FmtStr] 373 374 # this is every line that's been displayed (input and output) 375 # as with formatting applied. Logical lines that exceeded the terminal width 376 # at the time of output are split across multiple entries in this list. 377 self.display_lines: List[FmtStr] = [] 378 379 # this is every line that's been executed; it gets smaller on rewind 380 self.history = [] 381 382 # This is every logical line that's been displayed, both input and output. 383 # Like self.history, lines are unwrapped, uncolored, and without prompt. 384 # Entries are tuples, where 385 # - the first element the line (string, not fmtsr) 386 # - the second element is one of 2 global constants: "input" or "output" 387 # (use LineType.INPUT or LineType.OUTPUT to avoid typing these strings) 388 self.all_logical_lines: List[Tuple[str, LineType]] = [] 389 390 # formatted version of lines in the buffer kept around so we can 391 # unhighlight parens using self.reprint_line as called by bpython.Repl 392 self.display_buffer: List[FmtStr] = [] 393 394 # how many times display has been scrolled down 395 # because there wasn't room to display everything 396 self.scroll_offset = 0 397 398 # cursor position relative to start of current_line, 0 is first char 399 self._cursor_offset = 0 400 401 self.orig_tcattrs: Optional[List[Any]] = orig_tcattrs 402 403 self.coderunner = CodeRunner(self.interp, self.request_refresh) 404 405 # filenos match the backing device for libs that expect it, 406 # but writing to them will do weird things to the display 407 self.stdout = FakeOutput( 408 self.coderunner, 409 self.send_to_stdouterr, 410 real_fileobj=sys.__stdout__, 411 ) 412 self.stderr = FakeOutput( 413 self.coderunner, 414 self.send_to_stdouterr, 415 real_fileobj=sys.__stderr__, 416 ) 417 self.stdin = FakeStdin(self.coderunner, self, self.edit_keys) 418 419 # next paint should clear screen 420 self.request_paint_to_clear_screen = False 421 422 self.request_paint_to_pad_bottom = 0 423 424 # offscreen command yields results different from scrollback buffer 425 self.inconsistent_history = False 426 427 # history error message has already been displayed 428 self.history_already_messed_up = False 429 430 # some commands act differently based on the prev event 431 # this list doesn't include instances of event.Event, 432 # only keypress-type events (no refresh screen events etc.) 433 self.last_events: List[Optional[str]] = [None] * 50 434 435 # displays prev events in a column on the right hand side 436 self.presentation_mode = False 437 438 self.paste_mode = False 439 self.current_match = None 440 self.list_win_visible = False 441 # whether auto reloading active 442 self.watching_files = config.default_autoreload 443 444 # 'reverse_incremental_search', 'incremental_search' or None 445 self.incr_search_mode = None 446 447 self.incr_search_target = "" 448 449 self.original_modules = set(sys.modules.keys()) 450 451 # as long as the first event received is a window resize event, 452 # this works fine... 453 self.width: int = cast(int, None) 454 self.height: int = cast(int, None) 455 456 self.status_bar.message(banner) 457 458 self.watcher = ModuleChangedEventHandler([], self.request_reload) 459 if self.watcher and config.default_autoreload: 460 self.watcher.activate() 461 462 # The methods below should be overridden, but the default implementations 463 # below can be used as well. 464 465 def get_cursor_vertical_diff(self): 466 """Return how the cursor moved due to a window size change""" 467 return 0 468 469 def get_top_usable_line(self): 470 """Return the top line of display that can be rewritten""" 471 return 0 472 473 def get_term_hw(self): 474 """Returns the current width and height of the display area.""" 475 return (50, 10) 476 477 def _schedule_refresh(self, when: float): 478 """Arrange for the bpython display to be refreshed soon. 479 480 This method will be called when the Repl wants the display to be 481 refreshed at a known point in the future, and as such it should 482 interrupt a pending request to the user for input. 483 484 Because the worst-case effect of not refreshing 485 is only having an out of date UI until the user enters input, a 486 default NOP implementation is provided.""" 487 488 # The methods below must be overridden in subclasses. 489 490 def _request_refresh(self): 491 """Arrange for the bpython display to be refreshed soon. 492 493 This method will be called when the Repl wants to refresh the display, 494 but wants control returned to it afterwards. (it is assumed that simply 495 returning from process_event will cause an event refresh) 496 497 The very next event received by process_event should be a 498 RefreshRequestEvent.""" 499 raise NotImplementedError 500 501 def _request_reload(self, files_modified=("?",)): 502 """Like request_refresh, but for reload requests events.""" 503 raise NotImplementedError 504 505 def request_undo(self, n=1): 506 """Like request_refresh, but for undo request events.""" 507 raise NotImplementedError 508 509 def on_suspend(self): 510 """Will be called on sigtstp. 511 512 Do whatever cleanup would allow the user to use other programs.""" 513 raise NotImplementedError 514 515 def after_suspend(self): 516 """Will be called when process foregrounded after suspend. 517 518 See to it that process_event is called with None to trigger a refresh 519 if not in the middle of a process_event call when suspend happened.""" 520 raise NotImplementedError 521 522 # end methods that should be overridden in subclass 523 524 def request_refresh(self): 525 """Request that the bpython display to be refreshed soon.""" 526 if self.reevaluating or self.paste_mode: 527 self.fake_refresh_requested = True 528 else: 529 self._request_refresh() 530 531 def request_reload(self, files_modified=()): 532 """Request that a ReloadEvent be passed next into process_event""" 533 if self.watching_files: 534 self._request_reload(files_modified=files_modified) 535 536 def schedule_refresh(self, when="now"): 537 """Schedule a ScheduledRefreshRequestEvent for when. 538 539 Such a event should interrupt if blockied waiting for keyboard input""" 540 if self.reevaluating or self.paste_mode: 541 self.fake_refresh_requested = True 542 else: 543 self._schedule_refresh(when=when) 544 545 def __enter__(self): 546 self.orig_stdout = sys.stdout 547 self.orig_stderr = sys.stderr 548 self.orig_stdin = sys.stdin 549 sys.stdout = self.stdout 550 sys.stderr = self.stderr 551 sys.stdin = self.stdin 552 self.orig_sigwinch_handler = signal.getsignal(signal.SIGWINCH) 553 self.orig_sigtstp_handler = signal.getsignal(signal.SIGTSTP) 554 555 if is_main_thread(): 556 # This turns off resize detection and ctrl-z suspension. 557 signal.signal(signal.SIGWINCH, self.sigwinch_handler) 558 signal.signal(signal.SIGTSTP, self.sigtstp_handler) 559 560 self.orig_meta_path = sys.meta_path 561 if self.watcher: 562 meta_path = [] 563 for finder in sys.meta_path: 564 # All elements get wrapped in ImportFinder instances execepted for instances of 565 # _SixMetaPathImporter (from six). When importing six, it will check if the importer 566 # is already part of sys.meta_path and will remove instances. We do not want to 567 # break this feature (see also #874). 568 if type(finder).__name__ == "_SixMetaPathImporter": 569 meta_path.append(finder) 570 else: 571 meta_path.append(ImportFinder(finder, self.watcher)) 572 sys.meta_path = meta_path 573 574 sitefix.monkeypatch_quit() 575 return self 576 577 def __exit__( 578 self, 579 exc_type: Optional[Type[BaseException]], 580 exc_val: Optional[BaseException], 581 exc_tb: Optional[TracebackType], 582 ) -> Literal[False]: 583 sys.stdin = self.orig_stdin 584 sys.stdout = self.orig_stdout 585 sys.stderr = self.orig_stderr 586 587 if is_main_thread(): 588 # This turns off resize detection and ctrl-z suspension. 589 signal.signal(signal.SIGWINCH, self.orig_sigwinch_handler) 590 signal.signal(signal.SIGTSTP, self.orig_sigtstp_handler) 591 592 sys.meta_path = self.orig_meta_path 593 return False 594 595 def sigwinch_handler(self, signum, frame): 596 old_rows, old_columns = self.height, self.width 597 self.height, self.width = self.get_term_hw() 598 cursor_dy = self.get_cursor_vertical_diff() 599 self.scroll_offset -= cursor_dy 600 logger.info( 601 "sigwinch! Changed from %r to %r", 602 (old_rows, old_columns), 603 (self.height, self.width), 604 ) 605 logger.info( 606 "decreasing scroll offset by %d to %d", 607 cursor_dy, 608 self.scroll_offset, 609 ) 610 611 def sigtstp_handler(self, signum, frame): 612 self.scroll_offset = len(self.lines_for_display) 613 self.__exit__() 614 self.on_suspend() 615 os.kill(os.getpid(), signal.SIGTSTP) 616 self.after_suspend() 617 self.__enter__() 618 619 def clean_up_current_line_for_exit(self): 620 """Called when trying to exit to prep for final paint""" 621 logger.debug("unhighlighting paren for exit") 622 self.cursor_offset = -1 623 self.unhighlight_paren() 624 625 # Event handling 626 def process_event(self, e: Union[events.Event, str]) -> Optional[bool]: 627 """Returns True if shutting down, otherwise returns None. 628 Mostly mutates state of Repl object""" 629 630 logger.debug("processing event %r", e) 631 if isinstance(e, events.Event): 632 return self.process_control_event(e) 633 else: 634 self.last_events.append(e) 635 self.last_events.pop(0) 636 self.process_key_event(e) 637 return None 638 639 def process_control_event(self, e) -> Optional[bool]: 640 641 if isinstance(e, bpythonevents.ScheduledRefreshRequestEvent): 642 # This is a scheduled refresh - it's really just a refresh (so nop) 643 pass 644 645 elif isinstance(e, bpythonevents.RefreshRequestEvent): 646 logger.info("received ASAP refresh request event") 647 if self.status_bar.has_focus: 648 self.status_bar.process_event(e) 649 else: 650 assert self.coderunner.code_is_waiting 651 self.run_code_and_maybe_finish() 652 653 elif self.status_bar.has_focus: 654 self.status_bar.process_event(e) 655 656 # handles paste events for both stdin and repl 657 elif isinstance(e, events.PasteEvent): 658 ctrl_char = compress_paste_event(e) 659 if ctrl_char is not None: 660 return self.process_event(ctrl_char) 661 with self.in_paste_mode(): 662 # Might not really be a paste, UI might just be lagging 663 if len(e.events) <= MAX_EVENTS_POSSIBLY_NOT_PASTE and any( 664 not is_simple_event(ee) for ee in e.events 665 ): 666 for ee in e.events: 667 if self.stdin.has_focus: 668 self.stdin.process_event(ee) 669 else: 670 self.process_event(ee) 671 else: 672 simple_events = just_simple_events(e.events) 673 source = preprocess( 674 "".join(simple_events), self.interp.compile 675 ) 676 for ee in source: 677 if self.stdin.has_focus: 678 self.stdin.process_event(ee) 679 else: 680 self.process_simple_keypress(ee) 681 682 elif isinstance(e, bpythonevents.RunStartupFileEvent): 683 try: 684 self.startup() 685 except OSError as e: 686 self.status_bar.message( 687 _("Executing PYTHONSTARTUP failed: %s") % (e,) 688 ) 689 690 elif isinstance(e, bpythonevents.UndoEvent): 691 self.undo(n=e.n) 692 693 elif self.stdin.has_focus: 694 self.stdin.process_event(e) 695 696 elif isinstance(e, events.SigIntEvent): 697 logger.debug("received sigint event") 698 self.keyboard_interrupt() 699 700 elif isinstance(e, bpythonevents.ReloadEvent): 701 if self.watching_files: 702 self.clear_modules_and_reevaluate() 703 self.status_bar.message( 704 _("Reloaded at %s because %s modified.") 705 % (time.strftime("%X"), " & ".join(e.files_modified)) 706 ) 707 708 else: 709 raise ValueError("Don't know how to handle event type: %r" % e) 710 return None 711 712 def process_key_event(self, e: str) -> None: 713 # To find the curtsies name for a keypress, try 714 # python -m curtsies.events 715 if self.status_bar.has_focus: 716 return self.status_bar.process_event(e) 717 if self.stdin.has_focus: 718 return self.stdin.process_event(e) 719 720 if ( 721 e 722 in ( 723 key_dispatch[self.config.right_key] 724 + key_dispatch[self.config.end_of_line_key] 725 + ("<RIGHT>",) 726 ) 727 and self.config.curtsies_right_arrow_completion 728 and self.cursor_offset == len(self.current_line) 729 # if at end of current line and user presses RIGHT (to autocomplete) 730 ): 731 # then autocomplete 732 self.current_line += self.current_suggestion 733 self.cursor_offset = len(self.current_line) 734 elif e in ("<UP>",) + key_dispatch[self.config.up_one_line_key]: 735 self.up_one_line() 736 elif e in ("<DOWN>",) + key_dispatch[self.config.down_one_line_key]: 737 self.down_one_line() 738 elif e in ("<Ctrl-d>",): 739 self.on_control_d() 740 elif e in ("<Ctrl-o>",): 741 self.operate_and_get_next() 742 elif e in ("<Esc+.>",): 743 self.get_last_word() 744 elif e in key_dispatch[self.config.reverse_incremental_search_key]: 745 self.incremental_search(reverse=True) 746 elif e in key_dispatch[self.config.incremental_search_key]: 747 self.incremental_search() 748 elif ( 749 e in (("<BACKSPACE>",) + key_dispatch[self.config.backspace_key]) 750 and self.incr_search_mode 751 ): 752 self.add_to_incremental_search(self, backspace=True) 753 elif e in self.edit_keys.cut_buffer_edits: 754 self.readline_kill(e) 755 elif e in self.edit_keys.simple_edits: 756 self.cursor_offset, self.current_line = self.edit_keys.call( 757 e, 758 cursor_offset=self.cursor_offset, 759 line=self.current_line, 760 cut_buffer=self.cut_buffer, 761 ) 762 elif e in key_dispatch[self.config.cut_to_buffer_key]: 763 self.cut_to_buffer() 764 elif e in key_dispatch[self.config.reimport_key]: 765 self.clear_modules_and_reevaluate() 766 elif e in key_dispatch[self.config.toggle_file_watch_key]: 767 self.toggle_file_watch() 768 elif e in key_dispatch[self.config.clear_screen_key]: 769 self.request_paint_to_clear_screen = True 770 elif e in key_dispatch[self.config.show_source_key]: 771 self.show_source() 772 elif e in key_dispatch[self.config.help_key]: 773 self.pager(self.help_text()) 774 elif e in key_dispatch[self.config.exit_key]: 775 raise SystemExit() 776 elif e in ("\n", "\r", "<PADENTER>", "<Ctrl-j>", "<Ctrl-m>"): 777 self.on_enter() 778 elif e in ("<TAB>",): # tab 779 self.on_tab() 780 elif e in ("<Shift-TAB>",): 781 self.on_tab(back=True) 782 elif e in key_dispatch[self.config.undo_key]: # ctrl-r for undo 783 self.prompt_undo() 784 elif e in key_dispatch[self.config.redo_key]: # ctrl-g for redo 785 self.redo() 786 elif e in key_dispatch[self.config.save_key]: # ctrl-s for save 787 greenlet.greenlet(self.write2file).switch() 788 elif e in key_dispatch[self.config.pastebin_key]: # F8 for pastebin 789 greenlet.greenlet(self.pastebin).switch() 790 elif e in key_dispatch[self.config.copy_clipboard_key]: 791 greenlet.greenlet(self.copy2clipboard).switch() 792 elif e in key_dispatch[self.config.external_editor_key]: 793 self.send_session_to_external_editor() 794 elif e in key_dispatch[self.config.edit_config_key]: 795 greenlet.greenlet(self.edit_config).switch() 796 # TODO add PAD keys hack as in bpython.cli 797 elif e in key_dispatch[self.config.edit_current_block_key]: 798 self.send_current_block_to_external_editor() 799 elif e in ("<ESC>",): 800 self.incr_search_mode = None 801 elif e in ("<SPACE>",): 802 self.add_normal_character(" ") 803 else: 804 self.add_normal_character(e) 805 806 def get_last_word(self): 807 808 previous_word = _last_word(self.rl_history.entry) 809 word = _last_word(self.rl_history.back()) 810 line = self.current_line 811 self._set_current_line( 812 line[: len(line) - len(previous_word)] + word, 813 reset_rl_history=False, 814 ) 815 self._set_cursor_offset( 816 self.cursor_offset - len(previous_word) + len(word), 817 reset_rl_history=False, 818 ) 819 820 def incremental_search(self, reverse=False, include_current=False): 821 if self.incr_search_mode is None: 822 self.rl_history.enter(self.current_line) 823 self.incr_search_target = "" 824 else: 825 if self.incr_search_target: 826 line = ( 827 self.rl_history.back( 828 False, 829 search=True, 830 target=self.incr_search_target, 831 include_current=include_current, 832 ) 833 if reverse 834 else self.rl_history.forward( 835 False, 836 search=True, 837 target=self.incr_search_target, 838 include_current=include_current, 839 ) 840 ) 841 self._set_current_line( 842 line, reset_rl_history=False, clear_special_mode=False 843 ) 844 self._set_cursor_offset( 845 len(self.current_line), 846 reset_rl_history=False, 847 clear_special_mode=False, 848 ) 849 if reverse: 850 self.incr_search_mode = "reverse_incremental_search" 851 else: 852 self.incr_search_mode = "incremental_search" 853 854 def readline_kill(self, e): 855 func = self.edit_keys[e] 856 self.cursor_offset, self.current_line, cut = func( 857 self.cursor_offset, self.current_line 858 ) 859 if self.last_events[-2] == e: # consecutive kill commands accumulative 860 if func.kills == "ahead": 861 self.cut_buffer += cut 862 elif func.kills == "behind": 863 self.cut_buffer = cut + self.cut_buffer 864 else: 865 raise ValueError("cut value other than 'ahead' or 'behind'") 866 else: 867 self.cut_buffer = cut 868 869 def on_enter(self, new_code=True, reset_rl_history=True): 870 # so the cursor isn't touching a paren TODO: necessary? 871 if new_code: 872 self.redo_stack = [] 873 874 self._set_cursor_offset(-1, update_completion=False) 875 if reset_rl_history: 876 self.rl_history.reset() 877 878 self.history.append(self.current_line) 879 self.all_logical_lines.append((self.current_line, LineType.INPUT)) 880 self.push(self.current_line, insert_into_history=new_code) 881 882 def on_tab(self, back=False): 883 """Do something on tab key 884 taken from bpython.cli 885 886 Does one of the following: 887 1) add space to move up to the next %4==0 column 888 2) complete the current word with characters common to all completions 889 3) select the first or last match 890 4) select the next or previous match if already have a match 891 """ 892 893 def only_whitespace_left_of_cursor(): 894 """returns true if all characters before cursor are whitespace""" 895 return not self.current_line[: self.cursor_offset].strip() 896 897 logger.debug("self.matches_iter.matches:%r", self.matches_iter.matches) 898 if only_whitespace_left_of_cursor(): 899 front_ws = len(self.current_line[: self.cursor_offset]) - len( 900 self.current_line[: self.cursor_offset].lstrip() 901 ) 902 to_add = 4 - (front_ws % self.config.tab_length) 903 for unused in range(to_add): 904 self.add_normal_character(" ") 905 return 906 907 # run complete() if we don't already have matches 908 if len(self.matches_iter.matches) == 0: 909 self.list_win_visible = self.complete(tab=True) 910 911 # 3. check to see if we can expand the current word 912 if self.matches_iter.is_cseq(): 913 cursor_and_line = self.matches_iter.substitute_cseq() 914 self._cursor_offset, self._current_line = cursor_and_line 915 # using _current_line so we don't trigger a completion reset 916 if not self.matches_iter.matches: 917 self.list_win_visible = self.complete() 918 919 elif self.matches_iter.matches: 920 self.current_match = ( 921 back and self.matches_iter.previous() or next(self.matches_iter) 922 ) 923 cursor_and_line = self.matches_iter.cur_line() 924 self._cursor_offset, self._current_line = cursor_and_line 925 # using _current_line so we don't trigger a completion reset 926 self.list_win_visible = True 927 928 def on_control_d(self): 929 if self.current_line == "": 930 raise SystemExit() 931 else: 932 self.current_line = ( 933 self.current_line[: self.cursor_offset] 934 + self.current_line[(self.cursor_offset + 1) :] 935 ) 936 937 def cut_to_buffer(self): 938 self.cut_buffer = self.current_line[self.cursor_offset :] 939 self.current_line = self.current_line[: self.cursor_offset] 940 941 def yank_from_buffer(self): 942 pass 943 944 def operate_and_get_next(self): 945 # If we have not navigated back in history 946 # ctrl+o will have the same effect as enter 947 self.on_enter(reset_rl_history=False) 948 949 def up_one_line(self): 950 self.rl_history.enter(self.current_line) 951 self._set_current_line( 952 tabs_to_spaces( 953 self.rl_history.back( 954 False, search=self.config.curtsies_right_arrow_completion 955 ) 956 ), 957 update_completion=False, 958 reset_rl_history=False, 959 ) 960 self._set_cursor_offset(len(self.current_line), reset_rl_history=False) 961 962 def down_one_line(self): 963 self.rl_history.enter(self.current_line) 964 self._set_current_line( 965 tabs_to_spaces( 966 self.rl_history.forward( 967 False, search=self.config.curtsies_right_arrow_completion 968 ) 969 ), 970 update_completion=False, 971 reset_rl_history=False, 972 ) 973 self._set_cursor_offset(len(self.current_line), reset_rl_history=False) 974 975 def process_simple_keypress(self, e): 976 # '\n' needed for pastes 977 if e in ("<Ctrl-j>", "<Ctrl-m>", "<PADENTER>", "\n", "\r"): 978 self.on_enter() 979 while self.fake_refresh_requested: 980 self.fake_refresh_requested = False 981 self.process_event(bpythonevents.RefreshRequestEvent()) 982 elif isinstance(e, events.Event): 983 pass # ignore events 984 elif e in ("<SPACE>",): 985 self.add_normal_character(" ") 986 else: 987 self.add_normal_character(e) 988 989 def send_current_block_to_external_editor(self, filename=None): 990 """ 991 Sends the current code block to external editor to be edited. Usually bound to C-x. 992 """ 993 text = self.send_to_external_editor(self.get_current_block()) 994 lines = [line for line in text.split("\n")] 995 while lines and not lines[-1].split(): 996 lines.pop() 997 events = "\n".join(lines + ([""] if len(lines) == 1 else ["", ""])) 998 self.clear_current_block() 999 with self.in_paste_mode(): 1000 for e in events: 1001 self.process_simple_keypress(e) 1002 self.cursor_offset = len(self.current_line) 1003 1004 def send_session_to_external_editor(self, filename=None): 1005 """ 1006 Sends entire bpython session to external editor to be edited. Usually bound to F7. 1007 """ 1008 for_editor = EDIT_SESSION_HEADER 1009 for_editor += self.get_session_formatted_for_file() 1010 1011 text = self.send_to_external_editor(for_editor) 1012 if text == for_editor: 1013 self.status_bar.message( 1014 _("Session not reevaluated because it was not edited") 1015 ) 1016 return 1017 lines = text.split("\n") 1018 if len(lines) and not lines[-1].strip(): 1019 lines.pop() # strip last line if empty 1020 if len(lines) and lines[-1].startswith("### "): 1021 current_line = lines[-1][4:] 1022 else: 1023 current_line = "" 1024 from_editor = [ 1025 line for line in lines if line[:6] != "# OUT:" and line[:3] != "###" 1026 ] 1027 if all(not line.strip() for line in from_editor): 1028 self.status_bar.message( 1029 _("Session not reevaluated because saved file was blank") 1030 ) 1031 return 1032 1033 source = preprocess("\n".join(from_editor), self.interp.compile) 1034 lines = source.split("\n") 1035 self.history = lines 1036 self.reevaluate(new_code=True) 1037 self.current_line = current_line 1038 self.cursor_offset = len(self.current_line) 1039 self.status_bar.message(_("Session edited and reevaluated")) 1040 1041 def clear_modules_and_reevaluate(self): 1042 if self.watcher: 1043 self.watcher.reset() 1044 cursor, line = self.cursor_offset, self.current_line 1045 for modname in set(sys.modules.keys()) - self.original_modules: 1046 del sys.modules[modname] 1047 self.reevaluate(new_code=False) 1048 self.cursor_offset, self.current_line = cursor, line 1049 self.status_bar.message( 1050 _("Reloaded at %s by user.") % (time.strftime("%X"),) 1051 ) 1052 1053 def toggle_file_watch(self): 1054 if self.watcher: 1055 if self.watching_files: 1056 msg = _("Auto-reloading deactivated.") 1057 self.status_bar.message(msg) 1058 self.watcher.deactivate() 1059 self.watching_files = False 1060 else: 1061 msg = _("Auto-reloading active, watching for file changes...") 1062 self.status_bar.message(msg) 1063 self.watching_files = True 1064 self.watcher.activate() 1065 else: 1066 self.status_bar.message( 1067 _( 1068 "Auto-reloading not available because " 1069 "watchdog not installed." 1070 ) 1071 ) 1072 1073 # Handler Helpers 1074 def add_normal_character(self, char): 1075 if len(char) > 1 or is_nop(char): 1076 return 1077 if self.incr_search_mode: 1078 self.add_to_incremental_search(char) 1079 else: 1080 self._set_current_line( 1081 ( 1082 self.current_line[: self.cursor_offset] 1083 + char 1084 + self.current_line[self.cursor_offset :] 1085 ), 1086 update_completion=False, 1087 reset_rl_history=False, 1088 clear_special_mode=False, 1089 ) 1090 self.cursor_offset += 1 1091 if self.config.cli_trim_prompts and self.current_line.startswith( 1092 self.ps1 1093 ): 1094 self.current_line = self.current_line[4:] 1095 self.cursor_offset = max(0, self.cursor_offset - 4) 1096 1097 def add_to_incremental_search(self, char=None, backspace=False): 1098 """Modify the current search term while in incremental search. 1099 1100 The only operations allowed in incremental search mode are 1101 adding characters and backspacing.""" 1102 if char is None and not backspace: 1103 raise ValueError("must provide a char or set backspace to True") 1104 if backspace: 1105 self.incr_search_target = self.incr_search_target[:-1] 1106 else: 1107 self.incr_search_target += char 1108 if self.incr_search_mode == "reverse_incremental_search": 1109 self.incremental_search(reverse=True, include_current=True) 1110 elif self.incr_search_mode == "incremental_search": 1111 self.incremental_search(include_current=True) 1112 else: 1113 raise ValueError("add_to_incremental_search not in a special mode") 1114 1115 def update_completion(self, tab=False): 1116 """Update visible docstring and matches and box visibility""" 1117 # Update autocomplete info; self.matches_iter and self.funcprops 1118 # Should be called whenever the completion box might need to appear 1119 # or disappear; whenever current line or cursor offset changes, 1120 # unless this happened via selecting a match 1121 self.current_match = None 1122 self.list_win_visible = self.complete(tab) 1123 1124 def predicted_indent(self, line): 1125 # TODO get rid of this! It's repeated code! Combine with Repl. 1126 logger.debug("line is %r", line) 1127 indent = len(re.match(r"[ ]*", line).group()) 1128 if line.endswith(":"): 1129 indent = max(0, indent + self.config.tab_length) 1130 elif line and line.count(" ") == len(line): 1131 indent = max(0, indent - self.config.tab_length) 1132 elif ( 1133 line 1134 and ":" not in line 1135 and line.strip().startswith(("return", "pass", "raise", "yield")) 1136 ): 1137 indent = max(0, indent - self.config.tab_length) 1138 logger.debug("indent we found was %s", indent) 1139 return indent 1140 1141 def push(self, line, insert_into_history=True): 1142 """Push a line of code onto the buffer, start running the buffer 1143 1144 If the interpreter successfully runs the code, clear the buffer 1145 """ 1146 # Note that push() overrides its parent without calling it, unlike 1147 # urwid and cli which implement custom behavior and call repl.Repl.push 1148 if self.paste_mode: 1149 self.saved_indent = 0 1150 else: 1151 self.saved_indent = self.predicted_indent(line) 1152 1153 if self.config.syntax: 1154 display_line = bpythonparse( 1155 pygformat(self.tokenize(line), self.formatter) 1156 ) 1157 # self.tokenize requires that the line not be in self.buffer yet 1158 1159 logger.debug( 1160 "display line being pushed to buffer: %r -> %r", 1161 line, 1162 display_line, 1163 ) 1164 self.display_buffer.append(display_line) 1165 else: 1166 self.display_buffer.append(fmtstr(line)) 1167 1168 if insert_into_history: 1169 self.insert_into_history(line) 1170 self.buffer.append(line) 1171 1172 code_to_run = "\n".join(self.buffer) 1173 1174 logger.debug("running %r in interpreter", self.buffer) 1175 c, code_will_parse = code_finished_will_parse( 1176 "\n".join(self.buffer), self.interp.compile 1177 ) 1178 self.saved_predicted_parse_error = not code_will_parse 1179 if c: 1180 logger.debug("finished - buffer cleared") 1181 self.cursor_offset = 0 1182 self.display_lines.extend(self.display_buffer_lines) 1183 self.display_buffer = [] 1184 self.buffer = [] 1185 1186 self.coderunner.load_code(code_to_run) 1187 self.run_code_and_maybe_finish() 1188 1189 def run_code_and_maybe_finish(self, for_code=None): 1190 r = self.coderunner.run_code(for_code=for_code) 1191 if r: 1192 logger.debug("----- Running finish command stuff -----") 1193 logger.debug("saved_indent: %r", self.saved_indent) 1194 err = self.saved_predicted_parse_error 1195 self.saved_predicted_parse_error = False 1196 1197 indent = self.saved_indent 1198 if err: 1199 indent = 0 1200 1201 if self.rl_history.index == 0: 1202 self._set_current_line(" " * indent, update_completion=True) 1203 else: 1204 self._set_current_line( 1205 self.rl_history.entries[-self.rl_history.index], 1206 reset_rl_history=False, 1207 ) 1208 self.cursor_offset = len(self.current_line) 1209 1210 def keyboard_interrupt(self): 1211 # TODO factor out the common cleanup from running a line 1212 self.cursor_offset = -1 1213 self.unhighlight_paren() 1214 self.display_lines.extend(self.display_buffer_lines) 1215 self.display_lines.extend( 1216 paint.display_linize(self.current_cursor_line, self.width) 1217 ) 1218 self.display_lines.extend( 1219 paint.display_linize("KeyboardInterrupt", self.width) 1220 ) 1221 self.clear_current_block(remove_from_history=False) 1222 1223 def unhighlight_paren(self): 1224 """Modify line in self.display_buffer to unhighlight a paren if 1225 possible. 1226 1227 self.highlighted_paren should be a line in ? 1228 """ 1229 if self.highlighted_paren is not None and self.config.syntax: 1230 lineno, saved_tokens = self.highlighted_paren 1231 if lineno == len(self.display_buffer): 1232 # then this is the current line, so don't worry about it 1233 return 1234 self.highlighted_paren = None 1235 logger.debug("trying to unhighlight a paren on line %r", lineno) 1236 logger.debug("with these tokens: %r", saved_tokens) 1237 new = bpythonparse(pygformat(saved_tokens, self.formatter)) 1238 self.display_buffer[lineno] = self.display_buffer[ 1239 lineno 1240 ].setslice_with_length( 1241 0, len(new), new, len(self.display_buffer[lineno]) 1242 ) 1243 1244 def clear_current_block(self, remove_from_history=True): 1245 self.display_buffer = [] 1246 if remove_from_history: 1247 for unused in self.buffer: 1248 self.history.pop() 1249 self.all_logical_lines.pop() 1250 self.buffer = [] 1251 self.cursor_offset = 0 1252 self.saved_indent = 0 1253 self.current_line = "" 1254 self.cursor_offset = len(self.current_line) 1255 1256 def get_current_block(self): 1257 """ 1258 Returns the current code block as string (without prompts) 1259 """ 1260 return "\n".join(self.buffer + [self.current_line]) 1261 1262 def send_to_stdouterr(self, output): 1263 """Send unicode strings or FmtStr to Repl stdout or stderr 1264 1265 Must be able to handle FmtStrs because interpreter pass in 1266 tracebacks already formatted.""" 1267 lines = output.split("\n") 1268 logger.debug("display_lines: %r", self.display_lines) 1269 self.current_stdouterr_line += lines[0] 1270 if len(lines) > 1: 1271 self.display_lines.extend( 1272 paint.display_linize( 1273 self.current_stdouterr_line, self.width, blank_line=True 1274 ) 1275 ) 1276 self.display_lines.extend( 1277 sum( 1278 ( 1279 paint.display_linize(line, self.width, blank_line=True) 1280 for line in lines[1:-1] 1281 ), 1282 [], 1283 ) 1284 ) 1285 # These can be FmtStrs, but self.all_logical_lines only wants strings 1286 for line in itertools.chain( 1287 (self.current_stdouterr_line,), lines[1:-1] 1288 ): 1289 if isinstance(line, FmtStr): 1290 self.all_logical_lines.append((line.s, LineType.OUTPUT)) 1291 else: 1292 self.all_logical_lines.append((line, LineType.OUTPUT)) 1293 1294 self.current_stdouterr_line = lines[-1] 1295 logger.debug("display_lines: %r", self.display_lines) 1296 1297 def send_to_stdin(self, line): 1298 if line.endswith("\n"): 1299 self.display_lines.extend( 1300 paint.display_linize(self.current_output_line, self.width) 1301 ) 1302 self.current_output_line = "" 1303 1304 # formatting, output 1305 @property 1306 def done(self): 1307 """Whether the last block is complete - which prompt to use, ps1 or 1308 ps2""" 1309 return not self.buffer 1310 1311 @property 1312 def current_line_formatted(self): 1313 """The colored current line (no prompt, not wrapped)""" 1314 if self.config.syntax: 1315 fs = bpythonparse( 1316 pygformat(self.tokenize(self.current_line), self.formatter) 1317 ) 1318 if self.incr_search_mode: 1319 if self.incr_search_target in self.current_line: 1320 fs = fmtfuncs.on_magenta(self.incr_search_target).join( 1321 fs.split(self.incr_search_target) 1322 ) 1323 elif ( 1324 self.rl_history.saved_line 1325 and self.rl_history.saved_line in self.current_line 1326 ): 1327 if ( 1328 self.config.curtsies_right_arrow_completion 1329 and self.rl_history.index != 0 1330 ): 1331 fs = fmtfuncs.on_magenta(self.rl_history.saved_line).join( 1332 fs.split(self.rl_history.saved_line) 1333 ) 1334 logger.debug("Display line %r -> %r", self.current_line, fs) 1335 else: 1336 fs = fmtstr(self.current_line) 1337 if hasattr(self, "old_fs") and str(fs) != str(self.old_fs): 1338 pass 1339 self.old_fs = fs 1340 return fs 1341 1342 @property 1343 def lines_for_display(self): 1344 """All display lines (wrapped, colored, with prompts)""" 1345 return self.display_lines + self.display_buffer_lines 1346 1347 @property 1348 def display_buffer_lines(self): 1349 """The display lines (wrapped, colored, +prompts) of current buffer""" 1350 lines = [] 1351 for display_line in self.display_buffer: 1352 prompt = func_for_letter(self.config.color_scheme["prompt"]) 1353 more = func_for_letter(self.config.color_scheme["prompt_more"]) 1354 display_line = ( 1355 more(self.ps2) if lines else prompt(self.ps1) 1356 ) + display_line 1357 for line in paint.display_linize(display_line, self.width): 1358 lines.append(line) 1359 return lines 1360 1361 @property 1362 def display_line_with_prompt(self): 1363 """colored line with prompt""" 1364 prompt = func_for_letter(self.config.color_scheme["prompt"]) 1365 more = func_for_letter(self.config.color_scheme["prompt_more"]) 1366 if self.incr_search_mode == "reverse_incremental_search": 1367 return ( 1368 prompt(f"(reverse-i-search)`{self.incr_search_target}': ") 1369 + self.current_line_formatted 1370 ) 1371 elif self.incr_search_mode == "incremental_search": 1372 return prompt(f"(i-search)`%s': ") + self.current_line_formatted 1373 return ( 1374 prompt(self.ps1) if self.done else more(self.ps2) 1375 ) + self.current_line_formatted 1376 1377 @property 1378 def current_cursor_line_without_suggestion(self): 1379 """ 1380 Current line, either output/input or Python prompt + code 1381 1382 :returns: FmtStr 1383 """ 1384 value = self.current_output_line + ( 1385 "" if self.coderunner.running else self.display_line_with_prompt 1386 ) 1387 logger.debug("current cursor line: %r", value) 1388 return value 1389 1390 @property 1391 def current_cursor_line(self): 1392 if self.config.curtsies_right_arrow_completion: 1393 suggest = func_for_letter( 1394 self.config.color_scheme["right_arrow_suggestion"] 1395 ) 1396 return self.current_cursor_line_without_suggestion + suggest( 1397 self.current_suggestion 1398 ) 1399 else: 1400 return self.current_cursor_line_without_suggestion 1401 1402 @property 1403 def current_suggestion(self): 1404 if self.current_line: 1405 for entry in reversed(self.rl_history.entries): 1406 if entry.startswith(self.current_line): 1407 return entry[len(self.current_line) :] 1408 return "" 1409 1410 @property 1411 def current_output_line(self): 1412 """line of output currently being written, and stdin typed""" 1413 return self.current_stdouterr_line + self.stdin.current_line 1414 1415 @current_output_line.setter 1416 def current_output_line(self, value): 1417 self.current_stdouterr_line = "" 1418 self.stdin.current_line = "\n" 1419 1420 def number_of_padding_chars_on_current_cursor_line(self): 1421 """To avoid cutting off two-column characters at the end of lines where 1422 there's only one column left, curtsies adds a padding char (u' '). 1423 It's important to know about these for cursor positioning. 1424 1425 Should return zero unless there are fullwidth characters.""" 1426 full_line = self.current_cursor_line_without_suggestion 1427 line_with_padding = "".join( 1428 line.s 1429 for line in paint.display_linize( 1430 self.current_cursor_line_without_suggestion.s, self.width 1431 ) 1432 ) 1433 1434 # the difference in length here is how much padding there is 1435 return len(line_with_padding) - len(full_line) 1436 1437 def paint( 1438 self, 1439 about_to_exit=False, 1440 user_quit=False, 1441 try_preserve_history_height=30, 1442 min_infobox_height=5, 1443 ) -> Tuple[FSArray, Tuple[int, int]]: 1444 """Returns an array of min_height or more rows and width columns, plus 1445 cursor position 1446 1447 Paints the entire screen - ideally the terminal display layer will take 1448 a diff and only write to the screen in portions that have changed, but 1449 the idea is that we don't need to worry about that here, instead every 1450 frame is completely redrawn because less state is cool! 1451 1452 try_preserve_history_height is the the number of rows of content that 1453 must be visible before the suggestion box scrolls the terminal in order 1454 to display more than min_infobox_height rows of suggestions, docs etc. 1455 """ 1456 # The hairiest function in the curtsies 1457 if about_to_exit: 1458 # exception to not changing state! 1459 self.clean_up_current_line_for_exit() 1460 1461 width, min_height = self.width, self.height 1462 show_status_bar = ( 1463 bool(self.status_bar.should_show_message) 1464 or self.status_bar.has_focus 1465 ) and not self.request_paint_to_pad_bottom 1466 if show_status_bar: 1467 # because we're going to tack the status bar on at the end, shoot 1468 # for an array one less than the height of the screen 1469 min_height -= 1 1470 1471 current_line_start_row = len(self.lines_for_display) - max( 1472 0, self.scroll_offset 1473 ) 1474 # TODO how is the situation of self.scroll_offset < 0 possible? 1475 # or show_status_bar and about_to_exit ? 1476 if self.request_paint_to_clear_screen: 1477 self.request_paint_to_clear_screen = False 1478 arr = FSArray(min_height + current_line_start_row, width) 1479 elif self.request_paint_to_pad_bottom: 1480 # min_height - 1 for startup banner with python version 1481 height = min(self.request_paint_to_pad_bottom, min_height - 1) 1482 arr = FSArray(height, width) 1483 self.request_paint_to_pad_bottom = 0 1484 else: 1485 arr = FSArray(0, width) 1486 # TODO test case of current line filling up the whole screen (there 1487 # aren't enough rows to show it) 1488 1489 current_line = paint.paint_current_line( 1490 min_height, width, self.current_cursor_line 1491 ) 1492 # needs to happen before we calculate contents of history because 1493 # calculating self.current_cursor_line has the side effect of 1494 # unhighlighting parens in buffer 1495 1496 def move_screen_up(current_line_start_row): 1497 # move screen back up a screen minus a line 1498 while current_line_start_row < 0: 1499 logger.debug( 1500 "scroll_offset was %s, current_line_start_row " "was %s", 1501 self.scroll_offset, 1502 current_line_start_row, 1503 ) 1504 self.scroll_offset = self.scroll_offset - self.height 1505 current_line_start_row = len(self.lines_for_display) - max( 1506 -1, self.scroll_offset 1507 ) 1508 logger.debug( 1509 "scroll_offset changed to %s, " 1510 "current_line_start_row changed to %s", 1511 self.scroll_offset, 1512 current_line_start_row, 1513 ) 1514 return current_line_start_row 1515 1516 if self.inconsistent_history and not self.history_already_messed_up: 1517 logger.debug(INCONSISTENT_HISTORY_MSG) 1518 self.history_already_messed_up = True 1519 msg = INCONSISTENT_HISTORY_MSG 1520 arr[0, 0 : min(len(msg), width)] = [msg[:width]] 1521 current_line_start_row += 1 # for the message 1522 1523 # to make up for the scroll that will be received after the 1524 # scrolls are rendered down a line 1525 self.scroll_offset -= 1 1526 1527 current_line_start_row = move_screen_up(current_line_start_row) 1528 logger.debug("current_line_start_row: %r", current_line_start_row) 1529 1530 history = paint.paint_history( 1531 max(0, current_line_start_row - 1), 1532 width, 1533 self.lines_for_display, 1534 ) 1535 arr[1 : history.height + 1, : history.width] = history 1536 1537 if arr.height <= min_height: 1538 # force scroll down to hide broken history message 1539 arr[min_height, 0] = " " 1540 1541 elif current_line_start_row < 0: 1542 # if current line trying to be drawn off the top of the screen 1543 logger.debug(CONTIGUITY_BROKEN_MSG) 1544 msg = CONTIGUITY_BROKEN_MSG 1545 arr[0, 0 : min(len(msg), width)] = [msg[:width]] 1546 1547 current_line_start_row = move_screen_up(current_line_start_row) 1548 1549 history = paint.paint_history( 1550 max(0, current_line_start_row - 1), 1551 width, 1552 self.lines_for_display, 1553 ) 1554 arr[1 : history.height + 1, : history.width] = history 1555 1556 if arr.height <= min_height: 1557 # force scroll down to hide broken history message 1558 arr[min_height, 0] = " " 1559 1560 else: 1561 assert current_line_start_row >= 0 1562 logger.debug("no history issues. start %i", current_line_start_row) 1563 history = paint.paint_history( 1564 current_line_start_row, width, self.lines_for_display 1565 ) 1566 arr[: history.height, : history.width] = history 1567 1568 self.inconsistent_history = False 1569 1570 if user_quit: # quit() or exit() in interp 1571 current_line_start_row = ( 1572 current_line_start_row - current_line.height 1573 ) 1574 logger.debug( 1575 "---current line row slice %r, %r", 1576 current_line_start_row, 1577 current_line_start_row + current_line.height, 1578 ) 1579 logger.debug("---current line col slice %r, %r", 0, current_line.width) 1580 arr[ 1581 current_line_start_row : ( 1582 current_line_start_row + current_line.height 1583 ), 1584 0 : current_line.width, 1585 ] = current_line 1586 1587 if current_line.height > min_height: 1588 return arr, (0, 0) # short circuit, no room for infobox 1589 1590 lines = paint.display_linize(self.current_cursor_line + "X", width) 1591 # extra character for space for the cursor 1592 current_line_end_row = current_line_start_row + len(lines) - 1 1593 current_line_height = current_line_end_row - current_line_start_row 1594 1595 if self.stdin.has_focus: 1596 logger.debug( 1597 "stdouterr when self.stdin has focus: %r %r", 1598 type(self.current_stdouterr_line), 1599 self.current_stdouterr_line, 1600 ) 1601 # mypy can't do ternary type guards yet 1602 stdouterr = self.current_stdouterr_line 1603 if isinstance(stdouterr, FmtStr): 1604 stdouterr_width = stdouterr.width 1605 else: 1606 stdouterr_width = len(stdouterr) 1607 cursor_row, cursor_column = divmod( 1608 stdouterr_width 1609 + wcswidth( 1610 self.stdin.current_line, max(0, self.stdin.cursor_offset) 1611 ), 1612 width, 1613 ) 1614 assert cursor_row >= 0 and cursor_column >= 0, ( 1615 cursor_row, 1616 cursor_column, 1617 self.current_stdouterr_line, 1618 self.stdin.current_line, 1619 ) 1620 elif self.coderunner.running: # TODO does this ever happen? 1621 cursor_row, cursor_column = divmod( 1622 len(self.current_cursor_line_without_suggestion) 1623 + self.cursor_offset, 1624 width, 1625 ) 1626 assert cursor_row >= 0 and cursor_column >= 0, ( 1627 cursor_row, 1628 cursor_column, 1629 len(self.current_cursor_line), 1630 len(self.current_line), 1631 self.cursor_offset, 1632 ) 1633 else: # Common case for determining cursor position 1634 cursor_row, cursor_column = divmod( 1635 wcswidth(self.current_cursor_line_without_suggestion.s) 1636 - wcswidth(self.current_line) 1637 + wcswidth(self.current_line, max(0, self.cursor_offset)) 1638 + self.number_of_padding_chars_on_current_cursor_line(), 1639 width, 1640 ) 1641 assert cursor_row >= 0 and cursor_column >= 0, ( 1642 cursor_row, 1643 cursor_column, 1644 self.current_cursor_line_without_suggestion.s, 1645 self.current_line, 1646 self.cursor_offset, 1647 ) 1648 cursor_row += current_line_start_row 1649 1650 if self.list_win_visible and not self.coderunner.running: 1651 logger.debug("infobox display code running") 1652 visible_space_above = history.height 1653 potential_space_below = min_height - current_line_end_row - 1 1654 visible_space_below = ( 1655 potential_space_below - self.get_top_usable_line() 1656 ) 1657 1658 if self.config.curtsies_list_above: 1659 info_max_rows = max(visible_space_above, visible_space_below) 1660 else: 1661 # Logic for determining size of completion box 1662 # smallest allowed over-full completion box 1663 preferred_height = max( 1664 # always make infobox at least this height 1665 min_infobox_height, 1666 # use this value if there's so much space that we can 1667 # preserve this try_preserve_history_height rows history 1668 min_height - try_preserve_history_height, 1669 ) 1670 1671 info_max_rows = min( 1672 max(visible_space_below, preferred_height), 1673 min_height - current_line_height - 1, 1674 ) 1675 infobox = paint.paint_infobox( 1676 info_max_rows, 1677 int(width * self.config.cli_suggestion_width), 1678 self.matches_iter.matches, 1679 self.funcprops, 1680 self.arg_pos, 1681 self.current_match, 1682 self.docstring, 1683 self.config, 1684 self.matches_iter.completer.format 1685 if self.matches_iter.completer 1686 else None, 1687 ) 1688 1689 if ( 1690 visible_space_below >= infobox.height 1691 or not self.config.curtsies_list_above 1692 ): 1693 arr[ 1694 current_line_end_row 1695 + 1 : (current_line_end_row + 1 + infobox.height), 1696 0 : infobox.width, 1697 ] = infobox 1698 else: 1699 arr[ 1700 current_line_start_row 1701 - infobox.height : current_line_start_row, 1702 0 : infobox.width, 1703 ] = infobox 1704 logger.debug( 1705 "infobox of shape %r added to arr of shape %r", 1706 infobox.shape, 1707 arr.shape, 1708 ) 1709 1710 logger.debug("about to exit: %r", about_to_exit) 1711 if show_status_bar: 1712 statusbar_row = ( 1713 min_height if arr.height == min_height else arr.height 1714 ) 1715 if about_to_exit: 1716 arr[statusbar_row, :] = FSArray(1, width) 1717 else: 1718 arr[statusbar_row, :] = paint.paint_statusbar( 1719 1, width, self.status_bar.current_line, self.config 1720 ) 1721 1722 if self.presentation_mode: 1723 rows = arr.height 1724 columns = arr.width 1725 last_key_box = paint.paint_last_events( 1726 rows, 1727 columns, 1728 [events.pp_event(x) for x in self.last_events if x], 1729 self.config, 1730 ) 1731 arr[ 1732 arr.height - last_key_box.height : arr.height, 1733 arr.width - last_key_box.width : arr.width, 1734 ] = last_key_box 1735 1736 if self.config.color_scheme["background"] not in ("d", "D"): 1737 for r in range(arr.height): 1738 bg = color_for_letter(self.config.color_scheme["background"]) 1739 arr[r] = fmtstr(arr[r], bg=bg) 1740 logger.debug("returning arr of size %r", arr.shape) 1741 logger.debug("cursor pos: %r", (cursor_row, cursor_column)) 1742 return arr, (cursor_row, cursor_column) 1743 1744 @contextlib.contextmanager 1745 def in_paste_mode(self): 1746 orig_value = self.paste_mode 1747 self.paste_mode = True 1748 yield 1749 self.paste_mode = orig_value 1750 if not self.paste_mode: 1751 self.update_completion() 1752 1753 def __repr__(self): 1754 return f"""<{type(self)} 1755 cursor_offset: {self.cursor_offset} 1756 num display lines: {len(self.display_lines)} 1757 lines scrolled down: {self.scroll_offset} 1758>""" 1759 1760 @property 1761 def current_line(self): 1762 """The current line""" 1763 return self._current_line 1764 1765 @current_line.setter 1766 def current_line(self, value): 1767 self._set_current_line(value) 1768 1769 def _set_current_line( 1770 self, 1771 line, 1772 update_completion=True, 1773 reset_rl_history=True, 1774 clear_special_mode=True, 1775 ): 1776 if self._current_line == line: 1777 return 1778 self._current_line = line 1779 if self.paste_mode: 1780 return 1781 if update_completion: 1782 self.update_completion() 1783 if reset_rl_history: 1784 self.rl_history.reset() 1785 if clear_special_mode: 1786 self.special_mode = None 1787 self.unhighlight_paren() 1788 1789 @property 1790 def cursor_offset(self): 1791 """The current cursor offset from the front of the "line".""" 1792 return self._cursor_offset 1793 1794 @cursor_offset.setter 1795 def cursor_offset(self, value): 1796 self._set_cursor_offset(value) 1797 1798 def _set_cursor_offset( 1799 self, 1800 offset, 1801 update_completion=True, 1802 reset_rl_history=False, 1803 clear_special_mode=True, 1804 ): 1805 if self._cursor_offset == offset: 1806 return 1807 if self.paste_mode: 1808 self._cursor_offset = offset 1809 self.unhighlight_paren() 1810 return 1811 if reset_rl_history: 1812 self.rl_history.reset() 1813 if clear_special_mode: 1814 self.incr_search_mode = None 1815 self._cursor_offset = offset 1816 if update_completion: 1817 self.update_completion() 1818 self.unhighlight_paren() 1819 1820 def echo(self, msg, redraw=True): 1821 """ 1822 Notification that redrawing the current line is necessary (we don't 1823 care, since we always redraw the whole screen) 1824 1825 Supposed to parse and echo a formatted string with appropriate 1826 attributes. It's not supposed to update the screen if it's reevaluating 1827 the code (as it does with undo).""" 1828 logger.debug("echo called with %r" % msg) 1829 1830 @property 1831 def cpos(self): 1832 "many WATs were had - it's the pos from the end of the line back" 1833 return len(self.current_line) - self.cursor_offset 1834 1835 def reprint_line(self, lineno, tokens): 1836 logger.debug("calling reprint line with %r %r", lineno, tokens) 1837 if self.config.syntax: 1838 self.display_buffer[lineno] = bpythonparse( 1839 pygformat(tokens, self.formatter) 1840 ) 1841 1842 def take_back_buffer_line(self): 1843 assert len(self.buffer) > 0 1844 if len(self.buffer) == 1: 1845 self._cursor_offset = 0 1846 self.current_line = "" 1847 else: 1848 line = self.buffer[-1] 1849 indent = self.predicted_indent(line) 1850 self._current_line = indent * " " 1851 self.cursor_offset = len(self.current_line) 1852 self.display_buffer.pop() 1853 self.buffer.pop() 1854 self.history.pop() 1855 self.all_logical_lines.pop() 1856 1857 def take_back_empty_line(self): 1858 assert self.history and not self.history[-1] 1859 self.history.pop() 1860 self.display_lines.pop() 1861 self.all_logical_lines.pop() 1862 1863 def prompt_undo(self): 1864 if self.buffer: 1865 return self.take_back_buffer_line() 1866 if self.history and not self.history[-1]: 1867 return self.take_back_empty_line() 1868 1869 def prompt_for_undo(): 1870 n = super(BaseRepl, self).prompt_undo() 1871 if n > 0: 1872 self.request_undo(n=n) 1873 1874 greenlet.greenlet(prompt_for_undo).switch() 1875 1876 def redo(self): 1877 if self.redo_stack: 1878 temp = self.redo_stack.pop() 1879 self.history.append(temp) 1880 self.all_logical_lines.append((temp, LineType.INPUT)) 1881 self.push(temp) 1882 else: 1883 self.status_bar.message("Nothing to redo.") 1884 1885 def reevaluate(self, new_code=False): 1886 """bpython.Repl.undo calls this""" 1887 if self.watcher: 1888 self.watcher.reset() 1889 old_logical_lines = self.history 1890 old_display_lines = self.display_lines 1891 self.history = [] 1892 self.display_lines = [] 1893 self.all_logical_lines = [] 1894 1895 if not self.weak_rewind: 1896 self.interp = self.interp.__class__() 1897 self.interp.write = self.send_to_stdouterr 1898 self.coderunner.interp = self.interp 1899 self.initialize_interp() 1900 1901 self.buffer = [] 1902 self.display_buffer = [] 1903 self.highlighted_paren = None 1904 1905 self.process_event(bpythonevents.RunStartupFileEvent()) 1906 self.reevaluating = True 1907 sys.stdin = ReevaluateFakeStdin(self.stdin, self) 1908 for line in old_logical_lines: 1909 self._current_line = line 1910 self.on_enter(new_code=new_code) 1911 while self.fake_refresh_requested: 1912 self.fake_refresh_requested = False 1913 self.process_event(bpythonevents.RefreshRequestEvent()) 1914 sys.stdin = self.stdin 1915 self.reevaluating = False 1916 1917 num_lines_onscreen = len(self.lines_for_display) - max( 1918 0, self.scroll_offset 1919 ) 1920 display_lines_offscreen = self.display_lines[ 1921 : len(self.display_lines) - num_lines_onscreen 1922 ] 1923 old_display_lines_offscreen = old_display_lines[ 1924 : (len(self.display_lines) - num_lines_onscreen) 1925 ] 1926 logger.debug( 1927 "old_display_lines_offscreen %s", 1928 "|".join(str(x) for x in old_display_lines_offscreen), 1929 ) 1930 logger.debug( 1931 " display_lines_offscreen %s", 1932 "|".join(str(x) for x in display_lines_offscreen), 1933 ) 1934 if ( 1935 old_display_lines_offscreen[: len(display_lines_offscreen)] 1936 != display_lines_offscreen 1937 ) and not self.history_already_messed_up: 1938 self.inconsistent_history = True 1939 logger.debug( 1940 "after rewind, self.inconsistent_history is %r", 1941 self.inconsistent_history, 1942 ) 1943 1944 self._cursor_offset = 0 1945 self.current_line = "" 1946 1947 def initialize_interp(self) -> None: 1948 self.coderunner.interp.locals["_repl"] = self 1949 self.coderunner.interp.runsource( 1950 "from bpython.curtsiesfrontend._internal import _Helper\n" 1951 ) 1952 self.coderunner.interp.runsource("help = _Helper(_repl)\n") 1953 self.coderunner.interp.runsource("del _Helper\n") 1954 1955 del self.coderunner.interp.locals["_repl"] 1956 1957 def getstdout(self): 1958 """ 1959 Returns a string of the current bpython session, wrapped, WITH prompts. 1960 """ 1961 lines = self.lines_for_display + [self.current_line_formatted] 1962 s = ( 1963 "\n".join(x.s if isinstance(x, FmtStr) else x for x in lines) 1964 if lines 1965 else "" 1966 ) 1967 return s 1968 1969 def focus_on_subprocess(self, args): 1970 prev_sigwinch_handler = signal.getsignal(signal.SIGWINCH) 1971 try: 1972 signal.signal(signal.SIGWINCH, self.orig_sigwinch_handler) 1973 with Termmode(self.orig_stdin, self.orig_tcattrs): 1974 terminal = blessings.Terminal(stream=sys.__stdout__) 1975 with terminal.fullscreen(): 1976 sys.__stdout__.write(terminal.save) 1977 sys.__stdout__.write(terminal.move(0, 0)) 1978 sys.__stdout__.flush() 1979 p = subprocess.Popen( 1980 args, 1981 stdin=self.orig_stdin, 1982 stderr=sys.__stderr__, 1983 stdout=sys.__stdout__, 1984 ) 1985 p.wait() 1986 sys.__stdout__.write(terminal.restore) 1987 sys.__stdout__.flush() 1988 finally: 1989 signal.signal(signal.SIGWINCH, prev_sigwinch_handler) 1990 1991 def pager(self, text): 1992 """Runs an external pager on text 1993 1994 text must be a str""" 1995 command = get_pager_command() 1996 with tempfile.NamedTemporaryFile() as tmp: 1997 tmp.write(text.encode(getpreferredencoding())) 1998 tmp.flush() 1999 self.focus_on_subprocess(command + [tmp.name]) 2000 2001 def show_source(self): 2002 try: 2003 source = self.get_source_of_current_name() 2004 except SourceNotFound as e: 2005 self.status_bar.message(f"{e}") 2006 else: 2007 if self.config.highlight_show_source: 2008 source = pygformat( 2009 Python3Lexer().get_tokens(source), TerminalFormatter() 2010 ) 2011 self.pager(source) 2012 2013 def help_text(self): 2014 return self.version_help_text() + "\n" + self.key_help_text() 2015 2016 def version_help_text(self): 2017 help_message = _( 2018 """ 2019Thanks for using bpython! 2020 2021See http://bpython-interpreter.org/ for more information and http://docs.bpython-interpreter.org/ for docs. 2022Please report issues at https://github.com/bpython/bpython/issues 2023 2024Features: 2025Try using undo ({config.undo_key})! 2026Edit the current line ({config.edit_current_block_key}) or the entire session ({config.external_editor_key}) in an external editor. (currently {config.editor}) 2027Save sessions ({config.save_key}) or post them to pastebins ({config.pastebin_key})! Current pastebin helper: {config.pastebin_helper} 2028Reload all modules and rerun session ({config.reimport_key}) to test out changes to a module. 2029Toggle auto-reload mode ({config.toggle_file_watch_key}) to re-execute the current session when a module you've imported is modified. 2030 2031bpython -i your_script.py runs a file in interactive mode 2032bpython -t your_script.py pastes the contents of a file into the session 2033 2034A config file at {config.config_path} customizes keys and behavior of bpython. 2035You can also set which pastebin helper and which external editor to use. 2036See {example_config_url} for an example config file. 2037Press {config.edit_config_key} to edit this config file. 2038""" 2039 ).format(example_config_url=EXAMPLE_CONFIG_URL, config=self.config) 2040 2041 return f"bpython-curtsies version {__version__} using curtsies version {curtsies_version}\n{help_message}" 2042 2043 def key_help_text(self): 2044 NOT_IMPLEMENTED = ( 2045 "suspend", 2046 "cut to buffer", 2047 "search", 2048 "last output", 2049 "yank from buffer", 2050 "cut to buffer", 2051 ) 2052 pairs = [ 2053 ["complete history suggestion", "right arrow at end of line"], 2054 ["previous match with current line", "up arrow"], 2055 ] 2056 for functionality, key in ( 2057 (attr[:-4].replace("_", " "), getattr(self.config, attr)) 2058 for attr in self.config.__dict__ 2059 if attr.endswith("key") 2060 ): 2061 if functionality in NOT_IMPLEMENTED: 2062 key = "Not Implemented" 2063 if key == "": 2064 key = "Disabled" 2065 2066 pairs.append([functionality, key]) 2067 2068 max_func = max(len(func) for func, key in pairs) 2069 return "\n".join( 2070 "{} : {}".format(func.rjust(max_func), key) for func, key in pairs 2071 ) 2072 2073 def get_session_formatted_for_file(self) -> str: 2074 def process(): 2075 for line, lineType in self.all_logical_lines: 2076 if lineType == LineType.INPUT: 2077 yield line 2078 elif line.rstrip(): 2079 yield "# OUT: %s" % line 2080 yield "### %s" % self.current_line 2081 2082 return "\n".join(process()) 2083 2084 @property 2085 def ps1(self): 2086 return _process_ps(super().ps1, ">>> ") 2087 2088 @property 2089 def ps2(self): 2090 return _process_ps(super().ps2, "... ") 2091 2092 2093def is_nop(char): 2094 return unicodedata.category(str(char)) == "Cc" 2095 2096 2097def tabs_to_spaces(line): 2098 return line.replace("\t", " ") 2099 2100 2101def _last_word(line): 2102 split_line = line.split() 2103 return split_line.pop() if split_line else "" 2104 2105 2106def compress_paste_event(paste_event): 2107 """If all events in a paste event are identical and not simple characters, 2108 returns one of them 2109 2110 Useful for when the UI is running so slowly that repeated keypresses end up 2111 in a paste event. If we value not getting delayed and assume the user is 2112 holding down a key to produce such frequent key events, it makes sense to 2113 drop some of the events. 2114 """ 2115 if not all(paste_event.events[0] == e for e in paste_event.events): 2116 return None 2117 event = paste_event.events[0] 2118 # basically "is there a special curtsies names for this key?" 2119 if len(event) > 1: 2120 return event 2121 else: 2122 return None 2123 2124 2125def just_simple_events(event_list): 2126 simple_events = [] 2127 for e in event_list: 2128 # '\n' necessary for pastes 2129 if e in ("<Ctrl-j>", "<Ctrl-m>", "<PADENTER>", "\n", "\r"): 2130 simple_events.append("\n") 2131 elif isinstance(e, events.Event): 2132 pass # ignore events 2133 elif e in ("<SPACE>",): 2134 simple_events.append(" ") 2135 elif len(e) > 1: 2136 pass # get rid of <Ctrl-a> etc. 2137 else: 2138 simple_events.append(e) 2139 return simple_events 2140 2141 2142def is_simple_event(e): 2143 if isinstance(e, events.Event): 2144 return False 2145 if e in ("<Ctrl-j>", "<Ctrl-m>", "<PADENTER>", "\n", "\r", "<SPACE>"): 2146 return True 2147 if len(e) > 1: 2148 return False 2149 else: 2150 return True 2151