pw_console: Focus on next window/tab key binding

- Add keybindings to focus on the next+previous window pane or
  tab. This loops through all visible windows.

  Ctrl-Alt-n for next
  Ctrl-Alt-p for previous

- Update user guide with new bindings plus some fixes.

- Add back in missing window management keys from the keyboard shortcut
  help window.

- Update keyboard shortuct listing for better readability. Alternate
  keys are shown on separate lines instead of on the same line with
  comma separation. For example:

Old:

Execute code --------------------- Enter, Meta-Enter, Option-Enter
Reverse search history ----------- Ctrl-R
Erase input buffer. -------------- Ctrl-C

New:

Execute code --------------------- Enter
                                   Meta-Enter
                                   Option-Enter
Reverse search history ----------- Ctrl-R
Erase input buffer. -------------- Ctrl-C

Change-Id: I79f9b101f848d234f2d90f4335548c95ae0f0a2f
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/78500
Reviewed-by: Keir Mierle <keir@google.com>
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
diff --git a/pw_console/py/help_window_test.py b/pw_console/py/help_window_test.py
index 0df8695..14caa73 100644
--- a/pw_console/py/help_window_test.py
+++ b/pw_console/py/help_window_test.py
@@ -118,10 +118,6 @@
 
         self.assertIn(
             inspect.cleandoc("""
-            Pigweed CLI v0.1
-
-            ================================ Help ===============================
-
             Welcome to the Pigweed Console!
             Please enjoy this extra help text.
             """),
@@ -129,27 +125,31 @@
         )
         self.assertIn(
             inspect.cleandoc("""
-            ============================ Global Keys ============================
+            ==== Global Keys ====
             """),
             help_window.help_text,
         )
         self.assertIn(
             inspect.cleandoc("""
             Toggle help window. -----------------  F1
-            Quit the application. ---------------  Ctrl-Q, Ctrl-W
+            Quit the application. ---------------  Ctrl-Q
+                                                   Ctrl-W
             """),
             help_window.help_text,
         )
         self.assertIn(
             inspect.cleandoc("""
-            ============================= Focus Keys ============================
+            ==== Focus Keys ====
             """),
             help_window.help_text,
         )
         self.assertIn(
             inspect.cleandoc("""
-            Move focus to the next widget. ------  BackTab, Ctrl-Down, Ctrl-Right
-            Move focus to the previous widget. --  Ctrl-Left, Ctrl-Up
+            Move focus to the next widget. ------  Ctrl-Down
+                                                   Ctrl-Right
+                                                   Shift-Tab
+            Move focus to the previous widget. --  Ctrl-Left
+                                                   Ctrl-Up
             """),
             help_window.help_text,
         )
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index 013822d..ab212f4 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -495,6 +495,14 @@
                 '[View]',
                 children=[
                     #         [Menu Item             ][Keybind  ]
+                    MenuItem('Focus Next Window/Tab   Ctrl-Alt-n',
+                             handler=self.window_manager.focus_next_pane),
+                    #         [Menu Item             ][Keybind  ]
+                    MenuItem('Focus Prev Window/Tab   Ctrl-Alt-p',
+                             handler=self.window_manager.focus_previous_pane),
+                    MenuItem('-'),
+
+                    #         [Menu Item             ][Keybind  ]
                     MenuItem('Move Window Up         Ctrl-Alt-Up',
                              handler=functools.partial(
                                  self.run_pane_menu_option,
@@ -675,6 +683,9 @@
         self.keybind_help_window.add_keybind_help_text('Global',
                                                        self.key_bindings)
 
+        self.keybind_help_window.add_keybind_help_text(
+            'Window Management', self.window_manager.key_bindings)
+
         # Add activated plugin key bindings to the help text.
         for pane in self.window_manager.active_panes():
             for key_bindings in pane.get_all_key_bindings():
diff --git a/pw_console/py/pw_console/docs/user_guide.rst b/pw_console/py/pw_console/docs/user_guide.rst
index c6d3223..d92b602 100644
--- a/pw_console/py/pw_console/docs/user_guide.rst
+++ b/pw_console/py/pw_console/docs/user_guide.rst
@@ -33,21 +33,21 @@
 
 ::
 
-  +-----------------------------------------------------+
-  | [File] [View] [Window] [Help]       Pigweed Console |
-  +=====================================================+
-  |                                                     |
-  |                                                     |
-  |                                                     |
-  | Log Window                                          |
-  +=====================================================+
-  |                                                     |
-  |                                                     |
-  | Python Results                                      |
-  +- - - - - - - - - - - - - - - - - - - - - - - - - - -+
-  |                                                     |
-  | Python Input                                        |
-  +-----------------------------------------------------+
+  +---------------------------------------------------------+
+  | [File] [Edit] [View] [Window] [Help]    Pigweed Console |
+  +=========================================================+
+  |                                                         |
+  |                                                         |
+  |                                                         |
+  | Log Window                                              |
+  +=========================================================+
+  |                                                         |
+  |                                                         |
+  | Python Results                                          |
+  +- - - - - - - - - - - - - - - - - - - - - - - - - - - - -+
+  |                                                         |
+  | Python Input                                            |
+  +---------------------------------------------------------+
 
 
 Navigation
@@ -64,10 +64,12 @@
 ============================================  =====================
 Function                                      Keys
 ============================================  =====================
-Move focus between all active UI elements     :kbd:`Shift-Tab`
+Switch focus to the next window or tab        :kbd:`Ctrl-Alt-n`
+Switch focus to the previous window or tab    :kbd:`Ctrl-Alt-p`
 
-Move focus between windows and the main menu  :kbd:`Ctrl-Up`
-                                              :kbd:`Ctrl-Down`
+Switch focus to the next UI element           :kbd:`Shift-Tab`
+                                              :kbd:`Ctrl-Right`
+Switch focus to the previous UI element       :kbd:`Ctrl-Left`
 
 Move selection in the main menu               :kbd:`Up`
                                               :kbd:`Down`
@@ -413,7 +415,9 @@
 under the :guilabel:`[Window]` menu.
 
 The active window can be moved and resized with the following keys. There are
-also menu options under :guilabel:`[View]` for the same actions.
+also menu options under :guilabel:`[View]` for the same actions. Additionally,
+windows can be resized with the mouse by click dragging on the :guilabel:`====`
+text on the far right side of any toolbar.
 
 ============================================  =====================
 Function                                      Keys
diff --git a/pw_console/py/pw_console/help_window.py b/pw_console/py/pw_console/help_window.py
index 0e05b69..13c64b5 100644
--- a/pw_console/py/pw_console/help_window.py
+++ b/pw_console/py/pw_console/help_window.py
@@ -279,6 +279,7 @@
             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('BackTab', 'Shift-Tab')
             key_list.append(key_name)
 
             key_list_width = len(', '.join(key_list))
diff --git a/pw_console/py/pw_console/templates/keybind_list.jinja b/pw_console/py/pw_console/templates/keybind_list.jinja
index 92acdb9..140d13b 100644
--- a/pw_console/py/pw_console/templates/keybind_list.jinja
+++ b/pw_console/py/pw_console/templates/keybind_list.jinja
@@ -14,6 +14,7 @@
 the License.
 #}
 {% set total_width = [max_description_width + max_key_list_width + 5, max_additional_help_text_width]|max %}
+{% set key_whitespace_indentation = ' ' * (max_description_width + 5) %}
 {% if preamble %}
 {{ preamble }}
 
@@ -30,7 +31,7 @@
 {{ ' {} Keys '.format(section).center(total_width, '=') }}
 
 {% for description, key_list in key_dict.items() %}
-{{ (description+' ').ljust(max_description_width+3, '-') }}  {{ key_list|sort|join(', ') }}
+{{ (description+' ').ljust(max_description_width + 3, '-') }}  {{ key_list|sort|join('\n' + key_whitespace_indentation) }}
 {% endfor %}
 
 
diff --git a/pw_console/py/pw_console/widgets/window_pane.py b/pw_console/py/pw_console/widgets/window_pane.py
index cf562fe..e6326cb 100644
--- a/pw_console/py/pw_console/widgets/window_pane.py
+++ b/pw_console/py/pw_console/widgets/window_pane.py
@@ -101,6 +101,14 @@
         self.last_pane_width = 0
         self.last_pane_height = 0
 
+    def __repr__(self) -> str:
+        """Create a repr with this pane's title and subtitle."""
+        repr_str = f'{type(self).__qualname__}(pane_title="{self.pane_title()}"'
+        if self.pane_subtitle():
+            repr_str += f', pane_subtitle="{self.pane_subtitle()}"'
+        repr_str += ')'
+        return repr_str
+
     def pane_title(self) -> str:
         return self._pane_title
 
diff --git a/pw_console/py/pw_console/window_list.py b/pw_console/py/pw_console/window_list.py
index ca83a3a..a38eb9a 100644
--- a/pw_console/py/pw_console/window_list.py
+++ b/pw_console/py/pw_console/window_list.py
@@ -249,8 +249,8 @@
     def switch_to_tab(self, index: int):
         self.focused_pane_index = index
 
+        # refresh_ui() will focus on the new tab container.
         self.refresh_ui()
-        self.application.focus_on_container(self.active_panes[index])
 
     def set_display_mode(self, mode: DisplayMode):
         self.display_mode = mode
diff --git a/pw_console/py/pw_console/window_manager.py b/pw_console/py/pw_console/window_manager.py
index b128f74..091e857 100644
--- a/pw_console/py/pw_console/window_manager.py
+++ b/pw_console/py/pw_console/window_manager.py
@@ -105,6 +105,16 @@
             """Enlarge the current window split."""
             self.enlarge_split()
 
+        @bindings.add('escape', 'c-p')  # Ctrl-Alt-p
+        def focus_prev_pane(_event):
+            """Switch focus to the previous window pane or tab."""
+            self.focus_previous_pane()
+
+        @bindings.add('escape', 'c-n')  # Ctrl-Alt-n
+        def focus_next_pane(_event):
+            """Switch focus to the next window pane or tab."""
+            self.focus_next_pane()
+
         @bindings.add('c-u')
         def balance_window_panes(_event):
             """Balance all window sizes."""
@@ -193,6 +203,74 @@
         method_to_call()
         return
 
+    def focus_previous_pane(self) -> None:
+        """Focus on the previous visible window pane or tab."""
+        self.focus_next_pane(reverse_order=True)
+
+    def focus_next_pane(self, reverse_order=False) -> None:
+        """Focus on the next visible window pane or tab."""
+        active_window_list, active_pane = (
+            self._get_active_window_list_and_pane())
+        if not active_window_list:
+            return
+
+        # Total count of window lists and panes
+        window_list_count = len(self.window_lists)
+        pane_count = len(active_window_list.active_panes)
+
+        # Get currently focused indices
+        active_window_list_index = self.window_list_index(active_window_list)
+        active_pane_index = active_window_list.pane_index(active_pane)
+
+        increment = -1 if reverse_order else 1
+        # Assume we can switch to the next pane in the current window_list
+        next_pane_index = active_pane_index + increment
+
+        # Case 1: next_pane_index does not exist in this window list.
+        # Action: Switch to the first pane of the next window list.
+        if next_pane_index >= pane_count or next_pane_index < 0:
+            # Get the next window_list
+            next_window_list_index = ((active_window_list_index + increment) %
+                                      window_list_count)
+            next_window_list = self.window_lists[next_window_list_index]
+
+            # If tabbed window mode is enabled, switch to the first tab.
+            if next_window_list.display_mode == DisplayMode.TABBED:
+                if reverse_order:
+                    next_window_list.switch_to_tab(
+                        len(next_window_list.active_panes) - 1)
+                else:
+                    next_window_list.switch_to_tab(0)
+                return
+
+            # Otherwise switch to the first visible window pane.
+            pane_list = next_window_list.active_panes
+            if reverse_order:
+                pane_list = reversed(pane_list)
+            for pane in pane_list:
+                if pane.show_pane:
+                    self.application.focus_on_container(pane)
+                    return
+
+        # Case 2: next_pane_index does exist and display mode is tabs.
+        # Action: Switch to the next tab of the current window list.
+        if active_window_list.display_mode == DisplayMode.TABBED:
+            active_window_list.switch_to_tab(next_pane_index)
+            return
+
+        # Case 3: next_pane_index does exist and display mode is stacked.
+        # Action: Switch to the next visible window pane.
+        index_range = range(1, pane_count)
+        if reverse_order:
+            index_range = range(pane_count - 1, 0, -1)
+        for i in index_range:
+            next_pane_index = (active_pane_index + i) % pane_count
+            next_pane = active_window_list.active_panes[next_pane_index]
+            if next_pane.show_pane:
+                self.application.focus_on_container(next_pane)
+                return
+        return
+
     def move_pane_left(self):
         active_window_list, active_pane = (
             self._get_active_window_list_and_pane())
diff --git a/pw_console/py/window_manager_test.py b/pw_console/py/window_manager_test.py
index 3ccb0b0..7fd76c8 100644
--- a/pw_console/py/window_manager_test.py
+++ b/pw_console/py/window_manager_test.py
@@ -24,11 +24,12 @@
 
 from pw_console.console_app import ConsoleApp
 from pw_console.window_manager import _WINDOW_SPLIT_ADJUST
-from pw_console.window_list import _WINDOW_HEIGHT_ADJUST
+from pw_console.window_list import _WINDOW_HEIGHT_ADJUST, DisplayMode
 
 
 def _create_console_app(logger_count=2):
     console_app = ConsoleApp(color_depth=ColorDepth.DEPTH_8_BIT)
+    console_app.focus_on_container = MagicMock()
 
     loggers = {}
     for i in range(logger_count):
@@ -412,6 +413,193 @@
                 ],
             )
 
+    def test_focus_next_and_previous_pane(self) -> None:
+        """Test getting the window list for a given pane."""
+        with create_app_session(output=FakeOutput()):
+            console_app = _create_console_app(logger_count=4)
+
+            window_manager = console_app.window_manager
+            self.assertEqual(
+                _window_pane_titles(window_manager),
+                [
+                    [
+                        'Log3 - test_log3',
+                        'Log2 - test_log2',
+                        'Log1 - test_log1',
+                        'Log0 - test_log0',
+                        'Python Repl - ',
+                    ],
+                ],
+            )
+
+            # Scenario: Move between panes with a single stacked window list.
+
+            # Set the first pane in focus.
+            _target_list_and_pane(window_manager, 0, 0)
+            # Switch focus to the next pane
+            window_manager.focus_next_pane()
+            # Pane index 1 should now be focused.
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[0].active_panes[1])
+            console_app.focus_on_container.reset_mock()
+
+            # Set the first pane in focus.
+            _target_list_and_pane(window_manager, 0, 0)
+            # Switch focus to the previous pane
+            window_manager.focus_previous_pane()
+            # Previous pane should wrap around to the last pane in the first
+            # window_list.
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[0].active_panes[-1])
+            console_app.focus_on_container.reset_mock()
+
+            # Set the last pane in focus.
+            _target_list_and_pane(window_manager, 0, 4)
+            # Switch focus to the next pane
+            window_manager.focus_next_pane()
+            # Next pane should wrap around to the first pane in the first
+            # window_list.
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[0].active_panes[0])
+            console_app.focus_on_container.reset_mock()
+
+            # Scenario: Move between panes with a single tabbed window list.
+
+            # Switch to Tabbed view mode
+            window_manager.window_lists[0].set_display_mode(DisplayMode.TABBED)
+            # The set_display_mode call above will call focus_on_container once.
+            console_app.focus_on_container.reset_mock()
+
+            # Setup the switch_to_tab mock
+            window_manager.window_lists[0].switch_to_tab = MagicMock(
+                wraps=window_manager.window_lists[0].switch_to_tab)
+
+            # Set the first pane/tab in focus.
+            _target_list_and_pane(window_manager, 0, 0)
+            # Switch focus to the next pane/tab
+            window_manager.focus_next_pane()
+            # Check switch_to_tab is called
+            window_manager.window_lists[
+                0].switch_to_tab.assert_called_once_with(1)
+            # And that focus_on_container is called only once
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[0].active_panes[1])
+            console_app.focus_on_container.reset_mock()
+            window_manager.window_lists[0].switch_to_tab.reset_mock()
+
+            # Set the last pane/tab in focus.
+            _target_list_and_pane(window_manager, 0, 4)
+            # Switch focus to the next pane/tab
+            window_manager.focus_next_pane()
+            # Check switch_to_tab is called
+            window_manager.window_lists[
+                0].switch_to_tab.assert_called_once_with(0)
+            # And that focus_on_container is called only once
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[0].active_panes[0])
+            console_app.focus_on_container.reset_mock()
+            window_manager.window_lists[0].switch_to_tab.reset_mock()
+
+            # Set the first pane/tab in focus.
+            _target_list_and_pane(window_manager, 0, 0)
+            # Switch focus to the prev pane/tab
+            window_manager.focus_previous_pane()
+            # Check switch_to_tab is called
+            window_manager.window_lists[
+                0].switch_to_tab.assert_called_once_with(4)
+            # And that focus_on_container is called only once
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[0].active_panes[4])
+            console_app.focus_on_container.reset_mock()
+            window_manager.window_lists[0].switch_to_tab.reset_mock()
+
+            # Scenario: Move between multiple window lists with mixed stacked
+            # and tabbed view modes.
+
+            # Setup: Move two panes to the right into their own stacked
+            # window_list.
+            _target_list_and_pane(window_manager, 0, 4)
+            window_manager.move_pane_right()
+            _target_list_and_pane(window_manager, 0, 3)
+            window_manager.move_pane_right()
+            self.assertEqual(
+                _window_pane_titles(window_manager),
+                [
+                    [
+                        'Log3 - test_log3',
+                        'Log2 - test_log2',
+                        'Log1 - test_log1',
+                    ],
+                    [
+                        'Log0 - test_log0',
+                        'Python Repl - ',
+                    ],
+                ],
+            )
+
+            # Setup the switch_to_tab mock on the second window_list
+            window_manager.window_lists[1].switch_to_tab = MagicMock(
+                wraps=window_manager.window_lists[1].switch_to_tab)
+
+            # Set Log1 in focus
+            _target_list_and_pane(window_manager, 0, 2)
+            window_manager.focus_next_pane()
+            # Log0 should now have focus
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[1].active_panes[0])
+            console_app.focus_on_container.reset_mock()
+
+            # Set Log0 in focus
+            _target_list_and_pane(window_manager, 1, 0)
+            window_manager.focus_previous_pane()
+            # Log1 should now have focus
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[0].active_panes[2])
+            # The first window list is in tabbed mode so switch_to_tab should be
+            # called once.
+            window_manager.window_lists[
+                0].switch_to_tab.assert_called_once_with(2)
+            # Reset
+            window_manager.window_lists[0].switch_to_tab.reset_mock()
+            console_app.focus_on_container.reset_mock()
+
+            # Set Python Repl in focus
+            _target_list_and_pane(window_manager, 1, 1)
+            window_manager.focus_next_pane()
+            # Log3 should now have focus
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[0].active_panes[0])
+            window_manager.window_lists[
+                0].switch_to_tab.assert_called_once_with(0)
+            # Reset
+            window_manager.window_lists[0].switch_to_tab.reset_mock()
+            console_app.focus_on_container.reset_mock()
+
+            # Set Log3 in focus
+            _target_list_and_pane(window_manager, 0, 0)
+            window_manager.focus_next_pane()
+            # Log2 should now have focus
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[0].active_panes[1])
+            window_manager.window_lists[
+                0].switch_to_tab.assert_called_once_with(1)
+            # Reset
+            window_manager.window_lists[0].switch_to_tab.reset_mock()
+            console_app.focus_on_container.reset_mock()
+
+            # Set Python Repl in focus
+            _target_list_and_pane(window_manager, 1, 1)
+            window_manager.focus_previous_pane()
+            # Log0 should now have focus
+            console_app.focus_on_container.assert_called_once_with(
+                window_manager.window_lists[1].active_panes[0])
+            # The second window list is in stacked mode so switch_to_tab should
+            # not be called.
+            window_manager.window_lists[1].switch_to_tab.assert_not_called()
+            # Reset
+            window_manager.window_lists[1].switch_to_tab.reset_mock()
+            console_app.focus_on_container.reset_mock()
+
 
 if __name__ == '__main__':
     unittest.main()