aboutsummaryrefslogtreecommitdiff
path: root/pw_console/py/pw_console/log_pane.py
blob: 500b978ea801f386d63ada68ab8eb4ef5a99b8c9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
# 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.visual-select-all', key_bindings)
        def _select_all_logs(_event: KeyPressEvent) -> None:
            """Select all log lines."""
            self.log_pane.log_view.visual_select_all()

        @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.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:
        result_message = self.log_pane.application.set_system_clipboard(
            self.log_pane.log_view.get_web_socket_url()
        )
        if result_message:
            self._set_action_message(result_message)

    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