pw_console: HelpWindow for displaying keybindings No-Docs-Update-Reason: prompt_toolkit UI boilerplate Change-Id: I61cae3370390596cffe25e91640666457f0bfd2f Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/48802 Commit-Queue: Anthony DiGirolamo <tonymd@google.com> Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com> Reviewed-by: Keir Mierle <keir@google.com> Reviewed-by: Joe Ethier <jethier@google.com>
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn index e6b2848..d680a49 100644 --- a/pw_console/py/BUILD.gn +++ b/pw_console/py/BUILD.gn
@@ -22,10 +22,14 @@ "pw_console/__init__.py", "pw_console/__main__.py", "pw_console/console_app.py", + "pw_console/help_window.py", "pw_console/key_bindings.py", "pw_console/style.py", ] - tests = [ "console_app_test.py" ] + tests = [ + "console_app_test.py", + "help_window_test.py", + ] python_deps = [ "$dir_pw_cli/py", "$dir_pw_tokenizer/py",
diff --git a/pw_console/py/help_window_test.py b/pw_console/py/help_window_test.py new file mode 100644 index 0000000..f3298cf --- /dev/null +++ b/pw_console/py/help_window_test.py
@@ -0,0 +1,117 @@ +# 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. +"""Tests for pw_console.console_app""" + +import unittest +from inspect import cleandoc +from unittest.mock import Mock + +from prompt_toolkit.key_binding import KeyBindings + +from pw_console.console_app import ConsoleApp +from pw_console.help_window import HelpWindow, KEYBIND_TEMPLATE + + +class TestHelpWindow(unittest.TestCase): + """Tests for ConsoleApp.""" + def setUp(self): + self.maxDiff = None # pylint: disable=invalid-name + + def test_instantiate(self) -> None: + help_window = HelpWindow(ConsoleApp()) + self.assertIsNotNone(help_window) + + def test_template_loads(self) -> None: + self.assertIn('{%', KEYBIND_TEMPLATE) + + # pylint: disable=unused-variable,unused-argument + def test_add_keybind_help_text(self) -> None: + bindings = KeyBindings() + + @bindings.add('f1') + def show_help(event): + """Toggle help window.""" + + @bindings.add('c-w') + @bindings.add('c-q') + def exit_(event): + """Quit the application.""" + + app = Mock() + + help_window = HelpWindow(app) + help_window.add_keybind_help_text('Global', bindings) + + self.assertEqual( + help_window.help_text_sections, + { + 'Global': { + 'Quit the application.': ['ControlQ', 'ControlW'], + 'Toggle help window.': ['F1'], + } + }, + ) + + def test_generate_help_text(self) -> None: + """Test keybind list template generation.""" + global_bindings = KeyBindings() + + @global_bindings.add('f1') + def show_help(event): + """Toggle help window.""" + + @global_bindings.add('c-w') + @global_bindings.add('c-q') + def exit_(event): + """Quit the application.""" + + focus_bindings = KeyBindings() + + @focus_bindings.add('s-tab') + @focus_bindings.add('c-right') + @focus_bindings.add('c-down') + def app_focus_next(event): + """Move focus to the next widget.""" + + @focus_bindings.add('c-left') + @focus_bindings.add('c-up') + def app_focus_previous(event): + """Move focus to the previous widget.""" + + app = Mock() + + help_window = HelpWindow(app) + help_window.add_keybind_help_text('Global', global_bindings) + help_window.add_keybind_help_text('Focus', focus_bindings) + + help_text = help_window.generate_help_text() + + self.assertIn( + cleandoc(""" + Toggle help window. ----------------- F1 + Quit the application. --------------- ControlQ, ControlW + """), + help_text, + ) + self.assertIn( + cleandoc(""" + Move focus to the next widget. ------ BackTab, ControlDown, ControlRight + Move focus to the previous widget. -- ControlLeft, ControlUp + """), + help_text, + ) + + +if __name__ == '__main__': + unittest.main()
diff --git a/pw_console/py/pw_console/help_window.py b/pw_console/py/pw_console/help_window.py new file mode 100644 index 0000000..c9cfb3a --- /dev/null +++ b/pw_console/py/pw_console/help_window.py
@@ -0,0 +1,118 @@ +# 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 logging +import inspect +from functools import partial +from pathlib import Path + +from jinja2 import Template +from prompt_toolkit.filters import Condition +from prompt_toolkit.layout import ( + ConditionalContainer, + FormattedTextControl, + Window, +) +from prompt_toolkit.widgets import (Box, Frame) + +_LOG = logging.getLogger(__package__) + +HELP_TEMPLATE_PATH = Path(__file__).parent / "templates" / "keybind_list.jinja" +with HELP_TEMPLATE_PATH.open() as tmpl: + KEYBIND_TEMPLATE = tmpl.read() + + +class HelpWindow(ConditionalContainer): + """Help window container for displaying keybindings.""" + def get_tokens(self, application): + """Get all text for the help window.""" + help_window_content = ( + # Style + 'class:help_window_content', + # Text + self.help_text, + ) + + if application.show_help_window: + return [help_window_content] + return [] + + def __init__(self, application): + # Dict containing key = section title and value = list of key bindings. + self.help_text_sections = {} + self.max_description_width = 0 + # Generated keybinding text + self.help_text = '' + + help_text_window = Window( + FormattedTextControl(partial(self.get_tokens, application)), + style='class:help_window_content', + ) + + help_text_window_with_padding = Box( + body=help_text_window, + padding=1, + char=' ', + ) + + super().__init__( + # Add a text border frame. + Frame(body=help_text_window_with_padding), + filter=Condition(lambda: application.show_help_window), + ) + + def generate_help_text(self): + """Generate help text based on added key bindings.""" + + # pylint: disable=line-too-long + template = Template( + KEYBIND_TEMPLATE, + trim_blocks=True, + lstrip_blocks=True, + ) + + self.help_text = template.render( + sections=self.help_text_sections, + max_description_width=self.max_description_width, + ) + + return self.help_text + + def add_keybind_help_text(self, section_name, key_bindings): + """Append formatted key binding text to this help window.""" + + # Create a new keybind section + if section_name not in self.help_text_sections: + self.help_text_sections[section_name] = {} + + # Loop through passed in prompt_toolkit key_bindings. + for binding in key_bindings.bindings: + # Get the key binding description from the function doctstring. + description = inspect.cleandoc(binding.handler.__doc__) + + # 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 = getattr(binding.keys[0], 'name', str(binding.keys[0])) + key_list.append(key_name) + + # Update this functions key_list + self.help_text_sections[section_name][description] = key_list
diff --git a/pw_console/py/pw_console/templates/keybind_list.jinja b/pw_console/py/pw_console/templates/keybind_list.jinja new file mode 100644 index 0000000..2171076 --- /dev/null +++ b/pw_console/py/pw_console/templates/keybind_list.jinja
@@ -0,0 +1,23 @@ +{# +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. +#} +{% for section, key_dict in sections.items() %} +{{ section|center }} + +{% for description, key_list in key_dict.items() %} +{{ (description+' ').ljust(max_description_width+3, '-') }} {{ key_list|sort|join(', ') }} +{% endfor %} + +{% endfor %}
diff --git a/pw_console/py/setup.py b/pw_console/py/setup.py index 969b1f7..20d727d 100644 --- a/pw_console/py/setup.py +++ b/pw_console/py/setup.py
@@ -22,7 +22,12 @@ author_email='pigweed-developers@googlegroups.com', description='Pigweed interactive console', packages=setuptools.find_packages(), - package_data={'pw_console': ['py.typed']}, + package_data={ + 'pw_console': [ + 'py.typed', + 'templates/keybind_list.jinja', + ] + }, zip_safe=False, entry_points={ 'console_scripts': [
diff --git a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py index 27adade..4b73f97 100755 --- a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py +++ b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
@@ -332,7 +332,10 @@ COPYRIGHT_COMMENTS = r'(#|//| \*|REM|::)' COPYRIGHT_BLOCK_COMMENTS = ( # HTML comments - (r'<!--', r'-->'), ) + (r'<!--', r'-->'), + # Jinja comments + (r'{#', r'#}'), +) COPYRIGHT_FIRST_LINE_EXCEPTIONS = ( '#!',