pw_console: Log filtering user interface impl

- New filter toolbar for displaying active filters.
- New search bar with keybind help and options for filtering.
- Save search history similar to repl history.
- Added manual test procedure for search and filtering.

Bug: 410
Testing: Log Pane Search & Filtering (Steps 1-17)
Change-Id: I7456bbeb496bc0cf64723540371314f05b60fcbf
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/53903
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>
Reviewed-by: Keir Mierle <keir@google.com>
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn
index 610c94c..ff5d2dc 100644
--- a/pw_console/py/BUILD.gn
+++ b/pw_console/py/BUILD.gn
@@ -22,6 +22,7 @@
     "pw_console/__init__.py",
     "pw_console/__main__.py",
     "pw_console/console_app.py",
+    "pw_console/filter_toolbar.py",
     "pw_console/help_window.py",
     "pw_console/key_bindings.py",
     "pw_console/log_filter.py",
@@ -33,6 +34,7 @@
     "pw_console/mouse.py",
     "pw_console/pw_ptpython_repl.py",
     "pw_console/repl_pane.py",
+    "pw_console/search_toolbar.py",
     "pw_console/style.py",
     "pw_console/text_formatting.py",
     "pw_console/widgets/__init__.py",
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index 4a15ea0..081efcb 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -19,6 +19,7 @@
 import asyncio
 import logging
 import functools
+from pathlib import Path
 from threading import Thread
 from typing import Dict, Iterable, Optional, Union
 
@@ -43,6 +44,11 @@
     MenuItem,
 )
 from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings
+from prompt_toolkit.history import (
+    FileHistory,
+    History,
+    ThreadedHistory,
+)
 from ptpython.layout import CompletionVisualisation  # type: ignore
 from ptpython.key_bindings import (  # type: ignore
     load_python_bindings, load_sidebar_bindings,
@@ -122,6 +128,13 @@
 
         local_vars = local_vars or global_vars
 
+        # TODO(tonymd): Make these configurable per project.
+        self.repl_history_filename = Path.home() / '.pw_console_history'
+        self.search_history_filename = Path.home() / '.pw_console_search'
+        # History instance for search toolbars.
+        self.search_history: History = ThreadedHistory(
+            FileHistory(self.search_history_filename))
+
         # Event loop for executing user repl code.
         self.user_code_loop = asyncio.new_event_loop()
 
@@ -154,7 +167,9 @@
             get_globals=lambda: global_vars,
             get_locals=lambda: local_vars,
             color_depth=color_depth,
+            history_filename=self.repl_history_filename,
         )
+        self.input_history = self.pw_ptpython_repl.history
 
         self.repl_pane = ReplPane(
             application=self,
@@ -263,10 +278,10 @@
 
     def _run_pane_menu_option(self, function_to_run):
         function_to_run()
-        self._update_menu_items()
+        self.update_menu_items()
         self.focus_main_menu()
 
-    def _update_menu_items(self):
+    def update_menu_items(self):
         self.root_container.menu_items = self._create_menu_items()
 
     def _create_menu_items(self):
@@ -346,7 +361,7 @@
                     MenuItem(
                         '{index}: {title} {subtitle}'.format(
                             index=index + 1,
-                            title=pane.pane_title(),
+                            title=pane.menu_title(),
                             subtitle=pane.pane_subtitle()),
                         children=[
                             MenuItem(
@@ -402,7 +417,7 @@
         else:
             self.active_panes.append(new_pane)
 
-        self._update_menu_items()
+        self.update_menu_items()
         self._update_root_container_body()
 
         self.redraw_ui()
@@ -419,7 +434,7 @@
             # deque if existing_pane can't be found.
             pass
 
-        self._update_menu_items()
+        self.update_menu_items()
         self._update_root_container_body()
         if len(self.active_panes) > 0:
             existing_pane_index -= 1
@@ -502,13 +517,13 @@
     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_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_menu_items()
         self._update_root_container_body()
 
         # Set focus to the top level menu. This has the effect of keeping the
@@ -583,7 +598,7 @@
             _add_log_handler_to_pane(logger, existing_log_pane)
 
         self._update_root_container_body()
-        self._update_menu_items()
+        self.update_menu_items()
         self._update_help_window()
 
     def _user_code_thread_entry(self):
@@ -666,7 +681,7 @@
         """Toggle visibility of the help window."""
         self.vertical_split = not self.vertical_split
 
-        self._update_menu_items()
+        self.update_menu_items()
         self._update_root_container_body()
 
         self.redraw_ui()
diff --git a/pw_console/py/pw_console/filter_toolbar.py b/pw_console/py/pw_console/filter_toolbar.py
new file mode 100644
index 0000000..f340c71
--- /dev/null
+++ b/pw_console/py/pw_console/filter_toolbar.py
@@ -0,0 +1,114 @@
+# 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 FilterToolbar class."""
+
+from __future__ import annotations
+import functools
+from typing import TYPE_CHECKING
+
+from prompt_toolkit.filters import Condition
+from prompt_toolkit.layout import (
+    ConditionalContainer,
+    FormattedTextControl,
+    VSplit,
+    Window,
+    WindowAlign,
+    HorizontalAlign,
+)
+from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
+
+import pw_console.widgets.checkbox
+import pw_console.widgets.mouse_handlers
+import pw_console.style
+
+if TYPE_CHECKING:
+    from pw_console.log_pane import LogPane
+
+
+class FilterToolbar(ConditionalContainer):
+    """Container showing each filter applied in order."""
+
+    TOOLBAR_HEIGHT = 1
+
+    def mouse_handler_delete_filter(self, filter_text,
+                                    mouse_event: MouseEvent):
+        """Delete the given log filter."""
+        if mouse_event.event_type == MouseEventType.MOUSE_UP:
+            self.log_pane.log_view.delete_filter(filter_text)
+            return None
+        return NotImplemented
+
+    def get_left_fragments(self):
+        """Return formatted text tokens for display."""
+        separator = ('', '  ')
+        space = ('', ' ')
+        fragments = [('class:filter-bar-title', ' Filters '), separator]
+
+        for filter_text, log_filter in self.log_pane.log_view.filters.items():
+            fragments.append(('class:filter-bar-delimiter', '<'))
+
+            if log_filter.invert:
+                fragments.append(('class:filter-bar-setting', 'NOT '))
+
+            if log_filter.field:
+                fragments.append(
+                    ('class:filter-bar-setting', log_filter.field))
+                fragments.append(space)
+
+            fragments.append(('', filter_text))
+            fragments.append(space)
+
+            fragments.append(
+                ('class:filter-bar-delete', '(X)',
+                 functools.partial(self.mouse_handler_delete_filter,
+                                   filter_text)))  # type: ignore
+            fragments.append(('class:filter-bar-delimiter', '>'))
+
+            fragments.append(separator)
+        return fragments
+
+    def get_center_fragments(self):
+        """Return formatted text tokens for display."""
+        clear_filters = functools.partial(
+            pw_console.widgets.mouse_handlers.on_click,
+            self.log_pane.log_view.clear_filters)
+
+        return pw_console.widgets.checkbox.to_keybind_indicator(
+            'Ctrl-Alt-r', 'Clear Filters', clear_filters)
+
+    def __init__(self, log_pane: 'LogPane'):
+        self.log_pane = log_pane
+        left_bar_control = FormattedTextControl(self.get_left_fragments)
+        left_bar_window = Window(content=left_bar_control,
+                                 align=WindowAlign.LEFT,
+                                 dont_extend_width=True)
+        center_bar_control = FormattedTextControl(self.get_center_fragments)
+        center_bar_window = Window(content=center_bar_control,
+                                   align=WindowAlign.LEFT,
+                                   dont_extend_width=False)
+        super().__init__(
+            VSplit(
+                [
+                    left_bar_window,
+                    center_bar_window,
+                ],
+                style=functools.partial(pw_console.style.get_toolbar_style,
+                                        self.log_pane,
+                                        dim=True),
+                height=1,
+                align=HorizontalAlign.LEFT,
+            ),
+            # Only show if filtering is enabled.
+            filter=Condition(lambda: self.log_pane.log_view.filtering_on),
+        )
diff --git a/pw_console/py/pw_console/help_window.py b/pw_console/py/pw_console/help_window.py
index 8810bb8..89689ea 100644
--- a/pw_console/py/pw_console/help_window.py
+++ b/pw_console/py/pw_console/help_window.py
@@ -149,9 +149,11 @@
                 description, list())
 
             # Save the name of the key e.g. F1, q, ControlQ, ControlUp
-            key_name = getattr(binding.keys[0], 'name', str(binding.keys[0]))
+            key_name = '-'.join(
+                [getattr(key, 'name', str(key)) for key in binding.keys])
             key_name = key_name.replace('Control', 'Ctrl-')
             key_name = key_name.replace('Shift', 'Shift-')
+            key_name = key_name.replace('Escape-', 'Alt-')
             key_list.append(key_name)
 
             key_list_width = len(', '.join(key_list))
diff --git a/pw_console/py/pw_console/log_pane.py b/pw_console/py/pw_console/log_pane.py
index 2a38e15..ba43d92 100644
--- a/pw_console/py/pw_console/log_pane.py
+++ b/pw_console/py/pw_console/log_pane.py
@@ -45,9 +45,10 @@
 from pw_console.log_pane_toolbars import (
     BottomToolbarBar,
     LineInfoBar,
-    SearchToolbar,
     TableToolbar,
 )
+from pw_console.search_toolbar import SearchToolbar
+from pw_console.filter_toolbar import FilterToolbar
 
 
 class LogContentControl(FormattedTextControl):
@@ -81,6 +82,7 @@
         return super().create_content(width, height)
 
     def __init__(self, log_pane: 'LogPane', *args, **kwargs) -> None:
+        # pylint: disable=too-many-locals
         self.log_pane = log_pane
 
         # Key bindings.
@@ -169,18 +171,22 @@
             self.log_pane.log_view.scroll_down_one_page()
 
         @key_bindings.add('/')
+        @key_bindings.add('c-f')
         def _start_search(_event: KeyPressEvent) -> None:
             """Start searching."""
             self.log_pane.start_search()
 
         @key_bindings.add('n')
+        @key_bindings.add('c-s')
+        @key_bindings.add('c-g')
         def _next_search(_event: KeyPressEvent) -> None:
-            """Repeat the last search."""
+            """Next search match."""
             self.log_pane.log_view.search_forwards()
 
         @key_bindings.add('N')
+        @key_bindings.add('c-r')
         def _previous_search(_event: KeyPressEvent) -> None:
-            """Repeat the last search in the opposite direction."""
+            """Previous search match."""
             self.log_pane.log_view.search_backwards()
 
         @key_bindings.add('c-l')
@@ -188,6 +194,16 @@
             """Remove search highlighting."""
             self.log_pane.log_view.search_highlight = False
 
+        @key_bindings.add('escape', 'c-f')  # Alt-Ctrl-f
+        def _apply_filter(_event: KeyPressEvent) -> None:
+            """Apply current search as a filter."""
+            self.log_pane.log_view.apply_filter()
+
+        @key_bindings.add('escape', 'c-r')  # Alt-Ctrl-r
+        def _clear_filter(_event: KeyPressEvent) -> None:
+            """Reset / erase active filters."""
+            self.log_pane.log_view.clear_filters()
+
         kwargs['key_bindings'] = key_bindings
         super().__init__(*args, **kwargs)
 
@@ -199,8 +215,12 @@
         # 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)
+                # Focus the search bar if it is open.
+                if self.log_pane.search_bar_active:
+                    get_app().layout.focus(self.log_pane.search_toolbar)
+                # Otherwise focus on the log pane content.
+                else:
+                    get_app().layout.focus(self)
                 # Mouse event handled, return None.
                 return None
 
@@ -291,6 +311,7 @@
         # Search tracking
         self.search_bar_active = False
         self.search_toolbar = SearchToolbar(self)
+        self.filter_toolbar = FilterToolbar(self)
 
         # Table header bar, only shown if table view is active.
         self.table_header_toolbar = TableToolbar(self)
@@ -343,6 +364,7 @@
                     [
                         self.table_header_toolbar,
                         self.log_display_window,
+                        self.filter_toolbar,
                         self.search_toolbar,
                         self.bottom_toolbar,
                     ],
@@ -388,6 +410,20 @@
             title = 'Logs'
         return title
 
+    def menu_title(self):
+        """Return the title to display in the Window menu."""
+        title = self.pane_title()
+
+        # List active filters
+        if self.log_view.filtering_on:
+            title += ' (FILTERS: '
+            title += ' '.join([
+                log_filter.pattern()
+                for log_filter in self.log_view.filters.values()
+            ])
+            title += ')'
+        return title
+
     def append_pane_subtitle(self, text):
         if not self._pane_subtitle:
             self._pane_subtitle = text
@@ -411,12 +447,6 @@
         # Focus on the search bar
         self.application.focus_on_container(self.search_toolbar)
 
-    def apply_search(self, text: str):
-        self.log_view.new_search(text)
-
-    def apply_filter(self):
-        self.log_view.apply_filter()
-
     def update_log_pane_size(self, width, height):
         """Save width and height of the log pane for the current UI render
         pass."""
@@ -430,6 +460,8 @@
                 height -= TableToolbar.TOOLBAR_HEIGHT
             if self.search_bar_active:
                 height -= SearchToolbar.TOOLBAR_HEIGHT
+            if self.log_view.filtering_on:
+                height -= FilterToolbar.TOOLBAR_HEIGHT
             self.last_log_pane_height = self.current_log_pane_height
             self.current_log_pane_height = height
 
@@ -492,28 +524,41 @@
                         self.log_view.follow, end='')),
                 self.toggle_follow,
             ),
-            (
-                "Remove search highlighting",
-                self.log_view.disable_search_highlighting,
-            ),
             # Menu separator
             ('-', None),
             (
-                "Clear history",
+                'Clear history',
                 self.clear_history,
             ),
             (
-                "Duplicate pane",
+                'Duplicate pane',
                 self.duplicate,
             ),
         ]
-
         if self.is_a_duplicate:
             options += [(
-                "Remove pane",
+                'Remove pane',
                 functools.partial(self.application.remove_pane, self),
             )]
 
+        # Search / Filter section
+        options += [
+            # Menu separator
+            ('-', None),
+            (
+                'Hide search highlighting',
+                self.log_view.disable_search_highlighting,
+            ),
+            (
+                'Create filter from search results',
+                self.log_view.apply_filter,
+            ),
+            (
+                'Reset active filters',
+                self.log_view.clear_filters,
+            ),
+        ]
+
         return options
 
     def after_render_hook(self):
@@ -542,8 +587,8 @@
 
         # Set any existing search state.
         new_pane.log_view.search_text = self.log_view.search_text
-        new_pane.log_view.search_re_flags = self.log_view.search_re_flags
-        new_pane.log_view.search_regex = self.log_view.search_regex
+        new_pane.log_view.search_filter = self.log_view.search_filter
+        new_pane.log_view.search_matcher = self.log_view.search_matcher
         new_pane.log_view.search_highlight = self.log_view.search_highlight
 
         # Mark new pane as a duplicate so it can be deleted.
diff --git a/pw_console/py/pw_console/log_pane_toolbars.py b/pw_console/py/pw_console/log_pane_toolbars.py
index 5b2ce6e..9bcc32b 100644
--- a/pw_console/py/pw_console/log_pane_toolbars.py
+++ b/pw_console/py/pw_console/log_pane_toolbars.py
@@ -11,18 +11,16 @@
 # 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."""
+"""LogPane Info Toolbar classes."""
 
 from __future__ import annotations
 import functools
 from typing import TYPE_CHECKING
 
-from prompt_toolkit.buffer import Buffer
 from prompt_toolkit.filters import (
     Condition,
     has_focus,
 )
-from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
 from prompt_toolkit.layout import (
     ConditionalContainer,
     FormattedTextControl,
@@ -31,7 +29,6 @@
     WindowAlign,
     HorizontalAlign,
 )
-from prompt_toolkit.widgets import TextArea
 from prompt_toolkit.data_structures import Point
 
 import pw_console.widgets.checkbox
@@ -228,70 +225,3 @@
             toolbar_vsplit,
             filter=Condition(lambda: log_pane.show_bottom_toolbar),
         )
-
-
-class SearchToolbar(ConditionalContainer):
-    """One line toolbar for entering search text."""
-
-    TOOLBAR_HEIGHT = 1
-
-    def close_search_bar(self):
-        """Close search bar."""
-        self.log_pane.search_bar_active = False
-        # Focus on the log_pane.
-        self.log_pane.application.focus_on_container(self.log_pane)
-        self.log_pane.redraw_ui()
-
-    def __init__(self, log_pane: 'LogPane'):
-        self.log_pane = log_pane
-
-        # FormattedText of the search column headers.
-        self.input_field = TextArea(
-            prompt=[('class:logo', '/')],
-            focusable=True,
-            scrollbar=False,
-            multiline=False,
-            height=1,
-            dont_extend_height=True,
-            dont_extend_width=False,
-            accept_handler=self._search_accept_handler,
-        )
-
-        # Additional keybindings for the text area.
-        key_bindings = KeyBindings()
-
-        @key_bindings.add('escape')
-        @key_bindings.add('c-c')
-        @key_bindings.add('c-d')
-        @key_bindings.add('c-g')
-        def _close_search_bar(_event: KeyPressEvent) -> None:
-            """Close search bar."""
-            self.close_search_bar()
-
-        @key_bindings.add('c-f')
-        def _apply_filter(_event: KeyPressEvent) -> None:
-            """Apply search as a filter."""
-            self.log_pane.apply_filter()
-
-        self.input_field.control.key_bindings = key_bindings
-
-        super().__init__(VSplit([self.input_field],
-                                height=1,
-                                style=functools.partial(
-                                    pw_console.style.get_toolbar_style,
-                                    log_pane),
-                                align=HorizontalAlign.LEFT),
-                         filter=Condition(lambda: log_pane.search_bar_active))
-
-    def _search_accept_handler(self, buff: Buffer) -> bool:
-        """Function run when hitting Enter in the search bar."""
-        # Always close the search bar.
-        self.close_search_bar()
-
-        if len(buff.text) == 0:
-            # Don't apply an empty search.
-            return False
-
-        self.log_pane.apply_search(buff.text)
-        # Erase existing search text.
-        return False
diff --git a/pw_console/py/pw_console/log_view.py b/pw_console/py/pw_console/log_view.py
index af18eea..3a2f461 100644
--- a/pw_console/py/pw_console/log_view.py
+++ b/pw_console/py/pw_console/log_view.py
@@ -217,6 +217,8 @@
         # Reset existing search
         self.clear_search()
 
+        # Trigger a main menu update to set log window menu titles.
+        self.log_pane.application.update_menu_items()
         # Redraw the UI
         self.log_pane.application.redraw_ui()
 
diff --git a/pw_console/py/pw_console/pw_ptpython_repl.py b/pw_console/py/pw_console/pw_ptpython_repl.py
index a48d18d..44b6e33 100644
--- a/pw_console/py/pw_console/pw_ptpython_repl.py
+++ b/pw_console/py/pw_console/pw_ptpython_repl.py
@@ -18,7 +18,6 @@
 import io
 import logging
 import sys
-from pathlib import Path
 
 from prompt_toolkit.buffer import Buffer
 from prompt_toolkit.filters import (
@@ -44,7 +43,6 @@
         super().__init__(
             *args,
             create_app=False,
-            history_filename=(Path.home() / '.pw_console_history').as_posix(),
             # Use python_toolkit default color depth.
             # color_depth=ColorDepth.DEPTH_8_BIT,  # 256 Colors
             _input_buffer_height=Dimension(min=5, weight=30),
diff --git a/pw_console/py/pw_console/repl_pane.py b/pw_console/py/pw_console/repl_pane.py
index 784cb06..e128f2a 100644
--- a/pw_console/py/pw_console/repl_pane.py
+++ b/pw_console/py/pw_console/repl_pane.py
@@ -279,6 +279,10 @@
     def pane_title(self):  # pylint: disable=no-self-use
         return 'Python Repl'
 
+    def menu_title(self):
+        """Return the title to display in the Window menu."""
+        return self.pane_title()
+
     def pane_subtitle(self):  # pylint: disable=no-self-use
         return ''
 
diff --git a/pw_console/py/pw_console/search_toolbar.py b/pw_console/py/pw_console/search_toolbar.py
new file mode 100644
index 0000000..84691d9
--- /dev/null
+++ b/pw_console/py/pw_console/search_toolbar.py
@@ -0,0 +1,279 @@
+# 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.
+"""SearchToolbar class used by LogPanes."""
+
+from __future__ import annotations
+import functools
+from typing import TYPE_CHECKING
+
+from prompt_toolkit.buffer import Buffer
+from prompt_toolkit.filters import (
+    Condition, )
+from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
+from prompt_toolkit.layout import (
+    ConditionalContainer,
+    FormattedTextControl,
+    HSplit,
+    Window,
+    WindowAlign,
+)
+from prompt_toolkit.widgets import TextArea
+from prompt_toolkit.validation import DynamicValidator
+
+from pw_console.log_view import RegexValidator, SearchMatcher
+# import pw_console.widgets.checkbox
+import pw_console.widgets.mouse_handlers
+
+if TYPE_CHECKING:
+    from pw_console.log_pane import LogPane
+
+
+class SearchToolbar(ConditionalContainer):
+    """One line toolbar for entering search text."""
+
+    TOOLBAR_HEIGHT = 3
+
+    def focus_self(self):
+        self.log_pane.application.application.layout.focus(self)
+
+    def close_search_bar(self):
+        """Close search bar."""
+        # Reset invert setting for the next search
+        self._search_invert = False
+        # Hide the search bar
+        self.log_pane.search_bar_active = False
+        # Focus on the log_pane.
+        self.log_pane.application.focus_on_container(self.log_pane)
+        self.log_pane.redraw_ui()
+
+    def _start_search(self):
+        self.input_field.buffer.validate_and_handle()
+
+    def _invert_search(self):
+        self._search_invert = not self._search_invert
+
+    def _next_field(self):
+        fields = self.log_pane.log_view.log_store.table.all_column_names()
+        fields.append(None)
+        current_index = fields.index(self._search_field)
+        next_index = (current_index + 1) % len(fields)
+        self._search_field = fields[next_index]
+
+    def create_filter(self):
+        self._start_search()
+        if self._search_successful:
+            self.log_pane.log_view.apply_filter()
+
+    def get_search_help_fragments(self):
+        """Return FormattedText with search general help keybinds."""
+        focus = functools.partial(pw_console.widgets.mouse_handlers.on_click,
+                                  self.focus_self)
+        start_search = functools.partial(
+            pw_console.widgets.mouse_handlers.on_click, self._start_search)
+        add_filter = functools.partial(
+            pw_console.widgets.mouse_handlers.on_click, self.create_filter)
+        clear_filters = functools.partial(
+            pw_console.widgets.mouse_handlers.on_click,
+            self.log_pane.log_view.clear_filters)
+        close_search = functools.partial(
+            pw_console.widgets.mouse_handlers.on_click, self.close_search_bar)
+
+        separator_text = [('', '  ', focus)]
+
+        # Empty text matching the width of the search bar title.
+        fragments = [
+            ('', '        ', focus),
+        ]
+        fragments.extend(separator_text)
+
+        fragments.extend(
+            pw_console.widgets.checkbox.to_keybind_indicator(
+                'Enter', 'Search', start_search))
+        fragments.extend(separator_text)
+
+        fragments.extend(
+            pw_console.widgets.checkbox.to_keybind_indicator(
+                'Ctrl-Alt-f', 'Add Filter', add_filter))
+        fragments.extend(separator_text)
+
+        fragments.extend(
+            pw_console.widgets.checkbox.to_keybind_indicator(
+                'Ctrl-Alt-r', 'Clear Filters', clear_filters))
+        fragments.extend(separator_text)
+
+        fragments.extend(
+            pw_console.widgets.checkbox.to_keybind_indicator(
+                'Ctrl-c', 'Close', close_search))
+
+        fragments.extend(separator_text)
+        return fragments
+
+    def get_search_settings_fragments(self):
+        """Return FormattedText with current search settings and keybinds."""
+        focus = functools.partial(pw_console.widgets.mouse_handlers.on_click,
+                                  self.focus_self)
+        next_field = functools.partial(
+            pw_console.widgets.mouse_handlers.on_click, self._next_field)
+        toggle_invert = functools.partial(
+            pw_console.widgets.mouse_handlers.on_click, self._invert_search)
+        next_matcher = functools.partial(
+            pw_console.widgets.mouse_handlers.on_click,
+            self.log_pane.log_view.select_next_search_matcher)
+
+        separator_text = [('', '  ', focus)]
+
+        fragments = [
+            # Title
+            ('class:search-bar-title', ' Search ', focus),
+        ]
+        fragments.extend(separator_text)
+
+        fragments.extend(
+            pw_console.widgets.checkbox.to_keybind_indicator(
+                'Ctrl-t', 'Column:', next_field))
+        fragments.extend([
+            ('class:search-bar-setting',
+             (self._search_field.title() if self._search_field else 'All'),
+             next_field),
+        ])
+        fragments.extend(separator_text)
+
+        fragments.extend(
+            pw_console.widgets.checkbox.to_checkbox_with_keybind_indicator(
+                self._search_invert, 'Ctrl-v', 'Invert', toggle_invert))
+        fragments.extend(separator_text)
+
+        # Matching Method
+        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),
+        ])
+        fragments.extend(separator_text)
+
+        return fragments
+
+    def get_search_matcher(self):
+        if self.log_pane.log_view.search_matcher == SearchMatcher.REGEX:
+            return self.log_pane.log_view.search_validator
+        return False
+
+    def __init__(self, log_pane: 'LogPane'):
+        self.log_pane = log_pane
+        self.search_validator = RegexValidator()
+        self._search_successful = False
+        self._search_invert = False
+        self._search_field = None
+
+        # FormattedText of the search column headers.
+        self.input_field = TextArea(
+            prompt=[
+                ('class:search-bar-setting', '/',
+                 functools.partial(pw_console.widgets.mouse_handlers.on_click,
+                                   self.focus_self))
+            ],
+            focusable=True,
+            focus_on_click=True,
+            scrollbar=False,
+            multiline=False,
+            height=1,
+            dont_extend_height=True,
+            dont_extend_width=False,
+            accept_handler=self._search_accept_handler,
+            validator=DynamicValidator(self.get_search_matcher),
+            history=self.log_pane.application.search_history,
+        )
+
+        search_help_bar_control = FormattedTextControl(
+            self.get_search_help_fragments)
+        search_help_bar_window = Window(content=search_help_bar_control,
+                                        height=1,
+                                        align=WindowAlign.LEFT,
+                                        dont_extend_width=False)
+
+        search_settings_bar_control = FormattedTextControl(
+            self.get_search_settings_fragments)
+        search_settings_bar_window = Window(
+            content=search_settings_bar_control,
+            height=1,
+            align=WindowAlign.LEFT,
+            dont_extend_width=False)
+
+        # Additional keybindings for the text area.
+        key_bindings = KeyBindings()
+
+        @key_bindings.add('escape')
+        @key_bindings.add('c-c')
+        @key_bindings.add('c-d')
+        def _close_search_bar(_event: KeyPressEvent) -> None:
+            """Close search bar."""
+            self.close_search_bar()
+
+        @key_bindings.add('c-n')
+        def _select_next_search_matcher(_event: KeyPressEvent) -> None:
+            """Select the next search matcher."""
+            self.log_pane.log_view.select_next_search_matcher()
+
+        @key_bindings.add('escape', 'c-f')  # Alt-Ctrl-f
+        def _create_filter(_event: KeyPressEvent) -> None:
+            """Create a filter."""
+            self.create_filter()
+
+        @key_bindings.add('c-v')
+        def _toggle_search_invert(_event: KeyPressEvent) -> None:
+            """Toggle inverted search matching."""
+            self._invert_search()
+
+        @key_bindings.add('c-t')
+        def _select_next_field(_event: KeyPressEvent) -> None:
+            """Select next search field/column."""
+            self._next_field()
+
+        # Clear filter keybind is handled by the parent log_pane.
+
+        self.input_field.control.key_bindings = key_bindings
+
+        super().__init__(
+            HSplit(
+                [
+                    search_help_bar_window,
+                    search_settings_bar_window,
+                    self.input_field,
+                ],
+                height=SearchToolbar.TOOLBAR_HEIGHT,
+                style='class:search-bar',
+            ),
+            filter=Condition(lambda: log_pane.search_bar_active),
+        )
+
+    def _search_accept_handler(self, buff: Buffer) -> bool:
+        """Function run when hitting Enter in the search bar."""
+        self._search_successful = False
+        if len(buff.text) == 0:
+            self.close_search_bar()
+            # Don't apply an empty search.
+            return False
+
+        if self.log_pane.log_view.new_search(buff.text,
+                                             invert=self._search_invert,
+                                             field=self._search_field):
+            self._search_successful = True
+            self.close_search_bar()
+            # Erase existing search text.
+            return False
+
+        # Keep existing text if regex error
+        return True
diff --git a/pw_console/py/pw_console/style.py b/pw_console/py/pw_console/style.py
index 6fe47b4..4847809 100644
--- a/pw_console/py/pw_console/style.py
+++ b/pw_console/py/pw_console/style.py
@@ -187,6 +187,18 @@
         'log-table-column-5': '{}'.format(theme.yellow_accent),
         'log-table-column-6': '{}'.format(theme.orange_accent),
         'log-table-column-7': '{}'.format(theme.red_accent),
+
+        'search-bar-title': 'bg:{} {}'.format(theme.cyan_accent,
+                                              theme.default_bg),
+        'search-bar-setting': '{}'.format(theme.cyan_accent),
+        'search-bar': 'bg:{}'.format(theme.inactive_bg),
+
+        'filter-bar-title': 'bg:{} {}'.format(theme.red_accent,
+                                              theme.default_bg),
+        'filter-bar-setting': '{}'.format(theme.cyan_accent),
+        'filter-bar-delete': '{}'.format(theme.red_accent),
+        'filter-bar': 'bg:{}'.format(theme.inactive_bg),
+        'filter-bar-delimiter': '{}'.format(theme.purple_accent),
     } # yapf: disable
 
     return Style.from_dict(pw_console_styles)
diff --git a/pw_console/py/pw_console/widgets/table.py b/pw_console/py/pw_console/widgets/table.py
index f039e73..3781235 100644
--- a/pw_console/py/pw_console/widgets/table.py
+++ b/pw_console/py/pw_console/widgets/table.py
@@ -43,6 +43,12 @@
         # Width of all columns except the final message
         self.column_width_prefix_total = 0
 
+    def all_column_names(self):
+        columns_names = [
+            name for name, _width in self._ordered_column_widths()
+        ]
+        return columns_names + ['message', 'lvl', 'time']
+
     def _width_of_justified_fields(self):
         """Calculate the width of all columns except LAST_TABLE_COLUMN_NAMES."""
         padding_width = len(TableView.COLUMN_PADDING)
diff --git a/pw_console/testing.rst b/pw_console/testing.rst
index 3ca1e8b..e2979a7 100644
--- a/pw_console/testing.rst
+++ b/pw_console/testing.rst
@@ -20,10 +20,6 @@
 
   pw console --test-mode
 
-.. |uncheck| raw:: html
-
-    <input type="checkbox">
-
 2. Test the Log Pane
 ^^^^^^^^^^^^^^^^^^^^
 
@@ -35,30 +31,159 @@
      - Test Action
      - Expected Result
      - ✅
+
    * - 1
      - Click the ``Logs`` window title
      - Log pane is focused
-     - |uncheck|
+     - |checkbox|
+
    * - 2
      - Click ``Search`` on the log toolbar
-     - The search bar appears with the cursor enabled
-     - |uncheck|
+     - | The search bar appears
+       | The cursor should appear after the ``/``
+     - |checkbox|
+
    * - 3
      - Press ``Ctrl-c``
      - The search bar disappears
-     - |uncheck|
+     - |checkbox|
+
    * - 4
      - Click ``Follow`` on the log toolbar
      - Logs stop following
-     - |uncheck|
+     - |checkbox|
+
    * - 5
      - Click ``Table`` on the log toolbar
      - Table mode is disabled
-     - |uncheck|
+     - |checkbox|
+
    * - 6
      - Click ``Wrap`` on the log toolbar
      - Line wrapping is enabled
-     - |uncheck|
+     - |checkbox|
+
+2. Test Log Pane Search and Filtering
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+   :widths: 5 45 45 5
+   :header-rows: 1
+
+   * - #
+     - Test Action
+     - Expected Result
+     - ✅
+
+   * - 1
+     - | Click the ``Logs``
+       | window title
+     - Log pane is focused
+     - |checkbox|
+
+   * - 2
+     - Press ``/``
+     - | The search bar appears
+       | The cursor should appear after the ``/``
+     - |checkbox|
+
+   * - 3
+     - | Type ``lorem``
+       | Press ``Enter``
+     - | Logs stop following
+       | The previous ``Lorem`` word is highlighted in yellow
+       | All other ``Lorem`` words are highlighted in cyan
+     - |checkbox|
+
+   * - 4
+     - Press ``Ctrl-f``
+     - | The search bar appears
+       | The cursor should appear after the ``/``
+     - |checkbox|
+
+   * - 5
+     - Click ``Matcher:`` once
+     - ``Matcher:STRING`` is shown
+     - |checkbox|
+
+   * - 6
+     - | Type ``[=``
+       | Press ``Enter``
+     - All instances of ``[=`` should be highlighted
+     - |checkbox|
+
+   * - 7
+     - Press ``/``
+     - | The search bar appears
+       | The cursor should appear after the ``/``
+     - |checkbox|
+
+   * - 8
+     - Press ``Up``
+     - The text ``[=`` should appear in the search input field
+     - |checkbox|
+
+   * - 9
+     - Click ``Add Filter``
+     - | A ``Filters`` toolbar will appear
+       | showing the new filter: ``<\[= (X)>``.
+       | Only log messages matching ``[=`` appear in the logs.
+     - |checkbox|
+
+   * - 10
+     - | Press ``/``
+       | Type ``# 1``
+       | Click ``Add Filter``
+     - | The ``Filters`` toolbar shows a new filter: ``<\#\ 1 (X)>``.
+       | Only log messages matching both filters will appear in the logs.
+     - |checkbox|
+
+   * - 11
+     - | Click the first ``(X)``
+       | in the filter toolbar.
+     - | The ``Filters`` toolbar shows only one filter: ``<\#\ 1 (X)>``.
+       | More log messages will appear in the log window
+       | Lines all end in: ``# 1.*``
+     - |checkbox|
+
+   * - 12
+     - Click ``Clear Filters``
+     - | The ``Filters`` toolbar will disappear.
+       | All log messages will be shown in the log window.
+     - |checkbox|
+
+   * - 13
+     - | Press ``/``
+       | Type ``BAT``
+       | Click ``Column``
+     - ``Column:Module`` is shown
+     - |checkbox|
+
+   * - 14
+     - | Click ``Add Filter``
+     - | The Filters toolbar appears with one filter: ``<module BAT (X)>``
+       | Only logs with Module matching ``BAT`` appear.
+     - |checkbox|
+
+   * - 15
+     - Click ``Clear Filters``
+     - | The ``Filters`` toolbar will disappear.
+       | All log messages will be shown in the log window.
+     - |checkbox|
+
+   * - 16
+     - | Press ``/``
+       | Type ``BAT``
+       | Click ``Invert``
+     - ``[x] Invert`` setting is shown
+     - |checkbox|
+
+   * - 17
+     - | Click ``Add Filter``
+     - | The Filters toolbar appears
+       | One filter is shown: ``<NOT module BAT (X)>``
+       | Only logs with Modules other than ``BAT`` appear.
+     - |checkbox|
 
 3. Add note to the commit message
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -69,3 +194,7 @@
 .. code-block:: text
 
    Testing: Log Pane: Manual steps 1-6
+
+.. |checkbox| raw:: html
+
+    <input type="checkbox">