pw_console: Add LogPane class

No-Docs-Update-Reason: prompt_toolkit UI boilerplate
Change-Id: I8b6527587b14ecd490e9c91bca391114416345f2
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/48804
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Reviewed-by: Joe Ethier <jethier@google.com>
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn
index f6e54f0..98ec217 100644
--- a/pw_console/py/BUILD.gn
+++ b/pw_console/py/BUILD.gn
@@ -24,6 +24,7 @@
     "pw_console/console_app.py",
     "pw_console/help_window.py",
     "pw_console/key_bindings.py",
+    "pw_console/log_pane.py",
     "pw_console/repl_pane.py",
     "pw_console/style.py",
   ]
diff --git a/pw_console/py/pw_console/log_pane.py b/pw_console/py/pw_console/log_pane.py
new file mode 100644
index 0000000..bf39a39
--- /dev/null
+++ b/pw_console/py/pw_console/log_pane.py
@@ -0,0 +1,332 @@
+# 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.
+"""LogPane class."""
+
+from functools import partial
+from typing import Any, List, Optional
+
+from prompt_toolkit.filters import (
+    Condition,
+    has_focus,
+)
+from prompt_toolkit.layout import (
+    ConditionalContainer,
+    Dimension,
+    Float,
+    FloatContainer,
+    FormattedTextControl,
+    HSplit,
+    VSplit,
+    Window,
+    WindowAlign,
+    VerticalAlign,
+)
+from prompt_toolkit.layout.dimension import AnyDimension
+from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
+
+
+class LogPaneLineInfoBar(ConditionalContainer):
+    """One line bar for showing current and total log lines."""
+    @staticmethod
+    def get_tokens(unused_log_pane):
+        """Return formatted text tokens for display."""
+        tokens = ' Line {} / {} '.format(
+            # TODO: Replace fake counts with the current line (1) and total
+            # lines (10).
+            1,
+            10)
+        return [('', tokens)]
+
+    def __init__(self, log_pane):
+        info_bar_control = FormattedTextControl(
+            partial(LogPaneLineInfoBar.get_tokens, log_pane))
+        info_bar_window = Window(content=info_bar_control,
+                                 align=WindowAlign.RIGHT,
+                                 dont_extend_width=True)
+
+        super().__init__(
+            VSplit([info_bar_window],
+                   height=1,
+                   style='class:bottom_toolbar',
+                   align=WindowAlign.RIGHT),
+            # Only show current/total line info if not auto-following
+            # logs. Similar to tmux behavior.
+            filter=Condition(
+                lambda: True
+                # TODO: replace True with log follow status.
+                # not log_pane.log_container.follow
+            ))
+
+
+class LogPaneBottomToolbarBar(ConditionalContainer):
+    """One line toolbar for display at the bottom of the LogPane."""
+    TOOLBAR_HEIGHT = 1
+
+    @staticmethod
+    def mouse_handler_focus(log_pane, mouse_event: MouseEvent):
+        """Focus this pane on click."""
+        if mouse_event.event_type == MouseEventType.MOUSE_UP:
+            log_pane.application.application.layout.focus(log_pane)
+            return None
+        return NotImplemented
+
+    @staticmethod
+    def mouse_handler_toggle_wrap_lines(log_pane, mouse_event: MouseEvent):
+        """Toggle wrap lines on click."""
+        if mouse_event.event_type == MouseEventType.MOUSE_UP:
+            log_pane.toggle_wrap_lines()
+            return None
+        return NotImplemented
+
+    @staticmethod
+    def mouse_handler_clear_history(log_pane, mouse_event: MouseEvent):
+        """Clear history on click."""
+        if mouse_event.event_type == MouseEventType.MOUSE_UP:
+            log_pane.clear_history()
+            return None
+        return NotImplemented
+
+    @staticmethod
+    def mouse_handler_toggle_follow(log_pane, mouse_event: MouseEvent):
+        """Toggle follow on click."""
+        if mouse_event.event_type == MouseEventType.MOUSE_UP:
+            log_pane.toggle_follow()
+            return None
+        return NotImplemented
+
+    @staticmethod
+    def get_center_text_tokens(log_pane):
+        """Return formatted text tokens for display in the center part of the
+        toolbar."""
+
+        # Create mouse handler functions.
+        focus = partial(LogPaneBottomToolbarBar.mouse_handler_focus, log_pane)
+        toggle_wrap_lines = partial(
+            LogPaneBottomToolbarBar.mouse_handler_toggle_wrap_lines, log_pane)
+        clear_history = partial(
+            LogPaneBottomToolbarBar.mouse_handler_clear_history, log_pane)
+        toggle_follow = partial(
+            LogPaneBottomToolbarBar.mouse_handler_toggle_follow, log_pane)
+
+        # FormattedTextTuple contents: (Style, Text, Mouse handler)
+        separator_text = ('', ' ')  # 1 space of separaton between keybinds.
+
+        # If the log_pane is in focus, show keybinds in the toolbar.
+        if has_focus(log_pane.__pt_container__())():
+            return [
+                ('', ' [FOCUSED]'),
+                separator_text,
+                # TODO: Indicate toggle status with a checkbox?
+                ('class:keybind', 'w', toggle_wrap_lines),
+                ('class:keyhelp', ':Wrap', toggle_wrap_lines),
+                separator_text,
+                ('class:keybind', 'f', toggle_follow),
+                ('class:keyhelp', ':Follow', toggle_follow),
+                separator_text,
+                ('class:keybind', 'C', clear_history),
+                ('class:keyhelp', ':Clear', clear_history),
+            ]
+        # Show the click to focus button if log pane isn't in focus.
+        return [
+            ('class:keyhelp', ' [click to focus] ', focus),
+        ]
+
+    def __init__(self, log_pane):
+        title_section_text = FormattedTextControl([(
+            # Style
+            'class:logo',
+            # Text
+            ' Logs ',
+            # Mouse handler
+            partial(LogPaneBottomToolbarBar.mouse_handler_focus, log_pane),
+        )])
+
+        keybind_section_text = FormattedTextControl(
+            # Callable to get formatted text tuples.
+            partial(LogPaneBottomToolbarBar.get_center_text_tokens, log_pane))
+
+        title_section_window = Window(
+            content=title_section_text,
+            align=WindowAlign.LEFT,
+            dont_extend_width=True,
+        )
+
+        keybind_section_window = Window(
+            content=keybind_section_text,
+            align=WindowAlign.LEFT,
+            dont_extend_width=False,
+        )
+
+        toolbar_vsplit = VSplit(
+            [
+                title_section_window,
+                keybind_section_window,
+            ],
+            height=LogPaneBottomToolbarBar.TOOLBAR_HEIGHT,
+            style='class:bottom_toolbar',
+            align=WindowAlign.LEFT,
+        )
+
+        # ConditionalContainer init()
+        super().__init__(
+            # Contents
+            toolbar_vsplit,
+            filter=Condition(lambda: log_pane.show_bottom_toolbar),
+        )
+
+
+class LogLineHSplit(HSplit):
+    """PromptToolkit HSplit class with a write_to_screen function that saves the
+    width and height of the container to be rendered.
+    """
+    def __init__(self, log_pane, *args, **kwargs):
+        # Save a reference to the parent LogPane.
+        self.log_pane = log_pane
+        super().__init__(*args, **kwargs)
+
+    def write_to_screen(
+        self,
+        screen,
+        mouse_handlers,
+        write_position,
+        parent_style: str,
+        erase_bg: bool,
+        z_index: Optional[int],
+    ) -> None:
+        # Save the width and height for the current render pass. This will be
+        # used by the log pane to render the correct amount of log lines.
+        self.log_pane.update_log_pane_size(write_position.width,
+                                           write_position.height)
+        # Continue writing content to the screen.
+        super().write_to_screen(screen, mouse_handlers, write_position,
+                                parent_style, erase_bg, z_index)
+
+
+class LogPane:
+    """LogPane class."""
+
+    # pylint: disable=too-many-instance-attributes
+    def __init__(
+            self,
+            application: Any,
+            show_bottom_toolbar=True,
+            show_line_info=True,
+            wrap_lines=True,
+            # Default width and height to 50% of the screen
+            height: Optional[AnyDimension] = Dimension(weight=50),
+            width: Optional[AnyDimension] = Dimension(weight=50),
+    ):
+        self.application = application
+        self.show_bottom_toolbar = show_bottom_toolbar
+        self.show_line_info = show_line_info
+        self.wrap_lines = wrap_lines
+        self.height = height
+        self.width = width
+
+        # Log pane size variables. These are updated just befor rendering the
+        # pane by the LogLineHSplit class.
+        self.current_log_pane_width = 0
+        self.current_log_pane_height = 0
+        self.last_log_pane_width = 0
+        self.last_log_pane_height = 0
+
+        # Create the bottom toolbar for the whole log pane.
+        self.bottom_toolbar = LogPaneBottomToolbarBar(self)
+
+        # TODO: Render logs here
+        self.log_content_control = FormattedTextControl(
+            [('', 'Logs appear here')],
+            focusable=True,
+        )
+
+        self.log_display_window = Window(
+            content=self.log_content_control,
+            allow_scroll_beyond_bottom=True,
+            wrap_lines=Condition(lambda: self.wrap_lines),
+            cursorline=False,
+            # Don't make the window taller to fill the parent split container.
+            # Window should match the height of the log line content. This will
+            # also allow the parent HSplit to justify the content to the bottom
+            dont_extend_height=True,
+            # Window width should be extended to make backround highlighting
+            # extend to the end of the container. Otherwise backround colors
+            # will only appear until the end of the log line.
+            dont_extend_width=False,
+        )
+
+        # Root level container
+        self.container = FloatContainer(
+            # Horizonal split containing the log lines and the toolbar.
+            LogLineHSplit(
+                self,  # LogPane reference
+                [
+                    self.log_display_window,
+                    self.bottom_toolbar,
+                ],
+                # Align content with the bottom of the container.
+                align=VerticalAlign.BOTTOM,
+                height=self.height,
+                width=self.width,
+            ),
+            floats=[
+                # Floating LogPaneLineInfoBar
+                Float(top=0,
+                      right=0,
+                      height=1,
+                      content=LogPaneLineInfoBar(self)),
+            ])
+
+    def update_log_pane_size(self, width, height):
+        """Save width and height of the log pane for the current UI render
+        pass."""
+        if width:
+            self.last_log_pane_width = self.current_log_pane_width
+            self.current_log_pane_width = width
+        if height:
+            # Subtract the height of the LogPaneBottomToolbarBar
+            height -= LogPaneBottomToolbarBar.TOOLBAR_HEIGHT
+            self.last_log_pane_height = self.current_log_pane_height
+            self.current_log_pane_height = height
+
+    def redraw_ui(self):
+        """Trigger a prompt_toolkit UI redraw."""
+        self.application.redraw_ui()
+
+    def toggle_wrap_lines(self):
+        """Toggle line wraping/truncation."""
+        self.wrap_lines = not self.wrap_lines
+        self.redraw_ui()
+
+    def toggle_follow(self):
+        """Toggle following log lines."""
+        # TODO: self.log_container.toggle_follow()
+        self.redraw_ui()
+
+    def clear_history(self):
+        """Erase stored log lines."""
+        # TODO: self.log_container.clear_logs()
+        self.redraw_ui()
+
+    def __pt_container__(self):
+        """Return the prompt_toolkit root container for this log pane."""
+        return self.container
+
+    # pylint: disable=no-self-use
+    def get_all_key_bindings(self) -> List:
+        """Return all keybinds for this pane."""
+        # TODO: return log content control keybindings
+        return []
+
+    def after_render_hook(self):
+        """Run tasks after the last UI render."""