blob: 36c0463c3028d63fe3467df67cdb93222605e6ac [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
import time
from typing import (
Any,
Callable,
List,
Optional,
TYPE_CHECKING,
Tuple,
Union,
)
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,
FormattedTextControl,
HSplit,
UIContent,
UIControl,
VerticalAlign,
VSplit,
Window,
WindowAlign,
)
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseButton
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.style import (
get_pane_style,
)
from pw_console.widgets import (
ToolbarButton,
WindowPane,
WindowPaneHSplit,
WindowPaneToolbar,
create_border,
mouse_handlers,
to_checkbox_text,
to_keybind_indicator,
)
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.toggle-web-browser', key_bindings)
def _toggle_browser(_event: KeyPressEvent) -> None:
"""View logs in browser."""
self.log_pane.toggle_websocket_server()
@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 LogPaneWebsocketDialog(ConditionalContainer):
"""Dialog box for showing the websocket URL."""
# Height of the dialog box contens in lines of text.
DIALOG_HEIGHT = 2
def __init__(self, log_pane: 'LogPane'):
self.log_pane = log_pane
self._last_action_message: str = ''
self._last_action_time: float = 0
info_bar_control = FormattedTextControl(self.get_info_fragments)
info_bar_window = Window(
content=info_bar_control,
height=1,
align=WindowAlign.LEFT,
dont_extend_width=False,
)
message_bar_control = FormattedTextControl(self.get_message_fragments)
message_bar_window = Window(
content=message_bar_control,
height=1,
align=WindowAlign.RIGHT,
dont_extend_width=False,
)
action_bar_control = FormattedTextControl(self.get_action_fragments)
action_bar_window = Window(
content=action_bar_control,
height=1,
align=WindowAlign.RIGHT,
dont_extend_width=True,
)
super().__init__(
create_border(
HSplit(
[
info_bar_window,
VSplit([message_bar_window, action_bar_window]),
],
height=LogPaneWebsocketDialog.DIALOG_HEIGHT,
style='class:saveas-dialog',
),
content_height=LogPaneWebsocketDialog.DIALOG_HEIGHT,
title='Websocket Log Server',
border_style='class:saveas-dialog-border',
left_margin_columns=1,
),
filter=Condition(lambda: self.log_pane.websocket_dialog_active),
)
def focus_self(self) -> None:
# Nothing in this dialog can be focused, focus on the parent log_pane
# instead.
self.log_pane.application.focus_on_container(self.log_pane)
def close_dialog(self) -> None:
"""Close this dialog."""
self.log_pane.toggle_websocket_server()
self.log_pane.websocket_dialog_active = False
self.log_pane.application.focus_on_container(self.log_pane)
self.log_pane.redraw_ui()
def _set_action_message(self, text: str) -> None:
self._last_action_time = time.time()
self._last_action_message = text
def copy_url_to_clipboard(self) -> None:
self.log_pane.application.application.clipboard.set_text(
self.log_pane.log_view.get_web_socket_url()
)
self._set_action_message('Copied!')
def get_message_fragments(self):
"""Return FormattedText with the last action message."""
# Mouse handlers
focus = functools.partial(mouse_handlers.on_click, self.focus_self)
# Separator should have the focus mouse handler so clicking on any
# whitespace focuses the input field.
separator_text = ('', ' ', focus)
if self._last_action_time + 10 > time.time():
return [
('class:theme-fg-yellow', self._last_action_message, focus),
separator_text,
]
return [separator_text]
def get_info_fragments(self):
"""Return FormattedText with current URL info."""
# Mouse handlers
focus = functools.partial(mouse_handlers.on_click, self.focus_self)
# Separator should have the focus mouse handler so clicking on any
# whitespace focuses the input field.
separator_text = ('', ' ', focus)
fragments = [
('class:saveas-dialog-setting', 'URL: ', focus),
(
'class:saveas-dialog-title',
self.log_pane.log_view.get_web_socket_url(),
focus,
),
separator_text,
]
return fragments
def get_action_fragments(self):
"""Return FormattedText with the action buttons."""
# Mouse handlers
focus = functools.partial(mouse_handlers.on_click, self.focus_self)
cancel = functools.partial(mouse_handlers.on_click, self.close_dialog)
copy = functools.partial(
mouse_handlers.on_click,
self.copy_url_to_clipboard,
)
# Separator should have the focus mouse handler so clicking on any
# whitespace focuses the input field.
separator_text = ('', ' ', focus)
# Default button style
button_style = 'class:toolbar-button-inactive'
fragments = []
# Action buttons
fragments.extend(
to_keybind_indicator(
key=None,
description='Stop',
mouse_handler=cancel,
base_style=button_style,
)
)
fragments.append(separator_text)
fragments.extend(
to_keybind_indicator(
key=None,
description='Copy to Clipboard',
mouse_handler=copy,
base_style=button_style,
)
)
# One space separator
fragments.append(('', ' ', focus))
return fragments
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)
self.websocket_dialog = LogPaneWebsocketDialog(self)
self.websocket_dialog_active = False
# 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.bottom_toolbar.add_button(
ToolbarButton(
'Shift-o',
'Open in browser',
self.toggle_websocket_server,
is_checkbox=True,
checked=lambda: self.log_view.websocket_running,
)
)
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(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(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,
),
Float(
top=1,
left=2,
right=2,
height=LogPaneWebsocketDialog.DIALOG_HEIGHT + 2,
content=self.websocket_dialog,
),
],
),
filter=Condition(lambda: self.show_pane),
)
@property
def table_view(self):
if self.log_view.websocket_running:
return False
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."""
if self.log_view.websocket_running:
return
# 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 toggle_websocket_server(self):
"""Start or stop websocket server to send logs."""
if self.log_view.websocket_running:
self.log_view.stop_websocket_thread()
self.websocket_dialog_active = False
else:
self.search_toolbar.close_search_bar()
self.log_view.start_websocket_thread()
self.application.start_http_server()
self.saveas_dialog_active = False
self.websocket_dialog_active = True
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_window_menu_options(
self,
) -> List[Tuple[str, Union[Callable, None]]]:
"""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=to_checkbox_text(self.wrap_lines, end='')
),
self.toggle_wrap_lines,
),
(
'{check} Table view'.format(
check=to_checkbox_text(self._table_view, end='')
),
self.toggle_table_view,
),
(
'{check} Follow'.format(
check=to_checkbox_text(self.log_view.follow, end='')
),
self.toggle_follow,
),
(
'{check} Open in web browser'.format(
check=to_checkbox_text(
self.log_view.websocket_running, end=''
)
),
self.toggle_websocket_server,
),
# 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()
# Trigger any existing log messages to be added to the view.
self.log_view.new_logs_arrived()
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(logger_instance.name) # type: ignore