• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2021 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""ConsoleApp control class."""
15
16import asyncio
17import builtins
18import functools
19import logging
20import os
21from pathlib import Path
22import sys
23from threading import Thread
24from typing import Any, Callable, Iterable, List, Optional, Tuple, Union
25
26from jinja2 import Environment, FileSystemLoader, make_logging_undefined
27from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
28from prompt_toolkit.layout.menus import CompletionsMenu
29from prompt_toolkit.output import ColorDepth
30from prompt_toolkit.application import Application
31from prompt_toolkit.filters import Condition
32from prompt_toolkit.styles import (
33    DynamicStyle,
34    merge_styles,
35)
36from prompt_toolkit.layout import (
37    ConditionalContainer,
38    Float,
39    Layout,
40)
41from prompt_toolkit.widgets import FormattedTextToolbar
42from prompt_toolkit.widgets import (
43    MenuContainer,
44    MenuItem,
45)
46from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
47from prompt_toolkit.history import (
48    FileHistory,
49    History,
50    ThreadedHistory,
51)
52from ptpython.layout import CompletionVisualisation  # type: ignore
53from ptpython.key_bindings import (  # type: ignore
54    load_python_bindings, load_sidebar_bindings,
55)
56
57from pw_console.console_prefs import ConsolePrefs
58from pw_console.help_window import HelpWindow
59from pw_console.command_runner import CommandRunner
60import pw_console.key_bindings
61from pw_console.log_pane import LogPane
62from pw_console.log_store import LogStore
63from pw_console.pw_ptpython_repl import PwPtPythonRepl
64from pw_console.python_logging import all_loggers
65from pw_console.quit_dialog import QuitDialog
66from pw_console.repl_pane import ReplPane
67import pw_console.style
68import pw_console.widgets.checkbox
69import pw_console.widgets.mouse_handlers
70from pw_console.window_manager import WindowManager
71
72_LOG = logging.getLogger(__package__)
73
74# Fake logger for --test-mode
75FAKE_DEVICE_LOGGER_NAME = 'pw_console_fake_device'
76_FAKE_DEVICE_LOG = logging.getLogger(FAKE_DEVICE_LOGGER_NAME)
77# Don't send fake_device logs to the root Python logger.
78_FAKE_DEVICE_LOG.propagate = False
79
80MAX_FPS = 15
81MIN_REDRAW_INTERVAL = (60.0 / MAX_FPS) / 60.0
82
83
84class FloatingMessageBar(ConditionalContainer):
85    """Floating message bar for showing status messages."""
86    def __init__(self, application):
87        super().__init__(
88            FormattedTextToolbar(
89                (lambda: application.message if application.message else []),
90                style='class:toolbar_inactive',
91            ),
92            filter=Condition(
93                lambda: application.message and application.message != ''))
94
95
96def _add_log_handler_to_pane(logger: Union[str, logging.Logger],
97                             pane: 'LogPane',
98                             level_name: Optional[str] = None) -> None:
99    """A log pane handler for a given logger instance."""
100    if not pane:
101        return
102    pane.add_log_handler(logger, level_name=level_name)
103
104
105def get_default_colordepth(
106        color_depth: Optional[ColorDepth] = None) -> ColorDepth:
107    # Set prompt_toolkit color_depth to the highest possible.
108    if color_depth is None:
109        # Default to 24bit color
110        color_depth = ColorDepth.DEPTH_24_BIT
111
112        # If using Apple Terminal switch to 256 (8bit) color.
113        term_program = os.environ.get('TERM_PROGRAM', '')
114        if sys.platform == 'darwin' and 'Apple_Terminal' in term_program:
115            color_depth = ColorDepth.DEPTH_8_BIT
116
117    # Check for any PROMPT_TOOLKIT_COLOR_DEPTH environment variables
118    color_depth_override = os.environ.get('PROMPT_TOOLKIT_COLOR_DEPTH', '')
119    if color_depth_override:
120        color_depth = ColorDepth(color_depth_override)
121    return color_depth
122
123
124class ConsoleApp:
125    """The main ConsoleApp class that glues everything together."""
126
127    # pylint: disable=too-many-instance-attributes,too-many-public-methods
128    def __init__(
129        self,
130        global_vars=None,
131        local_vars=None,
132        repl_startup_message=None,
133        help_text=None,
134        app_title=None,
135        color_depth=None,
136        extra_completers=None,
137        prefs=None,
138    ):
139        self.prefs = prefs if prefs else ConsolePrefs()
140        self.color_depth = get_default_colordepth(color_depth)
141
142        # Create a default global and local symbol table. Values are the same
143        # structure as what is returned by globals():
144        #   https://docs.python.org/3/library/functions.html#globals
145        if global_vars is None:
146            global_vars = {
147                '__name__': '__main__',
148                '__package__': None,
149                '__doc__': None,
150                '__builtins__': builtins,
151            }
152
153        local_vars = local_vars or global_vars
154
155        # Setup the Jinja environment
156        self.jinja_env = Environment(
157            # Load templates automatically from pw_console/templates
158            loader=FileSystemLoader(Path(__file__).parent / 'templates'),
159            # Raise errors if variables are undefined in templates
160            undefined=make_logging_undefined(
161                logger=logging.getLogger(__package__), ),
162            # Trim whitespace in templates
163            trim_blocks=True,
164            lstrip_blocks=True,
165        )
166
167        self.repl_history_filename = self.prefs.repl_history
168        self.search_history_filename = self.prefs.search_history
169
170        # History instance for search toolbars.
171        self.search_history: History = ThreadedHistory(
172            FileHistory(self.search_history_filename))
173
174        # Event loop for executing user repl code.
175        self.user_code_loop = asyncio.new_event_loop()
176
177        self.app_title = app_title if app_title else 'Pigweed Console'
178
179        # Top level UI state toggles.
180        self.load_theme(self.prefs.ui_theme)
181
182        # Pigweed upstream RST user guide
183        self.user_guide_window = HelpWindow(self, title='User Guide')
184        self.user_guide_window.load_user_guide()
185
186        # Top title message
187        self.message = [('class:logo', self.app_title), ('', '  ')]
188
189        self.message.extend(
190            pw_console.widgets.checkbox.to_keybind_indicator(
191                'Ctrl-p',
192                'Search Menu',
193                functools.partial(pw_console.widgets.mouse_handlers.on_click,
194                                  self.open_command_runner_main_menu),
195                base_style='class:toolbar-button-inactive',
196            ))
197        # One space separator
198        self.message.append(('', ' '))
199
200        # Auto-generated keybindings list for all active panes
201        self.keybind_help_window = HelpWindow(self, title='Keyboard Shortcuts')
202
203        # Downstream project specific help text
204        self.app_help_text = help_text if help_text else None
205        self.app_help_window = HelpWindow(self,
206                                          additional_help_text=help_text,
207                                          title=(self.app_title + ' Help'))
208        self.app_help_window.generate_help_text()
209
210        self.prefs_file_window = HelpWindow(self, title='.pw_console.yaml')
211        self.prefs_file_window.load_yaml_text(
212            self.prefs.current_config_as_yaml())
213
214        # Used for tracking which pane was in focus before showing help window.
215        self.last_focused_pane = None
216
217        # Create a ptpython repl instance.
218        self.pw_ptpython_repl = PwPtPythonRepl(
219            get_globals=lambda: global_vars,
220            get_locals=lambda: local_vars,
221            color_depth=self.color_depth,
222            history_filename=self.repl_history_filename,
223            extra_completers=extra_completers,
224        )
225        self.input_history = self.pw_ptpython_repl.history
226
227        self.repl_pane = ReplPane(
228            application=self,
229            python_repl=self.pw_ptpython_repl,
230            startup_message=repl_startup_message,
231        )
232        self.pw_ptpython_repl.use_code_colorscheme(self.prefs.code_theme)
233
234        if self.prefs.swap_light_and_dark:
235            self.toggle_light_theme()
236
237        # Window panes are added via the window_manager
238        self.window_manager = WindowManager(self)
239        self.window_manager.add_pane_no_checks(self.repl_pane)
240
241        # Top of screen menu items
242        self.menu_items = self._create_menu_items()
243
244        self.quit_dialog = QuitDialog(self)
245
246        # Key bindings registry.
247        self.key_bindings = pw_console.key_bindings.create_key_bindings(self)
248
249        # Create help window text based global key_bindings and active panes.
250        self._update_help_window()
251
252        self.command_runner = CommandRunner(
253            self,
254            width=self.prefs.command_runner_width,
255            height=self.prefs.command_runner_height,
256        )
257
258        self.floats = [
259            # Top message bar
260            Float(
261                content=FloatingMessageBar(self),
262                top=0,
263                right=0,
264                height=1,
265            ),
266            # Centered floating help windows
267            Float(
268                content=self.prefs_file_window,
269                top=2,
270                bottom=2,
271                # Callable to get width
272                width=self.prefs_file_window.content_width,
273            ),
274            Float(
275                content=self.app_help_window,
276                top=2,
277                bottom=2,
278                # Callable to get width
279                width=self.app_help_window.content_width,
280            ),
281            Float(
282                content=self.user_guide_window,
283                top=2,
284                bottom=2,
285                # Callable to get width
286                width=self.user_guide_window.content_width,
287            ),
288            Float(
289                content=self.keybind_help_window,
290                top=2,
291                bottom=2,
292                # Callable to get width
293                width=self.keybind_help_window.content_width,
294            ),
295            # Completion menu that can overlap other panes since it lives in
296            # the top level Float container.
297            Float(
298                xcursor=True,
299                ycursor=True,
300                content=ConditionalContainer(
301                    content=CompletionsMenu(
302                        scroll_offset=(lambda: self.pw_ptpython_repl.
303                                       completion_menu_scroll_offset),
304                        max_height=16,
305                    ),
306                    # Only show our completion if ptpython's is disabled.
307                    filter=Condition(
308                        lambda: self.pw_ptpython_repl.completion_visualisation
309                        == CompletionVisualisation.NONE),
310                ),
311            ),
312            Float(
313                content=self.command_runner,
314                # Callable to get width
315                width=self.command_runner.content_width,
316                **self.prefs.command_runner_position,
317            ),
318            Float(
319                content=self.quit_dialog,
320                top=2,
321                left=2,
322            ),
323        ]
324
325        # prompt_toolkit root container.
326        self.root_container = MenuContainer(
327            body=self.window_manager.create_root_container(),
328            menu_items=self.menu_items,
329            floats=self.floats,
330        )
331
332        # NOTE: ptpython stores it's completion menus in this HSplit:
333        #
334        # self.pw_ptpython_repl.__pt_container__()
335        #   .children[0].children[0].children[0].floats[0].content.children
336        #
337        # Index 1 is a CompletionsMenu and is shown when:
338        #   self.pw_ptpython_repl
339        #     .completion_visualisation == CompletionVisualisation.POP_UP
340        #
341        # Index 2 is a MultiColumnCompletionsMenu and is shown when:
342        #   self.pw_ptpython_repl
343        #     .completion_visualisation == CompletionVisualisation.MULTI_COLUMN
344        #
345
346        # Setup the prompt_toolkit layout with the repl pane as the initially
347        # focused element.
348        self.layout: Layout = Layout(
349            self.root_container,
350            focused_element=self.pw_ptpython_repl,
351        )
352
353        # Create the prompt_toolkit Application instance.
354        self.application: Application = Application(
355            layout=self.layout,
356            key_bindings=merge_key_bindings([
357                # Pull key bindings from ptpython
358                load_python_bindings(self.pw_ptpython_repl),
359                load_sidebar_bindings(self.pw_ptpython_repl),
360                self.window_manager.key_bindings,
361                self.key_bindings,
362            ]),
363            style=DynamicStyle(lambda: merge_styles([
364                self._current_theme,
365                # Include ptpython styles
366                self.pw_ptpython_repl._current_style,  # pylint: disable=protected-access
367            ])),
368            style_transformation=self.pw_ptpython_repl.style_transformation,
369            enable_page_navigation_bindings=True,
370            full_screen=True,
371            mouse_support=True,
372            color_depth=self.color_depth,
373            clipboard=PyperclipClipboard(),
374            min_redraw_interval=MIN_REDRAW_INTERVAL,
375        )
376
377    def get_template(self, file_name: str):
378        return self.jinja_env.get_template(file_name)
379
380    def run_pane_menu_option(self, function_to_run):
381        # Run the function for a particular menu item.
382        return_value = function_to_run()
383        # It's return value dictates if the main menu should close or not.
384        # - True: The main menu stays open. This is the default prompt_toolkit
385        #   menu behavior.
386        # - False: The main menu closes.
387
388        # Update menu content. This will refresh checkboxes and add/remove
389        # items.
390        self.update_menu_items()
391        # Check if the main menu should stay open.
392        if not return_value:
393            # Keep the main menu open
394            self.focus_main_menu()
395
396    def open_new_log_pane_for_logger(
397            self,
398            logger_name: str,
399            level_name='NOTSET',
400            window_title: Optional[str] = None) -> None:
401        pane_title = window_title if window_title else logger_name
402        self.run_pane_menu_option(
403            functools.partial(self.add_log_handler,
404                              pane_title, [logger_name],
405                              log_level_name=level_name))
406
407    def set_ui_theme(self, theme_name: str) -> Callable:
408        call_function = functools.partial(
409            self.run_pane_menu_option,
410            functools.partial(self.load_theme, theme_name))
411        return call_function
412
413    def set_code_theme(self, theme_name: str) -> Callable:
414        call_function = functools.partial(
415            self.run_pane_menu_option,
416            functools.partial(self.pw_ptpython_repl.use_code_colorscheme,
417                              theme_name))
418        return call_function
419
420    def update_menu_items(self):
421        self.menu_items = self._create_menu_items()
422        self.root_container.menu_items = self.menu_items
423
424    def open_command_runner_main_menu(self) -> None:
425        self.command_runner.set_completions()
426        if not self.command_runner_is_open():
427            self.command_runner.open_dialog()
428
429    def open_command_runner_loggers(self) -> None:
430        self.command_runner.set_completions(
431            window_title='Open Logger',
432            load_completions=self._create_logger_completions)
433        if not self.command_runner_is_open():
434            self.command_runner.open_dialog()
435
436    def _create_logger_completions(self) -> List[Tuple[str, Callable]]:
437        completions: List[Tuple[str, Callable]] = [
438            (
439                'root',
440                functools.partial(self.open_new_log_pane_for_logger,
441                                  '',
442                                  window_title='root'),
443            ),
444        ]
445
446        all_logger_names = sorted([logger.name for logger in all_loggers()])
447
448        for logger_name in all_logger_names:
449            completions.append((
450                logger_name,
451                functools.partial(self.open_new_log_pane_for_logger,
452                                  logger_name),
453            ))
454        return completions
455
456    def _create_menu_items(self):
457        themes_submenu = [
458            MenuItem('Toggle Light/Dark', handler=self.toggle_light_theme),
459            MenuItem('-'),
460            MenuItem(
461                'UI Themes',
462                children=[
463                    MenuItem('Default: Dark', self.set_ui_theme('dark')),
464                    MenuItem('High Contrast',
465                             self.set_ui_theme('high-contrast-dark')),
466                    MenuItem('Nord', self.set_ui_theme('nord')),
467                    MenuItem('Nord Light', self.set_ui_theme('nord-light')),
468                    MenuItem('Moonlight', self.set_ui_theme('moonlight')),
469                ],
470            ),
471            MenuItem(
472                'Code Themes',
473                children=[
474                    MenuItem('Code: pigweed-code',
475                             self.set_code_theme('pigweed-code')),
476                    MenuItem('Code: pigweed-code-light',
477                             self.set_code_theme('pigweed-code-light')),
478                    MenuItem('Code: material',
479                             self.set_code_theme('material')),
480                    MenuItem('Code: gruvbox-light',
481                             self.set_code_theme('gruvbox-light')),
482                    MenuItem('Code: gruvbox-dark',
483                             self.set_code_theme('gruvbox-dark')),
484                    MenuItem('Code: tomorrow-night',
485                             self.set_code_theme('tomorrow-night')),
486                    MenuItem('Code: tomorrow-night-bright',
487                             self.set_code_theme('tomorrow-night-bright')),
488                    MenuItem('Code: tomorrow-night-blue',
489                             self.set_code_theme('tomorrow-night-blue')),
490                    MenuItem('Code: tomorrow-night-eighties',
491                             self.set_code_theme('tomorrow-night-eighties')),
492                    MenuItem('Code: dracula', self.set_code_theme('dracula')),
493                    MenuItem('Code: zenburn', self.set_code_theme('zenburn')),
494                ],
495            ),
496        ]
497
498        file_menu = [
499            # File menu
500            MenuItem(
501                '[File]',
502                children=[
503                    MenuItem('Open Logger',
504                             handler=self.open_command_runner_loggers),
505                    MenuItem(
506                        'Log Table View',
507                        children=[
508                            MenuItem(
509                                '{check} Hide Date'.format(
510                                    check=pw_console.widgets.checkbox.
511                                    to_checkbox_text(
512                                        self.prefs.hide_date_from_log_time,
513                                        end='')),
514                                handler=functools.partial(
515                                    self.run_pane_menu_option,
516                                    functools.partial(
517                                        self.toggle_pref_option,
518                                        'hide_date_from_log_time')),
519                            ),
520                            MenuItem(
521                                '{check} Show Source File'.format(
522                                    check=pw_console.widgets.checkbox.
523                                    to_checkbox_text(
524                                        self.prefs.show_source_file, end='')),
525                                handler=functools.partial(
526                                    self.run_pane_menu_option,
527                                    functools.partial(self.toggle_pref_option,
528                                                      'show_source_file')),
529                            ),
530                            MenuItem(
531                                '{check} Show Python File'.format(
532                                    check=pw_console.widgets.checkbox.
533                                    to_checkbox_text(
534                                        self.prefs.show_python_file, end='')),
535                                handler=functools.partial(
536                                    self.run_pane_menu_option,
537                                    functools.partial(self.toggle_pref_option,
538                                                      'show_python_file')),
539                            ),
540                            MenuItem(
541                                '{check} Show Python Logger'.format(
542                                    check=pw_console.widgets.checkbox.
543                                    to_checkbox_text(
544                                        self.prefs.show_python_logger,
545                                        end='')),
546                                handler=functools.partial(
547                                    self.run_pane_menu_option,
548                                    functools.partial(self.toggle_pref_option,
549                                                      'show_python_logger')),
550                            ),
551                        ]),
552                    MenuItem('-'),
553                    MenuItem(
554                        'Themes',
555                        children=themes_submenu,
556                    ),
557                    MenuItem('-'),
558                    MenuItem('Exit', handler=self.exit_console),
559                ],
560            ),
561        ]
562
563        edit_menu = [
564            MenuItem(
565                '[Edit]',
566                children=[
567                    MenuItem('Paste to Python Input',
568                             handler=self.repl_pane.
569                             paste_system_clipboard_to_input_buffer),
570                    MenuItem('-'),
571                    MenuItem('Copy all Python Output',
572                             handler=self.repl_pane.copy_all_output_text),
573                    MenuItem('Copy all Python Input',
574                             handler=self.repl_pane.copy_all_input_text),
575                ],
576            ),
577        ]
578
579        view_menu = [
580            MenuItem(
581                '[View]',
582                children=[
583                    #         [Menu Item             ][Keybind  ]
584                    MenuItem('Focus Next Window/Tab   Ctrl-Alt-n',
585                             handler=self.window_manager.focus_next_pane),
586                    #         [Menu Item             ][Keybind  ]
587                    MenuItem('Focus Prev Window/Tab   Ctrl-Alt-p',
588                             handler=self.window_manager.focus_previous_pane),
589                    MenuItem('-'),
590
591                    #         [Menu Item             ][Keybind  ]
592                    MenuItem('Move Window Up         Ctrl-Alt-Up',
593                             handler=functools.partial(
594                                 self.run_pane_menu_option,
595                                 self.window_manager.move_pane_up)),
596                    #         [Menu Item             ][Keybind  ]
597                    MenuItem('Move Window Down     Ctrl-Alt-Down',
598                             handler=functools.partial(
599                                 self.run_pane_menu_option,
600                                 self.window_manager.move_pane_down)),
601                    #         [Menu Item             ][Keybind  ]
602                    MenuItem('Move Window Left     Ctrl-Alt-Left',
603                             handler=functools.partial(
604                                 self.run_pane_menu_option,
605                                 self.window_manager.move_pane_left)),
606                    #         [Menu Item             ][Keybind  ]
607                    MenuItem('Move Window Right   Ctrl-Alt-Right',
608                             handler=functools.partial(
609                                 self.run_pane_menu_option,
610                                 self.window_manager.move_pane_right)),
611                    MenuItem('-'),
612
613                    #         [Menu Item             ][Keybind  ]
614                    MenuItem('Shrink Height            Alt-Minus',
615                             handler=functools.partial(
616                                 self.run_pane_menu_option,
617                                 self.window_manager.shrink_pane)),
618                    #         [Menu Item             ][Keybind  ]
619                    MenuItem('Enlarge Height               Alt-=',
620                             handler=functools.partial(
621                                 self.run_pane_menu_option,
622                                 self.window_manager.enlarge_pane)),
623                    MenuItem('-'),
624
625                    #         [Menu Item             ][Keybind  ]
626                    MenuItem('Shrink Column                Alt-,',
627                             handler=functools.partial(
628                                 self.run_pane_menu_option,
629                                 self.window_manager.shrink_split)),
630                    #         [Menu Item             ][Keybind  ]
631                    MenuItem('Enlarge Column               Alt-.',
632                             handler=functools.partial(
633                                 self.run_pane_menu_option,
634                                 self.window_manager.enlarge_split)),
635                    MenuItem('-'),
636
637                    #         [Menu Item            ][Keybind  ]
638                    MenuItem('Balance Window Sizes       Ctrl-u',
639                             handler=functools.partial(
640                                 self.run_pane_menu_option,
641                                 self.window_manager.balance_window_sizes)),
642                ],
643            ),
644        ]
645
646        window_menu = self.window_manager.create_window_menu()
647
648        help_menu_items = [
649            MenuItem(self.user_guide_window.menu_title(),
650                     handler=self.user_guide_window.toggle_display),
651            MenuItem(self.keybind_help_window.menu_title(),
652                     handler=self.keybind_help_window.toggle_display),
653            MenuItem('-'),
654            MenuItem('View Key Binding Config',
655                     handler=self.prefs_file_window.toggle_display),
656        ]
657
658        if self.app_help_text:
659            help_menu_items.extend([
660                MenuItem('-'),
661                MenuItem(self.app_help_window.menu_title(),
662                         handler=self.app_help_window.toggle_display)
663            ])
664
665        help_menu = [
666            # Info / Help
667            MenuItem(
668                '[Help]',
669                children=help_menu_items,
670            ),
671        ]
672
673        return file_menu + edit_menu + view_menu + window_menu + help_menu
674
675    def focus_main_menu(self):
676        """Set application focus to the main menu."""
677        self.application.layout.focus(self.root_container.window)
678
679    def focus_on_container(self, pane):
680        """Set application focus to a specific container."""
681        # Try to focus on the given pane
682        try:
683            self.application.layout.focus(pane)
684        except ValueError:
685            # If the container can't be focused, focus on the first visible
686            # window pane.
687            self.window_manager.focus_first_visible_pane()
688
689    def toggle_light_theme(self):
690        """Toggle light and dark theme colors."""
691        # Use ptpython's style_transformation to swap dark and light colors.
692        self.pw_ptpython_repl.swap_light_and_dark = (
693            not self.pw_ptpython_repl.swap_light_and_dark)
694        if self.application:
695            self.focus_main_menu()
696
697    def toggle_pref_option(self, setting_name):
698        self.prefs.toggle_bool_option(setting_name)
699
700    def load_theme(self, theme_name=None):
701        """Regenerate styles for the current theme_name."""
702        self._current_theme = pw_console.style.generate_styles(theme_name)
703        if theme_name:
704            self.prefs.set_ui_theme(theme_name)
705
706    def _create_log_pane(self,
707                         title: str = '',
708                         log_store: Optional[LogStore] = None) -> 'LogPane':
709        # Create one log pane.
710        log_pane = LogPane(application=self,
711                           pane_title=title,
712                           log_store=log_store)
713        self.window_manager.add_pane(log_pane)
714        return log_pane
715
716    def load_clean_config(self, config_file: Path) -> None:
717        self.prefs.reset_config()
718        self.prefs.load_config_file(config_file)
719
720    def apply_window_config(self) -> None:
721        self.window_manager.apply_config(self.prefs)
722
723    def refresh_layout(self) -> None:
724        self.window_manager.update_root_container_body()
725        self.update_menu_items()
726        self._update_help_window()
727
728    def add_log_handler(
729            self,
730            window_title: str,
731            logger_instances: Union[Iterable[logging.Logger], LogStore],
732            separate_log_panes: bool = False,
733            log_level_name: Optional[str] = None) -> Optional[LogPane]:
734        """Add the Log pane as a handler for this logger instance."""
735
736        existing_log_pane = None
737        # Find an existing LogPane with the same window_title.
738        for pane in self.window_manager.active_panes():
739            if isinstance(pane, LogPane) and pane.pane_title() == window_title:
740                existing_log_pane = pane
741                break
742
743        log_store: Optional[LogStore] = None
744        if isinstance(logger_instances, LogStore):
745            log_store = logger_instances
746
747        if not existing_log_pane or separate_log_panes:
748            existing_log_pane = self._create_log_pane(title=window_title,
749                                                      log_store=log_store)
750
751        if isinstance(logger_instances, list):
752            for logger in logger_instances:
753                _add_log_handler_to_pane(logger, existing_log_pane,
754                                         log_level_name)
755
756        self.refresh_layout()
757        return existing_log_pane
758
759    def _user_code_thread_entry(self):
760        """Entry point for the user code thread."""
761        asyncio.set_event_loop(self.user_code_loop)
762        self.user_code_loop.run_forever()
763
764    def start_user_code_thread(self):
765        """Create a thread for running user code so the UI isn't blocked."""
766        thread = Thread(target=self._user_code_thread_entry,
767                        args=(),
768                        daemon=True)
769        thread.start()
770
771    def _update_help_window(self):
772        """Generate the help window text based on active pane keybindings."""
773        # Add global mouse bindings to the help text.
774        mouse_functions = {
775            'Focus pane, menu or log line.': ['Click'],
776            'Scroll current window.': ['Scroll wheel'],
777        }
778
779        self.keybind_help_window.add_custom_keybinds_help_text(
780            'Global Mouse', mouse_functions)
781
782        # Add global key bindings to the help text.
783        self.keybind_help_window.add_keybind_help_text('Global',
784                                                       self.key_bindings)
785
786        self.keybind_help_window.add_keybind_help_text(
787            'Window Management', self.window_manager.key_bindings)
788
789        # Add activated plugin key bindings to the help text.
790        for pane in self.window_manager.active_panes():
791            for key_bindings in pane.get_all_key_bindings():
792                help_section_title = pane.__class__.__name__
793                if isinstance(key_bindings, KeyBindings):
794                    self.keybind_help_window.add_keybind_help_text(
795                        help_section_title, key_bindings)
796                elif isinstance(key_bindings, dict):
797                    self.keybind_help_window.add_custom_keybinds_help_text(
798                        help_section_title, key_bindings)
799
800        self.keybind_help_window.generate_help_text()
801
802    def toggle_log_line_wrapping(self):
803        """Menu item handler to toggle line wrapping of all log panes."""
804        for pane in self.window_manager.active_panes():
805            if isinstance(pane, LogPane):
806                pane.toggle_wrap_lines()
807
808    def focused_window(self):
809        """Return the currently focused window."""
810        return self.application.layout.current_window
811
812    def command_runner_is_open(self) -> bool:
813        return self.command_runner.show_dialog
814
815    def command_runner_last_focused_pane(self) -> Any:
816        return self.command_runner.last_focused_pane
817
818    def modal_window_is_open(self):
819        """Return true if any modal window or dialog is open."""
820        if self.app_help_text:
821            return (self.app_help_window.show_window
822                    or self.keybind_help_window.show_window
823                    or self.prefs_file_window.show_window
824                    or self.user_guide_window.show_window
825                    or self.quit_dialog.show_dialog
826                    or self.command_runner.show_dialog)
827        return (self.keybind_help_window.show_window
828                or self.prefs_file_window.show_window
829                or self.user_guide_window.show_window
830                or self.quit_dialog.show_dialog
831                or self.command_runner.show_dialog)
832
833    def exit_console(self):
834        """Quit the console prompt_toolkit application UI."""
835        self.application.exit()
836
837    def redraw_ui(self):
838        """Redraw the prompt_toolkit UI."""
839        if hasattr(self, 'application'):
840            # Thread safe way of sending a repaint trigger to the input event
841            # loop.
842            self.application.invalidate()
843
844    async def run(self, test_mode=False):
845        """Start the prompt_toolkit UI."""
846        if test_mode:
847            background_log_task = asyncio.create_task(self.log_forever())
848
849        # Repl pane has focus by default, if it's hidden switch focus to another
850        # visible pane.
851        if not self.repl_pane.show_pane:
852            self.window_manager.focus_first_visible_pane()
853
854        try:
855            unused_result = await self.application.run_async(
856                set_exception_handler=True)
857        finally:
858            if test_mode:
859                background_log_task.cancel()
860
861    async def log_forever(self):
862        """Test mode async log generator coroutine that runs forever."""
863        message_count = 0
864        # Sample log line format:
865        # Log message [=         ] # 100
866
867        # Fake module column names.
868        module_names = ['APP', 'RADIO', 'BAT', 'USB', 'CPU']
869        while True:
870            if message_count > 32 or message_count < 2:
871                await asyncio.sleep(1)
872            bar_size = 10
873            position = message_count % bar_size
874            bar_content = " " * (bar_size - position - 1) + "="
875            if position > 0:
876                bar_content = "=".rjust(position) + " " * (bar_size - position)
877            new_log_line = 'Log message [{}] # {}'.format(
878                bar_content, message_count)
879            if message_count % 10 == 0:
880                new_log_line += (
881                    ' Lorem ipsum \033[34m\033[1mdolor sit amet\033[0m'
882                    ', consectetur '
883                    'adipiscing elit.') * 8
884            if message_count % 11 == 0:
885                new_log_line += ' '
886                new_log_line += (
887                    '[PYTHON] START\n'
888                    'In []: import time;\n'
889                    '        def t(s):\n'
890                    '            time.sleep(s)\n'
891                    '            return "t({}) seconds done".format(s)\n\n')
892
893            module_name = module_names[message_count % len(module_names)]
894            _FAKE_DEVICE_LOG.info(new_log_line,
895                                  extra=dict(extra_metadata_fields=dict(
896                                      module=module_name, file='fake_app.cc')))
897            message_count += 1
898
899
900# TODO(tonymd): Remove this alias when not used by downstream projects.
901def embed(
902    *args,
903    **kwargs,
904) -> None:
905    """PwConsoleEmbed().embed() alias."""
906    # Import here to avoid circular dependency
907    from pw_console.embed import PwConsoleEmbed  # pylint: disable=import-outside-toplevel
908    console = PwConsoleEmbed(*args, **kwargs)
909    console.embed()
910