pw_console: WindowManager class

Refactor window management functions out of ConsoleApp.
This CL is just a move of the existing fuctions, no new
features or additional refactoring.

Test: Manual Window Management 1-9
Change-Id: I8f133e0c7d18921b54b88c6e8d2e51d195f41829
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/54960
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn
index b971a29..c1c0372 100644
--- a/pw_console/py/BUILD.gn
+++ b/pw_console/py/BUILD.gn
@@ -43,6 +43,7 @@
     "pw_console/widgets/focus_on_click_overlay.py",
     "pw_console/widgets/mouse_handlers.py",
     "pw_console/widgets/table.py",
+    "pw_console/window_manager.py",
   ]
   tests = [
     "console_app_test.py",
diff --git a/pw_console/py/console_app_test.py b/pw_console/py/console_app_test.py
index 7c4384b..2a76853 100644
--- a/pw_console/py/console_app_test.py
+++ b/pw_console/py/console_app_test.py
@@ -48,40 +48,47 @@
                 console_app.add_log_handler(window_title, logger_instances)
 
             # 4 panes, 3 for the loggers and 1 for the repl.
-            self.assertEqual(len(console_app.active_panes), 4)
+            self.assertEqual(len(console_app.window_manager.active_panes), 4)
 
             # Bypass prompt_toolkit has_focus()
-            console_app._get_current_active_pane = MagicMock(  # type: ignore
-                return_value=console_app.active_panes[0])
+            console_app.window_manager._get_current_active_pane = (
+                MagicMock(  # type: ignore
+                    return_value=console_app.window_manager.active_panes[0]))
 
             # Shrink the first pane
-            console_app.shrink_pane()
-            self.assertEqual(
-                [pane.height.weight for pane in console_app.active_panes],
-                [48, 52, 50, 50])
+            console_app.window_manager.shrink_pane()
+            self.assertEqual([
+                pane.height.weight
+                for pane in console_app.window_manager.active_panes
+            ], [48, 52, 50, 50])
 
             # Reset pane sizes
-            console_app.reset_pane_sizes()
-            self.assertEqual(
-                [pane.height.weight for pane in console_app.active_panes],
-                [50, 50, 50, 50])
+            console_app.window_manager.reset_pane_sizes()
+            self.assertEqual([
+                pane.height.weight
+                for pane in console_app.window_manager.active_panes
+            ], [50, 50, 50, 50])
 
             # Shrink last pane
-            console_app._get_current_active_pane = MagicMock(  # type: ignore
-                return_value=console_app.active_panes[3])
-            console_app.shrink_pane()
-            self.assertEqual(
-                [pane.height.weight for pane in console_app.active_panes],
-                [50, 50, 52, 48])
+            console_app.window_manager._get_current_active_pane = (
+                MagicMock(  # type: ignore
+                    return_value=console_app.window_manager.active_panes[3]))
+            console_app.window_manager.shrink_pane()
+            self.assertEqual([
+                pane.height.weight
+                for pane in console_app.window_manager.active_panes
+            ], [50, 50, 52, 48])
 
             # Enlarge second pane
-            console_app._get_current_active_pane = MagicMock(  # type: ignore
-                return_value=console_app.active_panes[1])
-            console_app.enlarge_pane()
-            console_app.enlarge_pane()
-            self.assertEqual(
-                [pane.height.weight for pane in console_app.active_panes],
-                [50, 54, 48, 48])
+            console_app.window_manager._get_current_active_pane = (
+                MagicMock(  # type: ignore
+                    return_value=console_app.window_manager.active_panes[1]))
+            console_app.window_manager.enlarge_pane()
+            console_app.window_manager.enlarge_pane()
+            self.assertEqual([
+                pane.height.weight
+                for pane in console_app.window_manager.active_panes
+            ], [50, 54, 48, 48])
 
     def test_multiple_loggers_in_one_pane(self) -> None:
         """Test window resizing."""
@@ -100,13 +107,17 @@
                 console_app.add_log_handler(window_title, logger_instances)
 
             # Two panes, one for the loggers and one for the repl.
-            self.assertEqual(len(console_app.active_panes), 2)
+            self.assertEqual(len(console_app.window_manager.active_panes), 2)
 
-            self.assertEqual(console_app.active_panes[0].pane_title(), 'Logs')
-            self.assertEqual(console_app.active_panes[0]._pane_subtitle,
-                             'test_log1, test_log2, test_log3')
-            self.assertEqual(console_app.active_panes[0].pane_subtitle(),
-                             'test_log1 + 3 more')
+            self.assertEqual(
+                console_app.window_manager.active_panes[0].pane_title(),
+                'Logs')
+            self.assertEqual(
+                console_app.window_manager.active_panes[0]._pane_subtitle,
+                'test_log1, test_log2, test_log3')
+            self.assertEqual(
+                console_app.window_manager.active_panes[0].pane_subtitle(),
+                'test_log1 + 3 more')
 
 
 if __name__ == '__main__':
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index abf6073..16a9c2f 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -13,8 +13,6 @@
 # the License.
 """ConsoleApp control class."""
 
-import collections
-import collections.abc
 import builtins
 import asyncio
 import logging
@@ -25,18 +23,15 @@
 
 from prompt_toolkit.layout.menus import CompletionsMenu
 from prompt_toolkit.application import Application
-from prompt_toolkit.filters import Condition, has_focus
+from prompt_toolkit.filters import Condition
 from prompt_toolkit.styles import (
     DynamicStyle,
     merge_styles,
 )
 from prompt_toolkit.layout import (
     ConditionalContainer,
-    Dimension,
     Float,
-    HSplit,
     Layout,
-    VSplit,
 )
 from prompt_toolkit.widgets import FormattedTextToolbar
 from prompt_toolkit.widgets import (
@@ -61,6 +56,7 @@
 from pw_console.log_pane import LogPane
 from pw_console.pw_ptpython_repl import PwPtPythonRepl
 from pw_console.repl_pane import ReplPane
+from pw_console.window_manager import WindowManager
 
 _LOG = logging.getLogger(__package__)
 
@@ -98,10 +94,6 @@
         logger_instance.name)
 
 
-# Weighted amount for adjusting window dimensions when enlarging and shrinking.
-_WINDOW_SIZE_ADJUST = 2
-
-
 class ConsoleApp:
     """The main ConsoleApp class that glues everything together."""
 
@@ -153,7 +145,6 @@
                 'Ctrl-W', 'Quit '))
 
         # Top level UI state toggles.
-        self.vertical_split = False
         self.load_theme()
 
         # Pigweed upstream RST user guide
@@ -187,13 +178,9 @@
             startup_message=repl_startup_message,
         )
 
-        # List of enabled panes.
-        self.active_panes: collections.deque = collections.deque()
-        self.active_panes.append(self.repl_pane)
-
-        # Reference to the current prompt_toolkit window split for the current
-        # set of active_panes.
-        self.active_pane_split = None
+        # Window panes are added via the window_manager
+        self.window_manager = WindowManager(self)
+        self.window_manager.add_window(self.repl_pane)
 
         # Top of screen menu items
         self.menu_items = self._create_menu_items()
@@ -206,7 +193,7 @@
 
         # prompt_toolkit root container.
         self.root_container = MenuContainer(
-            body=self._create_root_split(),
+            body=self.window_manager.create_root_split(),
             menu_items=self.menu_items,
             floats=[
                 # Top message bar
@@ -321,11 +308,13 @@
             MenuItem(
                 '[View]',
                 children=[
-                    MenuItem('{check} Vertical Window Spliting'.format(
-                        check=pw_console.widgets.checkbox.to_checkbox_text(
-                            self.vertical_split)),
-                             handler=self.toggle_vertical_split),
-                    MenuItem('Rotate Window Order', handler=self.rotate_panes),
+                    MenuItem(
+                        '{check} Vertical Window Spliting'.format(
+                            check=pw_console.widgets.checkbox.to_checkbox_text(
+                                self.window_manager.vertical_split)),
+                        handler=self.window_manager.toggle_vertical_split),
+                    MenuItem('Rotate Window Order',
+                             handler=self.window_manager.rotate_panes),
                     MenuItem('-'),
                     MenuItem(
                         'Themes',
@@ -393,7 +382,7 @@
                                     check=pw_console.widgets.checkbox.
                                     to_checkbox_text(pane.show_pane, end='')),
                                 handler=functools.partial(
-                                    self.toggle_pane, pane),
+                                    self.window_manager.toggle_pane, pane),
                             ),
                         ] + [
                             MenuItem(text,
@@ -401,7 +390,8 @@
                                          self._run_pane_menu_option, handler))
                             for text, handler in pane.get_all_menu_options()
                         ],
-                    ) for index, pane in enumerate(self.active_panes)
+                    ) for index, pane in enumerate(
+                        self.window_manager.active_panes)
                 ],
             )
         ]
@@ -430,142 +420,6 @@
 
         return file_and_view_menu + window_menu + help_menu
 
-    def _get_current_active_pane(self):
-        """Return the current active window pane."""
-        focused_pane = None
-        for pane in self.active_panes:
-            if has_focus(pane)():
-                focused_pane = pane
-                break
-        return focused_pane
-
-    def add_pane(self, new_pane, existing_pane=None):
-        existing_pane_index = None
-        if existing_pane:
-            try:
-                existing_pane_index = self.active_panes.index(existing_pane)
-            except ValueError:
-                # Ignore ValueError which can be raised by the self.active_panes
-                # deque if existing_pane can't be found.
-                pass
-        if existing_pane_index:
-            self.active_panes.insert(new_pane, existing_pane_index + 1)
-        else:
-            self.active_panes.append(new_pane)
-
-        self.update_menu_items()
-        self._update_root_container_body()
-
-        self.redraw_ui()
-
-    def remove_pane(self, existing_pane):
-        existing_pane_index = 0
-        if not existing_pane:
-            return
-        try:
-            existing_pane_index = self.active_panes.index(existing_pane)
-            self.active_panes.remove(existing_pane)
-        except ValueError:
-            # Ignore ValueError which can be raised by the self.active_panes
-            # deque if existing_pane can't be found.
-            pass
-
-        self.update_menu_items()
-        self._update_root_container_body()
-        if len(self.active_panes) > 0:
-            existing_pane_index -= 1
-            try:
-                self.focus_on_container(self.active_panes[existing_pane_index])
-            except ValueError:
-                # ValueError will be raised if the the pane at
-                # existing_pane_index can't be accessed.
-                # Focus on the main menu if the existing pane is hidden.
-                self.focus_main_menu()
-
-        self.redraw_ui()
-
-    def enlarge_pane(self):
-        """Enlarge the currently focused window pane."""
-        pane = self._get_current_active_pane()
-        if pane:
-            self.adjust_pane_size(pane, _WINDOW_SIZE_ADJUST)
-
-    def shrink_pane(self):
-        """Shrink the currently focused window pane."""
-        pane = self._get_current_active_pane()
-        if pane:
-            self.adjust_pane_size(pane, -_WINDOW_SIZE_ADJUST)
-
-    def adjust_pane_size(self, pane, diff: int = _WINDOW_SIZE_ADJUST):
-        """Increase or decrease a given pane's width or height weight."""
-        # Placeholder next_pane value to allow setting width and height without
-        # any consequences if there is no next visible pane.
-        next_pane = HSplit([],
-                           height=Dimension(weight=50),
-                           width=Dimension(weight=50))  # type: ignore
-        # Try to get the next visible pane to subtract a weight value from.
-        next_visible_pane = self._get_next_visible_pane_after(pane)
-        if next_visible_pane:
-            next_pane = next_visible_pane
-
-        # If the last pane is selected, and there are at least 2 panes, make
-        # next_pane the previous pane.
-        try:
-            if len(self.active_panes) >= 2 and (self.active_panes.index(pane)
-                                                == len(self.active_panes) - 1):
-                next_pane = self.active_panes[-2]
-        except ValueError:
-            # Ignore ValueError raised if self.active_panes[-2] doesn't exist.
-            pass
-
-        # Get current weight values
-        if self.vertical_split:
-            old_weight = pane.width.weight
-            next_old_weight = next_pane.width.weight  # type: ignore
-        else:  # Horizontal split
-            old_weight = pane.height.weight
-            next_old_weight = next_pane.height.weight  # type: ignore
-
-        # Add to the current pane
-        new_weight = old_weight + diff
-        if new_weight <= 0:
-            new_weight = old_weight
-
-        # Subtract from the next pane
-        next_new_weight = next_old_weight - diff
-        if next_new_weight <= 0:
-            next_new_weight = next_old_weight
-
-        # Set new weight values
-        if self.vertical_split:
-            pane.width.weight = new_weight
-            next_pane.width.weight = next_new_weight  # type: ignore
-        else:  # Horizontal split
-            pane.height.weight = new_weight
-            next_pane.height.weight = next_new_weight  # type: ignore
-
-    def reset_pane_sizes(self):
-        """Reset all active pane width and height to 50%"""
-        for pane in self.active_panes:
-            pane.height = Dimension(weight=50)
-            pane.width = Dimension(weight=50)
-
-    def rotate_panes(self, steps=1):
-        """Rotate the order of all active window panes."""
-        self.active_panes.rotate(steps)
-        self.update_menu_items()
-        self._update_root_container_body()
-
-    def toggle_pane(self, pane):
-        """Toggle a pane on or off."""
-        pane.show_pane = not pane.show_pane
-        self.update_menu_items()
-        self._update_root_container_body()
-
-        # Set focus to the top level menu. This has the effect of keeping the
-        # menu open if it's already open.
-        self.focus_main_menu()
-
     def focus_main_menu(self):
         """Set application focus to the main menu."""
         self.application.layout.focus(self.root_container.window)
@@ -574,30 +428,6 @@
         """Set application focus to a specific container."""
         self.application.layout.focus(pane)
 
-    def _get_next_visible_pane_after(self, target_pane):
-        """Return the next visible pane that appears after the target pane."""
-        try:
-            target_pane_index = self.active_panes.index(target_pane)
-        except ValueError:
-            # If pane can't be found, focus on the main menu.
-            return None
-
-        # Loop through active panes (not including the target_pane).
-        for i in range(1, len(self.active_panes)):
-            next_pane_index = (target_pane_index + i) % len(self.active_panes)
-            next_pane = self.active_panes[next_pane_index]
-            if next_pane.show_pane:
-                return next_pane
-        return None
-
-    def focus_next_visible_pane(self, pane):
-        """Focus on the next visible window pane if possible."""
-        next_visible_pane = self._get_next_visible_pane_after(pane)
-        if next_visible_pane:
-            self.application.layout.focus(next_visible_pane)
-            return
-        self.focus_main_menu()
-
     def toggle_light_theme(self):
         """Toggle light and dark theme colors."""
         # Use ptpython's style_transformation to swap dark and light colors.
@@ -610,9 +440,9 @@
 
     def _create_log_pane(self, title=None) -> 'LogPane':
         # Create one log pane.
-        self.active_panes.appendleft(
-            LogPane(application=self, pane_title=title))
-        return self.active_panes[0]
+        log_pane = LogPane(application=self, pane_title=title)
+        self.window_manager.add_pane(log_pane, add_at_beginning=True)
+        return log_pane
 
     def add_log_handler(self,
                         window_title: str,
@@ -622,7 +452,7 @@
 
         existing_log_pane = None
         # Find an existing LogPane with the same window_title.
-        for pane in self.active_panes:
+        for pane in self.window_manager.active_panes:
             if isinstance(pane, LogPane) and pane.pane_title() == window_title:
                 existing_log_pane = pane
                 break
@@ -633,7 +463,7 @@
         for logger in logger_instances:
             _add_log_handler_to_pane(logger, existing_log_pane)
 
-        self._update_root_container_body()
+        self.window_manager.update_root_container_body()
         self.update_menu_items()
         self._update_help_window()
 
@@ -644,7 +474,7 @@
 
     def run_after_render_hooks(self, *unused_args, **unused_kwargs):
         """Run each active pane's `after_render_hook` if defined."""
-        for pane in self.active_panes:
+        for pane in self.window_manager.active_panes:
             if hasattr(pane, 'after_render_hook'):
                 pane.after_render_hook()
 
@@ -671,7 +501,7 @@
                                                        self.key_bindings)
 
         # Add activated plugin key bindings to the help text.
-        for pane in self.active_panes:
+        for pane in self.window_manager.active_panes:
             for key_bindings in pane.get_all_key_bindings():
                 help_section_title = pane.__class__.__name__
                 if isinstance(key_bindings, KeyBindings):
@@ -683,46 +513,12 @@
 
         self.keybind_help_window.generate_help_text()
 
-    def _update_split_orientation(self):
-        if self.vertical_split:
-            self.active_pane_split = VSplit(
-                list(pane for pane in self.active_panes if pane.show_pane),
-                # Add a vertical separator between each active window pane.
-                padding=1,
-                padding_char='│',
-                padding_style='class:pane_separator',
-            )
-        else:
-            self.active_pane_split = HSplit(self.active_panes)
-
-    def _create_root_split(self):
-        """Create a vertical or horizontal split container for all active
-        panes."""
-        self._update_split_orientation()
-        return HSplit([
-            self.active_pane_split,
-        ])
-
-    def _update_root_container_body(self):
-        # Replace the root MenuContainer body with the new split.
-        self.root_container.container.content.children[
-            1] = self._create_root_split()
-
     def toggle_log_line_wrapping(self):
         """Menu item handler to toggle line wrapping of all log panes."""
-        for pane in self.active_panes:
+        for pane in self.window_manager.active_panes:
             if isinstance(pane, LogPane):
                 pane.toggle_wrap_lines()
 
-    def toggle_vertical_split(self):
-        """Toggle visibility of the help window."""
-        self.vertical_split = not self.vertical_split
-
-        self.update_menu_items()
-        self._update_root_container_body()
-
-        self.redraw_ui()
-
     def focused_window(self):
         """Return the currently focused window."""
         return self.application.layout.current_window
@@ -748,7 +544,7 @@
 
     async def run(self, test_mode=False):
         """Start the prompt_toolkit UI."""
-        self.reset_pane_sizes()
+        self.window_manager.reset_pane_sizes()
 
         if test_mode:
             background_log_task = asyncio.create_task(self.log_forever())
diff --git a/pw_console/py/pw_console/key_bindings.py b/pw_console/py/pw_console/key_bindings.py
index f141920..a7a4de6 100644
--- a/pw_console/py/pw_console/key_bindings.py
+++ b/pw_console/py/pw_console/key_bindings.py
@@ -51,32 +51,32 @@
     @bindings.add('f4')
     def toggle_vertical_split(event):
         """Toggle horizontal and vertical window splitting."""
-        console_app.toggle_vertical_split()
+        console_app.window_manager.toggle_vertical_split()
 
     @bindings.add('c-s-left')
     def rotate_panes(event):
         """Rotate window positions backward."""
-        console_app.rotate_panes(-1)
+        console_app.window_manager.rotate_panes(-1)
 
     @bindings.add('c-s-right')
     def rotate_panes(event):
         """Rotate window positions forward."""
-        console_app.rotate_panes()
+        console_app.window_manager.rotate_panes()
 
     @bindings.add('c-j')
     def enlarge_pane(event):
         """Enlarge the active window pane."""
-        console_app.enlarge_pane()
+        console_app.window_manager.enlarge_pane()
 
     @bindings.add('c-k')
     def shrink_pane(event):
         """Shrink the active window pane."""
-        console_app.shrink_pane()
+        console_app.window_manager.shrink_pane()
 
     @bindings.add('c-u')
     def balance_window_panes(event):
         """Balance all window pane sizes."""
-        console_app.reset_pane_sizes()
+        console_app.window_manager.reset_pane_sizes()
 
     @bindings.add('c-w')
     @bindings.add('c-q')
diff --git a/pw_console/py/pw_console/log_pane.py b/pw_console/py/pw_console/log_pane.py
index ba43d92..c8c96da 100644
--- a/pw_console/py/pw_console/log_pane.py
+++ b/pw_console/py/pw_console/log_pane.py
@@ -98,18 +98,17 @@
             """Toggle table view."""
             self.log_pane.toggle_table_view()
 
-        # TODO(tonymd): Make this a menu option instead of a keybind.
         @key_bindings.add('insert')
         def _duplicate(_event: KeyPressEvent) -> None:
             """Duplicate this log pane."""
             self.log_pane.duplicate()
 
-        # TODO(tonymd): Make this a menu option instead of a keybind.
         @key_bindings.add('delete')
         def _delete(_event: KeyPressEvent) -> None:
             """Remove log pane."""
             if self.log_pane.is_a_duplicate:
-                self.log_pane.application.remove_pane(self.log_pane)
+                self.log_pane.application.window_manager.remove_pane(
+                    self.log_pane)
 
         @key_bindings.add('C')
         def _clear_history(_event: KeyPressEvent) -> None:
@@ -538,7 +537,8 @@
         if self.is_a_duplicate:
             options += [(
                 'Remove pane',
-                functools.partial(self.application.remove_pane, self),
+                functools.partial(self.application.window_manager.remove_pane,
+                                  self),
             )]
 
         # Search / Filter section
@@ -595,5 +595,5 @@
         new_pane.is_a_duplicate = True
 
         # Add the new pane.
-        self.application.add_pane(new_pane)
+        self.application.window_manager.add_pane(new_pane)
         return new_pane
diff --git a/pw_console/py/pw_console/window_manager.py b/pw_console/py/pw_console/window_manager.py
new file mode 100644
index 0000000..1020584
--- /dev/null
+++ b/pw_console/py/pw_console/window_manager.py
@@ -0,0 +1,248 @@
+# 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.
+"""WindowManager"""
+
+import collections
+from typing import Any
+
+from prompt_toolkit.filters import has_focus
+from prompt_toolkit.layout import (
+    Dimension,
+    HSplit,
+    VSplit,
+)
+
+# Weighted amount for adjusting window dimensions when enlarging and shrinking.
+_WINDOW_SIZE_ADJUST = 2
+
+
+class WindowManager:
+    """The main ConsoleApp class containing the whole console."""
+
+    # pylint: disable=too-many-instance-attributes
+    def __init__(
+        self,
+        application: Any,
+    ):
+        self.application = application
+        self.active_panes: collections.deque = collections.deque()
+        self.vertical_split = False
+
+        # Reference to the current prompt_toolkit window split for the current
+        # set of active_panes.
+        self.active_pane_split = None
+
+    def add_window(self, pane: Any):
+        self.active_panes.append(pane)
+
+    def active_split(self):
+        return self.active_panes
+
+    def _get_current_active_pane(self):
+        """Return the current active window pane."""
+        focused_pane = None
+        for pane in self.active_panes:
+            if has_focus(pane)():
+                focused_pane = pane
+                break
+        return focused_pane
+
+    def toggle_vertical_split(self):
+        """Toggle visibility of the help window."""
+        self.vertical_split = not self.vertical_split
+
+        self.application.update_menu_items()
+        self.update_root_container_body()
+
+        self.application.redraw_ui()
+
+    def _update_split_orientation(self):
+        if self.vertical_split:
+            self.active_pane_split = VSplit(
+                list(pane for pane in self.active_panes if pane.show_pane),
+                # Add a vertical separator between each active window pane.
+                padding=1,
+                padding_char='│',
+                padding_style='class:pane_separator',
+            )
+        else:
+            self.active_pane_split = HSplit(self.active_panes)
+
+    def create_root_split(self):
+        """Create a vertical or horizontal split container for all active
+        panes."""
+        self._update_split_orientation()
+        return HSplit([
+            self.active_pane_split,
+        ])
+
+    def update_root_container_body(self):
+        # Replace the root MenuContainer body with the new split.
+        self.application.root_container.container.content.children[
+            1] = self.create_root_split()
+
+    def add_pane(self, new_pane, existing_pane=None, add_at_beginning=False):
+        existing_pane_index = None
+        if existing_pane:
+            try:
+                existing_pane_index = self.active_panes.index(existing_pane)
+            except ValueError:
+                # Ignore ValueError which can be raised by the self.active_panes
+                # deque if existing_pane can't be found.
+                pass
+        if existing_pane_index:
+            self.active_panes.insert(new_pane, existing_pane_index + 1)
+        else:
+            if add_at_beginning:
+                self.active_panes.appendleft(new_pane)
+            else:
+                self.active_panes.append(new_pane)
+
+        self.application.update_menu_items()
+        self.update_root_container_body()
+
+        self.application.redraw_ui()
+
+    def remove_pane(self, existing_pane):
+        existing_pane_index = 0
+        if not existing_pane:
+            return
+        try:
+            existing_pane_index = self.active_panes.index(existing_pane)
+            self.active_panes.remove(existing_pane)
+        except ValueError:
+            # Ignore ValueError which can be raised by the self.active_panes
+            # deque if existing_pane can't be found.
+            pass
+
+        self.application.update_menu_items()
+        self.update_root_container_body()
+        if len(self.active_panes) > 0:
+            existing_pane_index -= 1
+            try:
+                self.application.focus_on_container(
+                    self.active_panes[existing_pane_index])
+            except ValueError:
+                # ValueError will be raised if the the pane at
+                # existing_pane_index can't be accessed.
+                # Focus on the main menu if the existing pane is hidden.
+                self.application.focus_main_menu()
+
+        self.application.redraw_ui()
+
+    def enlarge_pane(self):
+        """Enlarge the currently focused window pane."""
+        pane = self._get_current_active_pane()
+        if pane:
+            self.adjust_pane_size(pane, _WINDOW_SIZE_ADJUST)
+
+    def shrink_pane(self):
+        """Shrink the currently focused window pane."""
+        pane = self._get_current_active_pane()
+        if pane:
+            self.adjust_pane_size(pane, -_WINDOW_SIZE_ADJUST)
+
+    def adjust_pane_size(self, pane, diff: int = _WINDOW_SIZE_ADJUST):
+        """Increase or decrease a given pane's width or height weight."""
+        # Placeholder next_pane value to allow setting width and height without
+        # any consequences if there is no next visible pane.
+        next_pane = HSplit([],
+                           height=Dimension(weight=50),
+                           width=Dimension(weight=50))  # type: ignore
+        # Try to get the next visible pane to subtract a weight value from.
+        next_visible_pane = self._get_next_visible_pane_after(pane)
+        if next_visible_pane:
+            next_pane = next_visible_pane
+
+        # If the last pane is selected, and there are at least 2 panes, make
+        # next_pane the previous pane.
+        try:
+            if len(self.active_panes) >= 2 and (self.active_panes.index(pane)
+                                                == len(self.active_panes) - 1):
+                next_pane = self.active_panes[-2]
+        except ValueError:
+            # Ignore ValueError raised if self.active_panes[-2] doesn't exist.
+            pass
+
+        # Get current weight values
+        if self.vertical_split:
+            old_weight = pane.width.weight
+            next_old_weight = next_pane.width.weight  # type: ignore
+        else:  # Horizontal split
+            old_weight = pane.height.weight
+            next_old_weight = next_pane.height.weight  # type: ignore
+
+        # Add to the current pane
+        new_weight = old_weight + diff
+        if new_weight <= 0:
+            new_weight = old_weight
+
+        # Subtract from the next pane
+        next_new_weight = next_old_weight - diff
+        if next_new_weight <= 0:
+            next_new_weight = next_old_weight
+
+        # Set new weight values
+        if self.vertical_split:
+            pane.width.weight = new_weight
+            next_pane.width.weight = next_new_weight  # type: ignore
+        else:  # Horizontal split
+            pane.height.weight = new_weight
+            next_pane.height.weight = next_new_weight  # type: ignore
+
+    def reset_pane_sizes(self):
+        """Reset all active pane width and height to 50%"""
+        for pane in self.active_panes:
+            pane.height = Dimension(weight=50)
+            pane.width = Dimension(weight=50)
+
+    def rotate_panes(self, steps=1):
+        """Rotate the order of all active window panes."""
+        self.active_panes.rotate(steps)
+        self.application.update_menu_items()
+        self.update_root_container_body()
+
+    def toggle_pane(self, pane):
+        """Toggle a pane on or off."""
+        pane.show_pane = not pane.show_pane
+        self.application.update_menu_items()
+        self.update_root_container_body()
+
+        # Set focus to the top level menu. This has the effect of keeping the
+        # menu open if it's already open.
+        self.application.focus_main_menu()
+
+    def _get_next_visible_pane_after(self, target_pane):
+        """Return the next visible pane that appears after the target pane."""
+        try:
+            target_pane_index = self.active_panes.index(target_pane)
+        except ValueError:
+            # If pane can't be found, focus on the main menu.
+            return None
+
+        # Loop through active panes (not including the target_pane).
+        for i in range(1, len(self.active_panes)):
+            next_pane_index = (target_pane_index + i) % len(self.active_panes)
+            next_pane = self.active_panes[next_pane_index]
+            if next_pane.show_pane:
+                return next_pane
+        return None
+
+    def focus_next_visible_pane(self, pane):
+        """Focus on the next visible window pane if possible."""
+        next_visible_pane = self._get_next_visible_pane_after(pane)
+        if next_visible_pane:
+            self.application.layout.focus(next_visible_pane)
+            return
+        self.application.focus_main_menu()
diff --git a/pw_console/testing.rst b/pw_console/testing.rst
index 585e983..fd69c47 100644
--- a/pw_console/testing.rst
+++ b/pw_console/testing.rst
@@ -229,7 +229,78 @@
      - Window is hidden
      - |checkbox|
 
-4. Add note to the commit message
+4. Test Window Management
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+   :widths: 5 45 45 5
+   :header-rows: 1
+
+   * - #
+     - Test Action
+     - Expected Result
+     - ✅
+
+   * - 1
+     - Click the :guilabel:`View > [ ] Vertical Window Spliting`
+     - | Log pane appears on the left
+       | Repl pane appears on the right
+     - |checkbox|
+
+   * - 2
+     - Click the :guilabel:`View > [x] Vertical Window Spliting`
+     - | Log pane appears on the top
+       | Repl pane appears on the bottom
+     - |checkbox|
+
+   * - 3
+     - | Click the :guilabel:`Logs`
+       | window title
+     - Log pane is focused
+     - |checkbox|
+
+   * - 4
+     - | Click the menu :guilabel:`Windows > 1: Logs fake_device.1`
+       | Click :guilabel:`Duplicate pane`
+     - | 3 panes are visible:
+       | Log pane on top
+       | Repl pane in the middle
+       | Log pane on the bottom
+     - |checkbox|
+
+   * - 5
+     - Click the :guilabel:`View > Rotate Window Order`
+     - | 3 panes are visible:
+       | Log pane on top
+       | Log pane in the middle
+       | Repl pane on the bottom
+     - |checkbox|
+
+   * - 6
+     - | Click the menu :guilabel:`Windows > 1: Logs fake_device.1`
+       | Click :guilabel:`Remove pane`
+     - | 2 panes are visible:
+       | Log pane on top
+       | Repl pane in the middle
+     - |checkbox|
+
+   * - 7
+     - | Click the :guilabel:`Logs`
+       | window title
+     - Log pane is focused
+     - |checkbox|
+
+   * - 8
+     - Hold the keys :guilabel:`Ctrl-j`
+     - Log pane is enlarged
+     - |checkbox|
+
+   * - 9
+     - Hold the keys :guilabel:`Ctrl-k`
+     - Log pane shrinks
+     - |checkbox|
+
+5. Add note to the commit message
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 Add a ``Testing:`` line to your commit message and mention the steps