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