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)