• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2021 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""LogPane class."""
15
16import functools
17import logging
18import re
19import time
20from typing import (
21    Any,
22    Callable,
23    List,
24    Optional,
25    TYPE_CHECKING,
26    Tuple,
27    Union,
28)
29
30from prompt_toolkit.application.current import get_app
31from prompt_toolkit.filters import (
32    Condition,
33    has_focus,
34)
35from prompt_toolkit.formatted_text import StyleAndTextTuples
36from prompt_toolkit.key_binding import (
37    KeyBindings,
38    KeyPressEvent,
39    KeyBindingsBase,
40)
41from prompt_toolkit.layout import (
42    ConditionalContainer,
43    Float,
44    FloatContainer,
45    FormattedTextControl,
46    HSplit,
47    UIContent,
48    UIControl,
49    VerticalAlign,
50    VSplit,
51    Window,
52    WindowAlign,
53)
54from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseButton
55
56from pw_console.log_view import LogView
57from pw_console.log_pane_toolbars import (
58    LineInfoBar,
59    TableToolbar,
60)
61from pw_console.log_pane_saveas_dialog import LogPaneSaveAsDialog
62from pw_console.log_pane_selection_dialog import LogPaneSelectionDialog
63from pw_console.log_store import LogStore
64from pw_console.search_toolbar import SearchToolbar
65from pw_console.filter_toolbar import FilterToolbar
66
67from pw_console.style import (
68    get_pane_style,
69)
70from pw_console.widgets import (
71    ToolbarButton,
72    WindowPane,
73    WindowPaneHSplit,
74    WindowPaneToolbar,
75    create_border,
76    mouse_handlers,
77    to_checkbox_text,
78    to_keybind_indicator,
79)
80
81
82if TYPE_CHECKING:
83    from pw_console.console_app import ConsoleApp
84
85_LOG_OUTPUT_SCROLL_AMOUNT = 5
86_LOG = logging.getLogger(__package__)
87
88
89class LogContentControl(UIControl):
90    """LogPane prompt_toolkit UIControl for displaying LogContainer lines."""
91
92    def __init__(self, log_pane: 'LogPane') -> None:
93        # pylint: disable=too-many-locals
94        self.log_pane = log_pane
95        self.log_view = log_pane.log_view
96
97        # Mouse drag visual selection flags.
98        self.visual_select_mode_drag_start = False
99        self.visual_select_mode_drag_stop = False
100
101        self.uicontent: Optional[UIContent] = None
102        self.lines: List[StyleAndTextTuples] = []
103
104        # Key bindings.
105        key_bindings = KeyBindings()
106        register = log_pane.application.prefs.register_keybinding
107
108        @register('log-pane.shift-line-to-top', key_bindings)
109        def _shift_log_to_top(_event: KeyPressEvent) -> None:
110            """Shift the selected log line to the top."""
111            self.log_view.move_selected_line_to_top()
112
113        @register('log-pane.shift-line-to-center', key_bindings)
114        def _shift_log_to_center(_event: KeyPressEvent) -> None:
115            """Shift the selected log line to the center."""
116            self.log_view.center_log_line()
117
118        @register('log-pane.toggle-wrap-lines', key_bindings)
119        def _toggle_wrap_lines(_event: KeyPressEvent) -> None:
120            """Toggle log line wrapping."""
121            self.log_pane.toggle_wrap_lines()
122
123        @register('log-pane.toggle-table-view', key_bindings)
124        def _toggle_table_view(_event: KeyPressEvent) -> None:
125            """Toggle table view."""
126            self.log_pane.toggle_table_view()
127
128        @register('log-pane.duplicate-log-pane', key_bindings)
129        def _duplicate(_event: KeyPressEvent) -> None:
130            """Duplicate this log pane."""
131            self.log_pane.duplicate()
132
133        @register('log-pane.remove-duplicated-log-pane', key_bindings)
134        def _delete(_event: KeyPressEvent) -> None:
135            """Remove log pane."""
136            if self.log_pane.is_a_duplicate:
137                self.log_pane.application.window_manager.remove_pane(
138                    self.log_pane
139                )
140
141        @register('log-pane.clear-history', key_bindings)
142        def _clear_history(_event: KeyPressEvent) -> None:
143            """Clear log pane history."""
144            self.log_pane.clear_history()
145
146        @register('log-pane.scroll-to-top', key_bindings)
147        def _scroll_to_top(_event: KeyPressEvent) -> None:
148            """Scroll to top."""
149            self.log_view.scroll_to_top()
150
151        @register('log-pane.scroll-to-bottom', key_bindings)
152        def _scroll_to_bottom(_event: KeyPressEvent) -> None:
153            """Scroll to bottom."""
154            self.log_view.scroll_to_bottom()
155
156        @register('log-pane.toggle-follow', key_bindings)
157        def _toggle_follow(_event: KeyPressEvent) -> None:
158            """Toggle log line following."""
159            self.log_pane.toggle_follow()
160
161        @register('log-pane.toggle-web-browser', key_bindings)
162        def _toggle_browser(_event: KeyPressEvent) -> None:
163            """View logs in browser."""
164            self.log_pane.toggle_websocket_server()
165
166        @register('log-pane.move-cursor-up', key_bindings)
167        def _up(_event: KeyPressEvent) -> None:
168            """Move cursor up."""
169            self.log_view.scroll_up()
170
171        @register('log-pane.move-cursor-down', key_bindings)
172        def _down(_event: KeyPressEvent) -> None:
173            """Move cursor down."""
174            self.log_view.scroll_down()
175
176        @register('log-pane.visual-select-up', key_bindings)
177        def _visual_select_up(_event: KeyPressEvent) -> None:
178            """Select previous log line."""
179            self.log_view.visual_select_up()
180
181        @register('log-pane.visual-select-down', key_bindings)
182        def _visual_select_down(_event: KeyPressEvent) -> None:
183            """Select next log line."""
184            self.log_view.visual_select_down()
185
186        @register('log-pane.scroll-page-up', key_bindings)
187        def _pageup(_event: KeyPressEvent) -> None:
188            """Scroll the logs up by one page."""
189            self.log_view.scroll_up_one_page()
190
191        @register('log-pane.scroll-page-down', key_bindings)
192        def _pagedown(_event: KeyPressEvent) -> None:
193            """Scroll the logs down by one page."""
194            self.log_view.scroll_down_one_page()
195
196        @register('log-pane.save-copy', key_bindings)
197        def _start_saveas(_event: KeyPressEvent) -> None:
198            """Save logs to a file."""
199            self.log_pane.start_saveas()
200
201        @register('log-pane.search', key_bindings)
202        def _start_search(_event: KeyPressEvent) -> None:
203            """Start searching."""
204            self.log_pane.start_search()
205
206        @register('log-pane.search-next-match', key_bindings)
207        def _next_search(_event: KeyPressEvent) -> None:
208            """Next search match."""
209            self.log_view.search_forwards()
210
211        @register('log-pane.search-previous-match', key_bindings)
212        def _previous_search(_event: KeyPressEvent) -> None:
213            """Previous search match."""
214            self.log_view.search_backwards()
215
216        @register('log-pane.visual-select-all', key_bindings)
217        def _select_all_logs(_event: KeyPressEvent) -> None:
218            """Clear search."""
219            self.log_pane.log_view.visual_select_all()
220
221        @register('log-pane.deselect-cancel-search', key_bindings)
222        def _clear_search_and_selection(_event: KeyPressEvent) -> None:
223            """Clear selection or search."""
224            if self.log_pane.log_view.visual_select_mode:
225                self.log_pane.log_view.clear_visual_selection()
226            elif self.log_pane.search_bar_active:
227                self.log_pane.search_toolbar.cancel_search()
228
229        @register('log-pane.search-apply-filter', key_bindings)
230        def _apply_filter(_event: KeyPressEvent) -> None:
231            """Apply current search as a filter."""
232            self.log_pane.search_toolbar.close_search_bar()
233            self.log_view.apply_filter()
234
235        @register('log-pane.clear-filters', key_bindings)
236        def _clear_filter(_event: KeyPressEvent) -> None:
237            """Reset / erase active filters."""
238            self.log_view.clear_filters()
239
240        self.key_bindings: KeyBindingsBase = key_bindings
241
242    def is_focusable(self) -> bool:
243        return True
244
245    def get_key_bindings(self) -> Optional[KeyBindingsBase]:
246        return self.key_bindings
247
248    def preferred_width(self, max_available_width: int) -> int:
249        """Return the width of the longest line."""
250        line_lengths = [len(l) for l in self.lines]
251        return max(line_lengths)
252
253    def preferred_height(
254        self,
255        width: int,
256        max_available_height: int,
257        wrap_lines: bool,
258        get_line_prefix,
259    ) -> Optional[int]:
260        """Return the preferred height for the log lines."""
261        content = self.create_content(width, None)
262        return content.line_count
263
264    def create_content(self, width: int, height: Optional[int]) -> UIContent:
265        # Update lines to render
266        self.lines = self.log_view.render_content()
267
268        # Create a UIContent instance if none exists
269        if self.uicontent is None:
270            self.uicontent = UIContent(
271                get_line=lambda i: self.lines[i],
272                line_count=len(self.lines),
273                show_cursor=False,
274            )
275
276        # Update line_count
277        self.uicontent.line_count = len(self.lines)
278
279        return self.uicontent
280
281    def mouse_handler(self, mouse_event: MouseEvent):
282        """Mouse handler for this control."""
283        mouse_position = mouse_event.position
284
285        # Left mouse button release should:
286        # 1. check if a mouse drag just completed.
287        # 2. If not in focus, switch focus to this log pane
288        #    If in focus, move the cursor to that position.
289        if (
290            mouse_event.event_type == MouseEventType.MOUSE_UP
291            and mouse_event.button == MouseButton.LEFT
292        ):
293            # If a drag was in progress and this is the first mouse release
294            # press, set the stop flag.
295            if (
296                self.visual_select_mode_drag_start
297                and not self.visual_select_mode_drag_stop
298            ):
299                self.visual_select_mode_drag_stop = True
300
301            if not has_focus(self)():
302                # Focus the save as dialog if open.
303                if self.log_pane.saveas_dialog_active:
304                    get_app().layout.focus(self.log_pane.saveas_dialog)
305                # Focus the search bar if open.
306                elif self.log_pane.search_bar_active:
307                    get_app().layout.focus(self.log_pane.search_toolbar)
308                # Otherwise, focus on the log pane content.
309                else:
310                    get_app().layout.focus(self)
311                # Mouse event handled, return None.
312                return None
313
314            # Log pane in focus already, move the cursor to the position of the
315            # mouse click.
316            self.log_pane.log_view.scroll_to_position(mouse_position)
317            # Mouse event handled, return None.
318            return None
319
320        # Mouse drag with left button should start selecting lines.
321        # The log pane does not need to be in focus to start this.
322        if (
323            mouse_event.event_type == MouseEventType.MOUSE_MOVE
324            and mouse_event.button == MouseButton.LEFT
325        ):
326            # If a previous mouse drag was completed, clear the selection.
327            if (
328                self.visual_select_mode_drag_start
329                and self.visual_select_mode_drag_stop
330            ):
331                self.log_pane.log_view.clear_visual_selection()
332            # Drag select in progress, set flags accordingly.
333            self.visual_select_mode_drag_start = True
334            self.visual_select_mode_drag_stop = False
335
336            self.log_pane.log_view.visual_select_line(mouse_position)
337            # Mouse event handled, return None.
338            return None
339
340        # Mouse wheel events should move the cursor +/- some amount of lines
341        # even if this pane is not in focus.
342        if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
343            self.log_pane.log_view.scroll_down(lines=_LOG_OUTPUT_SCROLL_AMOUNT)
344            # Mouse event handled, return None.
345            return None
346
347        if mouse_event.event_type == MouseEventType.SCROLL_UP:
348            self.log_pane.log_view.scroll_up(lines=_LOG_OUTPUT_SCROLL_AMOUNT)
349            # Mouse event handled, return None.
350            return None
351
352        # Mouse event not handled, return NotImplemented.
353        return NotImplemented
354
355
356class LogPaneWebsocketDialog(ConditionalContainer):
357    """Dialog box for showing the websocket URL."""
358
359    # Height of the dialog box contens in lines of text.
360    DIALOG_HEIGHT = 2
361
362    def __init__(self, log_pane: 'LogPane'):
363        self.log_pane = log_pane
364
365        self._last_action_message: str = ''
366        self._last_action_time: float = 0
367
368        info_bar_control = FormattedTextControl(self.get_info_fragments)
369        info_bar_window = Window(
370            content=info_bar_control,
371            height=1,
372            align=WindowAlign.LEFT,
373            dont_extend_width=False,
374        )
375
376        message_bar_control = FormattedTextControl(self.get_message_fragments)
377        message_bar_window = Window(
378            content=message_bar_control,
379            height=1,
380            align=WindowAlign.RIGHT,
381            dont_extend_width=False,
382        )
383
384        action_bar_control = FormattedTextControl(self.get_action_fragments)
385        action_bar_window = Window(
386            content=action_bar_control,
387            height=1,
388            align=WindowAlign.RIGHT,
389            dont_extend_width=True,
390        )
391
392        super().__init__(
393            create_border(
394                HSplit(
395                    [
396                        info_bar_window,
397                        VSplit([message_bar_window, action_bar_window]),
398                    ],
399                    height=LogPaneWebsocketDialog.DIALOG_HEIGHT,
400                    style='class:saveas-dialog',
401                ),
402                content_height=LogPaneWebsocketDialog.DIALOG_HEIGHT,
403                title='Websocket Log Server',
404                border_style='class:saveas-dialog-border',
405                left_margin_columns=1,
406            ),
407            filter=Condition(lambda: self.log_pane.websocket_dialog_active),
408        )
409
410    def focus_self(self) -> None:
411        # Nothing in this dialog can be focused, focus on the parent log_pane
412        # instead.
413        self.log_pane.application.focus_on_container(self.log_pane)
414
415    def close_dialog(self) -> None:
416        """Close this dialog."""
417        self.log_pane.toggle_websocket_server()
418        self.log_pane.websocket_dialog_active = False
419        self.log_pane.application.focus_on_container(self.log_pane)
420        self.log_pane.redraw_ui()
421
422    def _set_action_message(self, text: str) -> None:
423        self._last_action_time = time.time()
424        self._last_action_message = text
425
426    def copy_url_to_clipboard(self) -> None:
427        self.log_pane.application.application.clipboard.set_text(
428            self.log_pane.log_view.get_web_socket_url()
429        )
430        self._set_action_message('Copied!')
431
432    def get_message_fragments(self):
433        """Return FormattedText with the last action message."""
434        # Mouse handlers
435        focus = functools.partial(mouse_handlers.on_click, self.focus_self)
436        # Separator should have the focus mouse handler so clicking on any
437        # whitespace focuses the input field.
438        separator_text = ('', '  ', focus)
439
440        if self._last_action_time + 10 > time.time():
441            return [
442                ('class:theme-fg-yellow', self._last_action_message, focus),
443                separator_text,
444            ]
445        return [separator_text]
446
447    def get_info_fragments(self):
448        """Return FormattedText with current URL info."""
449        # Mouse handlers
450        focus = functools.partial(mouse_handlers.on_click, self.focus_self)
451        # Separator should have the focus mouse handler so clicking on any
452        # whitespace focuses the input field.
453        separator_text = ('', '  ', focus)
454
455        fragments = [
456            ('class:saveas-dialog-setting', 'URL:  ', focus),
457            (
458                'class:saveas-dialog-title',
459                self.log_pane.log_view.get_web_socket_url(),
460                focus,
461            ),
462            separator_text,
463        ]
464        return fragments
465
466    def get_action_fragments(self):
467        """Return FormattedText with the action buttons."""
468        # Mouse handlers
469        focus = functools.partial(mouse_handlers.on_click, self.focus_self)
470        cancel = functools.partial(mouse_handlers.on_click, self.close_dialog)
471        copy = functools.partial(
472            mouse_handlers.on_click,
473            self.copy_url_to_clipboard,
474        )
475
476        # Separator should have the focus mouse handler so clicking on any
477        # whitespace focuses the input field.
478        separator_text = ('', '  ', focus)
479
480        # Default button style
481        button_style = 'class:toolbar-button-inactive'
482
483        fragments = []
484
485        # Action buttons
486        fragments.extend(
487            to_keybind_indicator(
488                key=None,
489                description='Stop',
490                mouse_handler=cancel,
491                base_style=button_style,
492            )
493        )
494
495        fragments.append(separator_text)
496        fragments.extend(
497            to_keybind_indicator(
498                key=None,
499                description='Copy to Clipboard',
500                mouse_handler=copy,
501                base_style=button_style,
502            )
503        )
504
505        # One space separator
506        fragments.append(('', ' ', focus))
507
508        return fragments
509
510
511class LogPane(WindowPane):
512    """LogPane class."""
513
514    # pylint: disable=too-many-instance-attributes,too-many-public-methods
515
516    def __init__(
517        self,
518        application: Any,
519        pane_title: str = 'Logs',
520        log_store: Optional[LogStore] = None,
521    ):
522        super().__init__(application, pane_title)
523
524        # TODO(tonymd): Read these settings from a project (or user) config.
525        self.wrap_lines = False
526        self._table_view = True
527        self.is_a_duplicate = False
528
529        # Create the log container which stores and handles incoming logs.
530        self.log_view: LogView = LogView(
531            self, self.application, log_store=log_store
532        )
533
534        # Log pane size variables. These are updated just befor rendering the
535        # pane by the LogLineHSplit class.
536        self.current_log_pane_width = 0
537        self.current_log_pane_height = 0
538        self.last_log_pane_width = None
539        self.last_log_pane_height = None
540
541        # Search tracking
542        self.search_bar_active = False
543        self.search_toolbar = SearchToolbar(self)
544        self.filter_toolbar = FilterToolbar(self)
545
546        self.saveas_dialog = LogPaneSaveAsDialog(self)
547        self.saveas_dialog_active = False
548        self.visual_selection_dialog = LogPaneSelectionDialog(self)
549
550        self.websocket_dialog = LogPaneWebsocketDialog(self)
551        self.websocket_dialog_active = False
552
553        # Table header bar, only shown if table view is active.
554        self.table_header_toolbar = TableToolbar(self)
555
556        # Create the bottom toolbar for the whole log pane.
557        self.bottom_toolbar = WindowPaneToolbar(self)
558        self.bottom_toolbar.add_button(
559            ToolbarButton('/', 'Search', self.start_search)
560        )
561        self.bottom_toolbar.add_button(
562            ToolbarButton('Ctrl-o', 'Save', self.start_saveas)
563        )
564        self.bottom_toolbar.add_button(
565            ToolbarButton(
566                'f',
567                'Follow',
568                self.toggle_follow,
569                is_checkbox=True,
570                checked=lambda: self.log_view.follow,
571            )
572        )
573        self.bottom_toolbar.add_button(
574            ToolbarButton(
575                't',
576                'Table',
577                self.toggle_table_view,
578                is_checkbox=True,
579                checked=lambda: self.table_view,
580            )
581        )
582        self.bottom_toolbar.add_button(
583            ToolbarButton(
584                'w',
585                'Wrap',
586                self.toggle_wrap_lines,
587                is_checkbox=True,
588                checked=lambda: self.wrap_lines,
589            )
590        )
591        self.bottom_toolbar.add_button(
592            ToolbarButton('C', 'Clear', self.clear_history)
593        )
594
595        self.bottom_toolbar.add_button(
596            ToolbarButton(
597                'Shift-o',
598                'Open in browser',
599                self.toggle_websocket_server,
600                is_checkbox=True,
601                checked=lambda: self.log_view.websocket_running,
602            )
603        )
604
605        self.log_content_control = LogContentControl(self)
606
607        self.log_display_window = Window(
608            content=self.log_content_control,
609            # Scrolling is handled by LogScreen
610            allow_scroll_beyond_bottom=False,
611            # Line wrapping is handled by LogScreen
612            wrap_lines=False,
613            # Selected line highlighting is handled by LogScreen
614            cursorline=False,
615            # Don't make the window taller to fill the parent split container.
616            # Window should match the height of the log line content. This will
617            # also allow the parent HSplit to justify the content to the bottom
618            dont_extend_height=True,
619            # Window width should be extended to make backround highlighting
620            # extend to the end of the container. Otherwise backround colors
621            # will only appear until the end of the log line.
622            dont_extend_width=False,
623            # Needed for log lines ANSI sequences that don't specify foreground
624            # or background colors.
625            style=functools.partial(get_pane_style, self),
626        )
627
628        # Root level container
629        self.container = ConditionalContainer(
630            FloatContainer(
631                # Horizonal split containing the log lines and the toolbar.
632                WindowPaneHSplit(
633                    self,  # LogPane reference
634                    [
635                        self.table_header_toolbar,
636                        self.log_display_window,
637                        self.filter_toolbar,
638                        self.search_toolbar,
639                        self.bottom_toolbar,
640                    ],
641                    # Align content with the bottom of the container.
642                    align=VerticalAlign.BOTTOM,
643                    height=lambda: self.height,
644                    width=lambda: self.width,
645                    style=functools.partial(get_pane_style, self),
646                ),
647                floats=[
648                    Float(top=0, right=0, height=1, content=LineInfoBar(self)),
649                    Float(
650                        top=0,
651                        right=0,
652                        height=LogPaneSelectionDialog.DIALOG_HEIGHT,
653                        content=self.visual_selection_dialog,
654                    ),
655                    Float(
656                        top=3,
657                        left=2,
658                        right=2,
659                        height=LogPaneSaveAsDialog.DIALOG_HEIGHT + 2,
660                        content=self.saveas_dialog,
661                    ),
662                    Float(
663                        top=1,
664                        left=2,
665                        right=2,
666                        height=LogPaneWebsocketDialog.DIALOG_HEIGHT + 2,
667                        content=self.websocket_dialog,
668                    ),
669                ],
670            ),
671            filter=Condition(lambda: self.show_pane),
672        )
673
674    @property
675    def table_view(self):
676        if self.log_view.websocket_running:
677            return False
678        return self._table_view
679
680    @table_view.setter
681    def table_view(self, table_view):
682        self._table_view = table_view
683
684    def menu_title(self):
685        """Return the title to display in the Window menu."""
686        title = self.pane_title()
687
688        # List active filters
689        if self.log_view.filtering_on:
690            title += ' (FILTERS: '
691            title += ' '.join(
692                [
693                    log_filter.pattern()
694                    for log_filter in self.log_view.filters.values()
695                ]
696            )
697            title += ')'
698        return title
699
700    def append_pane_subtitle(self, text):
701        if not self._pane_subtitle:
702            self._pane_subtitle = text
703        else:
704            self._pane_subtitle = self._pane_subtitle + ', ' + text
705
706    def pane_subtitle(self) -> str:
707        if not self._pane_subtitle:
708            return ', '.join(self.log_view.log_store.channel_counts.keys())
709        logger_names = self._pane_subtitle.split(', ')
710        additional_text = ''
711        if len(logger_names) > 1:
712            additional_text = ' + {} more'.format(len(logger_names))
713
714        return logger_names[0] + additional_text
715
716    def start_search(self):
717        """Show the search bar to begin a search."""
718        if self.log_view.websocket_running:
719            return
720        # Show the search bar
721        self.search_bar_active = True
722        # Focus on the search bar
723        self.application.focus_on_container(self.search_toolbar)
724
725    def start_saveas(self, **export_kwargs) -> bool:
726        """Show the saveas bar to begin saving logs to a file."""
727        # Show the search bar
728        self.saveas_dialog_active = True
729        # Set export options if any
730        self.saveas_dialog.set_export_options(**export_kwargs)
731        # Focus on the search bar
732        self.application.focus_on_container(self.saveas_dialog)
733        return True
734
735    def pane_resized(self) -> bool:
736        """Return True if the current window size has changed."""
737        return (
738            self.last_log_pane_width != self.current_log_pane_width
739            or self.last_log_pane_height != self.current_log_pane_height
740        )
741
742    def update_pane_size(self, width, height):
743        """Save width and height of the log pane for the current UI render
744        pass."""
745        if width:
746            self.last_log_pane_width = self.current_log_pane_width
747            self.current_log_pane_width = width
748        if height:
749            # Subtract the height of the bottom toolbar
750            height -= WindowPaneToolbar.TOOLBAR_HEIGHT
751            if self._table_view:
752                height -= TableToolbar.TOOLBAR_HEIGHT
753            if self.search_bar_active:
754                height -= SearchToolbar.TOOLBAR_HEIGHT
755            if self.log_view.filtering_on:
756                height -= FilterToolbar.TOOLBAR_HEIGHT
757            self.last_log_pane_height = self.current_log_pane_height
758            self.current_log_pane_height = height
759
760    def toggle_table_view(self):
761        """Enable or disable table view."""
762        self._table_view = not self._table_view
763        self.log_view.view_mode_changed()
764        self.redraw_ui()
765
766    def toggle_wrap_lines(self):
767        """Enable or disable line wraping/truncation."""
768        self.wrap_lines = not self.wrap_lines
769        self.log_view.view_mode_changed()
770        self.redraw_ui()
771
772    def toggle_follow(self):
773        """Enable or disable following log lines."""
774        self.log_view.toggle_follow()
775        self.redraw_ui()
776
777    def clear_history(self):
778        """Erase stored log lines."""
779        self.log_view.clear_scrollback()
780        self.redraw_ui()
781
782    def toggle_websocket_server(self):
783        """Start or stop websocket server to send logs."""
784        if self.log_view.websocket_running:
785            self.log_view.stop_websocket_thread()
786            self.websocket_dialog_active = False
787        else:
788            self.search_toolbar.close_search_bar()
789            self.log_view.start_websocket_thread()
790            self.application.start_http_server()
791            self.saveas_dialog_active = False
792            self.websocket_dialog_active = True
793
794    def get_all_key_bindings(self) -> List:
795        """Return all keybinds for this pane."""
796        # Return log content control keybindings
797        return [self.log_content_control.get_key_bindings()]
798
799    def get_window_menu_options(
800        self,
801    ) -> List[Tuple[str, Union[Callable, None]]]:
802        """Return all menu options for the log pane."""
803
804        options = [
805            # Menu separator
806            ('-', None),
807            (
808                'Save/Export a copy',
809                self.start_saveas,
810            ),
811            ('-', None),
812            (
813                '{check} Line wrapping'.format(
814                    check=to_checkbox_text(self.wrap_lines, end='')
815                ),
816                self.toggle_wrap_lines,
817            ),
818            (
819                '{check} Table view'.format(
820                    check=to_checkbox_text(self._table_view, end='')
821                ),
822                self.toggle_table_view,
823            ),
824            (
825                '{check} Follow'.format(
826                    check=to_checkbox_text(self.log_view.follow, end='')
827                ),
828                self.toggle_follow,
829            ),
830            (
831                '{check} Open in web browser'.format(
832                    check=to_checkbox_text(
833                        self.log_view.websocket_running, end=''
834                    )
835                ),
836                self.toggle_websocket_server,
837            ),
838            # Menu separator
839            ('-', None),
840            (
841                'Clear history',
842                self.clear_history,
843            ),
844            (
845                'Duplicate pane',
846                self.duplicate,
847            ),
848        ]
849        if self.is_a_duplicate:
850            options += [
851                (
852                    'Remove/Delete pane',
853                    functools.partial(
854                        self.application.window_manager.remove_pane, self
855                    ),
856                )
857            ]
858
859        # Search / Filter section
860        options += [
861            # Menu separator
862            ('-', None),
863            (
864                'Hide search highlighting',
865                self.log_view.disable_search_highlighting,
866            ),
867            (
868                'Create filter from search results',
869                self.log_view.apply_filter,
870            ),
871            (
872                'Clear/Reset active filters',
873                self.log_view.clear_filters,
874            ),
875        ]
876
877        return options
878
879    def apply_filters_from_config(self, window_options) -> None:
880        if 'filters' not in window_options:
881            return
882
883        for field, criteria in window_options['filters'].items():
884            for matcher_name, search_string in criteria.items():
885                inverted = matcher_name.endswith('-inverted')
886                matcher_name = re.sub(r'-inverted$', '', matcher_name)
887                if field == 'all':
888                    field = None
889                if self.log_view.new_search(
890                    search_string,
891                    invert=inverted,
892                    field=field,
893                    search_matcher=matcher_name,
894                    interactive=False,
895                ):
896                    self.log_view.install_new_filter()
897
898        # Trigger any existing log messages to be added to the view.
899        self.log_view.new_logs_arrived()
900
901    def create_duplicate(self) -> 'LogPane':
902        """Create a duplicate of this LogView."""
903        new_pane = LogPane(self.application, pane_title=self.pane_title())
904        # Set the log_store
905        log_store = self.log_view.log_store
906        new_pane.log_view.log_store = log_store
907        # Register the duplicate pane as a viewer
908        log_store.register_viewer(new_pane.log_view)
909
910        # Set any existing search state.
911        new_pane.log_view.search_text = self.log_view.search_text
912        new_pane.log_view.search_filter = self.log_view.search_filter
913        new_pane.log_view.search_matcher = self.log_view.search_matcher
914        new_pane.log_view.search_highlight = self.log_view.search_highlight
915
916        # Mark new pane as a duplicate so it can be deleted.
917        new_pane.is_a_duplicate = True
918        return new_pane
919
920    def duplicate(self) -> None:
921        new_pane = self.create_duplicate()
922        # Add the new pane.
923        self.application.window_manager.add_pane(new_pane)
924
925    def add_log_handler(
926        self,
927        logger: Union[str, logging.Logger],
928        level_name: Optional[str] = None,
929    ) -> None:
930        """Add a log handlers to this LogPane."""
931
932        if isinstance(logger, logging.Logger):
933            logger_instance = logger
934        elif isinstance(logger, str):
935            logger_instance = logging.getLogger(logger)
936
937        if level_name:
938            if not hasattr(logging, level_name):
939                raise Exception(f'Unknown log level: {level_name}')
940            logger_instance.level = getattr(logging, level_name, logging.INFO)
941        logger_instance.addHandler(self.log_view.log_store)  # type: ignore
942        self.append_pane_subtitle(logger_instance.name)  # type: ignore
943