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