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 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 97_PW_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'] 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('Default: Dark', self.set_ui_theme('dark')), 684 MenuItem( 685 'High Contrast', self.set_ui_theme('high-contrast-dark') 686 ), 687 MenuItem('Nord', self.set_ui_theme('nord')), 688 MenuItem('Nord Light', self.set_ui_theme('nord-light')), 689 MenuItem('Moonlight', self.set_ui_theme('moonlight')), 690 ], 691 ), 692 MenuItem( 693 'Code Themes', 694 children=[ 695 MenuItem( 696 'Code: pigweed-code', 697 self.set_code_theme('pigweed-code'), 698 ), 699 MenuItem( 700 'Code: pigweed-code-light', 701 self.set_code_theme('pigweed-code-light'), 702 ), 703 MenuItem('Code: material', self.set_code_theme('material')), 704 MenuItem( 705 'Code: gruvbox-light', 706 self.set_code_theme('gruvbox-light'), 707 ), 708 MenuItem( 709 'Code: gruvbox-dark', 710 self.set_code_theme('gruvbox-dark'), 711 ), 712 MenuItem('Code: zenburn', self.set_code_theme('zenburn')), 713 ], 714 ), 715 ] 716 717 file_menu = [ 718 # File menu 719 MenuItem( 720 '[File]', 721 children=[ 722 MenuItem( 723 'Insert Repl Snippet', 724 handler=self.open_command_runner_snippets, 725 ), 726 MenuItem( 727 'Insert Repl History', 728 handler=self.open_command_runner_history, 729 ), 730 MenuItem( 731 'Open Logger', handler=self.open_command_runner_loggers 732 ), 733 MenuItem( 734 'Log Table View', 735 children=[ 736 # pylint: disable=line-too-long 737 MenuItem( 738 '{check} Hide Date'.format( 739 check=to_checkbox_text( 740 self.prefs.hide_date_from_log_time, 741 end='', 742 ) 743 ), 744 handler=functools.partial( 745 self.run_pane_menu_option, 746 functools.partial( 747 self.toggle_pref_option, 748 'hide_date_from_log_time', 749 ), 750 ), 751 ), 752 MenuItem( 753 '{check} Show Source File'.format( 754 check=to_checkbox_text( 755 self.prefs.show_source_file, end='' 756 ) 757 ), 758 handler=functools.partial( 759 self.run_pane_menu_option, 760 functools.partial( 761 self.toggle_pref_option, 762 'show_source_file', 763 ), 764 ), 765 ), 766 MenuItem( 767 '{check} Show Python File'.format( 768 check=to_checkbox_text( 769 self.prefs.show_python_file, end='' 770 ) 771 ), 772 handler=functools.partial( 773 self.run_pane_menu_option, 774 functools.partial( 775 self.toggle_pref_option, 776 'show_python_file', 777 ), 778 ), 779 ), 780 MenuItem( 781 '{check} Show Python Logger'.format( 782 check=to_checkbox_text( 783 self.prefs.show_python_logger, end='' 784 ) 785 ), 786 handler=functools.partial( 787 self.run_pane_menu_option, 788 functools.partial( 789 self.toggle_pref_option, 790 'show_python_logger', 791 ), 792 ), 793 ), 794 # pylint: enable=line-too-long 795 ], 796 ), 797 MenuItem('-'), 798 MenuItem( 799 'Themes', 800 children=themes_submenu, 801 ), 802 MenuItem('-'), 803 MenuItem('Exit', handler=self.exit_console), 804 ], 805 ), 806 ] 807 808 edit_menu = [ 809 MenuItem( 810 '[Edit]', 811 children=[ 812 # pylint: disable=line-too-long 813 MenuItem( 814 'Paste to Python Input', 815 handler=self.repl_pane.paste_system_clipboard_to_input_buffer, 816 ), 817 # pylint: enable=line-too-long 818 MenuItem('-'), 819 MenuItem( 820 'Copy all Python Output', 821 handler=self.repl_pane.copy_all_output_text, 822 ), 823 MenuItem( 824 'Copy all Python Input', 825 handler=self.repl_pane.copy_all_input_text, 826 ), 827 MenuItem('-'), 828 MenuItem( 829 'Clear Python Input', self.repl_pane.clear_input_buffer 830 ), 831 MenuItem( 832 'Clear Python Output', 833 self.repl_pane.clear_output_buffer, 834 ), 835 ], 836 ), 837 ] 838 839 view_menu = [ 840 MenuItem( 841 '[View]', 842 children=[ 843 # [Menu Item ][Keybind ] 844 MenuItem( 845 'Focus Next Window/Tab Ctrl-Alt-n', 846 handler=self.window_manager.focus_next_pane, 847 ), 848 # [Menu Item ][Keybind ] 849 MenuItem( 850 'Focus Prev Window/Tab Ctrl-Alt-p', 851 handler=self.window_manager.focus_previous_pane, 852 ), 853 MenuItem('-'), 854 # [Menu Item ][Keybind ] 855 MenuItem( 856 'Move Window Up Ctrl-Alt-Up', 857 handler=functools.partial( 858 self.run_pane_menu_option, 859 self.window_manager.move_pane_up, 860 ), 861 ), 862 # [Menu Item ][Keybind ] 863 MenuItem( 864 'Move Window Down Ctrl-Alt-Down', 865 handler=functools.partial( 866 self.run_pane_menu_option, 867 self.window_manager.move_pane_down, 868 ), 869 ), 870 # [Menu Item ][Keybind ] 871 MenuItem( 872 'Move Window Left Ctrl-Alt-Left', 873 handler=functools.partial( 874 self.run_pane_menu_option, 875 self.window_manager.move_pane_left, 876 ), 877 ), 878 # [Menu Item ][Keybind ] 879 MenuItem( 880 'Move Window Right Ctrl-Alt-Right', 881 handler=functools.partial( 882 self.run_pane_menu_option, 883 self.window_manager.move_pane_right, 884 ), 885 ), 886 MenuItem('-'), 887 # [Menu Item ][Keybind ] 888 MenuItem( 889 'Shrink Height Alt-Minus', 890 handler=functools.partial( 891 self.run_pane_menu_option, 892 self.window_manager.shrink_pane, 893 ), 894 ), 895 # [Menu Item ][Keybind ] 896 MenuItem( 897 'Enlarge Height Alt-=', 898 handler=functools.partial( 899 self.run_pane_menu_option, 900 self.window_manager.enlarge_pane, 901 ), 902 ), 903 MenuItem('-'), 904 # [Menu Item ][Keybind ] 905 MenuItem( 906 'Shrink Column Alt-,', 907 handler=functools.partial( 908 self.run_pane_menu_option, 909 self.window_manager.shrink_split, 910 ), 911 ), 912 # [Menu Item ][Keybind ] 913 MenuItem( 914 'Enlarge Column Alt-.', 915 handler=functools.partial( 916 self.run_pane_menu_option, 917 self.window_manager.enlarge_split, 918 ), 919 ), 920 MenuItem('-'), 921 # [Menu Item ][Keybind ] 922 MenuItem( 923 'Balance Window Sizes Ctrl-u', 924 handler=functools.partial( 925 self.run_pane_menu_option, 926 self.window_manager.balance_window_sizes, 927 ), 928 ), 929 ], 930 ), 931 ] 932 933 window_menu_items = self.window_manager.create_window_menu_items() 934 935 floating_window_items = [] 936 if self.floating_window_plugins: 937 floating_window_items.append(MenuItem('-', None)) 938 floating_window_items.extend( 939 MenuItem( 940 'Floating Window {index}: {title}'.format( 941 index=pane_index + 1, 942 title=pane.menu_title(), 943 ), 944 children=[ 945 MenuItem( 946 # pylint: disable=line-too-long 947 '{check} Show/Hide Window'.format( 948 check=to_checkbox_text(pane.show_pane, end='') 949 ), 950 # pylint: enable=line-too-long 951 handler=functools.partial( 952 self.run_pane_menu_option, pane.toggle_dialog 953 ), 954 ), 955 ] 956 + [ 957 MenuItem( 958 text, 959 handler=functools.partial( 960 self.run_pane_menu_option, handler 961 ), 962 ) 963 for text, handler in pane.get_window_menu_options() 964 ], 965 ) 966 for pane_index, pane in enumerate(self.floating_window_plugins) 967 ) 968 window_menu_items.extend(floating_window_items) 969 970 window_menu = [MenuItem('[Windows]', children=window_menu_items)] 971 972 top_level_plugin_menus = [] 973 for pane in self.window_manager.active_panes(): 974 top_level_plugin_menus.extend(pane.get_top_level_menus()) 975 if self.floating_window_plugins: 976 for pane in self.floating_window_plugins: 977 top_level_plugin_menus.extend(pane.get_top_level_menus()) 978 979 help_menu_items = [ 980 MenuItem( 981 self.user_guide_window.menu_title(), 982 handler=self.user_guide_window.toggle_display, 983 ), 984 MenuItem( 985 self.keybind_help_window.menu_title(), 986 handler=self.keybind_help_window.toggle_display, 987 ), 988 MenuItem('-'), 989 MenuItem( 990 'View Key Binding Config', 991 handler=self.prefs_file_window.toggle_display, 992 ), 993 ] 994 995 if self.app_help_text: 996 help_menu_items.extend( 997 [ 998 MenuItem('-'), 999 MenuItem( 1000 self.app_help_window.menu_title(), 1001 handler=self.app_help_window.toggle_display, 1002 ), 1003 ] 1004 ) 1005 1006 help_menu = [ 1007 # Info / Help 1008 MenuItem( 1009 '[Help]', 1010 children=help_menu_items, 1011 ), 1012 ] 1013 1014 return ( 1015 file_menu 1016 + edit_menu 1017 + view_menu 1018 + top_level_plugin_menus 1019 + window_menu 1020 + help_menu 1021 ) 1022 1023 def focus_main_menu(self): 1024 """Set application focus to the main menu.""" 1025 self.application.layout.focus(self.root_container.window) 1026 1027 def focus_on_container(self, pane): 1028 """Set application focus to a specific container.""" 1029 # Try to focus on the given pane 1030 try: 1031 self.application.layout.focus(pane) 1032 except ValueError: 1033 # If the container can't be focused, focus on the first visible 1034 # window pane. 1035 self.window_manager.focus_first_visible_pane() 1036 1037 def toggle_light_theme(self): 1038 """Toggle light and dark theme colors.""" 1039 # Use ptpython's style_transformation to swap dark and light colors. 1040 self.pw_ptpython_repl.swap_light_and_dark = ( 1041 not self.pw_ptpython_repl.swap_light_and_dark 1042 ) 1043 if self.application: 1044 self.focus_main_menu() 1045 1046 def toggle_pref_option(self, setting_name): 1047 self.prefs.toggle_bool_option(setting_name) 1048 1049 def load_theme(self, theme_name=None): 1050 """Regenerate styles for the current theme_name.""" 1051 self._current_theme = generate_styles(theme_name) 1052 if theme_name: 1053 self.prefs.set_ui_theme(theme_name) 1054 1055 def _create_log_pane( 1056 self, title: str = '', log_store: LogStore | None = None 1057 ) -> LogPane: 1058 # Create one log pane. 1059 log_pane = LogPane( 1060 application=self, pane_title=title, log_store=log_store 1061 ) 1062 self.window_manager.add_pane(log_pane) 1063 return log_pane 1064 1065 def load_clean_config(self, config_file: Path) -> None: 1066 self.prefs.reset_config() 1067 self.prefs.load_config_file(config_file) 1068 1069 # Reset colors 1070 self.load_theme(self.prefs.ui_theme) 1071 self.pw_ptpython_repl.use_code_colorscheme(self.prefs.code_theme) 1072 1073 def apply_window_config(self) -> None: 1074 self.window_manager.apply_config(self.prefs) 1075 1076 def refresh_layout(self) -> None: 1077 self.window_manager.update_root_container_body() 1078 self.update_menu_items() 1079 self._update_help_window() 1080 1081 def all_log_stores(self) -> list[LogStore]: 1082 log_stores: list[LogStore] = [] 1083 for pane in self.window_manager.active_panes(): 1084 if not isinstance(pane, LogPane): 1085 continue 1086 if pane.log_view.log_store not in log_stores: 1087 log_stores.append(pane.log_view.log_store) 1088 return log_stores 1089 1090 def add_log_handler( 1091 self, 1092 window_title: str, 1093 logger_instances: Iterable[logging.Logger] | LogStore, 1094 separate_log_panes: bool = False, 1095 log_level_name: str | None = None, 1096 ) -> LogPane | None: 1097 """Add the Log pane as a handler for this logger instance.""" 1098 1099 existing_log_pane = None 1100 # Find an existing LogPane with the same window_title. 1101 for pane in self.window_manager.active_panes(): 1102 if isinstance(pane, LogPane) and pane.pane_title() == window_title: 1103 existing_log_pane = pane 1104 break 1105 1106 log_store: LogStore | None = None 1107 if isinstance(logger_instances, LogStore): 1108 log_store = logger_instances 1109 1110 if not existing_log_pane or separate_log_panes: 1111 existing_log_pane = self._create_log_pane( 1112 title=window_title, log_store=log_store 1113 ) 1114 1115 if isinstance(logger_instances, list): 1116 for logger in logger_instances: 1117 _add_log_handler_to_pane( 1118 logger, existing_log_pane, log_level_name 1119 ) 1120 1121 self.refresh_layout() 1122 return existing_log_pane 1123 1124 def _user_code_thread_entry(self): 1125 """Entry point for the user code thread.""" 1126 asyncio.set_event_loop(self.user_code_loop) 1127 self.user_code_loop.run_forever() 1128 1129 def start_user_code_thread(self): 1130 """Create a thread for running user code so the UI isn't blocked.""" 1131 thread = Thread( 1132 target=self._user_code_thread_entry, args=(), daemon=True 1133 ) 1134 thread.start() 1135 1136 def _test_mode_log_thread_entry(self): 1137 """Entry point for the user code thread.""" 1138 asyncio.set_event_loop(self.test_mode_log_loop) 1139 self.test_mode_log_loop.run_forever() 1140 1141 def _update_help_window(self): 1142 """Generate the help window text based on active pane keybindings.""" 1143 # Add global mouse bindings to the help text. 1144 mouse_functions = { 1145 'Focus pane, menu or log line.': ['Click'], 1146 'Scroll current window.': ['Scroll wheel'], 1147 } 1148 1149 self.keybind_help_window.add_custom_keybinds_help_text( 1150 'Global Mouse', mouse_functions 1151 ) 1152 1153 # Add global key bindings to the help text. 1154 self.keybind_help_window.add_keybind_help_text( 1155 'Global', self.key_bindings 1156 ) 1157 1158 self.keybind_help_window.add_keybind_help_text( 1159 'Window Management', self.window_manager.key_bindings 1160 ) 1161 1162 # Add activated plugin key bindings to the help text. 1163 for pane in self.window_manager.active_panes(): 1164 for key_bindings in pane.get_all_key_bindings(): 1165 help_section_title = pane.__class__.__name__ 1166 if isinstance(key_bindings, KeyBindings): 1167 self.keybind_help_window.add_keybind_help_text( 1168 help_section_title, key_bindings 1169 ) 1170 elif isinstance(key_bindings, dict): 1171 self.keybind_help_window.add_custom_keybinds_help_text( 1172 help_section_title, key_bindings 1173 ) 1174 1175 self.keybind_help_window.generate_keybind_help_text() 1176 1177 def toggle_log_line_wrapping(self): 1178 """Menu item handler to toggle line wrapping of all log panes.""" 1179 for pane in self.window_manager.active_panes(): 1180 if isinstance(pane, LogPane): 1181 pane.toggle_wrap_lines() 1182 1183 def focused_window(self): 1184 """Return the currently focused window.""" 1185 return self.application.layout.current_window 1186 1187 def command_runner_is_open(self) -> bool: 1188 return self.command_runner.show_dialog 1189 1190 def command_runner_last_focused_pane(self) -> Any: 1191 return self.command_runner.last_focused_pane 1192 1193 def modal_window_is_open(self): 1194 """Return true if any modal window or dialog is open.""" 1195 floating_window_is_open = ( 1196 self.keybind_help_window.show_window 1197 or self.prefs_file_window.show_window 1198 or self.user_guide_window.show_window 1199 or self.quit_dialog.show_dialog 1200 or self.command_runner.show_dialog 1201 ) 1202 1203 if self.app_help_text: 1204 floating_window_is_open = ( 1205 self.app_help_window.show_window or floating_window_is_open 1206 ) 1207 1208 floating_plugin_is_open = any( 1209 plugin.show_pane for plugin in self.floating_window_plugins 1210 ) 1211 1212 return floating_window_is_open or floating_plugin_is_open 1213 1214 def exit_console(self): 1215 """Quit the console prompt_toolkit application UI.""" 1216 self.application.exit() 1217 1218 def logs_redraw(self): 1219 emit_time = time.time() 1220 # Has enough time passed since last UI redraw due to new logs? 1221 if emit_time > self._last_ui_update_time + self.log_ui_update_frequency: 1222 # Update last log time 1223 self._last_ui_update_time = emit_time 1224 1225 # Trigger Prompt Toolkit UI redraw. 1226 self.redraw_ui() 1227 1228 def redraw_ui(self): 1229 """Redraw the prompt_toolkit UI.""" 1230 if hasattr(self, 'application'): 1231 # Thread safe way of sending a repaint trigger to the input event 1232 # loop. 1233 self.application.invalidate() 1234 1235 def setup_command_runner_log_pane(self) -> None: 1236 if not self.system_command_output_pane is None: 1237 return 1238 1239 self.system_command_output_pane = LogPane( 1240 application=self, pane_title='Shell Output' 1241 ) 1242 self.system_command_output_pane.add_log_handler( 1243 _SYSTEM_COMMAND_LOG, level_name='INFO' 1244 ) 1245 self.system_command_output_pane.log_view.log_store.formatter = ( 1246 logging.Formatter('%(message)s') 1247 ) 1248 self.system_command_output_pane.table_view = False 1249 self.system_command_output_pane.show_pane = True 1250 # Enable line wrapping 1251 self.system_command_output_pane.toggle_wrap_lines() 1252 # Blank right side toolbar text 1253 # pylint: disable=protected-access 1254 self.system_command_output_pane._pane_subtitle = ' ' 1255 # pylint: enable=protected-access 1256 self.window_manager.add_pane(self.system_command_output_pane) 1257 1258 async def run(self, test_mode=False): 1259 """Start the prompt_toolkit UI.""" 1260 if test_mode: 1261 background_log_task = start_fake_logger( 1262 lines=self.user_guide_window.help_text_area.document.lines, 1263 log_thread_entry=self._test_mode_log_thread_entry, 1264 log_thread_loop=self.test_mode_log_loop, 1265 ) 1266 1267 # Repl pane has focus by default, if it's hidden switch focus to another 1268 # visible pane. 1269 if not self.repl_pane.show_pane: 1270 self.window_manager.focus_first_visible_pane() 1271 1272 try: 1273 unused_result = await self.application.run_async( 1274 set_exception_handler=True 1275 ) 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