pw_console: LogContainer, rendering and scrolling

New LogContainer class which:
- Holds log lines for the parent LogPane
- Tracks the currently selected line
- Handles scrolling
- Renders the number of log lines that will fit in the LogPane window

No-Docs-Update-Reason: Log viewer functionality still WIP.
Change-Id: Iba5c290daad44a49650cc537417e53e99d83a29f
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/49024
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Reviewed-by: Joe Ethier <jethier@google.com>
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn
index 10865c2..b53b2a6 100644
--- a/pw_console/py/BUILD.gn
+++ b/pw_console/py/BUILD.gn
@@ -25,6 +25,7 @@
     "pw_console/help_window.py",
     "pw_console/helpers.py",
     "pw_console/key_bindings.py",
+    "pw_console/log_container.py",
     "pw_console/log_pane.py",
     "pw_console/pw_ptpython_repl.py",
     "pw_console/repl_pane.py",
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index d9444f5..fe2e0da 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -188,10 +188,9 @@
             mouse_support=True,
         )
 
-    def add_log_handler(self, logger_instance: Iterable):
+    def add_log_handler(self, logger_instance: logging.Logger):
         """Add the Log pane as a handler for this logger instance."""
-        # TODO: Add log pane to addHandler call.
-        # logger_instance.addHandler(self.log_pane.log_container)
+        logger_instance.addHandler(self.log_pane.log_container)
 
     def _user_code_thread_entry(self):
         """Entry point for the user code thread."""
@@ -281,7 +280,7 @@
 def embed(
     global_vars=None,
     local_vars=None,
-    loggers: Optional[Iterable] = None,
+    loggers: Optional[Iterable[logging.Logger]] = None,
     test_mode=False,
 ) -> None:
     """Call this to embed pw console at the call point within your program.
diff --git a/pw_console/py/pw_console/helpers.py b/pw_console/py/pw_console/helpers.py
index 484009b..87ecf00 100644
--- a/pw_console/py/pw_console/helpers.py
+++ b/pw_console/py/pw_console/helpers.py
@@ -17,3 +17,26 @@
 def remove_formatting(formatted_text):
     """Throw away style info from formatted text tuples."""
     return ''.join([formatted_tuple[1] for formatted_tuple in formatted_text])  # pylint: disable=not-an-iterable
+
+
+def get_line_height(text_width, screen_width, prefix_width):
+    """Calculates line height for a string with line wrapping enabled."""
+    if text_width == 0:
+        return 0
+    # If text will fit on the screen without wrapping.
+    if text_width < screen_width:
+        return 1
+
+    # Start with height of 1 row.
+    total_height = 1
+    # One screen_width of characters has been displayed.
+    remaining_width = text_width - screen_width
+
+    # While the remaining character count won't fit on the screen:
+    while (remaining_width + prefix_width) > screen_width:
+        remaining_width += prefix_width
+        remaining_width -= screen_width
+        total_height += 1
+
+    # Add one for the last line that is < screen_width
+    return total_height + 1
diff --git a/pw_console/py/pw_console/log_container.py b/pw_console/py/pw_console/log_container.py
new file mode 100644
index 0000000..804d82c
--- /dev/null
+++ b/pw_console/py/pw_console/log_container.py
@@ -0,0 +1,445 @@
+# 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.
+"""LogLine and LogContainer."""
+
+import logging
+import re
+import sys
+import time
+
+from collections import deque
+from dataclasses import dataclass
+from datetime import datetime
+from typing import List, Dict, Optional
+
+from prompt_toolkit.data_structures import Point
+from prompt_toolkit.formatted_text import (
+    ANSI,
+    to_formatted_text,
+    fragment_list_width,
+)
+
+import pw_cli.color
+from pw_console.helpers import get_line_height
+
+_LOG = logging.getLogger(__package__)
+
+_ANSI_SEQUENCE_REGEX = re.compile(r'\x1b[^m]*m')
+
+
+@dataclass
+class LogLine:
+    """Class to hold a single log event."""
+    record: logging.LogRecord
+    formatted_log: str
+
+    def time(self):
+        """Return a datetime object for the log record."""
+        return datetime.fromtimestamp(self.record.created)
+
+    def get_fragments(self) -> List:
+        """Return this log line as a list of FormattedText tuples."""
+        # Manually make a FormattedText tuple, wrap in a list
+        # return [('class:bottom_toolbar_colored_text', self.record.msg)]
+        # Use ANSI, returns a list of FormattedText tuples.
+
+        # fragments = [('[SetCursorPosition]', '')]
+        # fragments += ANSI(self.formatted_log).__pt_formatted_text__()
+        # return fragments
+
+        # Add a trailing linebreak
+        return ANSI(self.formatted_log + '\n').__pt_formatted_text__()
+
+
+def create_empty_log_message():
+    """Create an empty LogLine instance."""
+    return LogLine(record=logging.makeLogRecord({}), formatted_log='')
+
+
+class LogContainer(logging.Handler):
+    """Class to hold many log events."""
+
+    # pylint: disable=too-many-instance-attributes
+    def __init__(self):
+        # Log storage deque for fast addition and deletion from the beginning
+        # and end of the iterable.
+        self.logs: deque = deque()
+        # Estimate of the logs in memory.
+        self.byte_size: int = 0
+
+        # Only allow this many log lines in memory.
+        self.max_history_size: int = 1000000
+
+        # Counts of logs per python logger name
+        self.channel_counts: Dict[str, int] = {}
+        # Widths of each logger prefix string. For example: the character length
+        # of the timestamp string.
+        self.channel_formatted_prefix_widths: Dict[str, int] = {}
+        # Longest of the above prefix widths.
+        self.longest_channel_prefix_width = 0
+
+        # Current log line index state variables:
+        self.line_index = 0
+        self._last_start_index = 0
+        self._last_end_index = 0
+        self._current_start_index = 0
+        self._current_end_index = 0
+
+        # LogPane prompt_toolkit container render size.
+        self._window_height = 20
+        self._window_width = 80
+
+        # Max frequency in seconds of prompt_toolkit UI redraws triggered by new
+        # log lines.
+        self._ui_update_frequency = 0.3
+        self._last_ui_update_time = time.time()
+
+        # Erase existing logs.
+        self.clear_logs()
+        # New logs have arrived flag.
+        self.has_new_logs = True
+
+        # Should new log lines be tailed?
+        self.follow = True
+
+        # Parent LogPane reference. Updated by calling `set_log_pane()`.
+        self.log_pane = None
+
+        # Cache of formatted text tuples used in the last UI render.  Used after
+        # rendering by `get_cursor_position()`.
+        self._line_fragment_cache = None
+
+        super().__init__()
+
+    def set_log_pane(self, log_pane):
+        """Set the parent LogPane instance."""
+        self.log_pane = log_pane
+
+    def set_formatting(self):
+        """Setup log formatting."""
+        # Copy of pw_cli log formatter
+        colors = pw_cli.color.colors(True)
+        timestamp_fmt = colors.black_on_white('%(asctime)s') + ' '
+        formatter = logging.Formatter(
+            timestamp_fmt + '%(levelname)s %(message)s', '%Y%m%d %H:%M:%S')
+
+        self.setLevel(logging.DEBUG)
+        self.setFormatter(formatter)
+
+    def get_current_line(self):
+        """Return the currently selected log event index."""
+        return self.line_index
+
+    def clear_logs(self):
+        """Erase all stored pane lines."""
+        self.logs = deque()
+        self.byte_size = 0
+        self.channel_counts = {}
+        self.channel_formatted_prefix_widths = {}
+        self.line_index = 0
+
+    def wrap_lines_enabled(self):
+        """Get the parent log pane wrap lines setting."""
+        if not self.log_pane:
+            return False
+        return self.log_pane.wrap_lines
+
+    def toggle_follow(self):
+        """Toggle auto line following."""
+        self.follow = not self.follow
+        if self.follow:
+            self.scroll_to_bottom()
+
+    def get_total_count(self):
+        """Total size of the logs container."""
+        return len(self.logs)
+
+    def get_last_log_line_index(self):
+        """Last valid index of the logs."""
+        # Subtract 1 since self.logs is zero indexed.
+        return len(self.logs) - 1
+
+    def get_channel_counts(self):
+        """Return the seen channel log counts for this conatiner."""
+        return ', '.join([
+            f'{name}: {count}' for name, count in self.channel_counts.items()
+        ])
+
+    def _update_log_prefix_width(self, record, formatted_log):
+        """Save the formatted prefix width if this is a new logger channel
+        name."""
+        if record.name not in self.channel_formatted_prefix_widths.keys():
+            # Delete ANSI escape sequences.
+            ansi_stripped_log = _ANSI_SEQUENCE_REGEX.sub('', formatted_log)
+            # Save the width of the formatted portion of the log message.
+            self.channel_formatted_prefix_widths[
+                record.name] = len(ansi_stripped_log) - len(record.msg)
+            # Set the max width of all known formats so far.
+            self.longest_channel_prefix_width = max(
+                self.channel_formatted_prefix_widths.values())
+
+    def _append_log(self, record):
+        """Add a new log event."""
+        # Format incoming log line.
+        formatted_log = self.format(record)
+        # Save this log.
+        self.logs.append(LogLine(record=record, formatted_log=formatted_log))
+        # Increment this logger count
+        self.channel_counts[record.name] = self.channel_counts.get(
+            record.name, 0) + 1
+
+        # Save prefix width of this log line.
+        self._update_log_prefix_width(record, formatted_log)
+
+        # Update estimated byte_size.
+        self.byte_size += sys.getsizeof(self.logs[-1])
+        # If the total log lines is > max_history_size, delete the oldest line.
+        if self.get_total_count() > self.max_history_size:
+            self.byte_size -= sys.getsizeof(self.logs.popleft())
+
+        # Set the has_new_logs flag.
+        self.has_new_logs = True
+        # If follow is on, scroll to the last line.
+        if self.follow:
+            self.scroll_to_bottom()
+
+    def _update_prompt_toolkit_ui(self):
+        """Update Prompt Toolkit UI if a certain amount of time has passed."""
+        emit_time = time.time()
+        # Has enough time passed since last UI redraw?
+        if emit_time > self._last_ui_update_time + self._ui_update_frequency:
+            # Update last log time
+            self._last_ui_update_time = emit_time
+
+            # Trigger Prompt Toolkit UI redraw.
+            console_app = self.log_pane.application
+            if hasattr(console_app, 'application'):
+                # Thread safe way of sending a repaint trigger to the input
+                # event loop.
+                console_app.application.invalidate()
+
+    def log_content_changed(self):
+        """Return True if new log lines have appeared since the last render."""
+        return self.has_new_logs
+
+    def get_cursor_position(self) -> Optional[Point]:
+        """Return the position of the cursor."""
+        # This implementation is based on get_cursor_position from
+        # prompt_toolkit's FormattedTextControl class.
+
+        fragment = "[SetCursorPosition]"
+        # If no lines were rendered.
+        if not self._line_fragment_cache:
+            return Point(0, 0)
+        # For each line rendered in the last pass:
+        for row, line in enumerate(self._line_fragment_cache):
+            column = 0
+            # For each style string and raw text tuple in this line:
+            for style_str, text, *_ in line:
+                # If [SetCursorPosition] is in the style set the cursor position
+                # to this row and column.
+                if fragment in style_str:
+                    return Point(x=column, y=row)
+                column += len(text)
+        return Point(0, 0)
+
+    def emit(self, record):
+        """Process a new log record.
+
+        This defines the logging.Handler emit() fuction which is called by
+        logging.Handler.handle() We don't implement handle() as it is done in
+        the parent class with thread safety and filters applied.
+        """
+        self._append_log(record)
+        self._update_prompt_toolkit_ui()
+
+    def scroll_to_top(self):
+        """Move selected index to the beginning."""
+        # Stop following so cursor doesn't jump back down to the bottom.
+        self.follow = False
+        self.line_index = 0
+
+    def scroll_to_bottom(self):
+        """Move selected index to the end."""
+        # Don't change following state like scroll_to_top.
+        self.line_index = max(0, self.get_last_log_line_index())
+
+    def scroll(self, lines):
+        """Scroll up or down by plus or minus lines.
+
+        This method is only called by user keybindings.
+        """
+        # If the user starts scrolling, stop auto following.
+        self.follow = False
+
+        # If scrolling to an index below zero, set to zero.
+        new_line_index = max(0, self.line_index + lines)
+        # If past the end, set to the last index of self.logs.
+        if new_line_index >= self.get_total_count():
+            new_line_index = self.get_last_log_line_index()
+        # Set the new selected line index.
+        self.line_index = new_line_index
+
+    def scroll_to_position(self, mouse_position: Point):
+        """Set the selected log line to the mouse_position."""
+        # If auto following don't move the cursor arbitrarily. That would stop
+        # following and position the cursor incorrectly.
+        if self.follow:
+            return
+
+        cursor_position = self.get_cursor_position()
+        if cursor_position:
+            scroll_amount = cursor_position.y - mouse_position.y
+            self.scroll(-1 * scroll_amount)
+
+    def scroll_up_one_page(self):
+        """Move the selected log index up by one window height."""
+        lines = 1
+        if self._window_height > 0:
+            lines = self._window_height
+        self.scroll(-1 * lines)
+
+    def scroll_down_one_page(self):
+        """Move the selected log index down by one window height."""
+        lines = 1
+        if self._window_height > 0:
+            lines = self._window_height
+        self.scroll(lines)
+
+    def scroll_down(self, lines=1):
+        """Move the selected log index down by one or more lines."""
+        self.scroll(lines)
+
+    def scroll_up(self, lines=1):
+        """Move the selected log index up by one or more lines."""
+        self.scroll(-1 * lines)
+
+    def get_log_window_indices(self,
+                               available_width=None,
+                               available_height=None):
+        """Get start and end index."""
+        self._last_start_index = self._current_start_index
+        self._last_end_index = self._current_end_index
+
+        starting_index = 0
+        ending_index = self.line_index
+
+        self._window_width = self.log_pane.current_log_pane_width
+        self._window_height = self.log_pane.current_log_pane_height
+        if available_width:
+            self._window_width = available_width
+        if available_height:
+            self._window_height = available_height
+
+        # If render info is available we use the last window height.
+        if self._window_height > 0:
+            # Window lines are zero indexed so subtract 1 from the height.
+            max_window_row_index = self._window_height - 1
+
+            starting_index = max(0, self.line_index - max_window_row_index)
+            # Use the current_window_height if line_index is less
+            ending_index = max(self.line_index, max_window_row_index)
+
+        if ending_index > self.get_last_log_line_index():
+            ending_index = self.get_last_log_line_index()
+
+        # Save start and end index.
+        self._current_start_index = starting_index
+        self._current_end_index = ending_index
+        self.has_new_logs = False
+
+        return starting_index, ending_index
+
+    def draw(self) -> List:
+        """Return log lines as a list of FormattedText tuples."""
+        # If we have no logs add one with at least a single space character for
+        # the cursor to land on. Otherwise the cursor will be left on the line
+        # above the log pane container.
+        if self.get_total_count() == 0:
+            # No style specified.
+            return [('', ' \n')]
+
+        starting_index, ending_index = self.get_log_window_indices()
+
+        window_width = self._window_width
+        total_used_lines = 0
+        self._line_fragment_cache = deque()
+        # Since range() is not inclusive use ending_index + 1.
+        # for i in range(starting_index, ending_index + 1):
+        # From the ending_index to the starting index in reverse:
+        for i in range(ending_index, starting_index - 1, -1):
+            # If we are past the last valid index.
+            if i > self.get_last_log_line_index():
+                break
+
+            line_fragments = self.logs[i].get_fragments()
+
+            # Get the width of this line.
+            fragment_width = fragment_list_width(line_fragments)
+            # Get the line height respecting line wrapping.
+            line_height = 1
+            if self.wrap_lines_enabled() and (fragment_width > window_width):
+                line_height = get_line_height(
+                    fragment_width, window_width,
+                    self.longest_channel_prefix_width)
+
+            # Keep track of how many lines is used
+            used_lines = 0
+            used_lines += line_height
+
+            # Count the number of line breaks included in the log line.
+            line_breaks = self.logs[i].record.msg.count('\n')
+            used_lines += line_breaks
+
+            # If this is the selected line apply a style class for highlighting.
+            if i == self.line_index:
+                # Set the cursor to this line
+                line_fragments = [('[SetCursorPosition]', '')] + line_fragments
+                # Compute the number of trailing empty characters
+
+                # Calculate the number of spaces to add at the end.
+                empty_characters = window_width - fragment_width
+
+                # If wrapping is enabled the width of the line prefix needs to
+                # be accounted for.
+                if self.wrap_lines_enabled() and (fragment_width >
+                                                  window_width):
+                    total_width = line_height * window_width
+                    content_width = (self.longest_channel_prefix_width *
+                                     (line_height - 1) + fragment_width)
+                    empty_characters = total_width - content_width
+
+                if empty_characters > 0:
+                    line_fragments[-1] = ('', ' ' * empty_characters + '\n')
+
+                line_fragments = to_formatted_text(
+                    line_fragments, style='class:selected-log-line')
+
+            self._line_fragment_cache.appendleft(line_fragments)
+            total_used_lines += used_lines
+            # If we have used more lines than available, stop adding new ones.
+            if total_used_lines > self._window_height:
+                break
+
+        fragments = []
+        for line_fragments in self._line_fragment_cache:
+            # Append all FormattedText tuples for this line.
+            for fragment in line_fragments:
+                fragments.append(fragment)
+
+        # Strip off any trailing line breaks
+        last_fragment = fragments[-1]
+        fragments[-1] = (last_fragment[0], last_fragment[1].rstrip('\n'))
+
+        return fragments
diff --git a/pw_console/py/pw_console/log_pane.py b/pw_console/py/pw_console/log_pane.py
index bf39a39..653f734 100644
--- a/pw_console/py/pw_console/log_pane.py
+++ b/pw_console/py/pw_console/log_pane.py
@@ -16,10 +16,13 @@
 from functools import partial
 from typing import Any, List, Optional
 
+from prompt_toolkit.application.current import get_app
 from prompt_toolkit.filters import (
     Condition,
     has_focus,
 )
+from prompt_toolkit.formatted_text import to_formatted_text
+from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
 from prompt_toolkit.layout import (
     ConditionalContainer,
     Dimension,
@@ -27,25 +30,28 @@
     FloatContainer,
     FormattedTextControl,
     HSplit,
+    ScrollOffsets,
+    UIContent,
     VSplit,
+    VerticalAlign,
     Window,
     WindowAlign,
-    VerticalAlign,
 )
 from prompt_toolkit.layout.dimension import AnyDimension
 from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
 
+from pw_console.log_container import LogContainer
+
 
 class LogPaneLineInfoBar(ConditionalContainer):
     """One line bar for showing current and total log lines."""
     @staticmethod
-    def get_tokens(unused_log_pane):
+    def get_tokens(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)
+            log_pane.log_container.get_current_line() + 1,
+            log_pane.log_container.get_total_count(),
+        )
         return [('', tokens)]
 
     def __init__(self, log_pane):
@@ -62,11 +68,7 @@
                    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
-            ))
+            filter=Condition(lambda: not log_pane.log_container.follow))
 
 
 class LogPaneBottomToolbarBar(ConditionalContainer):
@@ -186,6 +188,115 @@
         )
 
 
+class LogContentControl(FormattedTextControl):
+    """LogPane prompt_toolkit UIControl for displaying LogContainer lines."""
+    @staticmethod
+    def indent_wrapped_pw_log_format_line(log_pane, line_number, wrap_count):
+        """Indent wrapped lines to match pw_cli timestamp & level formatter."""
+        if wrap_count == 0:
+            return None
+
+        prefix = ' ' * log_pane.log_container.longest_channel_prefix_width
+
+        # If this line matches the selected log line, highlight it.
+        if line_number == log_pane.log_container.get_cursor_position().y:
+            return to_formatted_text(prefix, style='class:selected-log-line')
+        return prefix
+
+    def create_content(self, width: int, height: Optional[int]) -> UIContent:
+        # Save redered height
+        if height:
+            self.log_pane.last_log_content_height += height
+        return super().create_content(width, height)
+
+    def __init__(self, log_pane, *args, **kwargs):
+        self.log_pane = log_pane
+
+        # Key bindings.
+        key_bindings = KeyBindings()
+
+        @key_bindings.add('w')
+        def _toggle_wrap_lines(_event: KeyPressEvent) -> None:
+            """Toggle log line wrapping."""
+            self.log_pane.toggle_wrap_lines()
+
+        @key_bindings.add('C')
+        def _clear_history(_event: KeyPressEvent) -> None:
+            """Toggle log line wrapping."""
+            self.log_pane.clear_history()
+
+        @key_bindings.add('g')
+        def _scroll_to_top(_event: KeyPressEvent) -> None:
+            """Scroll to top."""
+            self.log_pane.log_container.scroll_to_top()
+
+        @key_bindings.add('G')
+        def _scroll_to_bottom(_event: KeyPressEvent) -> None:
+            """Scroll to bottom."""
+            self.log_pane.log_container.scroll_to_bottom()
+
+        @key_bindings.add('f')
+        def _toggle_follow(_event: KeyPressEvent) -> None:
+            """Toggle log line following."""
+            self.log_pane.toggle_follow()
+
+        @key_bindings.add('up')
+        @key_bindings.add('k')
+        def _up(_event: KeyPressEvent) -> None:
+            """Select previous log line."""
+            self.log_pane.log_container.scroll_up()
+
+        @key_bindings.add('down')
+        @key_bindings.add('j')
+        def _down(_event: KeyPressEvent) -> None:
+            """Select next log line."""
+            self.log_pane.log_container.scroll_down()
+
+        @key_bindings.add('pageup')
+        def _pageup(_event: KeyPressEvent) -> None:
+            """Scroll the logs up by one page."""
+            self.log_pane.log_container.scroll_up_one_page()
+
+        @key_bindings.add('pagedown')
+        def _pagedown(_event: KeyPressEvent) -> None:
+            """Scroll the logs down by one page."""
+            self.log_pane.log_container.scroll_down_one_page()
+
+        super().__init__(*args, key_bindings=key_bindings, **kwargs)
+
+    def mouse_handler(self, mouse_event: MouseEvent):
+        """Mouse handler for this control."""
+        mouse_position = mouse_event.position
+
+        # Check for pane focus first.
+        # If not in focus, change forus to the log pane and do nothing else.
+        if not has_focus(self)():
+            if mouse_event.event_type == MouseEventType.MOUSE_UP:
+                # Focus buffer when clicked.
+                get_app().layout.focus(self)
+                # Mouse event handled, return None.
+                return None
+
+        if mouse_event.event_type == MouseEventType.MOUSE_UP:
+            # Scroll to the line clicked.
+            self.log_pane.log_container.scroll_to_position(mouse_position)
+            # Mouse event handled, return None.
+            return None
+
+        if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
+            self.log_pane.log_container.scroll_down()
+            # Mouse event handled, return None.
+            return None
+
+        if mouse_event.event_type == MouseEventType.SCROLL_UP:
+            self.log_pane.log_container.scroll_up()
+            # Mouse event handled, return None.
+            return None
+
+        # Mouse event not handled, return NotImplemented.
+        return NotImplemented
+
+
 class LogLineHSplit(HSplit):
     """PromptToolkit HSplit class with a write_to_screen function that saves the
     width and height of the container to be rendered.
@@ -234,25 +345,40 @@
         self.height = height
         self.width = width
 
+        # Create the log container which stores and handles incoming logs.
+        self.log_container = LogContainer()
+        self.log_container.set_log_pane(self)
+        self.log_container.set_formatting()
+
         # 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
+        self.last_log_content_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')],
+        self.log_content_control = LogContentControl(
+            self,  # parent LogPane
+            # FormattedTextControl args:
+            self.log_container.draw,
+            # Hide the cursor, use cursorline=True in self.log_display_window to
+            # indicate currently selected line.
+            show_cursor=False,
             focusable=True,
+            get_cursor_position=self.log_content_control_get_cursor_position,
         )
 
         self.log_display_window = Window(
             content=self.log_content_control,
+            # TODO: ScrollOffsets here causes jumpiness when lines are wrapped.
+            scroll_offsets=ScrollOffsets(top=0, bottom=0),
             allow_scroll_beyond_bottom=True,
+            get_line_prefix=partial(
+                LogContentControl.indent_wrapped_pw_log_format_line, self),
             wrap_lines=Condition(lambda: self.wrap_lines),
             cursorline=False,
             # Don't make the window taller to fill the parent split container.
@@ -310,12 +436,12 @@
 
     def toggle_follow(self):
         """Toggle following log lines."""
-        # TODO: self.log_container.toggle_follow()
+        self.log_container.toggle_follow()
         self.redraw_ui()
 
     def clear_history(self):
         """Erase stored log lines."""
-        # TODO: self.log_container.clear_logs()
+        self.log_container.clear_logs()
         self.redraw_ui()
 
     def __pt_container__(self):
@@ -326,7 +452,15 @@
     def get_all_key_bindings(self) -> List:
         """Return all keybinds for this pane."""
         # TODO: return log content control keybindings
-        return []
+        return [self.log_content_control.get_key_bindings()]
 
     def after_render_hook(self):
         """Run tasks after the last UI render."""
+        self.reset_log_content_height()
+
+    def reset_log_content_height(self):
+        """Reset log line pane content height."""
+        self.last_log_content_height = 0
+
+    def log_content_control_get_cursor_position(self):
+        return self.log_container.get_cursor_position()