blob: e128f2a398046423239d1ea56d5f596f5a3f45fb [file] [log] [blame]
# 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 functools
import logging
from dataclasses import dataclass
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.layout.dimension import AnyDimension
from prompt_toolkit.widgets import TextArea
from prompt_toolkit.layout import (
ConditionalContainer,
Dimension,
FloatContainer,
FormattedTextControl,
HSplit,
VSplit,
Window,
WindowAlign,
)
from prompt_toolkit.lexers import PygmentsLexer # type: ignore
from pygments.lexers.python import PythonLexer # type: ignore
import pw_console.mouse
import pw_console.style
import pw_console.widgets.focus_on_click_overlay
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()
class ReplPaneBottomToolbarBar(ConditionalContainer):
"""Repl pane bottom toolbar."""
@staticmethod
def get_left_text_tokens(repl_pane):
"""Return toolbar indicator and title."""
title = ' Python Input '
mouse_handler = functools.partial(pw_console.mouse.focus_handler,
repl_pane)
return pw_console.style.get_pane_indicator(repl_pane, title,
mouse_handler)
@staticmethod
def get_center_text_tokens(repl_pane):
"""Return toolbar text showing if the ReplPane is in focus or not."""
focused_text = [
(
# Style
'',
# Text
' ',
# Mouse handler
functools.partial(pw_console.mouse.focus_handler, repl_pane),
),
]
focused_text.extend(
pw_console.widgets.checkbox.to_keybind_indicator('Enter', 'Run'))
if has_focus(repl_pane)():
return focused_text
return [('', '')]
@staticmethod
def get_right_text_tokens(repl_pane):
"""Return right toolbar text."""
fragments = []
separator_text = [('', ' ')]
if has_focus(repl_pane)():
fragments.extend(
pw_console.widgets.checkbox.to_keybind_indicator(
'F2', 'Settings'))
fragments.extend(separator_text)
fragments.extend(
pw_console.widgets.checkbox.to_keybind_indicator(
'F3', 'History'))
else:
fragments.append((
# Style
'class:keyhelp',
# Text
'[click to focus] ',
# Mouse handler
functools.partial(pw_console.mouse.focus_handler, repl_pane),
))
return fragments
def __init__(self, repl_pane):
left_section_window = Window(
content=FormattedTextControl(
# Callable to get formatted text tuples.
functools.partial(
ReplPaneBottomToolbarBar.get_left_text_tokens, repl_pane)),
align=WindowAlign.LEFT,
dont_extend_width=True,
)
center_section_window = Window(
content=FormattedTextControl(
# Callable to get formatted text tuples.
functools.partial(
ReplPaneBottomToolbarBar.get_center_text_tokens,
repl_pane)),
# 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=FormattedTextControl(
# Callable to get formatted text tuples.
functools.partial(
ReplPaneBottomToolbarBar.get_right_text_tokens,
repl_pane)),
# 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=functools.partial(pw_console.style.get_toolbar_style,
repl_pane),
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(tonymd): 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(min=5, weight=70),
# TODO(tonymd): 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] = None,
width: Optional[AnyDimension] = None,
startup_message: Optional[str] = None,
) -> None:
self.height = height if height else Dimension(weight=50)
self.width = width if width else Dimension(weight=50)
self.show_pane = True
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.startup_message = startup_message if startup_message else ''
self.output_field = TextArea(
height=output_height,
text=self.startup_message,
focusable=False,
scrollbar=True,
lexer=PygmentsLexer(PythonLexer),
)
self.bottom_toolbar = ReplPaneBottomToolbarBar(self)
# ReplPane root container
self.container = ConditionalContainer(
FloatContainer(
# Horizontal split of all Repl pane sections.
HSplit(
[
HSplit([
# 1. Repl Output
self.output_field,
# 2. Static separator toolbar.
VSplit(
[
Window(
content=FormattedTextControl(
functools.partial(
pw_console.style.
get_pane_indicator, self,
' Python Results ')),
align=WindowAlign.LEFT,
dont_extend_width=True,
height=1,
),
],
style=functools.partial(
pw_console.style.get_toolbar_style, self),
),
]),
HSplit([
# 3. Repl Input
self.pw_ptpython_repl,
# 4. Bottom toolbar
self.bottom_toolbar,
]),
],
height=lambda: self.height,
width=lambda: self.width,
style=functools.partial(pw_console.style.get_pane_style,
self),
),
floats=[
# Transparent float container that will focus on this
# ReplPane when clicked.
pw_console.widgets.focus_on_click_overlay.create_overlay(
self),
]),
filter=Condition(lambda: self.show_pane))
def pane_title(self): # pylint: disable=no-self-use
return 'Python Repl'
def menu_title(self):
"""Return the title to display in the Window menu."""
return self.pane_title()
def pane_subtitle(self): # pylint: disable=no-self-use
return ''
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."""
# ptpython native bindings:
# return [load_python_bindings(self.pw_ptpython_repl)]
# Hand-crafted bindings for display in the HelpWindow:
return [{
'Execute code': ['Enter', 'Option-Enter', 'Meta-Enter'],
'Reverse search history': ['Ctrl-R'],
'Erase input buffer.': ['Ctrl-C'],
'Show settings.': ['F2'],
'Show history.': ['F3'],
}]
def get_all_menu_options(self):
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):
# Erase input buffer.
self.pw_ptpython_repl.default_buffer.reset()
# Clear any displayed function signatures.
self.pw_ptpython_repl.on_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()