| # 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. |
| """Help window container class.""" |
| |
| import functools |
| import inspect |
| import logging |
| from pathlib import Path |
| from typing import Dict, TYPE_CHECKING |
| |
| from prompt_toolkit.document import Document |
| from prompt_toolkit.filters import Condition |
| from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent |
| from prompt_toolkit.layout import ( |
| ConditionalContainer, |
| DynamicContainer, |
| FormattedTextControl, |
| HSplit, |
| VSplit, |
| Window, |
| WindowAlign, |
| ) |
| from prompt_toolkit.layout.dimension import Dimension |
| from prompt_toolkit.lexers import PygmentsLexer |
| from prompt_toolkit.widgets import Box, TextArea |
| |
| from pygments.lexers.markup import RstLexer # type: ignore |
| from pygments.lexers.data import YamlLexer # type: ignore |
| import pw_console.widgets.mouse_handlers |
| |
| if TYPE_CHECKING: |
| from pw_console.console_app import ConsoleApp |
| |
| _LOG = logging.getLogger(__package__) |
| |
| |
| def _longest_line_length(text): |
| """Return the longest line in the given text.""" |
| max_line_length = 0 |
| for line in text.splitlines(): |
| if len(line) > max_line_length: |
| max_line_length = len(line) |
| return max_line_length |
| |
| |
| class HelpWindow(ConditionalContainer): |
| """Help window container for displaying keybindings.""" |
| |
| # pylint: disable=too-many-instance-attributes |
| |
| def _create_help_text_area(self, **kwargs): |
| help_text_area = TextArea( |
| focusable=True, |
| focus_on_click=True, |
| scrollbar=True, |
| style='class:help_window_content', |
| wrap_lines=False, |
| **kwargs, |
| ) |
| |
| # Additional keybindings for the text area. |
| key_bindings = KeyBindings() |
| register = self.application.prefs.register_keybinding |
| |
| @register('help-window.close', key_bindings) |
| def _close_window(_event: KeyPressEvent) -> None: |
| """Close the current dialog window.""" |
| self.toggle_display() |
| |
| @register('help-window.copy-all', key_bindings) |
| def _copy_all(_event: KeyPressEvent) -> None: |
| """Close the current dialog window.""" |
| self.copy_all_text() |
| |
| help_text_area.control.key_bindings = key_bindings |
| return help_text_area |
| |
| def __init__(self, |
| application: 'ConsoleApp', |
| preamble: str = '', |
| additional_help_text: str = '', |
| title: str = '') -> None: |
| # Dict containing key = section title and value = list of key bindings. |
| self.application: 'ConsoleApp' = application |
| self.show_window: bool = False |
| self.help_text_sections: Dict[str, Dict] = {} |
| self._pane_title: str = title |
| |
| # Tracks the last focused container, to enable restoring focus after |
| # closing the dialog. |
| self.last_focused_pane = None |
| |
| # Generated keybinding text |
| self.preamble: str = preamble |
| self.additional_help_text: str = additional_help_text |
| self.help_text: str = '' |
| |
| self.max_additional_help_text_width: int = (_longest_line_length( |
| self.additional_help_text) if additional_help_text else 0) |
| self.max_description_width: int = 0 |
| self.max_key_list_width: int = 0 |
| self.max_line_length: int = 0 |
| |
| self.help_text_area: TextArea = self._create_help_text_area() |
| |
| close_mouse_handler = functools.partial( |
| pw_console.widgets.mouse_handlers.on_click, self.toggle_display) |
| copy_mouse_handler = functools.partial( |
| pw_console.widgets.mouse_handlers.on_click, self.copy_all_text) |
| |
| toolbar_padding = 1 |
| toolbar_title = ' ' * toolbar_padding |
| toolbar_title += self.pane_title() |
| |
| buttons = [] |
| buttons.extend( |
| pw_console.widgets.checkbox.to_keybind_indicator( |
| 'Ctrl-c', |
| 'Copy All', |
| copy_mouse_handler, |
| base_style='class:toolbar-button-active')) |
| buttons.append(('', ' ')) |
| buttons.extend( |
| pw_console.widgets.checkbox.to_keybind_indicator( |
| 'q', |
| 'Close', |
| close_mouse_handler, |
| base_style='class:toolbar-button-active')) |
| top_toolbar = VSplit( |
| [ |
| Window( |
| content=FormattedTextControl( |
| # [('', toolbar_title)] |
| functools.partial(pw_console.style.get_pane_indicator, |
| self, toolbar_title)), |
| align=WindowAlign.LEFT, |
| dont_extend_width=True, |
| ), |
| Window( |
| content=FormattedTextControl([]), |
| align=WindowAlign.LEFT, |
| dont_extend_width=False, |
| ), |
| Window( |
| content=FormattedTextControl(buttons), |
| align=WindowAlign.RIGHT, |
| dont_extend_width=True, |
| ), |
| ], |
| height=1, |
| style='class:toolbar_active', |
| ) |
| |
| self.container = HSplit([ |
| top_toolbar, |
| Box( |
| body=DynamicContainer(lambda: self.help_text_area), |
| padding=Dimension(preferred=1, max=1), |
| padding_bottom=0, |
| padding_top=0, |
| char=' ', |
| style='class:frame.border', # Same style used for Frame. |
| ), |
| ]) |
| |
| super().__init__( |
| self.container, |
| filter=Condition(lambda: self.show_window), |
| ) |
| |
| def pane_title(self): |
| return self._pane_title |
| |
| def menu_title(self): |
| """Return the title to display in the Window menu.""" |
| return self.pane_title() |
| |
| def __pt_container__(self): |
| """Return the prompt_toolkit container for displaying this HelpWindow. |
| |
| This allows self to be used wherever prompt_toolkit expects a container |
| object.""" |
| return self.container |
| |
| def copy_all_text(self): |
| """Copy all text in the Python input to the system clipboard.""" |
| self.application.application.clipboard.set_text( |
| self.help_text_area.buffer.text) |
| |
| def toggle_display(self): |
| """Toggle visibility of this help window.""" |
| # Toggle state variable. |
| self.show_window = not self.show_window |
| |
| if self.show_window: |
| # Save previous focus |
| self.last_focused_pane = self.application.focused_window() |
| # Set the help window in focus. |
| self.application.layout.focus(self.help_text_area) |
| else: |
| # Restore original focus if possible. |
| if self.last_focused_pane: |
| self.application.layout.focus(self.last_focused_pane) |
| else: |
| # Fallback to focusing on the first window pane. |
| self.application.focus_main_menu() |
| |
| def content_width(self) -> int: |
| """Return total width of help window.""" |
| # Widths of UI elements |
| frame_width = 1 |
| padding_width = 1 |
| left_side_frame_and_padding_width = frame_width + padding_width |
| right_side_frame_and_padding_width = frame_width + padding_width |
| scrollbar_padding = 1 |
| scrollbar_width = 1 |
| |
| desired_width = self.max_line_length + ( |
| left_side_frame_and_padding_width + |
| right_side_frame_and_padding_width + scrollbar_padding + |
| scrollbar_width) |
| desired_width = max(60, desired_width) |
| |
| window_manager_width = ( |
| self.application.window_manager.current_window_manager_width) |
| if not window_manager_width: |
| window_manager_width = 80 |
| return min(desired_width, window_manager_width) |
| |
| def load_user_guide(self): |
| rstdoc = Path(__file__).parent / 'docs/user_guide.rst' |
| max_line_length = 0 |
| rst_text = '' |
| with rstdoc.open() as rstfile: |
| for line in rstfile.readlines(): |
| if 'https://' not in line and len(line) > max_line_length: |
| max_line_length = len(line) |
| rst_text += line |
| self.max_line_length = max_line_length |
| |
| self.help_text_area = self._create_help_text_area( |
| lexer=PygmentsLexer(RstLexer), |
| text=rst_text, |
| ) |
| |
| def load_yaml_text(self, content: str): |
| max_line_length = 0 |
| for line in content.splitlines(): |
| if 'https://' not in line and len(line) > max_line_length: |
| max_line_length = len(line) |
| self.max_line_length = max_line_length |
| |
| self.help_text_area = self._create_help_text_area( |
| lexer=PygmentsLexer(YamlLexer), |
| text=content, |
| ) |
| |
| def generate_help_text(self): |
| """Generate help text based on added key bindings.""" |
| |
| template = self.application.get_template('keybind_list.jinja') |
| |
| self.help_text = template.render( |
| sections=self.help_text_sections, |
| max_additional_help_text_width=self.max_additional_help_text_width, |
| max_description_width=self.max_description_width, |
| max_key_list_width=self.max_key_list_width, |
| preamble=self.preamble, |
| additional_help_text=self.additional_help_text, |
| ) |
| |
| # Find the longest line in the rendered template. |
| self.max_line_length = _longest_line_length(self.help_text) |
| |
| # Replace the TextArea content. |
| self.help_text_area.buffer.document = Document(text=self.help_text, |
| cursor_position=0) |
| |
| return self.help_text |
| |
| def add_custom_keybinds_help_text(self, section_name, key_bindings: Dict): |
| """Add hand written key_bindings.""" |
| self.help_text_sections[section_name] = key_bindings |
| |
| def add_keybind_help_text(self, section_name, key_bindings: KeyBindings): |
| """Append formatted key binding text to this help window.""" |
| |
| # Create a new keybind section, erasing any old section with thesame |
| # title. |
| self.help_text_sections[section_name] = {} |
| |
| # Loop through passed in prompt_toolkit key_bindings. |
| for binding in key_bindings.bindings: |
| # Skip this keybind if the method name ends in _hidden. |
| if binding.handler.__name__.endswith('_hidden'): |
| continue |
| |
| # Get the key binding description from the function doctstring. |
| docstring = binding.handler.__doc__ |
| if not docstring: |
| docstring = '' |
| description = inspect.cleandoc(docstring) |
| description = description.replace('\n', ' ') |
| |
| # Save the length of the description. |
| if len(description) > self.max_description_width: |
| self.max_description_width = len(description) |
| |
| # Get the existing list of keys for this function or make a new one. |
| key_list = self.help_text_sections[section_name].get( |
| description, list()) |
| |
| # Save the name of the key e.g. F1, q, ControlQ, ControlUp |
| key_name = ' '.join( |
| [getattr(key, 'name', str(key)) for key in binding.keys]) |
| key_name = key_name.replace('Control', 'Ctrl-') |
| key_name = key_name.replace('Shift', 'Shift-') |
| key_name = key_name.replace('Escape ', 'Alt-') |
| key_name = key_name.replace('Alt-Ctrl-', 'Ctrl-Alt-') |
| key_name = key_name.replace('BackTab', 'Shift-Tab') |
| key_list.append(key_name) |
| |
| key_list_width = len(', '.join(key_list)) |
| # Save the length of the key list. |
| if key_list_width > self.max_key_list_width: |
| self.max_key_list_width = key_list_width |
| |
| # Update this functions key_list |
| self.help_text_sections[section_name][description] = key_list |