xref: /aosp_15_r20/external/pigweed/pw_watch/py/pw_watch/watch_app.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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