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