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