blob: eb4fbfda44aded8cbde4509846ffceda121a38bc [file] [log] [blame]
# Copyright 2021 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""LogPane class."""
import functools
import logging
import re
from typing import Any, List, Optional, Union, TYPE_CHECKING
from prompt_toolkit.application.current import get_app
from prompt_toolkit.filters import (
Condition,
has_focus,
)
from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.key_binding import (
KeyBindings,
KeyPressEvent,
KeyBindingsBase,
)
from prompt_toolkit.layout import (
ConditionalContainer,
Float,
FloatContainer,
UIContent,
UIControl,
VerticalAlign,
Window,
)
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseButton
import pw_console.widgets.checkbox
import pw_console.style
from pw_console.log_view import LogView
from pw_console.log_pane_toolbars import (
LineInfoBar,
TableToolbar,
)
from pw_console.log_pane_saveas_dialog import LogPaneSaveAsDialog
from pw_console.log_pane_selection_dialog import LogPaneSelectionDialog
from pw_console.log_store import LogStore
from pw_console.search_toolbar import SearchToolbar
from pw_console.filter_toolbar import FilterToolbar
from pw_console.widgets import (
ToolbarButton,
WindowPane,
WindowPaneHSplit,
WindowPaneToolbar,
)
if TYPE_CHECKING:
from pw_console.console_app import ConsoleApp
_LOG_OUTPUT_SCROLL_AMOUNT = 5
_LOG = logging.getLogger(__package__)
class LogContentControl(UIControl):
"""LogPane prompt_toolkit UIControl for displaying LogContainer lines."""
def __init__(self, log_pane: 'LogPane') -> None:
# pylint: disable=too-many-locals
self.log_pane = log_pane
self.log_view = log_pane.log_view
# Mouse drag visual selection flags.
self.visual_select_mode_drag_start = False
self.visual_select_mode_drag_stop = False
self.uicontent: Optional[UIContent] = None
self.lines: List[StyleAndTextTuples] = []
# Key bindings.
key_bindings = KeyBindings()
register = log_pane.application.prefs.register_keybinding
@register('log-pane.shift-line-to-top', key_bindings)
def _shift_log_to_top(_event: KeyPressEvent) -> None:
"""Shift the selected log line to the top."""
self.log_view.move_selected_line_to_top()
@register('log-pane.shift-line-to-center', key_bindings)
def _shift_log_to_center(_event: KeyPressEvent) -> None:
"""Shift the selected log line to the center."""
self.log_view.center_log_line()
@register('log-pane.toggle-wrap-lines', key_bindings)
def _toggle_wrap_lines(_event: KeyPressEvent) -> None:
"""Toggle log line wrapping."""
self.log_pane.toggle_wrap_lines()
@register('log-pane.toggle-table-view', key_bindings)
def _toggle_table_view(_event: KeyPressEvent) -> None:
"""Toggle table view."""
self.log_pane.toggle_table_view()
@register('log-pane.duplicate-log-pane', key_bindings)
def _duplicate(_event: KeyPressEvent) -> None:
"""Duplicate this log pane."""
self.log_pane.duplicate()
@register('log-pane.remove-duplicated-log-pane', key_bindings)
def _delete(_event: KeyPressEvent) -> None:
"""Remove log pane."""
if self.log_pane.is_a_duplicate:
self.log_pane.application.window_manager.remove_pane(
self.log_pane)
@register('log-pane.clear-history', key_bindings)
def _clear_history(_event: KeyPressEvent) -> None:
"""Clear log pane history."""
self.log_pane.clear_history()
@register('log-pane.scroll-to-top', key_bindings)
def _scroll_to_top(_event: KeyPressEvent) -> None:
"""Scroll to top."""
self.log_view.scroll_to_top()
@register('log-pane.scroll-to-bottom', key_bindings)
def _scroll_to_bottom(_event: KeyPressEvent) -> None:
"""Scroll to bottom."""
self.log_view.scroll_to_bottom()
@register('log-pane.toggle-follow', key_bindings)
def _toggle_follow(_event: KeyPressEvent) -> None:
"""Toggle log line following."""
self.log_pane.toggle_follow()
@register('log-pane.move-cursor-up', key_bindings)
def _up(_event: KeyPressEvent) -> None:
"""Move cursor up."""
self.log_view.scroll_up()
@register('log-pane.move-cursor-down', key_bindings)
def _down(_event: KeyPressEvent) -> None:
"""Move cursor down."""
self.log_view.scroll_down()
@register('log-pane.visual-select-up', key_bindings)
def _visual_select_up(_event: KeyPressEvent) -> None:
"""Select previous log line."""
self.log_view.visual_select_up()
@register('log-pane.visual-select-down', key_bindings)
def _visual_select_down(_event: KeyPressEvent) -> None:
"""Select next log line."""
self.log_view.visual_select_down()
@register('log-pane.scroll-page-up', key_bindings)
def _pageup(_event: KeyPressEvent) -> None:
"""Scroll the logs up by one page."""
self.log_view.scroll_up_one_page()
@register('log-pane.scroll-page-down', key_bindings)
def _pagedown(_event: KeyPressEvent) -> None:
"""Scroll the logs down by one page."""
self.log_view.scroll_down_one_page()
@register('log-pane.save-copy', key_bindings)
def _start_saveas(_event: KeyPressEvent) -> None:
"""Save logs to a file."""
self.log_pane.start_saveas()
@register('log-pane.search', key_bindings)
def _start_search(_event: KeyPressEvent) -> None:
"""Start searching."""
self.log_pane.start_search()
@register('log-pane.search-next-match', key_bindings)
def _next_search(_event: KeyPressEvent) -> None:
"""Next search match."""
self.log_view.search_forwards()
@register('log-pane.search-previous-match', key_bindings)
def _previous_search(_event: KeyPressEvent) -> None:
"""Previous search match."""
self.log_view.search_backwards()
@register('log-pane.visual-select-all', key_bindings)
def _select_all_logs(_event: KeyPressEvent) -> None:
"""Clear search."""
self.log_pane.log_view.visual_select_all()
@register('log-pane.deselect-cancel-search', key_bindings)
def _clear_search_and_selection(_event: KeyPressEvent) -> None:
"""Clear selection or search."""
if self.log_pane.log_view.visual_select_mode:
self.log_pane.log_view.clear_visual_selection()
elif self.log_pane.search_bar_active:
self.log_pane.search_toolbar.cancel_search()
@register('log-pane.search-apply-filter', key_bindings)
def _apply_filter(_event: KeyPressEvent) -> None:
"""Apply current search as a filter."""
self.log_pane.search_toolbar.close_search_bar()
self.log_view.apply_filter()
@register('log-pane.clear-filters', key_bindings)
def _clear_filter(_event: KeyPressEvent) -> None:
"""Reset / erase active filters."""
self.log_view.clear_filters()
self.key_bindings: KeyBindingsBase = key_bindings
def is_focusable(self) -> bool:
return True
def get_key_bindings(self) -> Optional[KeyBindingsBase]:
return self.key_bindings
def preferred_width(self, max_available_width: int) -> int:
"""Return the width of the longest line."""
line_lengths = [len(l) for l in self.lines]
return max(line_lengths)
def preferred_height(
self,
width: int,
max_available_height: int,
wrap_lines: bool,
get_line_prefix,
) -> Optional[int]:
"""Return the preferred height for the log lines."""
content = self.create_content(width, None)
return content.line_count
def create_content(self, width: int, height: Optional[int]) -> UIContent:
# Update lines to render
self.lines = self.log_view.render_content()
# Create a UIContent instance if none exists
if self.uicontent is None:
self.uicontent = UIContent(get_line=lambda i: self.lines[i],
line_count=len(self.lines),
show_cursor=False)
# Update line_count
self.uicontent.line_count = len(self.lines)
return self.uicontent
def mouse_handler(self, mouse_event: MouseEvent):
"""Mouse handler for this control."""
mouse_position = mouse_event.position
# Left mouse button release should:
# 1. check if a mouse drag just completed.
# 2. If not in focus, switch focus to this log pane
# If in focus, move the cursor to that position.
if (mouse_event.event_type == MouseEventType.MOUSE_UP
and mouse_event.button == MouseButton.LEFT):
# If a drag was in progress and this is the first mouse release
# press, set the stop flag.
if (self.visual_select_mode_drag_start
and not self.visual_select_mode_drag_stop):
self.visual_select_mode_drag_stop = True
if not has_focus(self)():
# Focus the save as dialog if open.
if self.log_pane.saveas_dialog_active:
get_app().layout.focus(self.log_pane.saveas_dialog)
# Focus the search bar if open.
elif 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
# Log pane in focus already, move the cursor to the position of the
# mouse click.
self.log_pane.log_view.scroll_to_position(mouse_position)
# Mouse event handled, return None.
return None
# Mouse drag with left button should start selecting lines.
# The log pane does not need to be in focus to start this.
if (mouse_event.event_type == MouseEventType.MOUSE_MOVE
and mouse_event.button == MouseButton.LEFT):
# If a previous mouse drag was completed, clear the selection.
if (self.visual_select_mode_drag_start
and self.visual_select_mode_drag_stop):
self.log_pane.log_view.clear_visual_selection()
# Drag select in progress, set flags accordingly.
self.visual_select_mode_drag_start = True
self.visual_select_mode_drag_stop = False
self.log_pane.log_view.visual_select_line(mouse_position)
# Mouse event handled, return None.
return None
# Mouse wheel events should move the cursor +/- some amount of lines
# even if this pane is not in focus.
if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
self.log_pane.log_view.scroll_down(lines=_LOG_OUTPUT_SCROLL_AMOUNT)
# Mouse event handled, return None.
return None
if mouse_event.event_type == MouseEventType.SCROLL_UP:
self.log_pane.log_view.scroll_up(lines=_LOG_OUTPUT_SCROLL_AMOUNT)
# Mouse event handled, return None.
return None
# Mouse event not handled, return NotImplemented.
return NotImplemented
class LogPane(WindowPane):
"""LogPane class."""
# pylint: disable=too-many-instance-attributes,too-many-public-methods
def __init__(
self,
application: Any,
pane_title: str = 'Logs',
log_store: Optional[LogStore] = None,
):
super().__init__(application, pane_title)
# TODO(tonymd): Read these settings from a project (or user) config.
self.wrap_lines = False
self._table_view = True
self.is_a_duplicate = False
# Create the log container which stores and handles incoming logs.
self.log_view: LogView = LogView(self,
self.application,
log_store=log_store)
# 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 = None
self.last_log_pane_height = None
# Search tracking
self.search_bar_active = False
self.search_toolbar = SearchToolbar(self)
self.filter_toolbar = FilterToolbar(self)
self.saveas_dialog = LogPaneSaveAsDialog(self)
self.saveas_dialog_active = False
self.visual_selection_dialog = LogPaneSelectionDialog(self)
# Table header bar, only shown if table view is active.
self.table_header_toolbar = TableToolbar(self)
# Create the bottom toolbar for the whole log pane.
self.bottom_toolbar = WindowPaneToolbar(self)
self.bottom_toolbar.add_button(
ToolbarButton('/', 'Search', self.start_search))
self.bottom_toolbar.add_button(
ToolbarButton('Ctrl-o', 'Save', self.start_saveas))
self.bottom_toolbar.add_button(
ToolbarButton('f',
'Follow',
self.toggle_follow,
is_checkbox=True,
checked=lambda: self.log_view.follow))
self.bottom_toolbar.add_button(
ToolbarButton('t',
'Table',
self.toggle_table_view,
is_checkbox=True,
checked=lambda: self.table_view))
self.bottom_toolbar.add_button(
ToolbarButton('w',
'Wrap',
self.toggle_wrap_lines,
is_checkbox=True,
checked=lambda: self.wrap_lines))
self.bottom_toolbar.add_button(
ToolbarButton('C', 'Clear', self.clear_history))
self.log_content_control = LogContentControl(self)
self.log_display_window = Window(
content=self.log_content_control,
# Scrolling is handled by LogScreen
allow_scroll_beyond_bottom=False,
# Line wrapping is handled by LogScreen
wrap_lines=False,
# Selected line highlighting is handled by LogScreen
cursorline=False,
# Don't make the window taller to fill the parent split container.
# Window should match the height of the log line content. This will
# also allow the parent HSplit to justify the content to the bottom
dont_extend_height=True,
# Window width should be extended to make backround highlighting
# extend to the end of the container. Otherwise backround colors
# will only appear until the end of the log line.
dont_extend_width=False,
# Needed for log lines ANSI sequences that don't specify foreground
# or background colors.
style=functools.partial(pw_console.style.get_pane_style, self),
)
# Root level container
self.container = ConditionalContainer(
FloatContainer(
# Horizonal split containing the log lines and the toolbar.
WindowPaneHSplit(
self, # LogPane reference
[
self.table_header_toolbar,
self.log_display_window,
self.filter_toolbar,
self.search_toolbar,
self.bottom_toolbar,
],
# Align content with the bottom of the container.
align=VerticalAlign.BOTTOM,
height=lambda: self.height,
width=lambda: self.width,
style=functools.partial(pw_console.style.get_pane_style,
self),
),
floats=[
Float(top=0, right=0, height=1, content=LineInfoBar(self)),
Float(top=0,
right=0,
height=LogPaneSelectionDialog.DIALOG_HEIGHT,
content=self.visual_selection_dialog),
Float(top=3,
left=2,
right=2,
height=LogPaneSaveAsDialog.DIALOG_HEIGHT + 2,
content=self.saveas_dialog),
]),
filter=Condition(lambda: self.show_pane))
@property
def table_view(self):
return self._table_view
@table_view.setter
def table_view(self, table_view):
self._table_view = table_view
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
else:
self._pane_subtitle = self._pane_subtitle + ', ' + text
def pane_subtitle(self) -> str:
if not self._pane_subtitle:
return ', '.join(self.log_view.log_store.channel_counts.keys())
logger_names = self._pane_subtitle.split(', ')
additional_text = ''
if len(logger_names) > 1:
additional_text = ' + {} more'.format(len(logger_names))
return logger_names[0] + additional_text
def start_search(self):
"""Show the search bar to begin a search."""
# Show the search bar
self.search_bar_active = True
# Focus on the search bar
self.application.focus_on_container(self.search_toolbar)
def start_saveas(self, **export_kwargs) -> bool:
"""Show the saveas bar to begin saving logs to a file."""
# Show the search bar
self.saveas_dialog_active = True
# Set export options if any
self.saveas_dialog.set_export_options(**export_kwargs)
# Focus on the search bar
self.application.focus_on_container(self.saveas_dialog)
return True
def pane_resized(self) -> bool:
"""Return True if the current window size has changed."""
return (self.last_log_pane_width != self.current_log_pane_width
or self.last_log_pane_height != self.current_log_pane_height)
def update_pane_size(self, width, height):
"""Save width and height of the log pane for the current UI render
pass."""
if width:
self.last_log_pane_width = self.current_log_pane_width
self.current_log_pane_width = width
if height:
# Subtract the height of the bottom toolbar
height -= WindowPaneToolbar.TOOLBAR_HEIGHT
if self._table_view:
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
def toggle_table_view(self):
"""Enable or disable table view."""
self._table_view = not self._table_view
self.log_view.view_mode_changed()
self.redraw_ui()
def toggle_wrap_lines(self):
"""Enable or disable line wraping/truncation."""
self.wrap_lines = not self.wrap_lines
self.log_view.view_mode_changed()
self.redraw_ui()
def toggle_follow(self):
"""Enable or disable following log lines."""
self.log_view.toggle_follow()
self.redraw_ui()
def clear_history(self):
"""Erase stored log lines."""
self.log_view.clear_scrollback()
self.redraw_ui()
def get_all_key_bindings(self) -> List:
"""Return all keybinds for this pane."""
# Return log content control keybindings
return [self.log_content_control.get_key_bindings()]
def get_all_menu_options(self) -> List:
"""Return all menu options for the log pane."""
options = [
# Menu separator
('-', None),
(
'Save/Export a copy',
self.start_saveas,
),
('-', None),
(
'{check} Line wrapping'.format(
check=pw_console.widgets.checkbox.to_checkbox_text(
self.wrap_lines, end='')),
self.toggle_wrap_lines,
),
(
'{check} Table view'.format(
check=pw_console.widgets.checkbox.to_checkbox_text(
self._table_view, end='')),
self.toggle_table_view,
),
(
'{check} Follow'.format(
check=pw_console.widgets.checkbox.to_checkbox_text(
self.log_view.follow, end='')),
self.toggle_follow,
),
# Menu separator
('-', None),
(
'Clear history',
self.clear_history,
),
(
'Duplicate pane',
self.duplicate,
),
]
if self.is_a_duplicate:
options += [(
'Remove/Delete pane',
functools.partial(self.application.window_manager.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,
),
(
'Clear/Reset active filters',
self.log_view.clear_filters,
),
]
return options
def apply_filters_from_config(self, window_options) -> None:
if 'filters' not in window_options:
return
for field, criteria in window_options['filters'].items():
for matcher_name, search_string in criteria.items():
inverted = matcher_name.endswith('-inverted')
matcher_name = re.sub(r'-inverted$', '', matcher_name)
if field == 'all':
field = None
if self.log_view.new_search(
search_string,
invert=inverted,
field=field,
search_matcher=matcher_name,
interactive=False,
):
self.log_view.install_new_filter()
def create_duplicate(self) -> 'LogPane':
"""Create a duplicate of this LogView."""
new_pane = LogPane(self.application, pane_title=self.pane_title())
# Set the log_store
log_store = self.log_view.log_store
new_pane.log_view.log_store = log_store
# Register the duplicate pane as a viewer
log_store.register_viewer(new_pane.log_view)
# Set any existing search state.
new_pane.log_view.search_text = self.log_view.search_text
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.
new_pane.is_a_duplicate = True
return new_pane
def duplicate(self) -> None:
new_pane = self.create_duplicate()
# Add the new pane.
self.application.window_manager.add_pane(new_pane)
def add_log_handler(self,
logger: Union[str, logging.Logger],
level_name: Optional[str] = None) -> None:
"""Add a log handlers to this LogPane."""
if isinstance(logger, logging.Logger):
logger_instance = logger
elif isinstance(logger, str):
logger_instance = logging.getLogger(logger)
if level_name:
if not hasattr(logging, level_name):
raise Exception(f'Unknown log level: {level_name}')
logger_instance.level = getattr(logging, level_name, logging.INFO)
logger_instance.addHandler(self.log_view.log_store # type: ignore
)
self.append_pane_subtitle( # type: ignore
logger_instance.name)