pw_console: Add ReplPane class
No-Docs-Update-Reason: prompt_toolkit UI boilerplate
Change-Id: Ib284dac514a82a761abcbadf43235401540f1e99
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/48803
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Reviewed-by: Joe Ethier <jethier@google.com>
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn
index d680a49..f6e54f0 100644
--- a/pw_console/py/BUILD.gn
+++ b/pw_console/py/BUILD.gn
@@ -24,6 +24,7 @@
"pw_console/console_app.py",
"pw_console/help_window.py",
"pw_console/key_bindings.py",
+ "pw_console/repl_pane.py",
"pw_console/style.py",
]
tests = [
diff --git a/pw_console/py/pw_console/repl_pane.py b/pw_console/py/pw_console/repl_pane.py
new file mode 100644
index 0000000..5d301ca
--- /dev/null
+++ b/pw_console/py/pw_console/repl_pane.py
@@ -0,0 +1,267 @@
+# Copyright 2021 The Pigweed Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+"""ReplPane class."""
+
+import logging
+from functools import partial
+from typing import (
+ Any,
+ Callable,
+ Dict,
+ List,
+ Optional,
+)
+
+from prompt_toolkit.filters import (
+ Condition,
+ has_focus,
+)
+from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
+from prompt_toolkit.layout.dimension import AnyDimension
+from prompt_toolkit.widgets import TextArea
+from prompt_toolkit.layout import (
+ ConditionalContainer,
+ Dimension,
+ Float,
+ FloatContainer,
+ FormattedTextControl,
+ HSplit,
+ VSplit,
+ Window,
+ WindowAlign,
+)
+from prompt_toolkit.lexers import PygmentsLexer # type: ignore
+from pygments.lexers.python import PythonLexer # type: ignore
+
+_LOG = logging.getLogger(__package__)
+
+_Namespace = Dict[str, Any]
+_GetNamespace = Callable[[], _Namespace]
+
+
+def mouse_focus_handler(repl_pane, mouse_event: MouseEvent):
+ """Focus the repl_pane on click."""
+ if not has_focus(repl_pane)():
+ if mouse_event.event_type == MouseEventType.MOUSE_UP:
+ repl_pane.application.application.layout.focus(repl_pane)
+ return None
+ return NotImplemented
+
+
+class FocusOnClickFloatContainer(ConditionalContainer):
+ """Empty container rendered if the repl_pane is not in focus.
+
+ This container should be rendered with transparent=True so nothing is shown
+ to the user. Container is not rendered if the repl_pane is already in focus.
+ """
+ def __init__(self, repl_pane):
+
+ empty_text = FormattedTextControl([(
+ # Style
+ '',
+ # Text
+ ' ',
+ # Mouse handler
+ partial(mouse_focus_handler, repl_pane),
+ )])
+
+ super().__init__(
+ Window(empty_text),
+ filter=Condition(lambda: not has_focus(repl_pane)()),
+ )
+
+
+class ReplPaneBottomToolbarBar(ConditionalContainer):
+ """Repl pane bottom toolbar."""
+ @staticmethod
+ def get_center_text_tokens(repl_pane):
+ """Return toolbar text showing if the ReplPane is in focus or not."""
+ focused_text = (
+ # Style
+ "",
+ # Text
+ " [FOCUSED] ",
+ # Mouse handler
+ partial(mouse_focus_handler, repl_pane),
+ )
+
+ out_of_focus_text = (
+ # Style
+ 'class:keyhelp',
+ # Text
+ ' [click to focus] ',
+ # Mouse handler
+ partial(mouse_focus_handler, repl_pane),
+ )
+
+ if has_focus(repl_pane)():
+ return [focused_text]
+ return [out_of_focus_text]
+
+ def __init__(self, repl_pane):
+ left_section_text = FormattedTextControl([(
+ # Style
+ 'class:logo',
+ # Text
+ ' Python Input ',
+ # Mouse handler
+ partial(mouse_focus_handler, repl_pane),
+ )])
+
+ center_section_text = FormattedTextControl(
+ # Callable to get formatted text tuples.
+ partial(ReplPaneBottomToolbarBar.get_center_text_tokens,
+ repl_pane))
+
+ right_section_text = FormattedTextControl([(
+ # Style
+ 'class:bottom_toolbar_colored_text',
+ # Text
+ ' [Enter]: run code ',
+ )])
+
+ left_section_window = Window(
+ content=left_section_text,
+ align=WindowAlign.LEFT,
+ dont_extend_width=True,
+ )
+
+ center_section_window = Window(
+ content=center_section_text,
+ # Center text is left justified to appear just right of the left
+ # section text.
+ align=WindowAlign.LEFT,
+ # Expand center section to fill space between the left and right
+ # side of the toolbar.
+ dont_extend_width=False,
+ )
+
+ right_section_window = Window(
+ content=right_section_text,
+ # Right side text should appear at the far right of the toolbar
+ align=WindowAlign.RIGHT,
+ dont_extend_width=True,
+ )
+
+ toolbar_vsplit = VSplit(
+ [
+ left_section_window,
+ center_section_window,
+ right_section_window,
+ ],
+ height=1,
+ style='class:bottom_toolbar',
+ align=WindowAlign.LEFT,
+ )
+
+ super().__init__(
+ # Content
+ toolbar_vsplit,
+ filter=Condition(lambda: repl_pane.show_bottom_toolbar))
+
+
+class ReplPane:
+ """Pane for reading Python input."""
+
+ # pylint: disable=too-many-instance-attributes,too-few-public-methods
+ def __init__(
+ self,
+ application: Any,
+ # TODO: Include ptpython repl.
+ # python_repl: PwPtPythonRepl,
+ # TODO: Make the height of input+output windows match the log pane
+ # height. (Using minimum output height of 5 for now).
+ output_height: Optional[AnyDimension] = Dimension(preferred=5),
+ # TODO: Figure out how to resize ptpython input field.
+ _input_height: Optional[AnyDimension] = None,
+ # Default width and height to 50% of the screen
+ height: Optional[AnyDimension] = Dimension(weight=50),
+ width: Optional[AnyDimension] = Dimension(weight=50),
+ ) -> None:
+ self.height = height
+ self.width = width
+
+ self.executed_code: List = []
+ self.application = application
+ self.show_top_toolbar = True
+ self.show_bottom_toolbar = True
+
+ # TODO: Include ptpython repl.
+ self.pw_ptpython_repl = Window(content=FormattedTextControl(
+ [('', '>>> Repl input buffer')], focusable=True))
+ self.last_error_output = ""
+
+ self.output_field = TextArea(
+ text='Repl output buffer',
+ style='class:output-field',
+ height=output_height,
+ # text=help_text,
+ focusable=False,
+ scrollbar=True,
+ lexer=PygmentsLexer(PythonLexer),
+ )
+
+ self.bottom_toolbar = ReplPaneBottomToolbarBar(self)
+
+ # ReplPane root container
+ self.container = FloatContainer(
+ # Horizontal split of all Repl pane sections.
+ HSplit(
+ [
+ # 1. Repl Output
+ self.output_field,
+ # 2. Static separator toolbar.
+ Window(
+ content=FormattedTextControl([(
+ # Style
+ 'class:logo',
+ # Text
+ ' Python Results ',
+ )]),
+ height=1,
+ style='class:menu-bar'),
+ # 3. Repl Input
+ self.pw_ptpython_repl,
+ # 4. Bottom toolbar
+ self.bottom_toolbar,
+ ],
+ height=self.height,
+ width=self.width,
+ ),
+ floats=[
+ # Transparent float container that will focus on the repl_pane
+ # when clicked. It is hidden if already in focus.
+ Float(
+ FocusOnClickFloatContainer(self),
+ transparent=True,
+ # Full size of the ReplPane minus one line for the bottom
+ # toolbar.
+ right=0,
+ left=0,
+ top=0,
+ bottom=1,
+ ),
+ ])
+
+ def __pt_container__(self):
+ """Return the prompt_toolkit container for this ReplPane."""
+ return self.container
+
+ # pylint: disable=no-self-use
+ def get_all_key_bindings(self) -> List:
+ """Return all keybinds for this plugin."""
+ return []
+
+ def after_render_hook(self):
+ """Run tasks after the last UI render."""