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 logging 20import os 21from pathlib import Path 22import sys 23from threading import Thread 24from typing import Any, Callable, Iterable, List, Optional, Tuple, Union 25 26from jinja2 import Environment, FileSystemLoader, make_logging_undefined 27from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard 28from prompt_toolkit.layout.menus import CompletionsMenu 29from prompt_toolkit.output import ColorDepth 30from prompt_toolkit.application import Application 31from prompt_toolkit.filters import Condition 32from prompt_toolkit.styles import ( 33 DynamicStyle, 34 merge_styles, 35) 36from prompt_toolkit.layout import ( 37 ConditionalContainer, 38 Float, 39 Layout, 40) 41from prompt_toolkit.widgets import FormattedTextToolbar 42from prompt_toolkit.widgets import ( 43 MenuContainer, 44 MenuItem, 45) 46from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings 47from prompt_toolkit.history import ( 48 FileHistory, 49 History, 50 ThreadedHistory, 51) 52from ptpython.layout import CompletionVisualisation # type: ignore 53from ptpython.key_bindings import ( # type: ignore 54 load_python_bindings, load_sidebar_bindings, 55) 56 57from pw_console.console_prefs import ConsolePrefs 58from pw_console.help_window import HelpWindow 59from pw_console.command_runner import CommandRunner 60import pw_console.key_bindings 61from pw_console.log_pane import LogPane 62from pw_console.log_store import LogStore 63from pw_console.pw_ptpython_repl import PwPtPythonRepl 64from pw_console.python_logging import all_loggers 65from pw_console.quit_dialog import QuitDialog 66from pw_console.repl_pane import ReplPane 67import pw_console.style 68import pw_console.widgets.checkbox 69import pw_console.widgets.mouse_handlers 70from pw_console.window_manager import WindowManager 71 72_LOG = logging.getLogger(__package__) 73 74# Fake logger for --test-mode 75FAKE_DEVICE_LOGGER_NAME = 'pw_console_fake_device' 76_FAKE_DEVICE_LOG = logging.getLogger(FAKE_DEVICE_LOGGER_NAME) 77# Don't send fake_device logs to the root Python logger. 78_FAKE_DEVICE_LOG.propagate = False 79 80MAX_FPS = 15 81MIN_REDRAW_INTERVAL = (60.0 / MAX_FPS) / 60.0 82 83 84class FloatingMessageBar(ConditionalContainer): 85 """Floating message bar for showing status messages.""" 86 def __init__(self, application): 87 super().__init__( 88 FormattedTextToolbar( 89 (lambda: application.message if application.message else []), 90 style='class:toolbar_inactive', 91 ), 92 filter=Condition( 93 lambda: application.message and application.message != '')) 94 95 96def _add_log_handler_to_pane(logger: Union[str, logging.Logger], 97 pane: 'LogPane', 98 level_name: Optional[str] = None) -> None: 99 """A log pane handler for a given logger instance.""" 100 if not pane: 101 return 102 pane.add_log_handler(logger, level_name=level_name) 103 104 105def get_default_colordepth( 106 color_depth: Optional[ColorDepth] = None) -> ColorDepth: 107 # Set prompt_toolkit color_depth to the highest possible. 108 if color_depth is None: 109 # Default to 24bit color 110 color_depth = ColorDepth.DEPTH_24_BIT 111 112 # If using Apple Terminal switch to 256 (8bit) color. 113 term_program = os.environ.get('TERM_PROGRAM', '') 114 if sys.platform == 'darwin' and 'Apple_Terminal' in term_program: 115 color_depth = ColorDepth.DEPTH_8_BIT 116 117 # Check for any PROMPT_TOOLKIT_COLOR_DEPTH environment variables 118 color_depth_override = os.environ.get('PROMPT_TOOLKIT_COLOR_DEPTH', '') 119 if color_depth_override: 120 color_depth = ColorDepth(color_depth_override) 121 return color_depth 122 123 124class ConsoleApp: 125 """The main ConsoleApp class that glues everything together.""" 126 127 # pylint: disable=too-many-instance-attributes,too-many-public-methods 128 def __init__( 129 self, 130 global_vars=None, 131 local_vars=None, 132 repl_startup_message=None, 133 help_text=None, 134 app_title=None, 135 color_depth=None, 136 extra_completers=None, 137 prefs=None, 138 ): 139 self.prefs = prefs if prefs else ConsolePrefs() 140 self.color_depth = get_default_colordepth(color_depth) 141 142 # Create a default global and local symbol table. Values are the same 143 # structure as what is returned by globals(): 144 # https://docs.python.org/3/library/functions.html#globals 145 if global_vars is None: 146 global_vars = { 147 '__name__': '__main__', 148 '__package__': None, 149 '__doc__': None, 150 '__builtins__': builtins, 151 } 152 153 local_vars = local_vars or global_vars 154 155 # Setup the Jinja environment 156 self.jinja_env = Environment( 157 # Load templates automatically from pw_console/templates 158 loader=FileSystemLoader(Path(__file__).parent / 'templates'), 159 # Raise errors if variables are undefined in templates 160 undefined=make_logging_undefined( 161 logger=logging.getLogger(__package__), ), 162 # Trim whitespace in templates 163 trim_blocks=True, 164 lstrip_blocks=True, 165 ) 166 167 self.repl_history_filename = self.prefs.repl_history 168 self.search_history_filename = self.prefs.search_history 169 170 # History instance for search toolbars. 171 self.search_history: History = ThreadedHistory( 172 FileHistory(self.search_history_filename)) 173 174 # Event loop for executing user repl code. 175 self.user_code_loop = asyncio.new_event_loop() 176 177 self.app_title = app_title if app_title else 'Pigweed Console' 178 179 # Top level UI state toggles. 180 self.load_theme(self.prefs.ui_theme) 181 182 # Pigweed upstream RST user guide 183 self.user_guide_window = HelpWindow(self, title='User Guide') 184 self.user_guide_window.load_user_guide() 185 186 # Top title message 187 self.message = [('class:logo', self.app_title), ('', ' ')] 188 189 self.message.extend( 190 pw_console.widgets.checkbox.to_keybind_indicator( 191 'Ctrl-p', 192 'Search Menu', 193 functools.partial(pw_console.widgets.mouse_handlers.on_click, 194 self.open_command_runner_main_menu), 195 base_style='class:toolbar-button-inactive', 196 )) 197 # One space separator 198 self.message.append(('', ' ')) 199 200 # Auto-generated keybindings list for all active panes 201 self.keybind_help_window = HelpWindow(self, title='Keyboard Shortcuts') 202 203 # Downstream project specific help text 204 self.app_help_text = help_text if help_text else None 205 self.app_help_window = HelpWindow(self, 206 additional_help_text=help_text, 207 title=(self.app_title + ' Help')) 208 self.app_help_window.generate_help_text() 209 210 self.prefs_file_window = HelpWindow(self, title='.pw_console.yaml') 211 self.prefs_file_window.load_yaml_text( 212 self.prefs.current_config_as_yaml()) 213 214 # Used for tracking which pane was in focus before showing help window. 215 self.last_focused_pane = None 216 217 # Create a ptpython repl instance. 218 self.pw_ptpython_repl = PwPtPythonRepl( 219 get_globals=lambda: global_vars, 220 get_locals=lambda: local_vars, 221 color_depth=self.color_depth, 222 history_filename=self.repl_history_filename, 223 extra_completers=extra_completers, 224 ) 225 self.input_history = self.pw_ptpython_repl.history 226 227 self.repl_pane = ReplPane( 228 application=self, 229 python_repl=self.pw_ptpython_repl, 230 startup_message=repl_startup_message, 231 ) 232 self.pw_ptpython_repl.use_code_colorscheme(self.prefs.code_theme) 233 234 if self.prefs.swap_light_and_dark: 235 self.toggle_light_theme() 236 237 # Window panes are added via the window_manager 238 self.window_manager = WindowManager(self) 239 self.window_manager.add_pane_no_checks(self.repl_pane) 240 241 # Top of screen menu items 242 self.menu_items = self._create_menu_items() 243 244 self.quit_dialog = QuitDialog(self) 245 246 # Key bindings registry. 247 self.key_bindings = pw_console.key_bindings.create_key_bindings(self) 248 249 # Create help window text based global key_bindings and active panes. 250 self._update_help_window() 251 252 self.command_runner = CommandRunner( 253 self, 254 width=self.prefs.command_runner_width, 255 height=self.prefs.command_runner_height, 256 ) 257 258 self.floats = [ 259 # Top message bar 260 Float( 261 content=FloatingMessageBar(self), 262 top=0, 263 right=0, 264 height=1, 265 ), 266 # Centered floating help windows 267 Float( 268 content=self.prefs_file_window, 269 top=2, 270 bottom=2, 271 # Callable to get width 272 width=self.prefs_file_window.content_width, 273 ), 274 Float( 275 content=self.app_help_window, 276 top=2, 277 bottom=2, 278 # Callable to get width 279 width=self.app_help_window.content_width, 280 ), 281 Float( 282 content=self.user_guide_window, 283 top=2, 284 bottom=2, 285 # Callable to get width 286 width=self.user_guide_window.content_width, 287 ), 288 Float( 289 content=self.keybind_help_window, 290 top=2, 291 bottom=2, 292 # Callable to get width 293 width=self.keybind_help_window.content_width, 294 ), 295 # Completion menu that can overlap other panes since it lives in 296 # the top level Float container. 297 Float( 298 xcursor=True, 299 ycursor=True, 300 content=ConditionalContainer( 301 content=CompletionsMenu( 302 scroll_offset=(lambda: self.pw_ptpython_repl. 303 completion_menu_scroll_offset), 304 max_height=16, 305 ), 306 # Only show our completion if ptpython's is disabled. 307 filter=Condition( 308 lambda: self.pw_ptpython_repl.completion_visualisation 309 == CompletionVisualisation.NONE), 310 ), 311 ), 312 Float( 313 content=self.command_runner, 314 # Callable to get width 315 width=self.command_runner.content_width, 316 **self.prefs.command_runner_position, 317 ), 318 Float( 319 content=self.quit_dialog, 320 top=2, 321 left=2, 322 ), 323 ] 324 325 # prompt_toolkit root container. 326 self.root_container = MenuContainer( 327 body=self.window_manager.create_root_container(), 328 menu_items=self.menu_items, 329 floats=self.floats, 330 ) 331 332 # NOTE: ptpython stores it's completion menus in this HSplit: 333 # 334 # self.pw_ptpython_repl.__pt_container__() 335 # .children[0].children[0].children[0].floats[0].content.children 336 # 337 # Index 1 is a CompletionsMenu and is shown when: 338 # self.pw_ptpython_repl 339 # .completion_visualisation == CompletionVisualisation.POP_UP 340 # 341 # Index 2 is a MultiColumnCompletionsMenu and is shown when: 342 # self.pw_ptpython_repl 343 # .completion_visualisation == CompletionVisualisation.MULTI_COLUMN 344 # 345 346 # Setup the prompt_toolkit layout with the repl pane as the initially 347 # focused element. 348 self.layout: Layout = Layout( 349 self.root_container, 350 focused_element=self.pw_ptpython_repl, 351 ) 352 353 # Create the prompt_toolkit Application instance. 354 self.application: Application = Application( 355 layout=self.layout, 356 key_bindings=merge_key_bindings([ 357 # Pull key bindings from ptpython 358 load_python_bindings(self.pw_ptpython_repl), 359 load_sidebar_bindings(self.pw_ptpython_repl), 360 self.window_manager.key_bindings, 361 self.key_bindings, 362 ]), 363 style=DynamicStyle(lambda: merge_styles([ 364 self._current_theme, 365 # Include ptpython styles 366 self.pw_ptpython_repl._current_style, # pylint: disable=protected-access 367 ])), 368 style_transformation=self.pw_ptpython_repl.style_transformation, 369 enable_page_navigation_bindings=True, 370 full_screen=True, 371 mouse_support=True, 372 color_depth=self.color_depth, 373 clipboard=PyperclipClipboard(), 374 min_redraw_interval=MIN_REDRAW_INTERVAL, 375 ) 376 377 def get_template(self, file_name: str): 378 return self.jinja_env.get_template(file_name) 379 380 def run_pane_menu_option(self, function_to_run): 381 # Run the function for a particular menu item. 382 return_value = function_to_run() 383 # It's return value dictates if the main menu should close or not. 384 # - True: The main menu stays open. This is the default prompt_toolkit 385 # menu behavior. 386 # - False: The main menu closes. 387 388 # Update menu content. This will refresh checkboxes and add/remove 389 # items. 390 self.update_menu_items() 391 # Check if the main menu should stay open. 392 if not return_value: 393 # Keep the main menu open 394 self.focus_main_menu() 395 396 def open_new_log_pane_for_logger( 397 self, 398 logger_name: str, 399 level_name='NOTSET', 400 window_title: Optional[str] = None) -> None: 401 pane_title = window_title if window_title else logger_name 402 self.run_pane_menu_option( 403 functools.partial(self.add_log_handler, 404 pane_title, [logger_name], 405 log_level_name=level_name)) 406 407 def set_ui_theme(self, theme_name: str) -> Callable: 408 call_function = functools.partial( 409 self.run_pane_menu_option, 410 functools.partial(self.load_theme, theme_name)) 411 return call_function 412 413 def set_code_theme(self, theme_name: str) -> Callable: 414 call_function = functools.partial( 415 self.run_pane_menu_option, 416 functools.partial(self.pw_ptpython_repl.use_code_colorscheme, 417 theme_name)) 418 return call_function 419 420 def update_menu_items(self): 421 self.menu_items = self._create_menu_items() 422 self.root_container.menu_items = self.menu_items 423 424 def open_command_runner_main_menu(self) -> None: 425 self.command_runner.set_completions() 426 if not self.command_runner_is_open(): 427 self.command_runner.open_dialog() 428 429 def open_command_runner_loggers(self) -> None: 430 self.command_runner.set_completions( 431 window_title='Open Logger', 432 load_completions=self._create_logger_completions) 433 if not self.command_runner_is_open(): 434 self.command_runner.open_dialog() 435 436 def _create_logger_completions(self) -> List[Tuple[str, Callable]]: 437 completions: List[Tuple[str, Callable]] = [ 438 ( 439 'root', 440 functools.partial(self.open_new_log_pane_for_logger, 441 '', 442 window_title='root'), 443 ), 444 ] 445 446 all_logger_names = sorted([logger.name for logger in all_loggers()]) 447 448 for logger_name in all_logger_names: 449 completions.append(( 450 logger_name, 451 functools.partial(self.open_new_log_pane_for_logger, 452 logger_name), 453 )) 454 return completions 455 456 def _create_menu_items(self): 457 themes_submenu = [ 458 MenuItem('Toggle Light/Dark', handler=self.toggle_light_theme), 459 MenuItem('-'), 460 MenuItem( 461 'UI Themes', 462 children=[ 463 MenuItem('Default: Dark', self.set_ui_theme('dark')), 464 MenuItem('High Contrast', 465 self.set_ui_theme('high-contrast-dark')), 466 MenuItem('Nord', self.set_ui_theme('nord')), 467 MenuItem('Nord Light', self.set_ui_theme('nord-light')), 468 MenuItem('Moonlight', self.set_ui_theme('moonlight')), 469 ], 470 ), 471 MenuItem( 472 'Code Themes', 473 children=[ 474 MenuItem('Code: pigweed-code', 475 self.set_code_theme('pigweed-code')), 476 MenuItem('Code: pigweed-code-light', 477 self.set_code_theme('pigweed-code-light')), 478 MenuItem('Code: material', 479 self.set_code_theme('material')), 480 MenuItem('Code: gruvbox-light', 481 self.set_code_theme('gruvbox-light')), 482 MenuItem('Code: gruvbox-dark', 483 self.set_code_theme('gruvbox-dark')), 484 MenuItem('Code: tomorrow-night', 485 self.set_code_theme('tomorrow-night')), 486 MenuItem('Code: tomorrow-night-bright', 487 self.set_code_theme('tomorrow-night-bright')), 488 MenuItem('Code: tomorrow-night-blue', 489 self.set_code_theme('tomorrow-night-blue')), 490 MenuItem('Code: tomorrow-night-eighties', 491 self.set_code_theme('tomorrow-night-eighties')), 492 MenuItem('Code: dracula', self.set_code_theme('dracula')), 493 MenuItem('Code: zenburn', self.set_code_theme('zenburn')), 494 ], 495 ), 496 ] 497 498 file_menu = [ 499 # File menu 500 MenuItem( 501 '[File]', 502 children=[ 503 MenuItem('Open Logger', 504 handler=self.open_command_runner_loggers), 505 MenuItem( 506 'Log Table View', 507 children=[ 508 MenuItem( 509 '{check} Hide Date'.format( 510 check=pw_console.widgets.checkbox. 511 to_checkbox_text( 512 self.prefs.hide_date_from_log_time, 513 end='')), 514 handler=functools.partial( 515 self.run_pane_menu_option, 516 functools.partial( 517 self.toggle_pref_option, 518 'hide_date_from_log_time')), 519 ), 520 MenuItem( 521 '{check} Show Source File'.format( 522 check=pw_console.widgets.checkbox. 523 to_checkbox_text( 524 self.prefs.show_source_file, end='')), 525 handler=functools.partial( 526 self.run_pane_menu_option, 527 functools.partial(self.toggle_pref_option, 528 'show_source_file')), 529 ), 530 MenuItem( 531 '{check} Show Python File'.format( 532 check=pw_console.widgets.checkbox. 533 to_checkbox_text( 534 self.prefs.show_python_file, end='')), 535 handler=functools.partial( 536 self.run_pane_menu_option, 537 functools.partial(self.toggle_pref_option, 538 'show_python_file')), 539 ), 540 MenuItem( 541 '{check} Show Python Logger'.format( 542 check=pw_console.widgets.checkbox. 543 to_checkbox_text( 544 self.prefs.show_python_logger, 545 end='')), 546 handler=functools.partial( 547 self.run_pane_menu_option, 548 functools.partial(self.toggle_pref_option, 549 'show_python_logger')), 550 ), 551 ]), 552 MenuItem('-'), 553 MenuItem( 554 'Themes', 555 children=themes_submenu, 556 ), 557 MenuItem('-'), 558 MenuItem('Exit', handler=self.exit_console), 559 ], 560 ), 561 ] 562 563 edit_menu = [ 564 MenuItem( 565 '[Edit]', 566 children=[ 567 MenuItem('Paste to Python Input', 568 handler=self.repl_pane. 569 paste_system_clipboard_to_input_buffer), 570 MenuItem('-'), 571 MenuItem('Copy all Python Output', 572 handler=self.repl_pane.copy_all_output_text), 573 MenuItem('Copy all Python Input', 574 handler=self.repl_pane.copy_all_input_text), 575 ], 576 ), 577 ] 578 579 view_menu = [ 580 MenuItem( 581 '[View]', 582 children=[ 583 # [Menu Item ][Keybind ] 584 MenuItem('Focus Next Window/Tab Ctrl-Alt-n', 585 handler=self.window_manager.focus_next_pane), 586 # [Menu Item ][Keybind ] 587 MenuItem('Focus Prev Window/Tab Ctrl-Alt-p', 588 handler=self.window_manager.focus_previous_pane), 589 MenuItem('-'), 590 591 # [Menu Item ][Keybind ] 592 MenuItem('Move Window Up Ctrl-Alt-Up', 593 handler=functools.partial( 594 self.run_pane_menu_option, 595 self.window_manager.move_pane_up)), 596 # [Menu Item ][Keybind ] 597 MenuItem('Move Window Down Ctrl-Alt-Down', 598 handler=functools.partial( 599 self.run_pane_menu_option, 600 self.window_manager.move_pane_down)), 601 # [Menu Item ][Keybind ] 602 MenuItem('Move Window Left Ctrl-Alt-Left', 603 handler=functools.partial( 604 self.run_pane_menu_option, 605 self.window_manager.move_pane_left)), 606 # [Menu Item ][Keybind ] 607 MenuItem('Move Window Right Ctrl-Alt-Right', 608 handler=functools.partial( 609 self.run_pane_menu_option, 610 self.window_manager.move_pane_right)), 611 MenuItem('-'), 612 613 # [Menu Item ][Keybind ] 614 MenuItem('Shrink Height Alt-Minus', 615 handler=functools.partial( 616 self.run_pane_menu_option, 617 self.window_manager.shrink_pane)), 618 # [Menu Item ][Keybind ] 619 MenuItem('Enlarge Height Alt-=', 620 handler=functools.partial( 621 self.run_pane_menu_option, 622 self.window_manager.enlarge_pane)), 623 MenuItem('-'), 624 625 # [Menu Item ][Keybind ] 626 MenuItem('Shrink Column Alt-,', 627 handler=functools.partial( 628 self.run_pane_menu_option, 629 self.window_manager.shrink_split)), 630 # [Menu Item ][Keybind ] 631 MenuItem('Enlarge Column Alt-.', 632 handler=functools.partial( 633 self.run_pane_menu_option, 634 self.window_manager.enlarge_split)), 635 MenuItem('-'), 636 637 # [Menu Item ][Keybind ] 638 MenuItem('Balance Window Sizes Ctrl-u', 639 handler=functools.partial( 640 self.run_pane_menu_option, 641 self.window_manager.balance_window_sizes)), 642 ], 643 ), 644 ] 645 646 window_menu = self.window_manager.create_window_menu() 647 648 help_menu_items = [ 649 MenuItem(self.user_guide_window.menu_title(), 650 handler=self.user_guide_window.toggle_display), 651 MenuItem(self.keybind_help_window.menu_title(), 652 handler=self.keybind_help_window.toggle_display), 653 MenuItem('-'), 654 MenuItem('View Key Binding Config', 655 handler=self.prefs_file_window.toggle_display), 656 ] 657 658 if self.app_help_text: 659 help_menu_items.extend([ 660 MenuItem('-'), 661 MenuItem(self.app_help_window.menu_title(), 662 handler=self.app_help_window.toggle_display) 663 ]) 664 665 help_menu = [ 666 # Info / Help 667 MenuItem( 668 '[Help]', 669 children=help_menu_items, 670 ), 671 ] 672 673 return file_menu + edit_menu + view_menu + window_menu + help_menu 674 675 def focus_main_menu(self): 676 """Set application focus to the main menu.""" 677 self.application.layout.focus(self.root_container.window) 678 679 def focus_on_container(self, pane): 680 """Set application focus to a specific container.""" 681 # Try to focus on the given pane 682 try: 683 self.application.layout.focus(pane) 684 except ValueError: 685 # If the container can't be focused, focus on the first visible 686 # window pane. 687 self.window_manager.focus_first_visible_pane() 688 689 def toggle_light_theme(self): 690 """Toggle light and dark theme colors.""" 691 # Use ptpython's style_transformation to swap dark and light colors. 692 self.pw_ptpython_repl.swap_light_and_dark = ( 693 not self.pw_ptpython_repl.swap_light_and_dark) 694 if self.application: 695 self.focus_main_menu() 696 697 def toggle_pref_option(self, setting_name): 698 self.prefs.toggle_bool_option(setting_name) 699 700 def load_theme(self, theme_name=None): 701 """Regenerate styles for the current theme_name.""" 702 self._current_theme = pw_console.style.generate_styles(theme_name) 703 if theme_name: 704 self.prefs.set_ui_theme(theme_name) 705 706 def _create_log_pane(self, 707 title: str = '', 708 log_store: Optional[LogStore] = None) -> 'LogPane': 709 # Create one log pane. 710 log_pane = LogPane(application=self, 711 pane_title=title, 712 log_store=log_store) 713 self.window_manager.add_pane(log_pane) 714 return log_pane 715 716 def load_clean_config(self, config_file: Path) -> None: 717 self.prefs.reset_config() 718 self.prefs.load_config_file(config_file) 719 720 def apply_window_config(self) -> None: 721 self.window_manager.apply_config(self.prefs) 722 723 def refresh_layout(self) -> None: 724 self.window_manager.update_root_container_body() 725 self.update_menu_items() 726 self._update_help_window() 727 728 def add_log_handler( 729 self, 730 window_title: str, 731 logger_instances: Union[Iterable[logging.Logger], LogStore], 732 separate_log_panes: bool = False, 733 log_level_name: Optional[str] = None) -> Optional[LogPane]: 734 """Add the Log pane as a handler for this logger instance.""" 735 736 existing_log_pane = None 737 # Find an existing LogPane with the same window_title. 738 for pane in self.window_manager.active_panes(): 739 if isinstance(pane, LogPane) and pane.pane_title() == window_title: 740 existing_log_pane = pane 741 break 742 743 log_store: Optional[LogStore] = None 744 if isinstance(logger_instances, LogStore): 745 log_store = logger_instances 746 747 if not existing_log_pane or separate_log_panes: 748 existing_log_pane = self._create_log_pane(title=window_title, 749 log_store=log_store) 750 751 if isinstance(logger_instances, list): 752 for logger in logger_instances: 753 _add_log_handler_to_pane(logger, existing_log_pane, 754 log_level_name) 755 756 self.refresh_layout() 757 return existing_log_pane 758 759 def _user_code_thread_entry(self): 760 """Entry point for the user code thread.""" 761 asyncio.set_event_loop(self.user_code_loop) 762 self.user_code_loop.run_forever() 763 764 def start_user_code_thread(self): 765 """Create a thread for running user code so the UI isn't blocked.""" 766 thread = Thread(target=self._user_code_thread_entry, 767 args=(), 768 daemon=True) 769 thread.start() 770 771 def _update_help_window(self): 772 """Generate the help window text based on active pane keybindings.""" 773 # Add global mouse bindings to the help text. 774 mouse_functions = { 775 'Focus pane, menu or log line.': ['Click'], 776 'Scroll current window.': ['Scroll wheel'], 777 } 778 779 self.keybind_help_window.add_custom_keybinds_help_text( 780 'Global Mouse', mouse_functions) 781 782 # Add global key bindings to the help text. 783 self.keybind_help_window.add_keybind_help_text('Global', 784 self.key_bindings) 785 786 self.keybind_help_window.add_keybind_help_text( 787 'Window Management', self.window_manager.key_bindings) 788 789 # Add activated plugin key bindings to the help text. 790 for pane in self.window_manager.active_panes(): 791 for key_bindings in pane.get_all_key_bindings(): 792 help_section_title = pane.__class__.__name__ 793 if isinstance(key_bindings, KeyBindings): 794 self.keybind_help_window.add_keybind_help_text( 795 help_section_title, key_bindings) 796 elif isinstance(key_bindings, dict): 797 self.keybind_help_window.add_custom_keybinds_help_text( 798 help_section_title, key_bindings) 799 800 self.keybind_help_window.generate_help_text() 801 802 def toggle_log_line_wrapping(self): 803 """Menu item handler to toggle line wrapping of all log panes.""" 804 for pane in self.window_manager.active_panes(): 805 if isinstance(pane, LogPane): 806 pane.toggle_wrap_lines() 807 808 def focused_window(self): 809 """Return the currently focused window.""" 810 return self.application.layout.current_window 811 812 def command_runner_is_open(self) -> bool: 813 return self.command_runner.show_dialog 814 815 def command_runner_last_focused_pane(self) -> Any: 816 return self.command_runner.last_focused_pane 817 818 def modal_window_is_open(self): 819 """Return true if any modal window or dialog is open.""" 820 if self.app_help_text: 821 return (self.app_help_window.show_window 822 or self.keybind_help_window.show_window 823 or self.prefs_file_window.show_window 824 or self.user_guide_window.show_window 825 or self.quit_dialog.show_dialog 826 or self.command_runner.show_dialog) 827 return (self.keybind_help_window.show_window 828 or self.prefs_file_window.show_window 829 or self.user_guide_window.show_window 830 or self.quit_dialog.show_dialog 831 or self.command_runner.show_dialog) 832 833 def exit_console(self): 834 """Quit the console prompt_toolkit application UI.""" 835 self.application.exit() 836 837 def redraw_ui(self): 838 """Redraw the prompt_toolkit UI.""" 839 if hasattr(self, 'application'): 840 # Thread safe way of sending a repaint trigger to the input event 841 # loop. 842 self.application.invalidate() 843 844 async def run(self, test_mode=False): 845 """Start the prompt_toolkit UI.""" 846 if test_mode: 847 background_log_task = asyncio.create_task(self.log_forever()) 848 849 # Repl pane has focus by default, if it's hidden switch focus to another 850 # visible pane. 851 if not self.repl_pane.show_pane: 852 self.window_manager.focus_first_visible_pane() 853 854 try: 855 unused_result = await self.application.run_async( 856 set_exception_handler=True) 857 finally: 858 if test_mode: 859 background_log_task.cancel() 860 861 async def log_forever(self): 862 """Test mode async log generator coroutine that runs forever.""" 863 message_count = 0 864 # Sample log line format: 865 # Log message [= ] # 100 866 867 # Fake module column names. 868 module_names = ['APP', 'RADIO', 'BAT', 'USB', 'CPU'] 869 while True: 870 if message_count > 32 or message_count < 2: 871 await asyncio.sleep(1) 872 bar_size = 10 873 position = message_count % bar_size 874 bar_content = " " * (bar_size - position - 1) + "=" 875 if position > 0: 876 bar_content = "=".rjust(position) + " " * (bar_size - position) 877 new_log_line = 'Log message [{}] # {}'.format( 878 bar_content, message_count) 879 if message_count % 10 == 0: 880 new_log_line += ( 881 ' Lorem ipsum \033[34m\033[1mdolor sit amet\033[0m' 882 ', consectetur ' 883 'adipiscing elit.') * 8 884 if message_count % 11 == 0: 885 new_log_line += ' ' 886 new_log_line += ( 887 '[PYTHON] START\n' 888 'In []: import time;\n' 889 ' def t(s):\n' 890 ' time.sleep(s)\n' 891 ' return "t({}) seconds done".format(s)\n\n') 892 893 module_name = module_names[message_count % len(module_names)] 894 _FAKE_DEVICE_LOG.info(new_log_line, 895 extra=dict(extra_metadata_fields=dict( 896 module=module_name, file='fake_app.cc'))) 897 message_count += 1 898 899 900# TODO(tonymd): Remove this alias when not used by downstream projects. 901def embed( 902 *args, 903 **kwargs, 904) -> None: 905 """PwConsoleEmbed().embed() alias.""" 906 # Import here to avoid circular dependency 907 from pw_console.embed import PwConsoleEmbed # pylint: disable=import-outside-toplevel 908 console = PwConsoleEmbed(*args, **kwargs) 909 console.embed() 910