• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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