• 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
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
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
97_PW_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']
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('Default: Dark', self.set_ui_theme('dark')),
684                    MenuItem(
685                        'High Contrast', self.set_ui_theme('high-contrast-dark')
686                    ),
687                    MenuItem('Nord', self.set_ui_theme('nord')),
688                    MenuItem('Nord Light', self.set_ui_theme('nord-light')),
689                    MenuItem('Moonlight', self.set_ui_theme('moonlight')),
690                ],
691            ),
692            MenuItem(
693                'Code Themes',
694                children=[
695                    MenuItem(
696                        'Code: pigweed-code',
697                        self.set_code_theme('pigweed-code'),
698                    ),
699                    MenuItem(
700                        'Code: pigweed-code-light',
701                        self.set_code_theme('pigweed-code-light'),
702                    ),
703                    MenuItem('Code: material', self.set_code_theme('material')),
704                    MenuItem(
705                        'Code: gruvbox-light',
706                        self.set_code_theme('gruvbox-light'),
707                    ),
708                    MenuItem(
709                        'Code: gruvbox-dark',
710                        self.set_code_theme('gruvbox-dark'),
711                    ),
712                    MenuItem('Code: zenburn', self.set_code_theme('zenburn')),
713                ],
714            ),
715        ]
716
717        file_menu = [
718            # File menu
719            MenuItem(
720                '[File]',
721                children=[
722                    MenuItem(
723                        'Insert Repl Snippet',
724                        handler=self.open_command_runner_snippets,
725                    ),
726                    MenuItem(
727                        'Insert Repl History',
728                        handler=self.open_command_runner_history,
729                    ),
730                    MenuItem(
731                        'Open Logger', handler=self.open_command_runner_loggers
732                    ),
733                    MenuItem(
734                        'Log Table View',
735                        children=[
736                            # pylint: disable=line-too-long
737                            MenuItem(
738                                '{check} Hide Date'.format(
739                                    check=to_checkbox_text(
740                                        self.prefs.hide_date_from_log_time,
741                                        end='',
742                                    )
743                                ),
744                                handler=functools.partial(
745                                    self.run_pane_menu_option,
746                                    functools.partial(
747                                        self.toggle_pref_option,
748                                        'hide_date_from_log_time',
749                                    ),
750                                ),
751                            ),
752                            MenuItem(
753                                '{check} Show Source File'.format(
754                                    check=to_checkbox_text(
755                                        self.prefs.show_source_file, end=''
756                                    )
757                                ),
758                                handler=functools.partial(
759                                    self.run_pane_menu_option,
760                                    functools.partial(
761                                        self.toggle_pref_option,
762                                        'show_source_file',
763                                    ),
764                                ),
765                            ),
766                            MenuItem(
767                                '{check} Show Python File'.format(
768                                    check=to_checkbox_text(
769                                        self.prefs.show_python_file, end=''
770                                    )
771                                ),
772                                handler=functools.partial(
773                                    self.run_pane_menu_option,
774                                    functools.partial(
775                                        self.toggle_pref_option,
776                                        'show_python_file',
777                                    ),
778                                ),
779                            ),
780                            MenuItem(
781                                '{check} Show Python Logger'.format(
782                                    check=to_checkbox_text(
783                                        self.prefs.show_python_logger, end=''
784                                    )
785                                ),
786                                handler=functools.partial(
787                                    self.run_pane_menu_option,
788                                    functools.partial(
789                                        self.toggle_pref_option,
790                                        'show_python_logger',
791                                    ),
792                                ),
793                            ),
794                            # pylint: enable=line-too-long
795                        ],
796                    ),
797                    MenuItem('-'),
798                    MenuItem(
799                        'Themes',
800                        children=themes_submenu,
801                    ),
802                    MenuItem('-'),
803                    MenuItem('Exit', handler=self.exit_console),
804                ],
805            ),
806        ]
807
808        edit_menu = [
809            MenuItem(
810                '[Edit]',
811                children=[
812                    # pylint: disable=line-too-long
813                    MenuItem(
814                        'Paste to Python Input',
815                        handler=self.repl_pane.paste_system_clipboard_to_input_buffer,
816                    ),
817                    # pylint: enable=line-too-long
818                    MenuItem('-'),
819                    MenuItem(
820                        'Copy all Python Output',
821                        handler=self.repl_pane.copy_all_output_text,
822                    ),
823                    MenuItem(
824                        'Copy all Python Input',
825                        handler=self.repl_pane.copy_all_input_text,
826                    ),
827                    MenuItem('-'),
828                    MenuItem(
829                        'Clear Python Input', self.repl_pane.clear_input_buffer
830                    ),
831                    MenuItem(
832                        'Clear Python Output',
833                        self.repl_pane.clear_output_buffer,
834                    ),
835                ],
836            ),
837        ]
838
839        view_menu = [
840            MenuItem(
841                '[View]',
842                children=[
843                    #         [Menu Item             ][Keybind  ]
844                    MenuItem(
845                        'Focus Next Window/Tab   Ctrl-Alt-n',
846                        handler=self.window_manager.focus_next_pane,
847                    ),
848                    #         [Menu Item             ][Keybind  ]
849                    MenuItem(
850                        'Focus Prev Window/Tab   Ctrl-Alt-p',
851                        handler=self.window_manager.focus_previous_pane,
852                    ),
853                    MenuItem('-'),
854                    #         [Menu Item             ][Keybind  ]
855                    MenuItem(
856                        'Move Window Up         Ctrl-Alt-Up',
857                        handler=functools.partial(
858                            self.run_pane_menu_option,
859                            self.window_manager.move_pane_up,
860                        ),
861                    ),
862                    #         [Menu Item             ][Keybind  ]
863                    MenuItem(
864                        'Move Window Down     Ctrl-Alt-Down',
865                        handler=functools.partial(
866                            self.run_pane_menu_option,
867                            self.window_manager.move_pane_down,
868                        ),
869                    ),
870                    #         [Menu Item             ][Keybind  ]
871                    MenuItem(
872                        'Move Window Left     Ctrl-Alt-Left',
873                        handler=functools.partial(
874                            self.run_pane_menu_option,
875                            self.window_manager.move_pane_left,
876                        ),
877                    ),
878                    #         [Menu Item             ][Keybind  ]
879                    MenuItem(
880                        'Move Window Right   Ctrl-Alt-Right',
881                        handler=functools.partial(
882                            self.run_pane_menu_option,
883                            self.window_manager.move_pane_right,
884                        ),
885                    ),
886                    MenuItem('-'),
887                    #         [Menu Item             ][Keybind  ]
888                    MenuItem(
889                        'Shrink Height            Alt-Minus',
890                        handler=functools.partial(
891                            self.run_pane_menu_option,
892                            self.window_manager.shrink_pane,
893                        ),
894                    ),
895                    #         [Menu Item             ][Keybind  ]
896                    MenuItem(
897                        'Enlarge Height               Alt-=',
898                        handler=functools.partial(
899                            self.run_pane_menu_option,
900                            self.window_manager.enlarge_pane,
901                        ),
902                    ),
903                    MenuItem('-'),
904                    #         [Menu Item             ][Keybind  ]
905                    MenuItem(
906                        'Shrink Column                Alt-,',
907                        handler=functools.partial(
908                            self.run_pane_menu_option,
909                            self.window_manager.shrink_split,
910                        ),
911                    ),
912                    #         [Menu Item             ][Keybind  ]
913                    MenuItem(
914                        'Enlarge Column               Alt-.',
915                        handler=functools.partial(
916                            self.run_pane_menu_option,
917                            self.window_manager.enlarge_split,
918                        ),
919                    ),
920                    MenuItem('-'),
921                    #         [Menu Item            ][Keybind  ]
922                    MenuItem(
923                        'Balance Window Sizes       Ctrl-u',
924                        handler=functools.partial(
925                            self.run_pane_menu_option,
926                            self.window_manager.balance_window_sizes,
927                        ),
928                    ),
929                ],
930            ),
931        ]
932
933        window_menu_items = self.window_manager.create_window_menu_items()
934
935        floating_window_items = []
936        if self.floating_window_plugins:
937            floating_window_items.append(MenuItem('-', None))
938            floating_window_items.extend(
939                MenuItem(
940                    'Floating Window {index}: {title}'.format(
941                        index=pane_index + 1,
942                        title=pane.menu_title(),
943                    ),
944                    children=[
945                        MenuItem(
946                            # pylint: disable=line-too-long
947                            '{check} Show/Hide Window'.format(
948                                check=to_checkbox_text(pane.show_pane, end='')
949                            ),
950                            # pylint: enable=line-too-long
951                            handler=functools.partial(
952                                self.run_pane_menu_option, pane.toggle_dialog
953                            ),
954                        ),
955                    ]
956                    + [
957                        MenuItem(
958                            text,
959                            handler=functools.partial(
960                                self.run_pane_menu_option, handler
961                            ),
962                        )
963                        for text, handler in pane.get_window_menu_options()
964                    ],
965                )
966                for pane_index, pane in enumerate(self.floating_window_plugins)
967            )
968            window_menu_items.extend(floating_window_items)
969
970        window_menu = [MenuItem('[Windows]', children=window_menu_items)]
971
972        top_level_plugin_menus = []
973        for pane in self.window_manager.active_panes():
974            top_level_plugin_menus.extend(pane.get_top_level_menus())
975        if self.floating_window_plugins:
976            for pane in self.floating_window_plugins:
977                top_level_plugin_menus.extend(pane.get_top_level_menus())
978
979        help_menu_items = [
980            MenuItem(
981                self.user_guide_window.menu_title(),
982                handler=self.user_guide_window.toggle_display,
983            ),
984            MenuItem(
985                self.keybind_help_window.menu_title(),
986                handler=self.keybind_help_window.toggle_display,
987            ),
988            MenuItem('-'),
989            MenuItem(
990                'View Key Binding Config',
991                handler=self.prefs_file_window.toggle_display,
992            ),
993        ]
994
995        if self.app_help_text:
996            help_menu_items.extend(
997                [
998                    MenuItem('-'),
999                    MenuItem(
1000                        self.app_help_window.menu_title(),
1001                        handler=self.app_help_window.toggle_display,
1002                    ),
1003                ]
1004            )
1005
1006        help_menu = [
1007            # Info / Help
1008            MenuItem(
1009                '[Help]',
1010                children=help_menu_items,
1011            ),
1012        ]
1013
1014        return (
1015            file_menu
1016            + edit_menu
1017            + view_menu
1018            + top_level_plugin_menus
1019            + window_menu
1020            + help_menu
1021        )
1022
1023    def focus_main_menu(self):
1024        """Set application focus to the main menu."""
1025        self.application.layout.focus(self.root_container.window)
1026
1027    def focus_on_container(self, pane):
1028        """Set application focus to a specific container."""
1029        # Try to focus on the given pane
1030        try:
1031            self.application.layout.focus(pane)
1032        except ValueError:
1033            # If the container can't be focused, focus on the first visible
1034            # window pane.
1035            self.window_manager.focus_first_visible_pane()
1036
1037    def toggle_light_theme(self):
1038        """Toggle light and dark theme colors."""
1039        # Use ptpython's style_transformation to swap dark and light colors.
1040        self.pw_ptpython_repl.swap_light_and_dark = (
1041            not self.pw_ptpython_repl.swap_light_and_dark
1042        )
1043        if self.application:
1044            self.focus_main_menu()
1045
1046    def toggle_pref_option(self, setting_name):
1047        self.prefs.toggle_bool_option(setting_name)
1048
1049    def load_theme(self, theme_name=None):
1050        """Regenerate styles for the current theme_name."""
1051        self._current_theme = generate_styles(theme_name)
1052        if theme_name:
1053            self.prefs.set_ui_theme(theme_name)
1054
1055    def _create_log_pane(
1056        self, title: str = '', log_store: LogStore | None = None
1057    ) -> LogPane:
1058        # Create one log pane.
1059        log_pane = LogPane(
1060            application=self, pane_title=title, log_store=log_store
1061        )
1062        self.window_manager.add_pane(log_pane)
1063        return log_pane
1064
1065    def load_clean_config(self, config_file: Path) -> None:
1066        self.prefs.reset_config()
1067        self.prefs.load_config_file(config_file)
1068
1069        # Reset colors
1070        self.load_theme(self.prefs.ui_theme)
1071        self.pw_ptpython_repl.use_code_colorscheme(self.prefs.code_theme)
1072
1073    def apply_window_config(self) -> None:
1074        self.window_manager.apply_config(self.prefs)
1075
1076    def refresh_layout(self) -> None:
1077        self.window_manager.update_root_container_body()
1078        self.update_menu_items()
1079        self._update_help_window()
1080
1081    def all_log_stores(self) -> list[LogStore]:
1082        log_stores: list[LogStore] = []
1083        for pane in self.window_manager.active_panes():
1084            if not isinstance(pane, LogPane):
1085                continue
1086            if pane.log_view.log_store not in log_stores:
1087                log_stores.append(pane.log_view.log_store)
1088        return log_stores
1089
1090    def add_log_handler(
1091        self,
1092        window_title: str,
1093        logger_instances: Iterable[logging.Logger] | LogStore,
1094        separate_log_panes: bool = False,
1095        log_level_name: str | None = None,
1096    ) -> LogPane | None:
1097        """Add the Log pane as a handler for this logger instance."""
1098
1099        existing_log_pane = None
1100        # Find an existing LogPane with the same window_title.
1101        for pane in self.window_manager.active_panes():
1102            if isinstance(pane, LogPane) and pane.pane_title() == window_title:
1103                existing_log_pane = pane
1104                break
1105
1106        log_store: LogStore | None = None
1107        if isinstance(logger_instances, LogStore):
1108            log_store = logger_instances
1109
1110        if not existing_log_pane or separate_log_panes:
1111            existing_log_pane = self._create_log_pane(
1112                title=window_title, log_store=log_store
1113            )
1114
1115        if isinstance(logger_instances, list):
1116            for logger in logger_instances:
1117                _add_log_handler_to_pane(
1118                    logger, existing_log_pane, log_level_name
1119                )
1120
1121        self.refresh_layout()
1122        return existing_log_pane
1123
1124    def _user_code_thread_entry(self):
1125        """Entry point for the user code thread."""
1126        asyncio.set_event_loop(self.user_code_loop)
1127        self.user_code_loop.run_forever()
1128
1129    def start_user_code_thread(self):
1130        """Create a thread for running user code so the UI isn't blocked."""
1131        thread = Thread(
1132            target=self._user_code_thread_entry, args=(), daemon=True
1133        )
1134        thread.start()
1135
1136    def _test_mode_log_thread_entry(self):
1137        """Entry point for the user code thread."""
1138        asyncio.set_event_loop(self.test_mode_log_loop)
1139        self.test_mode_log_loop.run_forever()
1140
1141    def _update_help_window(self):
1142        """Generate the help window text based on active pane keybindings."""
1143        # Add global mouse bindings to the help text.
1144        mouse_functions = {
1145            'Focus pane, menu or log line.': ['Click'],
1146            'Scroll current window.': ['Scroll wheel'],
1147        }
1148
1149        self.keybind_help_window.add_custom_keybinds_help_text(
1150            'Global Mouse', mouse_functions
1151        )
1152
1153        # Add global key bindings to the help text.
1154        self.keybind_help_window.add_keybind_help_text(
1155            'Global', self.key_bindings
1156        )
1157
1158        self.keybind_help_window.add_keybind_help_text(
1159            'Window Management', self.window_manager.key_bindings
1160        )
1161
1162        # Add activated plugin key bindings to the help text.
1163        for pane in self.window_manager.active_panes():
1164            for key_bindings in pane.get_all_key_bindings():
1165                help_section_title = pane.__class__.__name__
1166                if isinstance(key_bindings, KeyBindings):
1167                    self.keybind_help_window.add_keybind_help_text(
1168                        help_section_title, key_bindings
1169                    )
1170                elif isinstance(key_bindings, dict):
1171                    self.keybind_help_window.add_custom_keybinds_help_text(
1172                        help_section_title, key_bindings
1173                    )
1174
1175        self.keybind_help_window.generate_keybind_help_text()
1176
1177    def toggle_log_line_wrapping(self):
1178        """Menu item handler to toggle line wrapping of all log panes."""
1179        for pane in self.window_manager.active_panes():
1180            if isinstance(pane, LogPane):
1181                pane.toggle_wrap_lines()
1182
1183    def focused_window(self):
1184        """Return the currently focused window."""
1185        return self.application.layout.current_window
1186
1187    def command_runner_is_open(self) -> bool:
1188        return self.command_runner.show_dialog
1189
1190    def command_runner_last_focused_pane(self) -> Any:
1191        return self.command_runner.last_focused_pane
1192
1193    def modal_window_is_open(self):
1194        """Return true if any modal window or dialog is open."""
1195        floating_window_is_open = (
1196            self.keybind_help_window.show_window
1197            or self.prefs_file_window.show_window
1198            or self.user_guide_window.show_window
1199            or self.quit_dialog.show_dialog
1200            or self.command_runner.show_dialog
1201        )
1202
1203        if self.app_help_text:
1204            floating_window_is_open = (
1205                self.app_help_window.show_window or floating_window_is_open
1206            )
1207
1208        floating_plugin_is_open = any(
1209            plugin.show_pane for plugin in self.floating_window_plugins
1210        )
1211
1212        return floating_window_is_open or floating_plugin_is_open
1213
1214    def exit_console(self):
1215        """Quit the console prompt_toolkit application UI."""
1216        self.application.exit()
1217
1218    def logs_redraw(self):
1219        emit_time = time.time()
1220        # Has enough time passed since last UI redraw due to new logs?
1221        if emit_time > self._last_ui_update_time + self.log_ui_update_frequency:
1222            # Update last log time
1223            self._last_ui_update_time = emit_time
1224
1225            # Trigger Prompt Toolkit UI redraw.
1226            self.redraw_ui()
1227
1228    def redraw_ui(self):
1229        """Redraw the prompt_toolkit UI."""
1230        if hasattr(self, 'application'):
1231            # Thread safe way of sending a repaint trigger to the input event
1232            # loop.
1233            self.application.invalidate()
1234
1235    def setup_command_runner_log_pane(self) -> None:
1236        if not self.system_command_output_pane is None:
1237            return
1238
1239        self.system_command_output_pane = LogPane(
1240            application=self, pane_title='Shell Output'
1241        )
1242        self.system_command_output_pane.add_log_handler(
1243            _SYSTEM_COMMAND_LOG, level_name='INFO'
1244        )
1245        self.system_command_output_pane.log_view.log_store.formatter = (
1246            logging.Formatter('%(message)s')
1247        )
1248        self.system_command_output_pane.table_view = False
1249        self.system_command_output_pane.show_pane = True
1250        # Enable line wrapping
1251        self.system_command_output_pane.toggle_wrap_lines()
1252        # Blank right side toolbar text
1253        # pylint: disable=protected-access
1254        self.system_command_output_pane._pane_subtitle = ' '
1255        # pylint: enable=protected-access
1256        self.window_manager.add_pane(self.system_command_output_pane)
1257
1258    async def run(self, test_mode=False):
1259        """Start the prompt_toolkit UI."""
1260        if test_mode:
1261            background_log_task = start_fake_logger(
1262                lines=self.user_guide_window.help_text_area.document.lines,
1263                log_thread_entry=self._test_mode_log_thread_entry,
1264                log_thread_loop=self.test_mode_log_loop,
1265            )
1266
1267        # Repl pane has focus by default, if it's hidden switch focus to another
1268        # visible pane.
1269        if not self.repl_pane.show_pane:
1270            self.window_manager.focus_first_visible_pane()
1271
1272        try:
1273            unused_result = await self.application.run_async(
1274                set_exception_handler=True
1275            )
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