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">