• 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 socketserver
20import importlib.resources
21import logging
22import os
23from pathlib import Path
24import sys
25import time
26from threading import Thread
27from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
28
29from jinja2 import Environment, DictLoader, make_logging_undefined
30from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
31from prompt_toolkit.layout.menus import CompletionsMenu
32from prompt_toolkit.output import ColorDepth
33from prompt_toolkit.application import Application
34from prompt_toolkit.filters import Condition
35from prompt_toolkit.styles import (
36    DynamicStyle,
37    merge_styles,
38)
39from prompt_toolkit.layout import (
40    ConditionalContainer,
41    Float,
42    Layout,
43)
44from prompt_toolkit.widgets import FormattedTextToolbar
45from prompt_toolkit.widgets import (
46    MenuContainer,
47    MenuItem,
48)
49from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
50from prompt_toolkit.history import (
51    FileHistory,
52    History,
53    ThreadedHistory,
54)
55from ptpython.layout import CompletionVisualisation  # type: ignore
56from ptpython.key_bindings import (  # type: ignore
57    load_python_bindings,
58    load_sidebar_bindings,
59)
60
61from pw_console.command_runner import CommandRunner
62from pw_console.console_log_server import (
63    ConsoleLogHTTPRequestHandler,
64    pw_console_http_server,
65)
66from pw_console.console_prefs import ConsolePrefs
67from pw_console.help_window import HelpWindow
68from pw_console.key_bindings import create_key_bindings
69from pw_console.log_pane import LogPane
70from pw_console.log_store import LogStore
71from pw_console.pw_ptpython_repl import PwPtPythonRepl
72from pw_console.python_logging import all_loggers
73from pw_console.quit_dialog import QuitDialog
74from pw_console.repl_pane import ReplPane
75from pw_console.style import generate_styles
76from pw_console.test_mode import start_fake_logger
77from pw_console.widgets import (
78    FloatingWindowPane,
79    mouse_handlers,
80    to_checkbox_text,
81    to_keybind_indicator,
82)
83from pw_console.window_manager import WindowManager
84
85_LOG = logging.getLogger(__package__)
86_ROOT_LOG = logging.getLogger('')
87
88_SYSTEM_COMMAND_LOG = logging.getLogger('pw_console_system_command')
89
90_PW_CONSOLE_MODULE = 'pw_console'
91
92MAX_FPS = 30
93MIN_REDRAW_INTERVAL = (60.0 / MAX_FPS) / 60.0
94
95
96class FloatingMessageBar(ConditionalContainer):
97    """Floating message bar for showing status messages."""
98
99    def __init__(self, application):
100        super().__init__(
101            FormattedTextToolbar(
102                (lambda: application.message if application.message else []),
103                style='class:toolbar_inactive',
104            ),
105            filter=Condition(
106                lambda: application.message and application.message != ''
107            ),
108        )
109
110
111def _add_log_handler_to_pane(
112    logger: Union[str, logging.Logger],
113    pane: 'LogPane',
114    level_name: Optional[str] = None,
115) -> None:
116    """A log pane handler for a given logger instance."""
117    if not pane:
118        return
119    pane.add_log_handler(logger, level_name=level_name)
120
121
122def get_default_colordepth(
123    color_depth: Optional[ColorDepth] = None,
124) -> ColorDepth:
125    # Set prompt_toolkit color_depth to the highest possible.
126    if color_depth is None:
127        # Default to 24bit color
128        color_depth = ColorDepth.DEPTH_24_BIT
129
130        # If using Apple Terminal switch to 256 (8bit) color.
131        term_program = os.environ.get('TERM_PROGRAM', '')
132        if sys.platform == 'darwin' and 'Apple_Terminal' in term_program:
133            color_depth = ColorDepth.DEPTH_8_BIT
134
135    # Check for any PROMPT_TOOLKIT_COLOR_DEPTH environment variables
136    color_depth_override = os.environ.get('PROMPT_TOOLKIT_COLOR_DEPTH', '')
137    if color_depth_override:
138        color_depth = ColorDepth(color_depth_override)
139    return color_depth
140
141
142class ConsoleApp:
143    """The main ConsoleApp class that glues everything together."""
144
145    # pylint: disable=too-many-instance-attributes,too-many-public-methods
146    def __init__(
147        self,
148        global_vars=None,
149        local_vars=None,
150        repl_startup_message=None,
151        help_text=None,
152        app_title=None,
153        color_depth=None,
154        extra_completers=None,
155        prefs=None,
156        floating_window_plugins: Optional[
157            List[Tuple[FloatingWindowPane, Dict]]
158        ] = None,
159    ):
160        self.prefs = prefs if prefs else ConsolePrefs()
161        self.color_depth = get_default_colordepth(color_depth)
162
163        # Max frequency in seconds of prompt_toolkit UI redraws triggered by new
164        # log lines.
165        self.log_ui_update_frequency = 0.1  # 10 FPS
166        self._last_ui_update_time = time.time()
167
168        self.http_server: Optional[socketserver.TCPServer] = None
169        self.html_files: Dict[str, str] = {}
170
171        # Create a default global and local symbol table. Values are the same
172        # structure as what is returned by globals():
173        #   https://docs.python.org/3/library/functions.html#globals
174        if global_vars is None:
175            global_vars = {
176                '__name__': '__main__',
177                '__package__': None,
178                '__doc__': None,
179                '__builtins__': builtins,
180            }
181
182        local_vars = local_vars or global_vars
183
184        jinja_templates = {
185            t: importlib.resources.read_text(
186                f'{_PW_CONSOLE_MODULE}.templates', t
187            )
188            for t in importlib.resources.contents(
189                f'{_PW_CONSOLE_MODULE}.templates'
190            )
191            if t.endswith('.jinja')
192        }
193
194        # Setup the Jinja environment
195        self.jinja_env = Environment(
196            # Load templates automatically from pw_console/templates
197            loader=DictLoader(jinja_templates),
198            # Raise errors if variables are undefined in templates
199            undefined=make_logging_undefined(
200                logger=logging.getLogger(__package__),
201            ),
202            # Trim whitespace in templates
203            trim_blocks=True,
204            lstrip_blocks=True,
205        )
206
207        self.repl_history_filename = self.prefs.repl_history
208        self.search_history_filename = self.prefs.search_history
209
210        # History instance for search toolbars.
211        self.search_history: History = ThreadedHistory(
212            FileHistory(self.search_history_filename)
213        )
214
215        # Event loop for executing user repl code.
216        self.user_code_loop = asyncio.new_event_loop()
217        self.test_mode_log_loop = asyncio.new_event_loop()
218
219        self.app_title = app_title if app_title else 'Pigweed Console'
220
221        # Top level UI state toggles.
222        self.load_theme(self.prefs.ui_theme)
223
224        # Pigweed upstream RST user guide
225        self.user_guide_window = HelpWindow(self, title='User Guide')
226        self.user_guide_window.load_user_guide()
227
228        # Top title message
229        self.message = [('class:logo', self.app_title), ('', '  ')]
230
231        self.message.extend(
232            to_keybind_indicator(
233                'Ctrl-p',
234                'Search Menu',
235                functools.partial(
236                    mouse_handlers.on_click,
237                    self.open_command_runner_main_menu,
238                ),
239                base_style='class:toolbar-button-inactive',
240            )
241        )
242        # One space separator
243        self.message.append(('', ' '))
244
245        # Auto-generated keybindings list for all active panes
246        self.keybind_help_window = HelpWindow(self, title='Keyboard Shortcuts')
247
248        # Downstream project specific help text
249        self.app_help_text = help_text if help_text else None
250        self.app_help_window = HelpWindow(
251            self,
252            additional_help_text=help_text,
253            title=(self.app_title + ' Help'),
254        )
255        self.app_help_window.generate_keybind_help_text()
256
257        self.prefs_file_window = HelpWindow(self, title='.pw_console.yaml')
258        self.prefs_file_window.load_yaml_text(
259            self.prefs.current_config_as_yaml()
260        )
261
262        self.floating_window_plugins: List[FloatingWindowPane] = []
263        if floating_window_plugins:
264            self.floating_window_plugins = [
265                plugin for plugin, _ in floating_window_plugins
266            ]
267
268        # Used for tracking which pane was in focus before showing help window.
269        self.last_focused_pane = None
270
271        # Create a ptpython repl instance.
272        self.pw_ptpython_repl = PwPtPythonRepl(
273            get_globals=lambda: global_vars,
274            get_locals=lambda: local_vars,
275            color_depth=self.color_depth,
276            history_filename=self.repl_history_filename,
277            extra_completers=extra_completers,
278        )
279        self.input_history = self.pw_ptpython_repl.history
280
281        self.repl_pane = ReplPane(
282            application=self,
283            python_repl=self.pw_ptpython_repl,
284            startup_message=repl_startup_message,
285        )
286        self.pw_ptpython_repl.use_code_colorscheme(self.prefs.code_theme)
287
288        self.system_command_output_pane: Optional[LogPane] = None
289
290        if self.prefs.swap_light_and_dark:
291            self.toggle_light_theme()
292
293        # Window panes are added via the window_manager
294        self.window_manager = WindowManager(self)
295        self.window_manager.add_pane_no_checks(self.repl_pane)
296
297        # Top of screen menu items
298        self.menu_items = self._create_menu_items()
299
300        self.quit_dialog = QuitDialog(self)
301
302        # Key bindings registry.
303        self.key_bindings = create_key_bindings(self)
304
305        # Create help window text based global key_bindings and active panes.
306        self._update_help_window()
307
308        self.command_runner = CommandRunner(
309            self,
310            width=self.prefs.command_runner_width,
311            height=self.prefs.command_runner_height,
312        )
313
314        self.floats = [
315            # Top message bar
316            Float(
317                content=FloatingMessageBar(self),
318                top=0,
319                right=0,
320                height=1,
321            ),
322            # Centered floating help windows
323            Float(
324                content=self.prefs_file_window,
325                top=2,
326                bottom=2,
327                # Callable to get width
328                width=self.prefs_file_window.content_width,
329            ),
330            Float(
331                content=self.app_help_window,
332                top=2,
333                bottom=2,
334                # Callable to get width
335                width=self.app_help_window.content_width,
336            ),
337            Float(
338                content=self.user_guide_window,
339                top=2,
340                bottom=2,
341                # Callable to get width
342                width=self.user_guide_window.content_width,
343            ),
344            Float(
345                content=self.keybind_help_window,
346                top=2,
347                bottom=2,
348                # Callable to get width
349                width=self.keybind_help_window.content_width,
350            ),
351        ]
352
353        if floating_window_plugins:
354            self.floats.extend(
355                [
356                    Float(content=plugin_container, **float_args)
357                    for plugin_container, float_args in floating_window_plugins
358                ]
359            )
360
361        self.floats.extend(
362            [
363                # Completion menu that can overlap other panes since it lives in
364                # the top level Float container.
365                # pylint: disable=line-too-long
366                Float(
367                    xcursor=True,
368                    ycursor=True,
369                    content=ConditionalContainer(
370                        content=CompletionsMenu(
371                            scroll_offset=(
372                                lambda: self.pw_ptpython_repl.completion_menu_scroll_offset
373                            ),
374                            max_height=16,
375                        ),
376                        # Only show our completion if ptpython's is disabled.
377                        filter=Condition(
378                            lambda: self.pw_ptpython_repl.completion_visualisation
379                            == CompletionVisualisation.NONE
380                        ),
381                    ),
382                ),
383                # pylint: enable=line-too-long
384                Float(
385                    content=self.command_runner,
386                    # Callable to get width
387                    width=self.command_runner.content_width,
388                    **self.prefs.command_runner_position,
389                ),
390                Float(
391                    content=self.quit_dialog,
392                    top=2,
393                    left=2,
394                ),
395            ]
396        )
397
398        # prompt_toolkit root container.
399        self.root_container = MenuContainer(
400            body=self.window_manager.create_root_container(),
401            menu_items=self.menu_items,
402            floats=self.floats,
403        )
404
405        # NOTE: ptpython stores it's completion menus in this HSplit:
406        #
407        # self.pw_ptpython_repl.__pt_container__()
408        #   .children[0].children[0].children[0].floats[0].content.children
409        #
410        # Index 1 is a CompletionsMenu and is shown when:
411        #   self.pw_ptpython_repl
412        #     .completion_visualisation == CompletionVisualisation.POP_UP
413        #
414        # Index 2 is a MultiColumnCompletionsMenu and is shown when:
415        #   self.pw_ptpython_repl
416        #     .completion_visualisation == CompletionVisualisation.MULTI_COLUMN
417        #
418
419        # Setup the prompt_toolkit layout with the repl pane as the initially
420        # focused element.
421        self.layout: Layout = Layout(
422            self.root_container,
423            focused_element=self.pw_ptpython_repl,
424        )
425
426        # Create the prompt_toolkit Application instance.
427        self.application: Application = Application(
428            layout=self.layout,
429            key_bindings=merge_key_bindings(
430                [
431                    # Pull key bindings from ptpython
432                    load_python_bindings(self.pw_ptpython_repl),
433                    load_sidebar_bindings(self.pw_ptpython_repl),
434                    self.window_manager.key_bindings,
435                    self.key_bindings,
436                ]
437            ),
438            style=DynamicStyle(
439                lambda: merge_styles(
440                    [
441                        self._current_theme,
442                        # Include ptpython styles
443                        self.pw_ptpython_repl._current_style,  # pylint: disable=protected-access
444                    ]
445                )
446            ),
447            style_transformation=self.pw_ptpython_repl.style_transformation,
448            enable_page_navigation_bindings=True,
449            full_screen=True,
450            mouse_support=True,
451            color_depth=self.color_depth,
452            clipboard=PyperclipClipboard(),
453            min_redraw_interval=MIN_REDRAW_INTERVAL,
454        )
455
456    def get_template(self, file_name: str):
457        return self.jinja_env.get_template(file_name)
458
459    def run_pane_menu_option(self, function_to_run):
460        # Run the function for a particular menu item.
461        return_value = function_to_run()
462        # It's return value dictates if the main menu should close or not.
463        # - False: The main menu stays open. This is the default prompt_toolkit
464        #   menu behavior.
465        # - True: The main menu closes.
466
467        # Update menu content. This will refresh checkboxes and add/remove
468        # items.
469        self.update_menu_items()
470        # Check if the main menu should stay open.
471        if not return_value:
472            # Keep the main menu open
473            self.focus_main_menu()
474
475    def open_new_log_pane_for_logger(
476        self,
477        logger_name: str,
478        level_name='NOTSET',
479        window_title: Optional[str] = None,
480    ) -> None:
481        pane_title = window_title if window_title else logger_name
482        self.run_pane_menu_option(
483            functools.partial(
484                self.add_log_handler,
485                pane_title,
486                [logger_name],
487                log_level_name=level_name,
488            )
489        )
490
491    def set_ui_theme(self, theme_name: str) -> Callable:
492        call_function = functools.partial(
493            self.run_pane_menu_option,
494            functools.partial(self.load_theme, theme_name),
495        )
496        return call_function
497
498    def set_code_theme(self, theme_name: str) -> Callable:
499        call_function = functools.partial(
500            self.run_pane_menu_option,
501            functools.partial(
502                self.pw_ptpython_repl.use_code_colorscheme, theme_name
503            ),
504        )
505        return call_function
506
507    def update_menu_items(self):
508        self.menu_items = self._create_menu_items()
509        self.root_container.menu_items = self.menu_items
510
511    def open_command_runner_main_menu(self) -> None:
512        self.command_runner.set_completions()
513        if not self.command_runner_is_open():
514            self.command_runner.open_dialog()
515
516    def open_command_runner_loggers(self) -> None:
517        self.command_runner.set_completions(
518            window_title='Open Logger',
519            load_completions=self._create_logger_completions,
520        )
521        if not self.command_runner_is_open():
522            self.command_runner.open_dialog()
523
524    def _create_logger_completions(self) -> List[Tuple[str, Callable]]:
525        completions: List[Tuple[str, Callable]] = [
526            (
527                'root',
528                functools.partial(
529                    self.open_new_log_pane_for_logger, '', window_title='root'
530                ),
531            ),
532        ]
533
534        all_logger_names = sorted([logger.name for logger in all_loggers()])
535
536        for logger_name in all_logger_names:
537            completions.append(
538                (
539                    logger_name,
540                    functools.partial(
541                        self.open_new_log_pane_for_logger, logger_name
542                    ),
543                )
544            )
545        return completions
546
547    def open_command_runner_history(self) -> None:
548        self.command_runner.set_completions(
549            window_title='History',
550            load_completions=self._create_history_completions,
551        )
552        if not self.command_runner_is_open():
553            self.command_runner.open_dialog()
554
555    def _create_history_completions(self) -> List[Tuple[str, Callable]]:
556        return [
557            (
558                description,
559                functools.partial(
560                    self.repl_pane.insert_text_into_input_buffer, text
561                ),
562            )
563            for description, text in self.repl_pane.history_completions()
564        ]
565
566    def open_command_runner_snippets(self) -> None:
567        self.command_runner.set_completions(
568            window_title='Snippets',
569            load_completions=self._create_snippet_completions,
570        )
571        if not self.command_runner_is_open():
572            self.command_runner.open_dialog()
573
574    def _http_server_entry(self) -> None:
575        handler = functools.partial(
576            ConsoleLogHTTPRequestHandler, self.html_files
577        )
578        pw_console_http_server(3000, handler)
579
580    def start_http_server(self):
581        if self.http_server is not None:
582            return
583
584        html_package_path = f'{_PW_CONSOLE_MODULE}.html'
585        self.html_files = {
586            '/{}'.format(t): importlib.resources.read_text(html_package_path, t)
587            for t in importlib.resources.contents(html_package_path)
588            if Path(t).suffix in ['.css', '.html', '.js']
589        }
590
591        server_thread = Thread(
592            target=self._http_server_entry, args=(), daemon=True
593        )
594        server_thread.start()
595
596    def _create_snippet_completions(self) -> List[Tuple[str, Callable]]:
597        completions: List[Tuple[str, Callable]] = [
598            (
599                description,
600                functools.partial(
601                    self.repl_pane.insert_text_into_input_buffer, text
602                ),
603            )
604            for description, text in self.prefs.snippet_completions()
605        ]
606
607        return completions
608
609    def _create_menu_items(self):
610        themes_submenu = [
611            MenuItem('Toggle Light/Dark', handler=self.toggle_light_theme),
612            MenuItem('-'),
613            MenuItem(
614                'UI Themes',
615                children=[
616                    MenuItem('Default: Dark', self.set_ui_theme('dark')),
617                    MenuItem(
618                        'High Contrast', self.set_ui_theme('high-contrast-dark')
619                    ),
620                    MenuItem('Nord', self.set_ui_theme('nord')),
621                    MenuItem('Nord Light', self.set_ui_theme('nord-light')),
622                    MenuItem('Moonlight', self.set_ui_theme('moonlight')),
623                ],
624            ),
625            MenuItem(
626                'Code Themes',
627                children=[
628                    MenuItem(
629                        'Code: pigweed-code',
630                        self.set_code_theme('pigweed-code'),
631                    ),
632                    MenuItem(
633                        'Code: pigweed-code-light',
634                        self.set_code_theme('pigweed-code-light'),
635                    ),
636                    MenuItem('Code: material', self.set_code_theme('material')),
637                    MenuItem(
638                        'Code: gruvbox-light',
639                        self.set_code_theme('gruvbox-light'),
640                    ),
641                    MenuItem(
642                        'Code: gruvbox-dark',
643                        self.set_code_theme('gruvbox-dark'),
644                    ),
645                    MenuItem('Code: zenburn', self.set_code_theme('zenburn')),
646                ],
647            ),
648        ]
649
650        file_menu = [
651            # File menu
652            MenuItem(
653                '[File]',
654                children=[
655                    MenuItem(
656                        'Insert Repl Snippet',
657                        handler=self.open_command_runner_snippets,
658                    ),
659                    MenuItem(
660                        'Insert Repl History',
661                        handler=self.open_command_runner_history,
662                    ),
663                    MenuItem(
664                        'Open Logger', handler=self.open_command_runner_loggers
665                    ),
666                    MenuItem(
667                        'Log Table View',
668                        children=[
669                            # pylint: disable=line-too-long
670                            MenuItem(
671                                '{check} Hide Date'.format(
672                                    check=to_checkbox_text(
673                                        self.prefs.hide_date_from_log_time,
674                                        end='',
675                                    )
676                                ),
677                                handler=functools.partial(
678                                    self.run_pane_menu_option,
679                                    functools.partial(
680                                        self.toggle_pref_option,
681                                        'hide_date_from_log_time',
682                                    ),
683                                ),
684                            ),
685                            MenuItem(
686                                '{check} Show Source File'.format(
687                                    check=to_checkbox_text(
688                                        self.prefs.show_source_file, end=''
689                                    )
690                                ),
691                                handler=functools.partial(
692                                    self.run_pane_menu_option,
693                                    functools.partial(
694                                        self.toggle_pref_option,
695                                        'show_source_file',
696                                    ),
697                                ),
698                            ),
699                            MenuItem(
700                                '{check} Show Python File'.format(
701                                    check=to_checkbox_text(
702                                        self.prefs.show_python_file, end=''
703                                    )
704                                ),
705                                handler=functools.partial(
706                                    self.run_pane_menu_option,
707                                    functools.partial(
708                                        self.toggle_pref_option,
709                                        'show_python_file',
710                                    ),
711                                ),
712                            ),
713                            MenuItem(
714                                '{check} Show Python Logger'.format(
715                                    check=to_checkbox_text(
716                                        self.prefs.show_python_logger, end=''
717                                    )
718                                ),
719                                handler=functools.partial(
720                                    self.run_pane_menu_option,
721                                    functools.partial(
722                                        self.toggle_pref_option,
723                                        'show_python_logger',
724                                    ),
725                                ),
726                            ),
727                            # pylint: enable=line-too-long
728                        ],
729                    ),
730                    MenuItem('-'),
731                    MenuItem(
732                        'Themes',
733                        children=themes_submenu,
734                    ),
735                    MenuItem('-'),
736                    MenuItem('Exit', handler=self.exit_console),
737                ],
738            ),
739        ]
740
741        edit_menu = [
742            MenuItem(
743                '[Edit]',
744                children=[
745                    # pylint: disable=line-too-long
746                    MenuItem(
747                        'Paste to Python Input',
748                        handler=self.repl_pane.paste_system_clipboard_to_input_buffer,
749                    ),
750                    # pylint: enable=line-too-long
751                    MenuItem('-'),
752                    MenuItem(
753                        'Copy all Python Output',
754                        handler=self.repl_pane.copy_all_output_text,
755                    ),
756                    MenuItem(
757                        'Copy all Python Input',
758                        handler=self.repl_pane.copy_all_input_text,
759                    ),
760                    MenuItem('-'),
761                    MenuItem(
762                        'Clear Python Input', self.repl_pane.clear_input_buffer
763                    ),
764                    MenuItem(
765                        'Clear Python Output',
766                        self.repl_pane.clear_output_buffer,
767                    ),
768                ],
769            ),
770        ]
771
772        view_menu = [
773            MenuItem(
774                '[View]',
775                children=[
776                    #         [Menu Item             ][Keybind  ]
777                    MenuItem(
778                        'Focus Next Window/Tab   Ctrl-Alt-n',
779                        handler=self.window_manager.focus_next_pane,
780                    ),
781                    #         [Menu Item             ][Keybind  ]
782                    MenuItem(
783                        'Focus Prev Window/Tab   Ctrl-Alt-p',
784                        handler=self.window_manager.focus_previous_pane,
785                    ),
786                    MenuItem('-'),
787                    #         [Menu Item             ][Keybind  ]
788                    MenuItem(
789                        'Move Window Up         Ctrl-Alt-Up',
790                        handler=functools.partial(
791                            self.run_pane_menu_option,
792                            self.window_manager.move_pane_up,
793                        ),
794                    ),
795                    #         [Menu Item             ][Keybind  ]
796                    MenuItem(
797                        'Move Window Down     Ctrl-Alt-Down',
798                        handler=functools.partial(
799                            self.run_pane_menu_option,
800                            self.window_manager.move_pane_down,
801                        ),
802                    ),
803                    #         [Menu Item             ][Keybind  ]
804                    MenuItem(
805                        'Move Window Left     Ctrl-Alt-Left',
806                        handler=functools.partial(
807                            self.run_pane_menu_option,
808                            self.window_manager.move_pane_left,
809                        ),
810                    ),
811                    #         [Menu Item             ][Keybind  ]
812                    MenuItem(
813                        'Move Window Right   Ctrl-Alt-Right',
814                        handler=functools.partial(
815                            self.run_pane_menu_option,
816                            self.window_manager.move_pane_right,
817                        ),
818                    ),
819                    MenuItem('-'),
820                    #         [Menu Item             ][Keybind  ]
821                    MenuItem(
822                        'Shrink Height            Alt-Minus',
823                        handler=functools.partial(
824                            self.run_pane_menu_option,
825                            self.window_manager.shrink_pane,
826                        ),
827                    ),
828                    #         [Menu Item             ][Keybind  ]
829                    MenuItem(
830                        'Enlarge Height               Alt-=',
831                        handler=functools.partial(
832                            self.run_pane_menu_option,
833                            self.window_manager.enlarge_pane,
834                        ),
835                    ),
836                    MenuItem('-'),
837                    #         [Menu Item             ][Keybind  ]
838                    MenuItem(
839                        'Shrink Column                Alt-,',
840                        handler=functools.partial(
841                            self.run_pane_menu_option,
842                            self.window_manager.shrink_split,
843                        ),
844                    ),
845                    #         [Menu Item             ][Keybind  ]
846                    MenuItem(
847                        'Enlarge Column               Alt-.',
848                        handler=functools.partial(
849                            self.run_pane_menu_option,
850                            self.window_manager.enlarge_split,
851                        ),
852                    ),
853                    MenuItem('-'),
854                    #         [Menu Item            ][Keybind  ]
855                    MenuItem(
856                        'Balance Window Sizes       Ctrl-u',
857                        handler=functools.partial(
858                            self.run_pane_menu_option,
859                            self.window_manager.balance_window_sizes,
860                        ),
861                    ),
862                ],
863            ),
864        ]
865
866        window_menu_items = self.window_manager.create_window_menu_items()
867
868        floating_window_items = []
869        if self.floating_window_plugins:
870            floating_window_items.append(MenuItem('-', None))
871            floating_window_items.extend(
872                MenuItem(
873                    'Floating Window {index}: {title}'.format(
874                        index=pane_index + 1,
875                        title=pane.menu_title(),
876                    ),
877                    children=[
878                        MenuItem(
879                            # pylint: disable=line-too-long
880                            '{check} Show/Hide Window'.format(
881                                check=to_checkbox_text(pane.show_pane, end='')
882                            ),
883                            # pylint: enable=line-too-long
884                            handler=functools.partial(
885                                self.run_pane_menu_option, pane.toggle_dialog
886                            ),
887                        ),
888                    ]
889                    + [
890                        MenuItem(
891                            text,
892                            handler=functools.partial(
893                                self.run_pane_menu_option, handler
894                            ),
895                        )
896                        for text, handler in pane.get_window_menu_options()
897                    ],
898                )
899                for pane_index, pane in enumerate(self.floating_window_plugins)
900            )
901            window_menu_items.extend(floating_window_items)
902
903        window_menu = [MenuItem('[Windows]', children=window_menu_items)]
904
905        top_level_plugin_menus = []
906        for pane in self.window_manager.active_panes():
907            top_level_plugin_menus.extend(pane.get_top_level_menus())
908        if self.floating_window_plugins:
909            for pane in self.floating_window_plugins:
910                top_level_plugin_menus.extend(pane.get_top_level_menus())
911
912        help_menu_items = [
913            MenuItem(
914                self.user_guide_window.menu_title(),
915                handler=self.user_guide_window.toggle_display,
916            ),
917            MenuItem(
918                self.keybind_help_window.menu_title(),
919                handler=self.keybind_help_window.toggle_display,
920            ),
921            MenuItem('-'),
922            MenuItem(
923                'View Key Binding Config',
924                handler=self.prefs_file_window.toggle_display,
925            ),
926        ]
927
928        if self.app_help_text:
929            help_menu_items.extend(
930                [
931                    MenuItem('-'),
932                    MenuItem(
933                        self.app_help_window.menu_title(),
934                        handler=self.app_help_window.toggle_display,
935                    ),
936                ]
937            )
938
939        help_menu = [
940            # Info / Help
941            MenuItem(
942                '[Help]',
943                children=help_menu_items,
944            ),
945        ]
946
947        return (
948            file_menu
949            + edit_menu
950            + view_menu
951            + top_level_plugin_menus
952            + window_menu
953            + help_menu
954        )
955
956    def focus_main_menu(self):
957        """Set application focus to the main menu."""
958        self.application.layout.focus(self.root_container.window)
959
960    def focus_on_container(self, pane):
961        """Set application focus to a specific container."""
962        # Try to focus on the given pane
963        try:
964            self.application.layout.focus(pane)
965        except ValueError:
966            # If the container can't be focused, focus on the first visible
967            # window pane.
968            self.window_manager.focus_first_visible_pane()
969
970    def toggle_light_theme(self):
971        """Toggle light and dark theme colors."""
972        # Use ptpython's style_transformation to swap dark and light colors.
973        self.pw_ptpython_repl.swap_light_and_dark = (
974            not self.pw_ptpython_repl.swap_light_and_dark
975        )
976        if self.application:
977            self.focus_main_menu()
978
979    def toggle_pref_option(self, setting_name):
980        self.prefs.toggle_bool_option(setting_name)
981
982    def load_theme(self, theme_name=None):
983        """Regenerate styles for the current theme_name."""
984        self._current_theme = generate_styles(theme_name)
985        if theme_name:
986            self.prefs.set_ui_theme(theme_name)
987
988    def _create_log_pane(
989        self, title: str = '', log_store: Optional[LogStore] = None
990    ) -> 'LogPane':
991        # Create one log pane.
992        log_pane = LogPane(
993            application=self, pane_title=title, log_store=log_store
994        )
995        self.window_manager.add_pane(log_pane)
996        return log_pane
997
998    def load_clean_config(self, config_file: Path) -> None:
999        self.prefs.reset_config()
1000        self.prefs.load_config_file(config_file)
1001
1002    def apply_window_config(self) -> None:
1003        self.window_manager.apply_config(self.prefs)
1004
1005    def refresh_layout(self) -> None:
1006        self.window_manager.update_root_container_body()
1007        self.update_menu_items()
1008        self._update_help_window()
1009
1010    def add_log_handler(
1011        self,
1012        window_title: str,
1013        logger_instances: Union[Iterable[logging.Logger], LogStore],
1014        separate_log_panes: bool = False,
1015        log_level_name: Optional[str] = None,
1016    ) -> Optional[LogPane]:
1017        """Add the Log pane as a handler for this logger instance."""
1018
1019        existing_log_pane = None
1020        # Find an existing LogPane with the same window_title.
1021        for pane in self.window_manager.active_panes():
1022            if isinstance(pane, LogPane) and pane.pane_title() == window_title:
1023                existing_log_pane = pane
1024                break
1025
1026        log_store: Optional[LogStore] = None
1027        if isinstance(logger_instances, LogStore):
1028            log_store = logger_instances
1029
1030        if not existing_log_pane or separate_log_panes:
1031            existing_log_pane = self._create_log_pane(
1032                title=window_title, log_store=log_store
1033            )
1034
1035        if isinstance(logger_instances, list):
1036            for logger in logger_instances:
1037                _add_log_handler_to_pane(
1038                    logger, existing_log_pane, log_level_name
1039                )
1040
1041        self.refresh_layout()
1042        return existing_log_pane
1043
1044    def _user_code_thread_entry(self):
1045        """Entry point for the user code thread."""
1046        asyncio.set_event_loop(self.user_code_loop)
1047        self.user_code_loop.run_forever()
1048
1049    def start_user_code_thread(self):
1050        """Create a thread for running user code so the UI isn't blocked."""
1051        thread = Thread(
1052            target=self._user_code_thread_entry, args=(), daemon=True
1053        )
1054        thread.start()
1055
1056    def _test_mode_log_thread_entry(self):
1057        """Entry point for the user code thread."""
1058        asyncio.set_event_loop(self.test_mode_log_loop)
1059        self.test_mode_log_loop.run_forever()
1060
1061    def _update_help_window(self):
1062        """Generate the help window text based on active pane keybindings."""
1063        # Add global mouse bindings to the help text.
1064        mouse_functions = {
1065            'Focus pane, menu or log line.': ['Click'],
1066            'Scroll current window.': ['Scroll wheel'],
1067        }
1068
1069        self.keybind_help_window.add_custom_keybinds_help_text(
1070            'Global Mouse', mouse_functions
1071        )
1072
1073        # Add global key bindings to the help text.
1074        self.keybind_help_window.add_keybind_help_text(
1075            'Global', self.key_bindings
1076        )
1077
1078        self.keybind_help_window.add_keybind_help_text(
1079            'Window Management', self.window_manager.key_bindings
1080        )
1081
1082        # Add activated plugin key bindings to the help text.
1083        for pane in self.window_manager.active_panes():
1084            for key_bindings in pane.get_all_key_bindings():
1085                help_section_title = pane.__class__.__name__
1086                if isinstance(key_bindings, KeyBindings):
1087                    self.keybind_help_window.add_keybind_help_text(
1088                        help_section_title, key_bindings
1089                    )
1090                elif isinstance(key_bindings, dict):
1091                    self.keybind_help_window.add_custom_keybinds_help_text(
1092                        help_section_title, key_bindings
1093                    )
1094
1095        self.keybind_help_window.generate_keybind_help_text()
1096
1097    def toggle_log_line_wrapping(self):
1098        """Menu item handler to toggle line wrapping of all log panes."""
1099        for pane in self.window_manager.active_panes():
1100            if isinstance(pane, LogPane):
1101                pane.toggle_wrap_lines()
1102
1103    def focused_window(self):
1104        """Return the currently focused window."""
1105        return self.application.layout.current_window
1106
1107    def command_runner_is_open(self) -> bool:
1108        return self.command_runner.show_dialog
1109
1110    def command_runner_last_focused_pane(self) -> Any:
1111        return self.command_runner.last_focused_pane
1112
1113    def modal_window_is_open(self):
1114        """Return true if any modal window or dialog is open."""
1115        floating_window_is_open = (
1116            self.keybind_help_window.show_window
1117            or self.prefs_file_window.show_window
1118            or self.user_guide_window.show_window
1119            or self.quit_dialog.show_dialog
1120            or self.command_runner.show_dialog
1121        )
1122
1123        if self.app_help_text:
1124            floating_window_is_open = (
1125                self.app_help_window.show_window or floating_window_is_open
1126            )
1127
1128        floating_plugin_is_open = any(
1129            plugin.show_pane for plugin in self.floating_window_plugins
1130        )
1131
1132        return floating_window_is_open or floating_plugin_is_open
1133
1134    def exit_console(self):
1135        """Quit the console prompt_toolkit application UI."""
1136        self.application.exit()
1137
1138    def logs_redraw(self):
1139        emit_time = time.time()
1140        # Has enough time passed since last UI redraw due to new logs?
1141        if emit_time > self._last_ui_update_time + self.log_ui_update_frequency:
1142            # Update last log time
1143            self._last_ui_update_time = emit_time
1144
1145            # Trigger Prompt Toolkit UI redraw.
1146            self.redraw_ui()
1147
1148    def redraw_ui(self):
1149        """Redraw the prompt_toolkit UI."""
1150        if hasattr(self, 'application'):
1151            # Thread safe way of sending a repaint trigger to the input event
1152            # loop.
1153            self.application.invalidate()
1154
1155    def setup_command_runner_log_pane(self) -> None:
1156        if not self.system_command_output_pane is None:
1157            return
1158
1159        self.system_command_output_pane = LogPane(
1160            application=self, pane_title='Shell Output'
1161        )
1162        self.system_command_output_pane.add_log_handler(
1163            _SYSTEM_COMMAND_LOG, level_name='INFO'
1164        )
1165        self.system_command_output_pane.log_view.log_store.formatter = (
1166            logging.Formatter('%(message)s')
1167        )
1168        self.system_command_output_pane.table_view = False
1169        self.system_command_output_pane.show_pane = True
1170        # Enable line wrapping
1171        self.system_command_output_pane.toggle_wrap_lines()
1172        # Blank right side toolbar text
1173        # pylint: disable=protected-access
1174        self.system_command_output_pane._pane_subtitle = ' '
1175        # pylint: enable=protected-access
1176        self.window_manager.add_pane(self.system_command_output_pane)
1177
1178    async def run(self, test_mode=False):
1179        """Start the prompt_toolkit UI."""
1180        if test_mode:
1181            background_log_task = start_fake_logger(
1182                lines=self.user_guide_window.help_text_area.document.lines,
1183                log_thread_entry=self._test_mode_log_thread_entry,
1184                log_thread_loop=self.test_mode_log_loop,
1185            )
1186
1187        # Repl pane has focus by default, if it's hidden switch focus to another
1188        # visible pane.
1189        if not self.repl_pane.show_pane:
1190            self.window_manager.focus_first_visible_pane()
1191
1192        try:
1193            unused_result = await self.application.run_async(
1194                set_exception_handler=True
1195            )
1196        finally:
1197            if test_mode:
1198                background_log_task.cancel()
1199
1200
1201# TODO(tonymd): Remove this alias when not used by downstream projects.
1202def embed(
1203    *args,
1204    **kwargs,
1205) -> None:
1206    """PwConsoleEmbed().embed() alias."""
1207    # Import here to avoid circular dependency
1208    # pylint: disable=import-outside-toplevel
1209    from pw_console.embed import PwConsoleEmbed
1210
1211    # pylint: enable=import-outside-toplevel
1212
1213    console = PwConsoleEmbed(*args, **kwargs)
1214    console.embed()
1215