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)