| # 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 concurrent |
| import logging |
| from dataclasses import dataclass |
| from functools import partial |
| from pathlib import Path |
| from typing import ( |
| Any, |
| Callable, |
| Dict, |
| List, |
| Optional, |
| ) |
| |
| from jinja2 import Template |
| from prompt_toolkit.filters import ( |
| Condition, |
| has_focus, |
| ) |
| from prompt_toolkit.document import Document |
| 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 |
| |
| from pw_console.pw_ptpython_repl import PwPtPythonRepl |
| |
| _LOG = logging.getLogger(__package__) |
| |
| _Namespace = Dict[str, Any] |
| _GetNamespace = Callable[[], _Namespace] |
| |
| _OUTPUT_TEMPLATE_PATH = (Path(__file__).parent / 'templates' / |
| 'repl_output.jinja') |
| with _OUTPUT_TEMPLATE_PATH.open() as tmpl: |
| OUTPUT_TEMPLATE = tmpl.read() |
| |
| |
| 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)) |
| |
| |
| @dataclass |
| class UserCodeExecution: |
| """Class to hold a single user repl execution event.""" |
| input: str |
| future: concurrent.futures.Future |
| output: str |
| stdout: str |
| stderr: str |
| |
| @property |
| def is_running(self): |
| return not self.future.done() |
| |
| |
| class ReplPane: |
| """Pane for reading Python input.""" |
| |
| # pylint: disable=too-many-instance-attributes,too-few-public-methods |
| def __init__( |
| self, |
| application: Any, |
| 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 |
| |
| self.pw_ptpython_repl = python_repl |
| self.pw_ptpython_repl.set_repl_pane(self) |
| |
| self.output_field = TextArea( |
| 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.""" |
| |
| def ctrl_c(self): |
| """Ctrl-C keybinding behavior.""" |
| # If there is text in the input buffer, clear it. |
| if self.pw_ptpython_repl.default_buffer.text: |
| self.clear_input_buffer() |
| else: |
| self.interrupt_last_code_execution() |
| |
| def clear_input_buffer(self): |
| self.pw_ptpython_repl.default_buffer.reset() |
| |
| def interrupt_last_code_execution(self): |
| code = self._get_currently_running_code() |
| if code: |
| code.future.cancel() |
| code.output = 'Canceled' |
| self.pw_ptpython_repl.clear_last_result() |
| self.update_output_buffer() |
| |
| def _get_currently_running_code(self): |
| for code in self.executed_code: |
| if not code.future.done(): |
| return code |
| return None |
| |
| def _get_executed_code(self, future): |
| for code in self.executed_code: |
| if code.future == future: |
| return code |
| return None |
| |
| def _log_executed_code(self, code, prefix=''): |
| text = self.get_output_buffer_text([code], show_index=False) |
| _LOG.info('[PYTHON] %s\n%s', prefix, text) |
| |
| def append_executed_code(self, text, future): |
| user_code = UserCodeExecution(input=text, |
| future=future, |
| output=None, |
| stdout=None, |
| stderr=None) |
| self.executed_code.append(user_code) |
| self._log_executed_code(user_code, prefix='START') |
| |
| def append_result_to_executed_code(self, |
| _input_text, |
| future, |
| result_text, |
| stdout_text='', |
| stderr_text=''): |
| code = self._get_executed_code(future) |
| if code: |
| code.output = result_text |
| code.stdout = stdout_text |
| code.stderr = stderr_text |
| self._log_executed_code(code, prefix='FINISH') |
| self.update_output_buffer() |
| |
| def get_output_buffer_text(self, code_items=None, show_index=True): |
| executed_code = code_items or self.executed_code |
| template = Template(OUTPUT_TEMPLATE, |
| trim_blocks=True, |
| lstrip_blocks=True) |
| return template.render(code_items=executed_code, |
| show_index=show_index).strip() |
| |
| def update_output_buffer(self): |
| text = self.get_output_buffer_text() |
| self.output_field.buffer.document = Document(text=text, |
| cursor_position=len(text)) |
| self.application.redraw_ui() |