1#!/usr/bin/env python 2# Copyright 2022 The Pigweed Authors 3# 4# Licensed under the Apache License, Version 2.0 (the "License"); you may not 5# use this file except in compliance with the License. You may obtain a copy of 6# the License at 7# 8# https://www.apache.org/licenses/LICENSE-2.0 9# 10# Unless required by applicable law or agreed to in writing, software 11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13# License for the specific language governing permissions and limitations under 14# the License. 15""" Prompt toolkit application for pw watch. """ 16 17import asyncio 18import functools 19import logging 20import os 21import time 22from typing import Callable, Iterable, NoReturn 23 24from prompt_toolkit.application import Application 25from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard 26from prompt_toolkit.filters import Condition 27from prompt_toolkit.history import ( 28 InMemoryHistory, 29 History, 30 ThreadedHistory, 31) 32from prompt_toolkit.key_binding import ( 33 KeyBindings, 34 KeyBindingsBase, 35 merge_key_bindings, 36) 37from prompt_toolkit.layout import ( 38 DynamicContainer, 39 Float, 40 FloatContainer, 41 FormattedTextControl, 42 HSplit, 43 Layout, 44 Window, 45) 46from prompt_toolkit.layout.controls import BufferControl 47from prompt_toolkit.styles import ( 48 ConditionalStyleTransformation, 49 DynamicStyle, 50 SwapLightAndDarkStyleTransformation, 51 merge_style_transformations, 52 merge_styles, 53 style_from_pygments_cls, 54) 55from prompt_toolkit.formatted_text import StyleAndTextTuples 56from prompt_toolkit.lexers import PygmentsLexer 57from pygments.lexers.markup import MarkdownLexer # type: ignore 58 59from pw_config_loader import yaml_config_loader_mixin 60 61from pw_console.console_app import get_default_colordepth, MIN_REDRAW_INTERVAL 62from pw_console.get_pw_console_app import PW_CONSOLE_APP_CONTEXTVAR 63from pw_console.help_window import HelpWindow 64from pw_console.key_bindings import DEFAULT_KEY_BINDINGS 65from pw_console.log_pane import LogPane 66from pw_console.plugin_mixin import PluginMixin 67import pw_console.python_logging 68from pw_console.quit_dialog import QuitDialog 69from pw_console.style import generate_styles, get_theme_colors 70from pw_console.pigweed_code_style import PigweedCodeStyle 71from pw_console.widgets import ( 72 FloatingWindowPane, 73 ToolbarButton, 74 WindowPaneToolbar, 75 create_border, 76 mouse_handlers, 77 to_checkbox, 78) 79from pw_console.window_list import DisplayMode 80from pw_console.window_manager import WindowManager 81 82from pw_build.project_builder_prefs import ProjectBuilderPrefs 83from pw_build.project_builder_context import get_project_builder_context 84 85 86_LOG = logging.getLogger('pw_build.watch') 87 88BUILDER_CONTEXT = get_project_builder_context() 89 90_HELP_TEXT = """ 91Mouse Keys 92========== 93 94- Click on a line in the bottom progress bar to switch to that tab. 95- Click on any tab, or button to activate. 96- Scroll wheel in the the log windows moves back through the history. 97 98 99Global Keys 100=========== 101 102Quit with confirmation dialog. -------------------- Ctrl-D 103Quit without confirmation. ------------------------ Ctrl-X Ctrl-C 104Toggle user guide window. ------------------------- F1 105Trigger a rebuild. -------------------------------- Enter 106 107 108Window Management Keys 109====================== 110 111Switch focus to the next window pane or tab. ------ Ctrl-Alt-N 112Switch focus to the previous window pane or tab. -- Ctrl-Alt-P 113Move window pane left. ---------------------------- Ctrl-Alt-Left 114Move window pane right. --------------------------- Ctrl-Alt-Right 115Move window pane down. ---------------------------- Ctrl-Alt-Down 116Move window pane up. ------------------------------ Ctrl-Alt-Up 117Balance all window sizes. ------------------------- Ctrl-U 118 119 120Bottom Toolbar Controls 121======================= 122 123Rebuild Enter --------------- Click or press Enter to trigger a rebuild. 124[x] Auto Rebuild ------------ Click to globaly enable or disable automatic 125 rebuilding when files change. 126Help F1 --------------------- Click or press F1 to open this help window. 127Quit Ctrl-d ----------------- Click or press Ctrl-d to quit pw_watch. 128Next Tab Ctrl-Alt-n --------- Switch to the next log tab. 129Previous Tab Ctrl-Alt-p ----- Switch to the previous log tab. 130 131 132Build Status Bar 133================ 134 135The build status bar shows the current status of all build directories outlined 136in a colored frame. 137 138 ┏━━ BUILDING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 139 ┃ [✓] out_directory Building Last line of standard out. ┃ 140 ┃ [✓] out_dir2 Waiting Last line of standard out. ┃ 141 ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ 142 143Each checkbox on the far left controls whether that directory is built when 144files change and manual builds are run. 145 146 147Copying Text 148============ 149 150- Click drag will select whole lines in the log windows. 151- `Ctrl-c` will copy selected lines to your system clipboard. 152 153If running over SSH you will need to use your terminal's built in text 154selection. 155 156Linux 157----- 158 159- Holding `Shift` and dragging the mouse in most terminals. 160 161Mac 162--- 163 164- Apple Terminal: 165 166 Hold `Fn` and drag the mouse 167 168- iTerm2: 169 170 Hold `Cmd+Option` and drag the mouse 171 172Windows 173------- 174 175- Git CMD (included in `Git for Windows) 176 177 1. Click on the Git window icon in the upper left of the title bar 178 2. Click `Edit` then `Mark` 179 3. Drag the mouse to select text and press Enter to copy. 180 181- Windows Terminal 182 183 1. Hold `Shift` and drag the mouse to select text 184 2. Press `Ctrl-Shift-C` to copy. 185 186""" 187 188 189class WatchAppPrefs(ProjectBuilderPrefs): 190 """Add pw_console specific prefs standard ProjectBuilderPrefs.""" 191 192 def __init__(self, *args, **kwargs) -> None: 193 super().__init__(*args, **kwargs) 194 195 self.registered_commands = DEFAULT_KEY_BINDINGS 196 self.registered_commands.update(self.user_key_bindings) 197 198 new_config_settings = { 199 'key_bindings': DEFAULT_KEY_BINDINGS, 200 'show_python_logger': True, 201 } 202 self.default_config.update(new_config_settings) 203 self._update_config( 204 new_config_settings, 205 yaml_config_loader_mixin.Stage.DEFAULT, 206 ) 207 208 # Required pw_console preferences for key bindings and themes 209 @property 210 def user_key_bindings(self) -> dict[str, list[str]]: 211 return self._config.get('key_bindings', {}) 212 213 @property 214 def ui_theme(self) -> str: 215 return self._config.get('ui_theme', '') 216 217 @ui_theme.setter 218 def ui_theme(self, new_ui_theme: str) -> None: 219 self._config['ui_theme'] = new_ui_theme 220 221 @property 222 def theme_colors(self): 223 return get_theme_colors(self.ui_theme) 224 225 @property 226 def swap_light_and_dark(self) -> bool: 227 return self._config.get('swap_light_and_dark', False) 228 229 def get_function_keys(self, name: str) -> list: 230 """Return the keys for the named function.""" 231 try: 232 return self.registered_commands[name] 233 except KeyError as error: 234 raise KeyError('Unbound key function: {}'.format(name)) from error 235 236 def register_named_key_function( 237 self, name: str, default_bindings: list[str] 238 ) -> None: 239 self.registered_commands[name] = default_bindings 240 241 def register_keybinding( 242 self, name: str, key_bindings: KeyBindings, **kwargs 243 ) -> Callable: 244 """Apply registered keys for the given named function.""" 245 246 def decorator(handler: Callable) -> Callable: 247 "`handler` is a callable or Binding." 248 for keys in self.get_function_keys(name): 249 key_bindings.add(*keys.split(' '), **kwargs)(handler) 250 return handler 251 252 return decorator 253 254 # Required pw_console preferences for using a log window pane. 255 @property 256 def spaces_between_columns(self) -> int: 257 return 2 258 259 @property 260 def window_column_split_method(self) -> str: 261 return 'vertical' 262 263 @property 264 def hide_date_from_log_time(self) -> bool: 265 return True 266 267 @property 268 def column_order(self) -> list: 269 return [] 270 271 def column_style( # pylint: disable=no-self-use 272 self, 273 _column_name: str, 274 _column_value: str, 275 default='', 276 ) -> str: 277 return default 278 279 @property 280 def show_python_file(self) -> bool: 281 return self._config.get('show_python_file', False) 282 283 @property 284 def show_source_file(self) -> bool: 285 return self._config.get('show_source_file', False) 286 287 @property 288 def show_python_logger(self) -> bool: 289 return self._config.get('show_python_logger', False) 290 291 292class WatchWindowManager(WindowManager): 293 def update_root_container_body(self): 294 self.application.window_manager_container = self.create_root_container() 295 296 297class WatchApp(PluginMixin): 298 """Pigweed Watch main window application.""" 299 300 # pylint: disable=too-many-instance-attributes 301 def __init__( 302 self, 303 event_handler, 304 prefs: WatchAppPrefs, 305 ): 306 self.event_handler = event_handler 307 308 self.color_depth = get_default_colordepth() 309 310 # Necessary for some of pw_console's window manager features to work 311 # such as mouse drag resizing. 312 PW_CONSOLE_APP_CONTEXTVAR.set(self) # type: ignore 313 314 self.prefs = prefs 315 316 self.quit_dialog = QuitDialog(self, self.exit) # type: ignore 317 318 self.search_history: History = ThreadedHistory(InMemoryHistory()) 319 320 self.window_manager = WatchWindowManager(self) 321 322 self._build_error_count = 0 323 self._errors_in_output = False 324 325 self.log_ui_update_frequency = 0.1 # 10 FPS 326 self._last_ui_update_time = time.time() 327 328 self.recipe_name_to_log_pane: dict[str, LogPane] = {} 329 self.recipe_index_to_log_pane: dict[int, LogPane] = {} 330 331 debug_logging = ( 332 event_handler.project_builder.default_log_level == logging.DEBUG 333 ) 334 level_name = 'DEBUG' if debug_logging else 'INFO' 335 336 no_propagation_loggers = [] 337 338 if event_handler.separate_logfiles: 339 pane_index = len(event_handler.project_builder.build_recipes) - 1 340 for recipe in reversed(event_handler.project_builder.build_recipes): 341 log_pane = self.add_build_log_pane( 342 recipe.display_name, 343 loggers=[recipe.log], 344 level_name=level_name, 345 ) 346 if recipe.log.propagate is False: 347 no_propagation_loggers.append(recipe.log) 348 349 self.recipe_name_to_log_pane[recipe.display_name] = log_pane 350 self.recipe_index_to_log_pane[pane_index] = log_pane 351 pane_index -= 1 352 353 pw_console.python_logging.setup_python_logging( 354 loggers_with_no_propagation=no_propagation_loggers 355 ) 356 357 self.root_log_pane = self.add_build_log_pane( 358 'Root Log', 359 loggers=[ 360 logging.getLogger('pw_build'), 361 ], 362 level_name=level_name, 363 ) 364 # Repeat the Attaching filesystem watcher message for the full screen 365 # interface. The original log in watch.py will be hidden from view. 366 _LOG.info('Attaching filesystem watcher...') 367 368 self.window_manager.window_lists[0].display_mode = DisplayMode.TABBED 369 370 self.window_manager_container = ( 371 self.window_manager.create_root_container() 372 ) 373 374 self.status_bar_border_style = 'class:command-runner-border' 375 376 self.status_bar_control = FormattedTextControl(self.get_status_bar_text) 377 378 self.status_bar_container = create_border( 379 HSplit( 380 [ 381 # Result Toolbar. 382 Window( 383 content=self.status_bar_control, 384 height=len(self.event_handler.project_builder), 385 wrap_lines=False, 386 style='class:pane_active', 387 ), 388 ] 389 ), 390 content_height=len(self.event_handler.project_builder), 391 title=BUILDER_CONTEXT.get_title_bar_text, 392 border_style=(BUILDER_CONTEXT.get_title_style), 393 base_style='class:pane_active', 394 left_margin_columns=1, 395 right_margin_columns=1, 396 ) 397 398 self.floating_window_plugins: list[FloatingWindowPane] = [] 399 400 self.user_guide_window = HelpWindow( 401 self, # type: ignore 402 title='Pigweed Watch', 403 disable_ctrl_c=True, 404 ) 405 self.user_guide_window.set_help_text( 406 _HELP_TEXT, lexer=PygmentsLexer(MarkdownLexer) 407 ) 408 409 self.help_toolbar = WindowPaneToolbar( 410 title='Pigweed Watch', 411 include_resize_handle=False, 412 focus_action_callable=self.switch_to_root_log, 413 click_to_focus_text='', 414 ) 415 self.help_toolbar.add_button( 416 ToolbarButton('Enter', 'Rebuild', self.run_build) 417 ) 418 self.help_toolbar.add_button( 419 ToolbarButton( 420 description='Auto Rebuild', 421 mouse_handler=self.toggle_restart_on_filechange, 422 is_checkbox=True, 423 checked=lambda: self.restart_on_changes, 424 ) 425 ) 426 self.help_toolbar.add_button( 427 ToolbarButton('F1', 'Help', self.user_guide_window.toggle_display) 428 ) 429 self.help_toolbar.add_button(ToolbarButton('Ctrl-d', 'Quit', self.exit)) 430 self.help_toolbar.add_button( 431 ToolbarButton( 432 'Ctrl-Alt-n', 'Next Tab', self.window_manager.focus_next_pane 433 ) 434 ) 435 self.help_toolbar.add_button( 436 ToolbarButton( 437 'Ctrl-Alt-p', 438 'Previous Tab', 439 self.window_manager.focus_previous_pane, 440 ) 441 ) 442 443 self.root_container = FloatContainer( 444 HSplit( 445 [ 446 # Window pane content: 447 DynamicContainer(lambda: self.window_manager_container), 448 self.status_bar_container, 449 self.help_toolbar, 450 ] 451 ), 452 floats=[ 453 Float( 454 content=self.user_guide_window, 455 top=2, 456 left=4, 457 bottom=4, 458 width=self.user_guide_window.content_width, 459 ), 460 Float( 461 content=self.quit_dialog, 462 top=2, 463 left=2, 464 ), 465 ], 466 ) 467 468 key_bindings = KeyBindings() 469 470 @key_bindings.add('enter', filter=self.input_box_not_focused()) 471 def _run_build(_event): 472 "Rebuild." 473 self.run_build() 474 475 register = self.prefs.register_keybinding 476 477 @register('global.exit-no-confirmation', key_bindings) 478 def _quit_no_confirm(_event): 479 """Quit without confirmation.""" 480 _LOG.info('Got quit signal; exiting...') 481 self.exit(0) 482 483 @register('global.exit-with-confirmation', key_bindings) 484 def _quit_with_confirm(_event): 485 """Quit with confirmation dialog.""" 486 self.quit_dialog.open_dialog() 487 488 @register( 489 'global.open-user-guide', 490 key_bindings, 491 filter=Condition(lambda: not self.modal_window_is_open()), 492 ) 493 def _show_help(_event): 494 """Toggle user guide window.""" 495 self.user_guide_window.toggle_display() 496 497 self.key_bindings = merge_key_bindings( 498 [ 499 self.window_manager.key_bindings, 500 key_bindings, 501 ] 502 ) 503 504 self.current_theme = generate_styles(self.prefs.ui_theme) 505 506 self.style_transformation = merge_style_transformations( 507 [ 508 ConditionalStyleTransformation( 509 SwapLightAndDarkStyleTransformation(), 510 filter=Condition(lambda: self.prefs.swap_light_and_dark), 511 ), 512 ] 513 ) 514 515 self.code_theme = style_from_pygments_cls(PigweedCodeStyle) 516 517 self.layout = Layout( 518 self.root_container, 519 focused_element=self.root_log_pane, 520 ) 521 522 self.application: Application = Application( 523 layout=self.layout, 524 key_bindings=self.key_bindings, 525 mouse_support=True, 526 color_depth=self.color_depth, 527 clipboard=PyperclipClipboard(), 528 style=DynamicStyle( 529 lambda: merge_styles( 530 [ 531 self.current_theme, 532 self.code_theme, 533 ] 534 ) 535 ), 536 style_transformation=self.style_transformation, 537 full_screen=True, 538 min_redraw_interval=MIN_REDRAW_INTERVAL, 539 ) 540 541 self.plugin_init( 542 plugin_callback=self.check_build_status, 543 plugin_callback_frequency=0.5, 544 plugin_logger_name='pw_watch_stdout_checker', 545 ) 546 547 def add_build_log_pane( 548 self, title: str, loggers: list[logging.Logger], level_name: str 549 ) -> LogPane: 550 """Setup a new build log pane.""" 551 new_log_pane = LogPane(application=self, pane_title=title) 552 for logger in loggers: 553 new_log_pane.add_log_handler(logger, level_name=level_name) 554 555 # Set python log format to just the message itself. 556 new_log_pane.log_view.log_store.formatter = logging.Formatter( 557 '%(message)s' 558 ) 559 560 new_log_pane.table_view = False 561 562 # Disable line wrapping for improved error visibility. 563 if new_log_pane.wrap_lines: 564 new_log_pane.toggle_wrap_lines() 565 566 # Blank right side toolbar text 567 new_log_pane._pane_subtitle = ' ' # pylint: disable=protected-access 568 569 # Make tab and shift-tab search for next and previous error 570 next_error_bindings = KeyBindings() 571 572 @next_error_bindings.add('s-tab') 573 def _previous_error(_event): 574 self.jump_to_error(backwards=True) 575 576 @next_error_bindings.add('tab') 577 def _next_error(_event): 578 self.jump_to_error() 579 580 existing_log_bindings: ( 581 KeyBindingsBase | None 582 ) = new_log_pane.log_content_control.key_bindings 583 584 key_binding_list: list[KeyBindingsBase] = [] 585 if existing_log_bindings: 586 key_binding_list.append(existing_log_bindings) 587 key_binding_list.append(next_error_bindings) 588 new_log_pane.log_content_control.key_bindings = merge_key_bindings( 589 key_binding_list 590 ) 591 592 # Only show a few buttons in the log pane toolbars. 593 new_buttons = [] 594 for button in new_log_pane.bottom_toolbar.buttons: 595 if button.description in [ 596 'Search', 597 'Save', 598 'Follow', 599 'Wrap', 600 'Clear', 601 ]: 602 new_buttons.append(button) 603 new_log_pane.bottom_toolbar.buttons = new_buttons 604 605 self.window_manager.add_pane(new_log_pane) 606 return new_log_pane 607 608 def logs_redraw(self): 609 emit_time = time.time() 610 # Has enough time passed since last UI redraw due to new logs? 611 if emit_time > self._last_ui_update_time + self.log_ui_update_frequency: 612 # Update last log time 613 self._last_ui_update_time = emit_time 614 615 # Trigger Prompt Toolkit UI redraw. 616 self.redraw_ui() 617 618 def jump_to_error(self, backwards: bool = False) -> None: 619 if not self.root_log_pane.log_view.search_text: 620 self.root_log_pane.log_view.set_search_regex( 621 '^FAILE?D?: ', False, None 622 ) 623 if backwards: 624 self.root_log_pane.log_view.search_backwards() 625 else: 626 self.root_log_pane.log_view.search_forwards() 627 self.root_log_pane.log_view.log_screen.reset_logs( 628 log_index=self.root_log_pane.log_view.log_index 629 ) 630 631 self.root_log_pane.log_view.move_selected_line_to_top() 632 633 def refresh_layout(self) -> None: 634 self.window_manager.update_root_container_body() 635 636 def update_menu_items(self): 637 """Required by the Window Manager Class.""" 638 639 def redraw_ui(self): 640 """Redraw the prompt_toolkit UI.""" 641 if hasattr(self, 'application'): 642 self.application.invalidate() 643 644 def focus_on_container(self, pane): 645 """Set application focus to a specific container.""" 646 # Try to focus on the given pane 647 try: 648 self.application.layout.focus(pane) 649 except ValueError: 650 # If the container can't be focused, focus on the first visible 651 # window pane. 652 self.window_manager.focus_first_visible_pane() 653 654 def focused_window(self): 655 """Return the currently focused window.""" 656 return self.application.layout.current_window 657 658 def focus_main_menu(self): 659 """Focus on the main menu. 660 661 Currently pw_watch has no main menu so focus on the first visible pane 662 instead.""" 663 self.window_manager.focus_first_visible_pane() 664 665 def switch_to_root_log(self) -> None: 666 ( 667 window_list, 668 pane_index, 669 ) = self.window_manager.find_window_list_and_pane_index( 670 self.root_log_pane 671 ) 672 window_list.switch_to_tab(pane_index) 673 674 def switch_to_build_log(self, log_index: int) -> None: 675 pane = self.recipe_index_to_log_pane.get(log_index, None) 676 if not pane: 677 return 678 679 ( 680 window_list, 681 pane_index, 682 ) = self.window_manager.find_window_list_and_pane_index(pane) 683 window_list.switch_to_tab(pane_index) 684 685 def command_runner_is_open(self) -> bool: 686 # pylint: disable=no-self-use 687 return False 688 689 def all_log_panes(self) -> Iterable[LogPane]: 690 for pane in self.window_manager.active_panes(): 691 if isinstance(pane, LogPane): 692 yield pane 693 694 def clear_log_panes(self) -> None: 695 """Erase all log pane content and turn on follow. 696 697 This is called whenever rebuilds occur. Either a manual build from 698 self.run_build or on file changes called from 699 pw_watch._handle_matched_event.""" 700 for pane in self.all_log_panes(): 701 pane.log_view.clear_visual_selection() 702 pane.log_view.clear_filters() 703 pane.log_view.log_store.clear_logs() 704 pane.log_view.view_mode_changed() 705 # Re-enable follow if needed 706 if not pane.log_view.follow: 707 pane.log_view.toggle_follow() 708 709 def run_build(self) -> None: 710 """Manually trigger a rebuild from the UI.""" 711 self.clear_log_panes() 712 self.event_handler.rebuild() 713 714 @property 715 def restart_on_changes(self) -> bool: 716 return self.event_handler.restart_on_changes 717 718 def toggle_restart_on_filechange(self) -> None: 719 self.event_handler.restart_on_changes = ( 720 not self.event_handler.restart_on_changes 721 ) 722 723 def get_status_bar_text(self) -> StyleAndTextTuples: 724 """Return formatted text for build status bar.""" 725 formatted_text: StyleAndTextTuples = [] 726 727 separator = ('', ' ') 728 name_width = self.event_handler.project_builder.max_name_width 729 730 # pylint: disable=protected-access 731 ( 732 _window_list, 733 pane, 734 ) = self.window_manager._get_active_window_list_and_pane() 735 # pylint: enable=protected-access 736 restarting = BUILDER_CONTEXT.restart_flag 737 738 for i, cfg in enumerate(self.event_handler.project_builder): 739 # The build directory 740 name_style = '' 741 if not pane: 742 formatted_text.append(('', '\n')) 743 continue 744 745 # Dim the build name if disabled 746 if not cfg.enabled: 747 name_style = 'class:theme-fg-inactive' 748 749 # If this build tab is selected, highlight with cyan. 750 if pane.pane_title() == cfg.display_name: 751 name_style = 'class:theme-fg-cyan' 752 753 formatted_text.append( 754 to_checkbox( 755 cfg.enabled, 756 functools.partial( 757 mouse_handlers.on_click, 758 cfg.toggle_enabled, 759 ), 760 end=' ', 761 unchecked_style='class:checkbox', 762 checked_style='class:checkbox-checked', 763 ) 764 ) 765 formatted_text.append( 766 ( 767 name_style, 768 f'{cfg.display_name}'.ljust(name_width), 769 functools.partial( 770 mouse_handlers.on_click, 771 functools.partial(self.switch_to_build_log, i), 772 ), 773 ) 774 ) 775 formatted_text.append(separator) 776 # Status 777 formatted_text.append(cfg.status.status_slug(restarting=restarting)) 778 formatted_text.append(separator) 779 # Current stdout line 780 formatted_text.extend(cfg.status.current_step_formatted()) 781 formatted_text.append(('', '\n')) 782 783 if not formatted_text: 784 formatted_text = [('', 'Loading...')] 785 786 self.set_tab_bar_colors() 787 788 return formatted_text 789 790 def set_tab_bar_colors(self) -> None: 791 restarting = BUILDER_CONTEXT.restart_flag 792 793 for cfg in BUILDER_CONTEXT.recipes: 794 pane = self.recipe_name_to_log_pane.get(cfg.display_name, None) 795 if not pane: 796 continue 797 798 pane.extra_tab_style = None 799 if not restarting and cfg.status.failed(): 800 pane.extra_tab_style = 'class:theme-fg-red' 801 802 def exit( 803 self, 804 exit_code: int = 1, 805 log_after_shutdown: Callable[[], None] | None = None, 806 ) -> None: 807 _LOG.info('Exiting...') 808 BUILDER_CONTEXT.ctrl_c_pressed = True 809 810 # Shut everything down after the prompt_toolkit app exits. 811 def _really_exit(future: asyncio.Future) -> NoReturn: 812 BUILDER_CONTEXT.restore_logging_and_shutdown(log_after_shutdown) 813 os._exit(future.result()) # pylint: disable=protected-access 814 815 if self.application.future: 816 self.application.future.add_done_callback(_really_exit) 817 self.application.exit(result=exit_code) 818 819 def check_build_status(self) -> bool: 820 if not self.event_handler.current_stdout: 821 return False 822 823 if self._errors_in_output: 824 return True 825 826 if self.event_handler.current_build_errors > self._build_error_count: 827 self._errors_in_output = True 828 self.jump_to_error() 829 830 return True 831 832 def run(self): 833 self.plugin_start() 834 # Run the prompt_toolkit application 835 self.application.run(set_exception_handler=True) 836 837 def input_box_not_focused(self) -> Condition: 838 """Condition checking the focused control is not a text input field.""" 839 840 @Condition 841 def _test() -> bool: 842 """Check if the currently focused control is an input buffer. 843 844 Returns: 845 bool: True if the currently focused control is not a text input 846 box. For example if the user presses enter when typing in 847 the search box, return False. 848 """ 849 return not isinstance( 850 self.application.layout.current_control, BufferControl 851 ) 852 853 return _test 854 855 def modal_window_is_open(self): 856 """Return true if any modal window or dialog is open.""" 857 floating_window_is_open = ( 858 self.user_guide_window.show_window or self.quit_dialog.show_dialog 859 ) 860 861 floating_plugin_is_open = any( 862 plugin.show_pane for plugin in self.floating_window_plugins 863 ) 864 865 return floating_window_is_open or floating_plugin_is_open 866