pw_console: Hookup repl and log panes Add remainder of the prompt_toolkit boilerplate needed to render all UI elements. No-Docs-Update-Reason: prompt_toolkit UI boilerplate Change-Id: I2bb2cc424390e6906bb69a45885216c17696ab2d Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/48805 Reviewed-by: Joe Ethier <jethier@google.com> Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
diff --git a/pw_console/py/console_app_test.py b/pw_console/py/console_app_test.py index 2e72e18..f36cf4a 100644 --- a/pw_console/py/console_app_test.py +++ b/pw_console/py/console_app_test.py
@@ -15,14 +15,19 @@ import unittest +from prompt_toolkit.application import create_app_session +# inclusive-language: ignore +from prompt_toolkit.output import DummyOutput as FakeOutput + from pw_console.console_app import ConsoleApp class TestConsoleApp(unittest.TestCase): """Tests for ConsoleApp.""" def test_instantiate(self) -> None: - console_app = ConsoleApp() - self.assertIsNotNone(console_app) + with create_app_session(output=FakeOutput()): + console_app = ConsoleApp() + self.assertIsNotNone(console_app) if __name__ == '__main__':
diff --git a/pw_console/py/help_window_test.py b/pw_console/py/help_window_test.py index f3298cf..55feb3d 100644 --- a/pw_console/py/help_window_test.py +++ b/pw_console/py/help_window_test.py
@@ -19,7 +19,6 @@ from prompt_toolkit.key_binding import KeyBindings -from pw_console.console_app import ConsoleApp from pw_console.help_window import HelpWindow, KEYBIND_TEMPLATE @@ -29,7 +28,8 @@ self.maxDiff = None # pylint: disable=invalid-name def test_instantiate(self) -> None: - help_window = HelpWindow(ConsoleApp()) + app = Mock() + help_window = HelpWindow(app) self.assertIsNotNone(help_window) def test_template_loads(self) -> None:
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py index 0135425..ae50e93 100644 --- a/pw_console/py/pw_console/console_app.py +++ b/pw_console/py/pw_console/console_app.py
@@ -14,14 +14,54 @@ """ConsoleApp control class.""" import builtins +import asyncio import logging +from threading import Thread from typing import Iterable, Optional +from prompt_toolkit.application import Application +from prompt_toolkit.filters import Condition +from prompt_toolkit.styles import ( + DynamicStyle, + merge_styles, +) +from prompt_toolkit.layout import ( + ConditionalContainer, + Float, + HSplit, + Layout, + VSplit, +) +from prompt_toolkit.widgets import FormattedTextToolbar +from prompt_toolkit.widgets import ( + MenuContainer, + MenuItem, +) +from prompt_toolkit.key_binding import merge_key_bindings + +from pw_console.help_window import HelpWindow +from pw_console.key_bindings import create_key_bindings +from pw_console.log_pane import LogPane +from pw_console.repl_pane import ReplPane +from pw_console.style import pw_console_styles + _LOG = logging.getLogger(__package__) +class FloatingMessageBar(ConditionalContainer): + """Floating message bar for showing status messages.""" + def __init__(self, application): + super().__init__( + FormattedTextToolbar(lambda: application.message + if application.message else []), + filter=Condition( + lambda: application.message and application.message != '')) + + class ConsoleApp: """The main ConsoleApp class containing the whole console.""" + + # pylint: disable=too-many-instance-attributes def __init__(self, global_vars=None, local_vars=None): # Create a default global and local symbol table. Values are the same # structure as what is returned by globals(): @@ -36,16 +76,201 @@ local_vars = local_vars or global_vars - def add_log_handler(self, logger_instance): + # Event loop for executing user repl code. + self.user_code_loop = asyncio.new_event_loop() + + # Top title message + # 'Pigweed CLI v0.1 | Mouse supported | F1:Help Ctrl-W:Quit.' + self.message = [ + ('class:logo', ' Pigweed CLI v0.1 '), + ('class:menu-bar', '| Mouse supported; click on pane to focus | '), + ('class:keybind', 'F1'), + ('class:keyhelp', ':Help '), + ('class:keybind', 'Ctrl-W'), + ('class:keyhelp', ':Quit '), + ] + + # Top level UI state toggles. + self.show_help_window = False + self.vertical_split = False + + # Create one log pane and the repl pane. + self.log_pane = LogPane(application=self) + self.repl_pane = ReplPane(application=self) + + # List of enabled panes. + self.active_panes = [ + self.log_pane, + self.repl_pane, + ] + + # Top of screen menu items + self.menu_items = [ + # File menu + MenuItem( + '[File] ', + children=[ + MenuItem('Exit', handler=self.exit_console), + ], + ), + # View menu + MenuItem( + '[View] ', + children=[ + MenuItem('Toggle Vertical/Horizontal Split', + handler=self.toggle_vertical_split), + MenuItem('Toggle Log line Wrapping', + handler=self.toggle_log_line_wrapping), + ], + ), + # Info / Help + MenuItem( + '[Help] ', + children=[ + MenuItem('Keyboard Shortcuts', handler=self.toggle_help), + ], + ), + ] + + # Key bindings registry. + self.key_bindings = create_key_bindings(self) + + # prompt_toolkit root container. + self.root_container = MenuContainer( + body=self._create_root_split(), + menu_items=self.menu_items, + floats=[ + # Top message bar + Float(top=0, + right=0, + height=1, + content=FloatingMessageBar(self)), + # Centered floating Help Window + Float(content=self._create_help_window()), + ], + ) + + # Setup the prompt_toolkit layout with the repl pane as the initially + # focused element. + self.layout: Layout = Layout( + self.root_container, + focused_element=self.repl_pane, + ) + + # Create the prompt_toolkit Application instance. + self.application: Application = Application( + layout=self.layout, + after_render=self.run_after_render_hooks, + key_bindings=merge_key_bindings([ + # TODO: pull key bindings from ptpython + # load_python_bindings(self.pw_ptpython_repl), + self.key_bindings, + ]), + style=DynamicStyle(lambda: merge_styles([ + pw_console_styles, + # TODO: Include ptpython styles + # self.pw_ptpython_repl._current_style + ])), + enable_page_navigation_bindings=True, + full_screen=True, + mouse_support=True, + ) + + def add_log_handler(self, logger_instance: Iterable): """Add the Log pane as a handler for this logger instance.""" # TODO: Add log pane to addHandler call. - # logger_instance.addHandler(...) + # logger_instance.addHandler(self.log_pane.log_container) + + def _user_code_thread_entry(self): + """Entry point for the user code thread.""" + asyncio.set_event_loop(self.user_code_loop) + self.user_code_loop.run_forever() + + def run_after_render_hooks(self, *unused_args, **unused_kwargs): + """Run each active pane's `after_render_hook` if defined.""" + for pane in self.active_panes: + if hasattr(pane, 'after_render_hook'): + pane.after_render_hook() + + def start_user_code_thread(self): + """Create a thread for running user code so the UI isn't blocked.""" + thread = Thread(target=self._user_code_thread_entry, + args=(), + daemon=True) + thread.start() + + def _create_help_window(self): + help_window = HelpWindow(self) + # Create the help window and generate help text. + # Add global key bindings to the help text + help_window.add_keybind_help_text('Global', self.key_bindings) + # Add activated plugin key bindings to the help text + for pane in self.active_panes: + for key_bindings in pane.get_all_key_bindings(): + help_window.add_keybind_help_text(pane.__class__.__name__, + key_bindings) + help_window.generate_help_text() + return help_window + + def _create_root_split(self): + """Create a vertical or horizontal split container for all active + panes.""" + if self.vertical_split: + self.active_pane_split = VSplit( + self.active_panes, + # Add a vertical separator between each active window pane. + padding=1, + padding_char='│', + padding_style='', + ) + else: + self.active_pane_split = HSplit(self.active_panes) + + return HSplit([ + self.active_pane_split, + ]) + + def toggle_log_line_wrapping(self): + """Menu item handler to toggle line wrapping of the first log pane.""" + self.log_pane.toggle_wrap_lines() + + def toggle_vertical_split(self): + """Toggle visibility of the help window.""" + self.vertical_split = not self.vertical_split + + # Replace the root MenuContainer body with the new split. + self.root_container.container.content.children[ + 1] = self._create_root_split() + + self.redraw_ui() + + def toggle_help(self): + """Toggle visibility of the help window.""" + self.show_help_window = not self.show_help_window + + def exit_console(self): + """Quit the console prompt_toolkit application UI.""" + self.application.exit() + + def redraw_ui(self): + """Redraw the prompt_toolkit UI.""" + self.application.invalidate() + + async def run( + self, + # TODO: remove pylint disable line. + test_mode=False # pylint: disable=unused-argument + ): + """Start the prompt_toolkit UI.""" + unused_result = await self.application.run_async( + set_exception_handler=True) def embed( global_vars=None, local_vars=None, loggers: Optional[Iterable] = None, + test_mode=False, ) -> None: """Call this to embed pw console at the call point within your program. It's similar to `ptpython.embed` and `IPython.embed`. :: @@ -84,3 +309,9 @@ # TODO: Start prompt_toolkit app here _LOG.debug('Pigweed Console Start') + + # Start a thread for running user code. + console_app.start_user_code_thread() + + # Start the prompt_toolkit UI app. + asyncio.run(console_app.run(test_mode=test_mode), debug=test_mode)