pw_console: Re-style buttons

Change keybind -> Function toolbar text to look more like a button
by surrounding them with styled brackets. E.g. [Function Keybind]

Add a Window title and close button to the top of modal help windows.
Make F1 close help window if it's already open.

Remove repl minimum window height restriction.

Bug: 474
Bug: 475
Change-Id: If7aae0f4045e8125317c1fff51d756f2bb8bd926
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/56787
Reviewed-by: Joe Ethier <jethier@google.com>
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index 752c306..3a2e722 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -56,6 +56,7 @@
 
 import pw_console.key_bindings
 import pw_console.widgets.checkbox
+import pw_console.widgets.mouse_handlers
 import pw_console.style
 from pw_console.help_window import HelpWindow
 from pw_console.log_pane import LogPane
@@ -164,30 +165,36 @@
 
         self.app_title = app_title if app_title else 'Pigweed Console'
 
-        # Top title message
-        self.message = [
-            ('class:logo', self.app_title),
-            ('class:menu-bar', '  '),
-        ]
-        self.message.extend(
-            pw_console.widgets.checkbox.to_keybind_indicator('F1', 'Help '))
-        self.message.extend(
-            pw_console.widgets.checkbox.to_keybind_indicator(
-                'Ctrl-W', 'Quit '))
-
         # Top level UI state toggles.
         self.load_theme()
 
         # Pigweed upstream RST user guide
-        self.user_guide_window = HelpWindow(self)
+        self.user_guide_window = HelpWindow(self, title='User Guide')
         self.user_guide_window.load_user_guide()
 
+        # Top title message
+        self.message = [('class:logo', self.app_title), ('', '  ')]
+
+        self.message.extend(
+            pw_console.widgets.checkbox.to_keybind_indicator(
+                'F1', 'Help',
+                functools.partial(pw_console.widgets.mouse_handlers.on_click,
+                                  self.user_guide_window.toggle_display)))
+
+        # Two space separator
+        self.message.append(('', '  '))
+
+        self.message.extend(
+            pw_console.widgets.checkbox.to_keybind_indicator('Ctrl-W', 'Quit'))
+
         # Auto-generated keybindings list for all active panes
-        self.keybind_help_window = HelpWindow(self)
+        self.keybind_help_window = HelpWindow(self, title='Keyboard Shortcuts')
 
         # Downstream project specific help text
         self.app_help_text = help_text if help_text else None
-        self.app_help_window = HelpWindow(self, additional_help_text=help_text)
+        self.app_help_window = HelpWindow(self,
+                                          additional_help_text=help_text,
+                                          title=(self.app_title + ' Help'))
         self.app_help_window.generate_help_text()
 
         # Used for tracking which pane was in focus before showing help window.
@@ -475,16 +482,16 @@
         window_menu = self.window_manager.create_window_menu()
 
         help_menu_items = [
-            MenuItem('User Guide',
+            MenuItem(self.user_guide_window.menu_title(),
                      handler=self.user_guide_window.toggle_display),
-            MenuItem('Keyboard Shortcuts',
+            MenuItem(self.keybind_help_window.menu_title(),
                      handler=self.keybind_help_window.toggle_display),
         ]
 
         if self.app_help_text:
             help_menu_items.extend([
                 MenuItem('-'),
-                MenuItem(self.app_title + ' Help',
+                MenuItem(self.app_help_window.menu_title(),
                          handler=self.app_help_window.toggle_display)
             ])
 
diff --git a/pw_console/py/pw_console/help_window.py b/pw_console/py/pw_console/help_window.py
index 9d3867a..6784b72 100644
--- a/pw_console/py/pw_console/help_window.py
+++ b/pw_console/py/pw_console/help_window.py
@@ -13,10 +13,11 @@
 # the License.
 """Help window container class."""
 
-import logging
+import functools
 import inspect
+import logging
 from pathlib import Path
-from typing import Dict
+from typing import Dict, TYPE_CHECKING
 
 from prompt_toolkit.document import Document
 from prompt_toolkit.filters import Condition
@@ -24,13 +25,21 @@
 from prompt_toolkit.layout import (
     ConditionalContainer,
     DynamicContainer,
+    FormattedTextControl,
     HSplit,
+    VSplit,
+    Window,
+    WindowAlign,
 )
 from prompt_toolkit.layout.dimension import Dimension
 from prompt_toolkit.lexers import PygmentsLexer
-from prompt_toolkit.widgets import Box, Frame, TextArea
+from prompt_toolkit.widgets import Box, TextArea
 
 from pygments.lexers.markup import RstLexer  # type: ignore
+import pw_console.widgets.mouse_handlers
+
+if TYPE_CHECKING:
+    from pw_console.console_app import ConsoleApp
 
 _LOG = logging.getLogger(__package__)
 
@@ -46,6 +55,9 @@
 
 class HelpWindow(ConditionalContainer):
     """Help window container for displaying keybindings."""
+
+    # pylint: disable=too-many-instance-attributes
+
     def _create_help_text_area(self, **kwargs):
         help_text_area = TextArea(
             focusable=True,
@@ -59,6 +71,7 @@
         key_bindings = KeyBindings()
 
         @key_bindings.add('q')
+        @key_bindings.add('f1')
         def _close_window(_event: KeyPressEvent) -> None:
             """Close the current dialog window."""
             self.toggle_display()
@@ -66,40 +79,95 @@
         help_text_area.control.key_bindings = key_bindings
         return help_text_area
 
-    def __init__(self, application, preamble='', additional_help_text=''):
+    def __init__(self,
+                 application: 'ConsoleApp',
+                 preamble: str = '',
+                 additional_help_text: str = '',
+                 title: str = '') -> None:
         # Dict containing key = section title and value = list of key bindings.
-        self.application = application
-        self.show_window = False
-        self.help_text_sections = {}
+        self.application: 'ConsoleApp' = application
+        self.show_window: bool = False
+        self.help_text_sections: Dict[str, Dict] = {}
+        self._pane_title: str = title
 
         # Generated keybinding text
-        self.preamble = preamble
-        self.additional_help_text = additional_help_text
-        self.help_text = ''
+        self.preamble: str = preamble
+        self.additional_help_text: str = additional_help_text
+        self.help_text: str = ''
 
-        self.max_additional_help_text_width = (_longest_line_length(
+        self.max_additional_help_text_width: int = (_longest_line_length(
             self.additional_help_text) if additional_help_text else 0)
-        self.max_description_width = 0
-        self.max_key_list_width = 0
-        self.max_line_length = 0
+        self.max_description_width: int = 0
+        self.max_key_list_width: int = 0
+        self.max_line_length: int = 0
 
-        self.help_text_area = self._create_help_text_area()
+        self.help_text_area: TextArea = self._create_help_text_area()
 
-        frame = Frame(
-            body=Box(
+        close_mouse_handler = functools.partial(
+            pw_console.widgets.mouse_handlers.on_click, self.toggle_display)
+
+        toolbar_padding = 1
+        toolbar_title = ' ' * toolbar_padding
+        toolbar_title += self.pane_title()
+
+        top_toolbar = VSplit(
+            [
+                Window(
+                    content=FormattedTextControl(
+                        # [('', toolbar_title)]
+                        functools.partial(pw_console.style.get_pane_indicator,
+                                          self, toolbar_title)),
+                    align=WindowAlign.LEFT,
+                    dont_extend_width=True,
+                ),
+                Window(
+                    content=FormattedTextControl([]),
+                    align=WindowAlign.LEFT,
+                    dont_extend_width=False,
+                ),
+                Window(
+                    content=FormattedTextControl(
+                        pw_console.widgets.checkbox.to_keybind_indicator(
+                            'q', 'Close', close_mouse_handler)),
+                    align=WindowAlign.RIGHT,
+                    dont_extend_width=True,
+                ),
+            ],
+            height=1,
+            style='class:toolbar_active',
+        )
+
+        self.container = HSplit([
+            top_toolbar,
+            Box(
                 body=DynamicContainer(lambda: self.help_text_area),
                 padding=Dimension(preferred=1, max=1),
                 padding_bottom=0,
                 padding_top=0,
                 char=' ',
                 style='class:frame.border',  # Same style used for Frame.
-            ), )
+            ),
+        ])
 
         super().__init__(
-            HSplit([frame]),
+            self.container,
             filter=Condition(lambda: self.show_window),
         )
 
+    def pane_title(self):
+        return self._pane_title
+
+    def menu_title(self):
+        """Return the title to display in the Window menu."""
+        return self.pane_title()
+
+    def __pt_container__(self):
+        """Return the prompt_toolkit container for displaying this HelpWindow.
+
+        This allows self to be used wherever prompt_toolkit expects a container
+        object."""
+        return self.container
+
     def toggle_display(self):
         """Toggle visibility of this help window."""
         # Toggle state variable.
diff --git a/pw_console/py/pw_console/log_pane.py b/pw_console/py/pw_console/log_pane.py
index 6bdcbff..97928e1 100644
--- a/pw_console/py/pw_console/log_pane.py
+++ b/pw_console/py/pw_console/log_pane.py
@@ -500,7 +500,10 @@
         self.redraw_ui()
 
     def __pt_container__(self):
-        """Return the prompt_toolkit root container for this log pane."""
+        """Return the prompt_toolkit root container for this log pane.
+
+        This allows self to be used wherever prompt_toolkit expects a container
+        object."""
         return self.container
 
     def get_all_key_bindings(self) -> List:
diff --git a/pw_console/py/pw_console/log_pane_toolbars.py b/pw_console/py/pw_console/log_pane_toolbars.py
index dc8413d..28d7766 100644
--- a/pw_console/py/pw_console/log_pane_toolbars.py
+++ b/pw_console/py/pw_console/log_pane_toolbars.py
@@ -183,7 +183,9 @@
                                   self.log_pane.focus_self)
         fragments = []
         if not has_focus(self.log_pane.__pt_container__())():
-            fragments.append(('class:keyhelp', '[click to focus] ', focus))
+            fragments.append(('class:toolbar-button-decoration', '[', focus))
+            fragments.append(('class:keyhelp', 'click to focus', focus))
+            fragments.append(('class:toolbar-button-decoration', '] ', focus))
         fragments.append(
             ('', ' {} '.format(self.log_pane.pane_subtitle()), focus))
         return fragments
diff --git a/pw_console/py/pw_console/pw_ptpython_repl.py b/pw_console/py/pw_console/pw_ptpython_repl.py
index 07e47a9..5d0bf79 100644
--- a/pw_console/py/pw_console/pw_ptpython_repl.py
+++ b/pw_console/py/pw_console/pw_ptpython_repl.py
@@ -73,7 +73,8 @@
         super().__init__(
             *args,
             create_app=False,
-            _input_buffer_height=Dimension(min=5, weight=30),
+            # Absolute minimum height of 1
+            _input_buffer_height=Dimension(min=1),
             _completer=completer,
             **ptpython_kwargs,
         )
@@ -107,7 +108,10 @@
         self._last_exception = None
 
     def __pt_container__(self):
-        """Return the prompt_toolkit root container for class."""
+        """Return the prompt_toolkit root container for class.
+
+        This allows self to be used wherever prompt_toolkit expects a container
+        object."""
         return self.ptpython_layout.root_container
 
     def set_repl_pane(self, repl_pane):
@@ -268,6 +272,9 @@
         # Don't keep input for now. Return True to keep input text.
         return False
 
+    def line_break_count(self) -> int:
+        return self.default_buffer.text.count('\n')
+
     def has_focus_and_input_empty_condition(self) -> Condition:
         @Condition
         def test() -> bool:
diff --git a/pw_console/py/pw_console/repl_pane.py b/pw_console/py/pw_console/repl_pane.py
index 1058cf7..982e39b 100644
--- a/pw_console/py/pw_console/repl_pane.py
+++ b/pw_console/py/pw_console/repl_pane.py
@@ -130,14 +130,18 @@
                 pw_console.widgets.checkbox.to_keybind_indicator(
                     'F3', 'History'))
         else:
+            focus = functools.partial(pw_console.mouse.focus_handler,
+                                      repl_pane)
+            fragments.append(('class:toolbar-button-decoration', '[', focus))
             fragments.append((
                 # Style
                 'class:keyhelp',
                 # Text
-                '[click to focus] ',
+                'click to focus',
                 # Mouse handler
-                functools.partial(pw_console.mouse.focus_handler, repl_pane),
+                focus,
             ))
+            fragments.append(('class:toolbar-button-decoration', '] ', focus))
         return fragments
 
     def __init__(self, repl_pane):
@@ -253,16 +257,11 @@
         self,
         application: Any,
         python_repl: PwPtPythonRepl,
-        # TODO(tonymd): Make the height of input+output windows match the log
-        # pane height. (Using minimum output height of 5 for now).
-        output_height: Optional[AnyDimension] = Dimension(min=5, weight=70),
-        # TODO(tonymd): Figure out how to resize ptpython input field.
-        _input_height: Optional[AnyDimension] = None,
-        # Default width and height to 50% of the screen
         height: Optional[AnyDimension] = None,
         width: Optional[AnyDimension] = None,
         startup_message: Optional[str] = None,
     ) -> None:
+        # Default width and height to 50% of the screen
         self.height = height if height else Dimension(weight=50)
         self.width = width if width else Dimension(weight=50)
         self.show_pane = True
@@ -283,7 +282,6 @@
         self.startup_message = startup_message if startup_message else ''
 
         self.output_field = TextArea(
-            height=output_height,
             text=self.startup_message,
             focusable=True,
             focus_on_click=True,
@@ -312,19 +310,28 @@
                 ReplHSplit(
                     self,
                     [
-                        HSplit([
-                            # 1. Repl Output
-                            self.output_field,
-                            # 2. Static separator toolbar.
-                            self.results_toolbar,
-                        ]),
-                        HSplit([
-                            # 3. Repl Input
-                            self.pw_ptpython_repl,
-                            # 4. Bottom toolbar
-                            self.bottom_toolbar,
-                        ]),
+                        HSplit(
+                            [
+                                # 1. Repl Output
+                                self.output_field,
+                                # 2. Static separator toolbar.
+                                self.results_toolbar,
+                            ],
+                            # Output area only dimensions
+                            height=self.get_output_height,
+                        ),
+                        HSplit(
+                            [
+                                # 3. Repl Input
+                                self.pw_ptpython_repl,
+                                # 4. Bottom toolbar
+                                self.bottom_toolbar,
+                            ],
+                            # Input area only dimensions
+                            height=self.get_input_height,
+                        ),
                     ],
+                    # Repl pane dimensions
                     height=lambda: self.height,
                     width=lambda: self.width,
                     style=functools.partial(pw_console.style.get_pane_style,
@@ -340,6 +347,29 @@
                 ]),
             filter=Condition(lambda: self.show_pane))
 
+    def get_output_height(self) -> AnyDimension:
+        # pylint: disable=no-self-use
+        return Dimension(min=1)
+
+    def get_input_height(self) -> AnyDimension:
+        desired_max_height = 10
+        # Check number of line breaks in the input buffer.
+        input_line_count = self.pw_ptpython_repl.line_break_count()
+        if input_line_count > desired_max_height:
+            desired_max_height = input_line_count
+        # Check if it's taller than the available space
+        if desired_max_height > self.current_pane_height:
+            # Leave space for minimum of
+            #   1 line of content in the output
+            #   + 1 for output toolbar
+            #   + 1 for input toolbar
+            desired_max_height = self.current_pane_height - 3
+
+        if desired_max_height > 1:
+            return Dimension(min=1, max=desired_max_height)
+        # Fall back to at least a height of 1
+        return Dimension(min=1)
+
     def update_pane_size(self, width, height):
         """Save width and height of the repl pane for the current UI render
         pass."""
@@ -427,7 +457,10 @@
         return ''
 
     def __pt_container__(self):
-        """Return the prompt_toolkit container for this ReplPane."""
+        """Return the prompt_toolkit container for this ReplPane.
+
+        This allows self to be used wherever prompt_toolkit expects a container
+        object."""
         return self.container
 
     def copy_output_selection(self):
diff --git a/pw_console/py/pw_console/search_toolbar.py b/pw_console/py/pw_console/search_toolbar.py
index 84691d9..0d63c20 100644
--- a/pw_console/py/pw_console/search_toolbar.py
+++ b/pw_console/py/pw_console/search_toolbar.py
@@ -139,14 +139,17 @@
         ]
         fragments.extend(separator_text)
 
-        fragments.extend(
-            pw_console.widgets.checkbox.to_keybind_indicator(
-                'Ctrl-t', 'Column:', next_field))
-        fragments.extend([
+        selected_column_text = [
             ('class:search-bar-setting',
              (self._search_field.title() if self._search_field else 'All'),
              next_field),
-        ])
+        ]
+        fragments.extend(
+            pw_console.widgets.checkbox.to_keybind_indicator(
+                'Ctrl-t',
+                'Column:',
+                next_field,
+                middle_fragments=selected_column_text))
         fragments.extend(separator_text)
 
         fragments.extend(
@@ -155,13 +158,16 @@
         fragments.extend(separator_text)
 
         # Matching Method
+        current_matcher_text = [
+            ('class:search-bar-setting',
+             str(self.log_pane.log_view.search_matcher.name), next_matcher)
+        ]
         fragments.extend(
             pw_console.widgets.checkbox.to_keybind_indicator(
-                'Ctrl-n', 'Matcher:', next_matcher))
-        fragments.extend([
-            ('class:search-bar-setting',
-             str(self.log_pane.log_view.search_matcher.name), next_matcher),
-        ])
+                'Ctrl-n',
+                'Matcher:',
+                next_matcher,
+                middle_fragments=current_matcher_text))
         fragments.extend(separator_text)
 
         return fragments
diff --git a/pw_console/py/pw_console/style.py b/pw_console/py/pw_console/style.py
index 4539866..1ee85eb 100644
--- a/pw_console/py/pw_console/style.py
+++ b/pw_console/py/pw_console/style.py
@@ -108,6 +108,7 @@
         # Used for pane titles
         'toolbar_accent': theme.cyan_accent,
 
+        'toolbar-button-decoration': '{}'.format(theme.cyan_accent),
         'toolbar-setting-active': 'bg:{} {}'.format(
             theme.green_accent,
             theme.active_bg,
diff --git a/pw_console/py/pw_console/widgets/checkbox.py b/pw_console/py/pw_console/widgets/checkbox.py
index eb669a8..71e01ff 100644
--- a/pw_console/py/pw_console/widgets/checkbox.py
+++ b/pw_console/py/pw_console/widgets/checkbox.py
@@ -20,11 +20,10 @@
 
 from prompt_toolkit.formatted_text import StyleAndTextTuples
 
-_KEY_SEPARATOR = ' → '
+_KEY_SEPARATOR = ' '
 _CHECKED_BOX = '[✓]'
 
 if sys.platform in ['win32']:
-    _KEY_SEPARATOR = ' : '
     _CHECKED_BOX = '[x]'
 
 
@@ -67,32 +66,45 @@
             key,
             description,
             mouse_handler,
-            extra_fragments=[to_checkbox(checked, mouse_handler)])
+            leading_fragments=[to_checkbox(checked, mouse_handler)])
     return to_keybind_indicator(key,
                                 description,
-                                extra_fragments=[to_checkbox(checked)])
+                                leading_fragments=[to_checkbox(checked)])
 
 
-def to_keybind_indicator(key: str,
-                         description: str,
-                         mouse_handler=None,
-                         extra_fragments: Optional[Iterable] = None):
+def to_keybind_indicator(
+    key: str,
+    description: str,
+    mouse_handler=None,
+    leading_fragments: Optional[Iterable] = None,
+    middle_fragments: Optional[Iterable] = None,
+):
     """Create a clickable keybind indicator for toolbars."""
     fragments: StyleAndTextTuples = []
+    fragments.append(('class:toolbar-button-decoration', '['))
 
-    if mouse_handler:
-        fragments.append(('class:keybind', key, mouse_handler))
-        fragments.append(('class:keyhelp', _KEY_SEPARATOR, mouse_handler))
-    else:
-        fragments.append(('class:keybind', key))
-        fragments.append(('class:keyhelp', _KEY_SEPARATOR))
-
-    if extra_fragments:
-        for fragment in extra_fragments:
+    # Add any starting fragments first
+    if leading_fragments:
+        for fragment in leading_fragments:
             fragments.append(fragment)
 
+    # Function name
     if mouse_handler:
         fragments.append(('class:keyhelp', description, mouse_handler))
     else:
         fragments.append(('class:keyhelp', description))
+
+    if middle_fragments:
+        for fragment in middle_fragments:
+            fragments.append(fragment)
+
+    # Separator and keybind
+    if mouse_handler:
+        fragments.append(('class:keyhelp', _KEY_SEPARATOR, mouse_handler))
+        fragments.append(('class:keybind', key, mouse_handler))
+    else:
+        fragments.append(('class:keyhelp', _KEY_SEPARATOR))
+        fragments.append(('class:keybind', key))
+
+    fragments.append(('class:toolbar-button-decoration', ']'))
     return fragments
diff --git a/pw_console/testing.rst b/pw_console/testing.rst
index e4a959b..1e35ebd 100644
--- a/pw_console/testing.rst
+++ b/pw_console/testing.rst
@@ -221,7 +221,7 @@
      - |checkbox|
 
    * - 4
-     - Press :guilabel:`q`
+     - Press :guilabel:`f1`
      - Window is hidden
      - |checkbox|
 
@@ -232,7 +232,7 @@
      - |checkbox|
 
    * - 6
-     - Press :guilabel:`q`
+     - Click the :guilabel:`[Close q]` button.
      - Window is hidden
      - |checkbox|