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 = (
'#!',