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."""