1# Copyright 2021 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""ConsoleApp control class.""" 15 16from __future__ import annotations 17 18import asyncio 19import base64 20import builtins 21import functools 22import socketserver 23import importlib.resources 24import logging 25import os 26from pathlib import Path 27import subprocess 28import sys 29import tempfile 30import time 31from threading import Thread 32from typing import Any, Callable, Iterable 33 34from jinja2 import Environment, DictLoader, make_logging_undefined 35from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard 36from prompt_toolkit.clipboard import ClipboardData 37from prompt_toolkit.layout.menus import CompletionsMenu 38from prompt_toolkit.output import ColorDepth 39from prompt_toolkit.application import Application 40from prompt_toolkit.filters import Condition 41from prompt_toolkit.styles import ( 42 DynamicStyle, 43 merge_styles, 44) 45from prompt_toolkit.layout import ( 46 ConditionalContainer, 47 Float, 48 Layout, 49) 50from prompt_toolkit.widgets import FormattedTextToolbar 51from prompt_toolkit.widgets import ( 52 MenuContainer, 53 MenuItem, 54) 55from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings 56from prompt_toolkit.history import ( 57 FileHistory, 58 History, 59 ThreadedHistory, 60) 61from ptpython.layout import CompletionVisualisation # type: ignore 62from ptpython.key_bindings import ( # type: ignore 63 load_python_bindings, 64 load_sidebar_bindings, 65) 66from pyperclip import PyperclipException # type: ignore 67 68from pw_console.command_runner import CommandRunner, CommandRunnerItem 69from pw_console.console_log_server import ( 70 ConsoleLogHTTPRequestHandler, 71 pw_console_http_server, 72) 73from pw_console.console_prefs import ConsolePrefs 74from pw_console.help_window import HelpWindow 75from pw_console.key_bindings import create_key_bindings 76from pw_console.log_pane import LogPane 77from pw_console.log_store import LogStore 78from pw_console.pw_ptpython_repl import PwPtPythonRepl 79from pw_console.python_logging import all_loggers 80from pw_console.quit_dialog import QuitDialog 81from pw_console.repl_pane import ReplPane 82from pw_console.style import generate_styles, THEME_NAME_MAPPING 83from pw_console.test_mode import start_fake_logger 84from pw_console.widgets import ( 85 FloatingWindowPane, 86 mouse_handlers, 87 to_checkbox_text, 88 to_keybind_indicator, 89) 90from pw_console.window_manager import WindowManager 91 92_LOG = logging.getLogger(__package__) 93_ROOT_LOG = logging.getLogger('') 94 95_SYSTEM_COMMAND_LOG = logging.getLogger('pw_console_system_command') 96 97PW_CONSOLE_MODULE = 'pw_console' 98 99MAX_FPS = 30 100MIN_REDRAW_INTERVAL = (60.0 / MAX_FPS) / 60.0 101 102 103class FloatingMessageBar(ConditionalContainer): 104 """Floating message bar for showing status messages.""" 105 106 def __init__(self, application): 107 super().__init__( 108 FormattedTextToolbar( 109 (lambda: application.message if application.message else []), 110 style='class:toolbar_inactive', 111 ), 112 filter=Condition( 113 lambda: application.message and application.message != '' 114 ), 115 ) 116 117 118def _add_log_handler_to_pane( 119 logger: str | logging.Logger, 120 pane: LogPane, 121 level_name: str | None = None, 122) -> None: 123 """A log pane handler for a given logger instance.""" 124 if not pane: 125 return 126 pane.add_log_handler(logger, level_name=level_name) 127 128 129def get_default_colordepth( 130 color_depth: ColorDepth | None = None, 131) -> ColorDepth: 132 # Set prompt_toolkit color_depth to the highest possible. 133 if color_depth is None: 134 # Default to 24bit color 135 color_depth = ColorDepth.DEPTH_24_BIT 136 137 # If using Apple Terminal switch to 256 (8bit) color. 138 term_program = os.environ.get('TERM_PROGRAM', '') 139 if sys.platform == 'darwin' and 'Apple_Terminal' in term_program: 140 color_depth = ColorDepth.DEPTH_8_BIT 141 142 # Check for any PROMPT_TOOLKIT_COLOR_DEPTH environment variables 143 color_depth_override = os.environ.get('PROMPT_TOOLKIT_COLOR_DEPTH', '') 144 if color_depth_override: 145 color_depth = ColorDepth(color_depth_override) 146 return color_depth 147 148 149class ConsoleApp: 150 """The main ConsoleApp class that glues everything together.""" 151 152 # pylint: disable=too-many-instance-attributes,too-many-public-methods 153 def __init__( 154 self, 155 global_vars=None, 156 local_vars=None, 157 repl_startup_message=None, 158 help_text=None, 159 app_title=None, 160 color_depth=None, 161 extra_completers=None, 162 prefs=None, 163 floating_window_plugins: ( 164 list[tuple[FloatingWindowPane, dict]] | None 165 ) = None, 166 ): 167 self.prefs = prefs if prefs else ConsolePrefs() 168 self.color_depth = get_default_colordepth(color_depth) 169 170 # Max frequency in seconds of prompt_toolkit UI redraws triggered by new 171 # log lines. 172 self.log_ui_update_frequency = 0.1 # 10 FPS 173 self._last_ui_update_time = time.time() 174 175 self.http_server: socketserver.TCPServer | None = None 176 self.html_files: dict[str, str] = {} 177 178 # Create a default global and local symbol table. Values are the same 179 # structure as what is returned by globals(): 180 # https://docs.python.org/3/library/functions.html#globals 181 if global_vars is None: 182 global_vars = { 183 '__name__': '__main__', 184 '__package__': None, 185 '__doc__': None, 186 '__builtins__': builtins, 187 } 188 189 local_vars = local_vars or global_vars 190 191 jinja_templates = { 192 t: importlib.resources.read_text( 193 f'{PW_CONSOLE_MODULE}.templates', t 194 ) 195 for t in importlib.resources.contents( 196 f'{PW_CONSOLE_MODULE}.templates' 197 ) 198 if t.endswith('.jinja') 199 } 200 201 # Setup the Jinja environment 202 self.jinja_env = Environment( 203 # Load templates automatically from pw_console/templates 204 loader=DictLoader(jinja_templates), 205 # Raise errors if variables are undefined in templates 206 undefined=make_logging_undefined( 207 logger=logging.getLogger(__package__), 208 ), 209 # Trim whitespace in templates 210 trim_blocks=True, 211 lstrip_blocks=True, 212 ) 213 214 self.repl_history_filename = self.prefs.repl_history 215 self.search_history_filename = self.prefs.search_history 216 217 # History instance for search toolbars. 218 self.search_history: History = ThreadedHistory( 219 FileHistory(self.search_history_filename) 220 ) 221 222 # Event loop for executing user repl code. 223 self.user_code_loop = asyncio.new_event_loop() 224 self.test_mode_log_loop = asyncio.new_event_loop() 225 226 self.app_title = app_title if app_title else 'Pigweed Console' 227 228 # Top level UI state toggles. 229 self.load_theme(self.prefs.ui_theme) 230 231 # Pigweed upstream RST user guide 232 self.user_guide_window = HelpWindow(self, title='User Guide') 233 self.user_guide_window.load_user_guide() 234 235 # Top title message 236 self.message = [('class:logo', self.app_title), ('', ' ')] 237 238 self.message.extend( 239 to_keybind_indicator( 240 'Ctrl-p', 241 'Search Menu', 242 functools.partial( 243 mouse_handlers.on_click, 244 self.open_command_runner_main_menu, 245 ), 246 base_style='class:toolbar-button-inactive', 247 ) 248 ) 249 # One space separator 250 self.message.append(('', ' ')) 251 252 # Auto-generated keybindings list for all active panes 253 self.keybind_help_window = HelpWindow(self, title='Keyboard Shortcuts') 254 255 # Downstream project specific help text 256 self.app_help_text = help_text if help_text else None 257 self.app_help_window = HelpWindow( 258 self, 259 additional_help_text=help_text, 260 title=(self.app_title + ' Help'), 261 ) 262 self.app_help_window.generate_keybind_help_text() 263 264 self.prefs_file_window = HelpWindow(self, title='.pw_console.yaml') 265 self.prefs_file_window.load_yaml_text( 266 self.prefs.current_config_as_yaml() 267 ) 268 269 self.floating_window_plugins: list[FloatingWindowPane] = [] 270 if floating_window_plugins: 271 self.floating_window_plugins = [ 272 plugin for plugin, _ in floating_window_plugins 273 ] 274 275 # Used for tracking which pane was in focus before showing help window. 276 self.last_focused_pane = None 277 278 # Create a ptpython repl instance. 279 self.pw_ptpython_repl = PwPtPythonRepl( 280 get_globals=lambda: global_vars, 281 get_locals=lambda: local_vars, 282 color_depth=self.color_depth, 283 history_filename=self.repl_history_filename, 284 extra_completers=extra_completers, 285 ) 286 self.input_history = self.pw_ptpython_repl.history 287 288 self.repl_pane = ReplPane( 289 application=self, 290 python_repl=self.pw_ptpython_repl, 291 startup_message=repl_startup_message, 292 ) 293 self.pw_ptpython_repl.use_code_colorscheme(self.prefs.code_theme) 294 295 self.system_command_output_pane: LogPane | None = None 296 297 if self.prefs.swap_light_and_dark: 298 self.toggle_light_theme() 299 300 # Window panes are added via the window_manager 301 self.window_manager = WindowManager(self) 302 self.window_manager.add_pane_no_checks(self.repl_pane) 303 304 # Top of screen menu items 305 self.menu_items = self._create_menu_items() 306 307 self.quit_dialog = QuitDialog(self) 308 309 # Key bindings registry. 310 self.key_bindings = create_key_bindings(self) 311 312 # Create help window text based global key_bindings and active panes. 313 self._update_help_window() 314 315 self.command_runner = CommandRunner( 316 self, 317 width=self.prefs.command_runner_width, 318 height=self.prefs.command_runner_height, 319 ) 320 321 self.floats = [ 322 # Top message bar 323 Float( 324 content=FloatingMessageBar(self), 325 top=0, 326 right=0, 327 height=1, 328 ), 329 # Centered floating help windows 330 Float( 331 content=self.prefs_file_window, 332 top=2, 333 bottom=2, 334 # Callable to get width 335 width=self.prefs_file_window.content_width, 336 ), 337 Float( 338 content=self.app_help_window, 339 top=2, 340 bottom=2, 341 # Callable to get width 342 width=self.app_help_window.content_width, 343 ), 344 Float( 345 content=self.user_guide_window, 346 top=2, 347 bottom=2, 348 # Callable to get width 349 width=self.user_guide_window.content_width, 350 ), 351 Float( 352 content=self.keybind_help_window, 353 top=2, 354 bottom=2, 355 # Callable to get width 356 width=self.keybind_help_window.content_width, 357 ), 358 ] 359 360 if floating_window_plugins: 361 self.floats.extend( 362 [ 363 Float(content=plugin_container, **float_args) 364 for plugin_container, float_args in floating_window_plugins 365 ] 366 ) 367 368 self.floats.extend( 369 [ 370 # Completion menu that can overlap other panes since it lives in 371 # the top level Float container. 372 # pylint: disable=line-too-long 373 Float( 374 xcursor=True, 375 ycursor=True, 376 content=ConditionalContainer( 377 content=CompletionsMenu( 378 scroll_offset=( 379 lambda: self.pw_ptpython_repl.completion_menu_scroll_offset 380 ), 381 max_height=16, 382 ), 383 # Only show our completion if ptpython's is disabled. 384 filter=Condition( 385 lambda: self.pw_ptpython_repl.completion_visualisation 386 == CompletionVisualisation.NONE 387 ), 388 ), 389 ), 390 # pylint: enable=line-too-long 391 Float( 392 content=self.command_runner, 393 # Callable to get width 394 width=self.command_runner.content_width, 395 **self.prefs.command_runner_position, 396 ), 397 Float( 398 content=self.quit_dialog, 399 top=2, 400 left=2, 401 ), 402 ] 403 ) 404 405 # prompt_toolkit root container. 406 self.root_container = MenuContainer( 407 body=self.window_manager.create_root_container(), 408 menu_items=self.menu_items, 409 floats=self.floats, 410 ) 411 412 # NOTE: ptpython stores it's completion menus in this HSplit: 413 # 414 # self.pw_ptpython_repl.__pt_container__() 415 # .children[0].children[0].children[0].floats[0].content.children 416 # 417 # Index 1 is a CompletionsMenu and is shown when: 418 # self.pw_ptpython_repl 419 # .completion_visualisation == CompletionVisualisation.POP_UP 420 # 421 # Index 2 is a MultiColumnCompletionsMenu and is shown when: 422 # self.pw_ptpython_repl 423 # .completion_visualisation == CompletionVisualisation.MULTI_COLUMN 424 # 425 426 # Setup the prompt_toolkit layout with the repl pane as the initially 427 # focused element. 428 self.layout: Layout = Layout( 429 self.root_container, 430 focused_element=self.pw_ptpython_repl, 431 ) 432 433 # Create the prompt_toolkit Application instance. 434 self.application: Application = Application( 435 layout=self.layout, 436 key_bindings=merge_key_bindings( 437 [ 438 # Pull key bindings from ptpython 439 load_python_bindings(self.pw_ptpython_repl), 440 load_sidebar_bindings(self.pw_ptpython_repl), 441 self.window_manager.key_bindings, 442 self.key_bindings, 443 ] 444 ), 445 style=DynamicStyle( 446 lambda: merge_styles( 447 [ 448 self._current_theme, 449 # Include ptpython styles 450 self.pw_ptpython_repl._current_style, # pylint: disable=protected-access 451 ] 452 ) 453 ), 454 style_transformation=self.pw_ptpython_repl.style_transformation, 455 enable_page_navigation_bindings=True, 456 full_screen=True, 457 mouse_support=True, 458 color_depth=self.color_depth, 459 clipboard=PyperclipClipboard(), 460 min_redraw_interval=MIN_REDRAW_INTERVAL, 461 ) 462 463 def get_template(self, file_name: str): 464 return self.jinja_env.get_template(file_name) 465 466 def run_pane_menu_option(self, function_to_run): 467 # Run the function for a particular menu item. 468 return_value = function_to_run() 469 # It's return value dictates if the main menu should close or not. 470 # - False: The main menu stays open. This is the default prompt_toolkit 471 # menu behavior. 472 # - True: The main menu closes. 473 474 # Update menu content. This will refresh checkboxes and add/remove 475 # items. 476 self.update_menu_items() 477 # Check if the main menu should stay open. 478 if not return_value: 479 # Keep the main menu open 480 self.focus_main_menu() 481 482 def open_new_log_pane_for_logger( 483 self, 484 logger_name: str, 485 level_name='NOTSET', 486 window_title: str | None = None, 487 ) -> None: 488 pane_title = window_title if window_title else logger_name 489 self.run_pane_menu_option( 490 functools.partial( 491 self.add_log_handler, 492 pane_title, 493 [logger_name], 494 log_level_name=level_name, 495 ) 496 ) 497 498 def set_ui_theme(self, theme_name: str) -> Callable: 499 call_function = functools.partial( 500 self.run_pane_menu_option, 501 functools.partial(self.load_theme, theme_name), 502 ) 503 return call_function 504 505 def set_code_theme(self, theme_name: str) -> Callable: 506 call_function = functools.partial( 507 self.run_pane_menu_option, 508 functools.partial( 509 self.pw_ptpython_repl.use_code_colorscheme, theme_name 510 ), 511 ) 512 return call_function 513 514 def set_system_clipboard_data(self, data: ClipboardData) -> str: 515 return self.set_system_clipboard(data.text) 516 517 def set_system_clipboard(self, text: str) -> str: 518 """Set the host system clipboard. 519 520 The following methods are attempted in order: 521 522 - The pyperclip package which uses various cross platform methods. 523 - Teminal OSC 52 escape sequence which works on some terminal emulators 524 such as: iTerm2 (MacOS), Alacritty, xterm. 525 - Tmux paste buffer via the load-buffer command. This only happens if 526 pw-console is running inside tmux. You can paste in tmux by pressing: 527 ctrl-b = 528 """ 529 copied = False 530 copy_methods = [] 531 try: 532 self.application.clipboard.set_text(text) 533 534 copied = True 535 copy_methods.append('system clipboard') 536 except PyperclipException: 537 pass 538 539 # Set the clipboard via terminal escape sequence. 540 b64_data = base64.b64encode(text.encode('utf-8')) 541 sys.stdout.write(f"\x1B]52;c;{b64_data.decode('utf-8')}\x07") 542 _LOG.debug('Clipboard set via teminal escape sequence') 543 copy_methods.append('teminal') 544 copied = True 545 546 if os.environ.get('TMUX'): 547 with tempfile.NamedTemporaryFile( 548 prefix='pw_console_clipboard_', 549 delete=True, 550 ) as clipboard_file: 551 clipboard_file.write(text.encode('utf-8')) 552 clipboard_file.flush() 553 subprocess.run( 554 ['tmux', 'load-buffer', '-w', clipboard_file.name] 555 ) 556 _LOG.debug('Clipboard set via tmux load-buffer') 557 copy_methods.append('tmux') 558 copied = True 559 560 message = '' 561 if copied: 562 message = 'Copied to: ' 563 message += ', '.join(copy_methods) 564 return message 565 566 def update_menu_items(self): 567 self.menu_items = self._create_menu_items() 568 self.root_container.menu_items = self.menu_items 569 570 def open_command_runner_main_menu(self) -> None: 571 self.command_runner.set_completions() 572 if not self.command_runner_is_open(): 573 self.command_runner.open_dialog() 574 575 def open_command_runner_loggers(self) -> None: 576 self.command_runner.set_completions( 577 window_title='Open Logger', 578 load_completions=self._create_logger_completions, 579 ) 580 if not self.command_runner_is_open(): 581 self.command_runner.open_dialog() 582 583 def _create_logger_completions(self) -> list[CommandRunnerItem]: 584 completions: list[CommandRunnerItem] = [ 585 CommandRunnerItem( 586 title='root', 587 handler=functools.partial( 588 self.open_new_log_pane_for_logger, '', window_title='root' 589 ), 590 ), 591 ] 592 593 all_logger_names = sorted([logger.name for logger in all_loggers()]) 594 595 for logger_name in all_logger_names: 596 completions.append( 597 CommandRunnerItem( 598 title=logger_name, 599 handler=functools.partial( 600 self.open_new_log_pane_for_logger, logger_name 601 ), 602 ) 603 ) 604 return completions 605 606 def open_command_runner_history(self) -> None: 607 self.command_runner.set_completions( 608 window_title='History', 609 load_completions=self._create_history_completions, 610 ) 611 if not self.command_runner_is_open(): 612 self.command_runner.open_dialog() 613 614 def _create_history_completions(self) -> list[CommandRunnerItem]: 615 return [ 616 CommandRunnerItem( 617 title=title, 618 handler=functools.partial( 619 self.repl_pane.insert_text_into_input_buffer, text 620 ), 621 ) 622 for title, text in self.repl_pane.history_completions() 623 ] 624 625 def open_command_runner_snippets(self) -> None: 626 self.command_runner.set_completions( 627 window_title='Snippets', 628 load_completions=self._create_snippet_completions, 629 ) 630 if not self.command_runner_is_open(): 631 self.command_runner.open_dialog() 632 633 def _http_server_entry(self) -> None: 634 handler = functools.partial( 635 ConsoleLogHTTPRequestHandler, self.html_files 636 ) 637 pw_console_http_server(3000, handler) 638 639 def start_http_server(self): 640 if self.http_server is not None: 641 return 642 643 html_package_path = f'{PW_CONSOLE_MODULE}.html' 644 self.html_files = { 645 '/{}'.format(t): importlib.resources.read_text(html_package_path, t) 646 for t in importlib.resources.contents(html_package_path) 647 if Path(t).suffix in ['.css', '.html', '.js', '.json'] 648 } 649 650 server_thread = Thread( 651 target=self._http_server_entry, args=(), daemon=True 652 ) 653 server_thread.start() 654 655 def _create_snippet_completions(self) -> list[CommandRunnerItem]: 656 completions: list[CommandRunnerItem] = [] 657 658 for snippet in self.prefs.snippet_completions(): 659 fenced_code = f'```python\n{snippet.code.strip()}\n```' 660 description = '\n' + fenced_code + '\n' 661 if snippet.description: 662 description += '\n' + snippet.description.strip() + '\n' 663 completions.append( 664 CommandRunnerItem( 665 title=snippet.title, 666 handler=functools.partial( 667 self.repl_pane.insert_text_into_input_buffer, 668 snippet.code, 669 ), 670 description=description, 671 ) 672 ) 673 674 return completions 675 676 def _create_menu_items(self): 677 themes_submenu = [ 678 MenuItem('Toggle Light/Dark', handler=self.toggle_light_theme), 679 MenuItem('-'), 680 MenuItem( 681 'UI Themes', 682 children=[ 683 MenuItem(theme.display_name, self.set_ui_theme(theme_name)) 684 for theme_name, theme in THEME_NAME_MAPPING.items() 685 ], 686 ), 687 MenuItem( 688 'Code Themes', 689 children=[ 690 MenuItem( 691 'Code: pigweed-code', 692 self.set_code_theme('pigweed-code'), 693 ), 694 MenuItem( 695 'Code: pigweed-code-light', 696 self.set_code_theme('pigweed-code-light'), 697 ), 698 MenuItem( 699 'Code: synthwave84', 700 self.set_code_theme('synthwave84'), 701 ), 702 MenuItem('Code: material', self.set_code_theme('material')), 703 MenuItem( 704 'Code: gruvbox-light', 705 self.set_code_theme('gruvbox-light'), 706 ), 707 MenuItem( 708 'Code: gruvbox-dark', 709 self.set_code_theme('gruvbox-dark'), 710 ), 711 MenuItem('Code: zenburn', self.set_code_theme('zenburn')), 712 ], 713 ), 714 ] 715 716 file_menu = [ 717 # File menu 718 MenuItem( 719 '[File]', 720 children=[ 721 MenuItem( 722 'Insert Repl Snippet', 723 handler=self.open_command_runner_snippets, 724 ), 725 MenuItem( 726 'Insert Repl History', 727 handler=self.open_command_runner_history, 728 ), 729 MenuItem( 730 'Open Logger', handler=self.open_command_runner_loggers 731 ), 732 MenuItem( 733 'Log Table View', 734 children=[ 735 # pylint: disable=line-too-long 736 MenuItem( 737 '{check} Hide Date'.format( 738 check=to_checkbox_text( 739 self.prefs.hide_date_from_log_time, 740 end='', 741 ) 742 ), 743 handler=functools.partial( 744 self.run_pane_menu_option, 745 functools.partial( 746 self.toggle_pref_option, 747 'hide_date_from_log_time', 748 ), 749 ), 750 ), 751 MenuItem( 752 '{check} Show Source File'.format( 753 check=to_checkbox_text( 754 self.prefs.show_source_file, end='' 755 ) 756 ), 757 handler=functools.partial( 758 self.run_pane_menu_option, 759 functools.partial( 760 self.toggle_pref_option, 761 'show_source_file', 762 ), 763 ), 764 ), 765 MenuItem( 766 '{check} Show Python File'.format( 767 check=to_checkbox_text( 768 self.prefs.show_python_file, end='' 769 ) 770 ), 771 handler=functools.partial( 772 self.run_pane_menu_option, 773 functools.partial( 774 self.toggle_pref_option, 775 'show_python_file', 776 ), 777 ), 778 ), 779 MenuItem( 780 '{check} Show Python Logger'.format( 781 check=to_checkbox_text( 782 self.prefs.show_python_logger, end='' 783 ) 784 ), 785 handler=functools.partial( 786 self.run_pane_menu_option, 787 functools.partial( 788 self.toggle_pref_option, 789 'show_python_logger', 790 ), 791 ), 792 ), 793 # pylint: enable=line-too-long 794 ], 795 ), 796 MenuItem('-'), 797 MenuItem( 798 'Themes', 799 children=themes_submenu, 800 ), 801 MenuItem('-'), 802 MenuItem('Exit', handler=self.exit_console), 803 ], 804 ), 805 ] 806 807 edit_menu = [ 808 MenuItem( 809 '[Edit]', 810 children=[ 811 # pylint: disable=line-too-long 812 MenuItem( 813 'Paste to Python Input', 814 handler=self.repl_pane.paste_system_clipboard_to_input_buffer, 815 ), 816 # pylint: enable=line-too-long 817 MenuItem('-'), 818 MenuItem( 819 'Copy all Python Output', 820 handler=self.repl_pane.copy_all_output_text, 821 ), 822 MenuItem( 823 'Copy all Python Input', 824 handler=self.repl_pane.copy_all_input_text, 825 ), 826 MenuItem('-'), 827 MenuItem( 828 'Clear Python Input', self.repl_pane.clear_input_buffer 829 ), 830 MenuItem( 831 'Clear Python Output', 832 self.repl_pane.clear_output_buffer, 833 ), 834 ], 835 ), 836 ] 837 838 view_menu = [ 839 MenuItem( 840 '[View]', 841 children=[ 842 # [Menu Item ][Keybind ] 843 MenuItem( 844 'Focus Next Window/Tab Ctrl-Alt-n', 845 handler=self.window_manager.focus_next_pane, 846 ), 847 # [Menu Item ][Keybind ] 848 MenuItem( 849 'Focus Prev Window/Tab Ctrl-Alt-p', 850 handler=self.window_manager.focus_previous_pane, 851 ), 852 MenuItem('-'), 853 # [Menu Item ][Keybind ] 854 MenuItem( 855 'Move Window Up Ctrl-Alt-Up', 856 handler=functools.partial( 857 self.run_pane_menu_option, 858 self.window_manager.move_pane_up, 859 ), 860 ), 861 # [Menu Item ][Keybind ] 862 MenuItem( 863 'Move Window Down Ctrl-Alt-Down', 864 handler=functools.partial( 865 self.run_pane_menu_option, 866 self.window_manager.move_pane_down, 867 ), 868 ), 869 # [Menu Item ][Keybind ] 870 MenuItem( 871 'Move Window Left Ctrl-Alt-Left', 872 handler=functools.partial( 873 self.run_pane_menu_option, 874 self.window_manager.move_pane_left, 875 ), 876 ), 877 # [Menu Item ][Keybind ] 878 MenuItem( 879 'Move Window Right Ctrl-Alt-Right', 880 handler=functools.partial( 881 self.run_pane_menu_option, 882 self.window_manager.move_pane_right, 883 ), 884 ), 885 MenuItem('-'), 886 # [Menu Item ][Keybind ] 887 MenuItem( 888 'Shrink Height Alt-Minus', 889 handler=functools.partial( 890 self.run_pane_menu_option, 891 self.window_manager.shrink_pane, 892 ), 893 ), 894 # [Menu Item ][Keybind ] 895 MenuItem( 896 'Enlarge Height Alt-=', 897 handler=functools.partial( 898 self.run_pane_menu_option, 899 self.window_manager.enlarge_pane, 900 ), 901 ), 902 MenuItem('-'), 903 # [Menu Item ][Keybind ] 904 MenuItem( 905 'Shrink Column Alt-,', 906 handler=functools.partial( 907 self.run_pane_menu_option, 908 self.window_manager.shrink_split, 909 ), 910 ), 911 # [Menu Item ][Keybind ] 912 MenuItem( 913 'Enlarge Column Alt-.', 914 handler=functools.partial( 915 self.run_pane_menu_option, 916 self.window_manager.enlarge_split, 917 ), 918 ), 919 MenuItem('-'), 920 # [Menu Item ][Keybind ] 921 MenuItem( 922 'Balance Window Sizes Ctrl-u', 923 handler=functools.partial( 924 self.run_pane_menu_option, 925 self.window_manager.balance_window_sizes, 926 ), 927 ), 928 ], 929 ), 930 ] 931 932 window_menu_items = self.window_manager.create_window_menu_items() 933 934 floating_window_items = [] 935 if self.floating_window_plugins: 936 floating_window_items.append(MenuItem('-', None)) 937 floating_window_items.extend( 938 MenuItem( 939 'Floating Window {index}: {title}'.format( 940 index=pane_index + 1, 941 title=pane.menu_title(), 942 ), 943 children=[ 944 MenuItem( 945 # pylint: disable=line-too-long 946 '{check} Show/Hide Window'.format( 947 check=to_checkbox_text(pane.show_pane, end='') 948 ), 949 # pylint: enable=line-too-long 950 handler=functools.partial( 951 self.run_pane_menu_option, pane.toggle_dialog 952 ), 953 ), 954 ] 955 + [ 956 MenuItem( 957 text, 958 handler=functools.partial( 959 self.run_pane_menu_option, handler 960 ), 961 ) 962 for text, handler in pane.get_window_menu_options() 963 ], 964 ) 965 for pane_index, pane in enumerate(self.floating_window_plugins) 966 ) 967 window_menu_items.extend(floating_window_items) 968 969 window_menu = [MenuItem('[Windows]', children=window_menu_items)] 970 971 top_level_plugin_menus = [] 972 for pane in self.window_manager.active_panes(): 973 top_level_plugin_menus.extend(pane.get_top_level_menus()) 974 if self.floating_window_plugins: 975 for pane in self.floating_window_plugins: 976 top_level_plugin_menus.extend(pane.get_top_level_menus()) 977 978 help_menu_items = [ 979 MenuItem( 980 self.user_guide_window.menu_title(), 981 handler=self.user_guide_window.toggle_display, 982 ), 983 MenuItem( 984 self.keybind_help_window.menu_title(), 985 handler=self.keybind_help_window.toggle_display, 986 ), 987 MenuItem('-'), 988 MenuItem( 989 'View Key Binding Config', 990 handler=self.prefs_file_window.toggle_display, 991 ), 992 ] 993 994 if self.app_help_text: 995 help_menu_items.extend( 996 [ 997 MenuItem('-'), 998 MenuItem( 999 self.app_help_window.menu_title(), 1000 handler=self.app_help_window.toggle_display, 1001 ), 1002 ] 1003 ) 1004 1005 help_menu = [ 1006 # Info / Help 1007 MenuItem( 1008 '[Help]', 1009 children=help_menu_items, 1010 ), 1011 ] 1012 1013 return ( 1014 file_menu 1015 + edit_menu 1016 + view_menu 1017 + top_level_plugin_menus 1018 + window_menu 1019 + help_menu 1020 ) 1021 1022 def focus_main_menu(self): 1023 """Set application focus to the main menu.""" 1024 self.application.layout.focus(self.root_container.window) 1025 1026 def focus_on_container(self, pane): 1027 """Set application focus to a specific container.""" 1028 # Try to focus on the given pane 1029 try: 1030 self.application.layout.focus(pane) 1031 except ValueError: 1032 # If the container can't be focused, focus on the first visible 1033 # window pane. 1034 self.window_manager.focus_first_visible_pane() 1035 1036 def toggle_light_theme(self): 1037 """Toggle light and dark theme colors.""" 1038 # Use ptpython's style_transformation to swap dark and light colors. 1039 self.pw_ptpython_repl.swap_light_and_dark = ( 1040 not self.pw_ptpython_repl.swap_light_and_dark 1041 ) 1042 if self.application: 1043 self.focus_main_menu() 1044 1045 def toggle_pref_option(self, setting_name): 1046 self.prefs.toggle_bool_option(setting_name) 1047 1048 def load_theme(self, theme_name=None): 1049 """Regenerate styles for the current theme_name.""" 1050 self._current_theme = generate_styles(theme_name) 1051 if theme_name: 1052 self.prefs.set_ui_theme(theme_name) 1053 1054 def _create_log_pane( 1055 self, title: str = '', log_store: LogStore | None = None 1056 ) -> LogPane: 1057 # Create one log pane. 1058 log_pane = LogPane( 1059 application=self, pane_title=title, log_store=log_store 1060 ) 1061 self.window_manager.add_pane(log_pane) 1062 return log_pane 1063 1064 def load_config(self, config_file: Path) -> None: 1065 self.prefs.reset_config() 1066 self.prefs.load_config_file(config_file) 1067 # Re-apply user settings. 1068 if self.prefs.user_file: 1069 self.prefs.load_config_file(self.prefs.user_file) 1070 1071 # Reset colors 1072 self.load_theme(self.prefs.ui_theme) 1073 self.pw_ptpython_repl.use_code_colorscheme(self.prefs.code_theme) 1074 1075 def apply_window_config(self) -> None: 1076 self.window_manager.apply_config(self.prefs) 1077 1078 def refresh_layout(self) -> None: 1079 self.window_manager.update_root_container_body() 1080 self.update_menu_items() 1081 self._update_help_window() 1082 1083 def all_log_stores(self) -> list[LogStore]: 1084 log_stores: list[LogStore] = [] 1085 for pane in self.window_manager.active_panes(): 1086 if not isinstance(pane, LogPane): 1087 continue 1088 if pane.log_view.log_store not in log_stores: 1089 log_stores.append(pane.log_view.log_store) 1090 return log_stores 1091 1092 def add_log_handler( 1093 self, 1094 window_title: str, 1095 logger_instances: Iterable[logging.Logger] | LogStore, 1096 separate_log_panes: bool = False, 1097 log_level_name: str | None = None, 1098 ) -> LogPane | None: 1099 """Add the Log pane as a handler for this logger instance.""" 1100 1101 existing_log_pane = None 1102 # Find an existing LogPane with the same window_title. 1103 for pane in self.window_manager.active_panes(): 1104 if isinstance(pane, LogPane) and pane.pane_title() == window_title: 1105 existing_log_pane = pane 1106 break 1107 1108 log_store: LogStore | None = None 1109 if isinstance(logger_instances, LogStore): 1110 log_store = logger_instances 1111 1112 if not existing_log_pane or separate_log_panes: 1113 existing_log_pane = self._create_log_pane( 1114 title=window_title, log_store=log_store 1115 ) 1116 1117 if isinstance(logger_instances, list): 1118 for logger in logger_instances: 1119 _add_log_handler_to_pane( 1120 logger, existing_log_pane, log_level_name 1121 ) 1122 1123 self.refresh_layout() 1124 return existing_log_pane 1125 1126 def _user_code_thread_entry(self): 1127 """Entry point for the user code thread.""" 1128 asyncio.set_event_loop(self.user_code_loop) 1129 self.user_code_loop.run_forever() 1130 1131 def start_user_code_thread(self): 1132 """Create a thread for running user code so the UI isn't blocked.""" 1133 thread = Thread( 1134 target=self._user_code_thread_entry, args=(), daemon=True 1135 ) 1136 thread.start() 1137 1138 def _test_mode_log_thread_entry(self): 1139 """Entry point for the user code thread.""" 1140 asyncio.set_event_loop(self.test_mode_log_loop) 1141 self.test_mode_log_loop.run_forever() 1142 1143 def _update_help_window(self): 1144 """Generate the help window text based on active pane keybindings.""" 1145 # Add global mouse bindings to the help text. 1146 mouse_functions = { 1147 'Focus pane, menu or log line.': ['Click'], 1148 'Scroll current window.': ['Scroll wheel'], 1149 } 1150 1151 self.keybind_help_window.add_custom_keybinds_help_text( 1152 'Global Mouse', mouse_functions 1153 ) 1154 1155 # Add global key bindings to the help text. 1156 self.keybind_help_window.add_keybind_help_text( 1157 'Global', self.key_bindings 1158 ) 1159 1160 self.keybind_help_window.add_keybind_help_text( 1161 'Window Management', self.window_manager.key_bindings 1162 ) 1163 1164 # Add activated plugin key bindings to the help text. 1165 for pane in self.window_manager.active_panes(): 1166 for key_bindings in pane.get_all_key_bindings(): 1167 help_section_title = pane.__class__.__name__ 1168 if isinstance(key_bindings, KeyBindings): 1169 self.keybind_help_window.add_keybind_help_text( 1170 help_section_title, key_bindings 1171 ) 1172 elif isinstance(key_bindings, dict): 1173 self.keybind_help_window.add_custom_keybinds_help_text( 1174 help_section_title, key_bindings 1175 ) 1176 1177 self.keybind_help_window.generate_keybind_help_text() 1178 1179 def toggle_log_line_wrapping(self): 1180 """Menu item handler to toggle line wrapping of all log panes.""" 1181 for pane in self.window_manager.active_panes(): 1182 if isinstance(pane, LogPane): 1183 pane.toggle_wrap_lines() 1184 1185 def focused_window(self): 1186 """Return the currently focused window.""" 1187 return self.application.layout.current_window 1188 1189 def command_runner_is_open(self) -> bool: 1190 return self.command_runner.show_dialog 1191 1192 def command_runner_last_focused_pane(self) -> Any: 1193 return self.command_runner.last_focused_pane 1194 1195 def modal_window_is_open(self): 1196 """Return true if any modal window or dialog is open.""" 1197 floating_window_is_open = ( 1198 self.keybind_help_window.show_window 1199 or self.prefs_file_window.show_window 1200 or self.user_guide_window.show_window 1201 or self.quit_dialog.show_dialog 1202 or self.command_runner.show_dialog 1203 ) 1204 1205 if self.app_help_text: 1206 floating_window_is_open = ( 1207 self.app_help_window.show_window or floating_window_is_open 1208 ) 1209 1210 floating_plugin_is_open = any( 1211 plugin.show_pane for plugin in self.floating_window_plugins 1212 ) 1213 1214 return floating_window_is_open or floating_plugin_is_open 1215 1216 def exit_console(self): 1217 """Quit the console prompt_toolkit application UI.""" 1218 self.application.exit() 1219 1220 def logs_redraw(self): 1221 emit_time = time.time() 1222 # Has enough time passed since last UI redraw due to new logs? 1223 if emit_time > self._last_ui_update_time + self.log_ui_update_frequency: 1224 # Update last log time 1225 self._last_ui_update_time = emit_time 1226 1227 # Trigger Prompt Toolkit UI redraw. 1228 self.redraw_ui() 1229 1230 def redraw_ui(self): 1231 """Redraw the prompt_toolkit UI.""" 1232 if hasattr(self, 'application'): 1233 # Thread safe way of sending a repaint trigger to the input event 1234 # loop. 1235 self.application.invalidate() 1236 1237 def setup_command_runner_log_pane(self) -> None: 1238 if self.system_command_output_pane is not None: 1239 return 1240 1241 self.system_command_output_pane = LogPane( 1242 application=self, pane_title='Shell Output' 1243 ) 1244 self.system_command_output_pane.add_log_handler( 1245 _SYSTEM_COMMAND_LOG, level_name='INFO' 1246 ) 1247 self.system_command_output_pane.log_view.log_store.formatter = ( 1248 logging.Formatter('%(message)s') 1249 ) 1250 self.system_command_output_pane.table_view = False 1251 self.system_command_output_pane.show_pane = True 1252 # Enable line wrapping 1253 self.system_command_output_pane.toggle_wrap_lines() 1254 # Blank right side toolbar text 1255 # pylint: disable=protected-access 1256 self.system_command_output_pane._pane_subtitle = ' ' 1257 # pylint: enable=protected-access 1258 self.window_manager.add_pane(self.system_command_output_pane) 1259 1260 async def run(self, test_mode=False): 1261 """Start the prompt_toolkit UI.""" 1262 if test_mode: 1263 background_log_task = start_fake_logger( 1264 lines=self.user_guide_window.help_text_area.document.lines, 1265 log_thread_entry=self._test_mode_log_thread_entry, 1266 log_thread_loop=self.test_mode_log_loop, 1267 ) 1268 1269 # Repl pane has focus by default, if it's hidden switch focus to another 1270 # visible pane. 1271 if not self.repl_pane.show_pane: 1272 self.window_manager.focus_first_visible_pane() 1273 1274 try: 1275 await self.application.run_async(set_exception_handler=True) 1276 finally: 1277 if test_mode: 1278 background_log_task.cancel() 1279 1280 1281# TODO(tonymd): Remove this alias when not used by downstream projects. 1282def embed( 1283 *args, 1284 **kwargs, 1285) -> None: 1286 """PwConsoleEmbed().embed() alias.""" 1287 # Import here to avoid circular dependency 1288 # pylint: disable=import-outside-toplevel 1289 from pw_console.embed import PwConsoleEmbed 1290 1291 # pylint: enable=import-outside-toplevel 1292 1293 console = PwConsoleEmbed(*args, **kwargs) 1294 console.embed() 1295