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