• 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
19from typing import Any, List, Optional, Union, TYPE_CHECKING
20
21from prompt_toolkit.application.current import get_app
22from prompt_toolkit.filters import (
23    Condition,
24    has_focus,
25)
26from prompt_toolkit.formatted_text import StyleAndTextTuples
27from prompt_toolkit.key_binding import (
28    KeyBindings,
29    KeyPressEvent,
30    KeyBindingsBase,
31)
32from prompt_toolkit.layout import (
33    ConditionalContainer,
34    Float,
35    FloatContainer,
36    UIContent,
37    UIControl,
38    VerticalAlign,
39    Window,
40)
41from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseButton
42
43import pw_console.widgets.checkbox
44import pw_console.style
45from pw_console.log_view import LogView
46from pw_console.log_pane_toolbars import (
47    LineInfoBar,
48    TableToolbar,
49)
50from pw_console.log_pane_saveas_dialog import LogPaneSaveAsDialog
51from pw_console.log_pane_selection_dialog import LogPaneSelectionDialog
52from pw_console.log_store import LogStore
53from pw_console.search_toolbar import SearchToolbar
54from pw_console.filter_toolbar import FilterToolbar
55from pw_console.widgets import (
56    ToolbarButton,
57    WindowPane,
58    WindowPaneHSplit,
59    WindowPaneToolbar,
60)
61
62if TYPE_CHECKING:
63    from pw_console.console_app import ConsoleApp
64
65_LOG_OUTPUT_SCROLL_AMOUNT = 5
66_LOG = logging.getLogger(__package__)
67
68
69class LogContentControl(UIControl):
70    """LogPane prompt_toolkit UIControl for displaying LogContainer lines."""
71    def __init__(self, log_pane: 'LogPane') -> None:
72        # pylint: disable=too-many-locals
73        self.log_pane = log_pane
74        self.log_view = log_pane.log_view
75
76        # Mouse drag visual selection flags.
77        self.visual_select_mode_drag_start = False
78        self.visual_select_mode_drag_stop = False
79
80        self.uicontent: Optional[UIContent] = None
81        self.lines: List[StyleAndTextTuples] = []
82
83        # Key bindings.
84        key_bindings = KeyBindings()
85        register = log_pane.application.prefs.register_keybinding
86
87        @register('log-pane.shift-line-to-top', key_bindings)
88        def _shift_log_to_top(_event: KeyPressEvent) -> None:
89            """Shift the selected log line to the top."""
90            self.log_view.move_selected_line_to_top()
91
92        @register('log-pane.shift-line-to-center', key_bindings)
93        def _shift_log_to_center(_event: KeyPressEvent) -> None:
94            """Shift the selected log line to the center."""
95            self.log_view.center_log_line()
96
97        @register('log-pane.toggle-wrap-lines', key_bindings)
98        def _toggle_wrap_lines(_event: KeyPressEvent) -> None:
99            """Toggle log line wrapping."""
100            self.log_pane.toggle_wrap_lines()
101
102        @register('log-pane.toggle-table-view', key_bindings)
103        def _toggle_table_view(_event: KeyPressEvent) -> None:
104            """Toggle table view."""
105            self.log_pane.toggle_table_view()
106
107        @register('log-pane.duplicate-log-pane', key_bindings)
108        def _duplicate(_event: KeyPressEvent) -> None:
109            """Duplicate this log pane."""
110            self.log_pane.duplicate()
111
112        @register('log-pane.remove-duplicated-log-pane', key_bindings)
113        def _delete(_event: KeyPressEvent) -> None:
114            """Remove log pane."""
115            if self.log_pane.is_a_duplicate:
116                self.log_pane.application.window_manager.remove_pane(
117                    self.log_pane)
118
119        @register('log-pane.clear-history', key_bindings)
120        def _clear_history(_event: KeyPressEvent) -> None:
121            """Clear log pane history."""
122            self.log_pane.clear_history()
123
124        @register('log-pane.scroll-to-top', key_bindings)
125        def _scroll_to_top(_event: KeyPressEvent) -> None:
126            """Scroll to top."""
127            self.log_view.scroll_to_top()
128
129        @register('log-pane.scroll-to-bottom', key_bindings)
130        def _scroll_to_bottom(_event: KeyPressEvent) -> None:
131            """Scroll to bottom."""
132            self.log_view.scroll_to_bottom()
133
134        @register('log-pane.toggle-follow', key_bindings)
135        def _toggle_follow(_event: KeyPressEvent) -> None:
136            """Toggle log line following."""
137            self.log_pane.toggle_follow()
138
139        @register('log-pane.move-cursor-up', key_bindings)
140        def _up(_event: KeyPressEvent) -> None:
141            """Move cursor up."""
142            self.log_view.scroll_up()
143
144        @register('log-pane.move-cursor-down', key_bindings)
145        def _down(_event: KeyPressEvent) -> None:
146            """Move cursor down."""
147            self.log_view.scroll_down()
148
149        @register('log-pane.visual-select-up', key_bindings)
150        def _visual_select_up(_event: KeyPressEvent) -> None:
151            """Select previous log line."""
152            self.log_view.visual_select_up()
153
154        @register('log-pane.visual-select-down', key_bindings)
155        def _visual_select_down(_event: KeyPressEvent) -> None:
156            """Select next log line."""
157            self.log_view.visual_select_down()
158
159        @register('log-pane.scroll-page-up', key_bindings)
160        def _pageup(_event: KeyPressEvent) -> None:
161            """Scroll the logs up by one page."""
162            self.log_view.scroll_up_one_page()
163
164        @register('log-pane.scroll-page-down', key_bindings)
165        def _pagedown(_event: KeyPressEvent) -> None:
166            """Scroll the logs down by one page."""
167            self.log_view.scroll_down_one_page()
168
169        @register('log-pane.save-copy', key_bindings)
170        def _start_saveas(_event: KeyPressEvent) -> None:
171            """Save logs to a file."""
172            self.log_pane.start_saveas()
173
174        @register('log-pane.search', key_bindings)
175        def _start_search(_event: KeyPressEvent) -> None:
176            """Start searching."""
177            self.log_pane.start_search()
178
179        @register('log-pane.search-next-match', key_bindings)
180        def _next_search(_event: KeyPressEvent) -> None:
181            """Next search match."""
182            self.log_view.search_forwards()
183
184        @register('log-pane.search-previous-match', key_bindings)
185        def _previous_search(_event: KeyPressEvent) -> None:
186            """Previous search match."""
187            self.log_view.search_backwards()
188
189        @register('log-pane.visual-select-all', key_bindings)
190        def _select_all_logs(_event: KeyPressEvent) -> None:
191            """Clear search."""
192            self.log_pane.log_view.visual_select_all()
193
194        @register('log-pane.deselect-cancel-search', key_bindings)
195        def _clear_search_and_selection(_event: KeyPressEvent) -> None:
196            """Clear selection or search."""
197            if self.log_pane.log_view.visual_select_mode:
198                self.log_pane.log_view.clear_visual_selection()
199            elif self.log_pane.search_bar_active:
200                self.log_pane.search_toolbar.cancel_search()
201
202        @register('log-pane.search-apply-filter', key_bindings)
203        def _apply_filter(_event: KeyPressEvent) -> None:
204            """Apply current search as a filter."""
205            self.log_pane.search_toolbar.close_search_bar()
206            self.log_view.apply_filter()
207
208        @register('log-pane.clear-filters', key_bindings)
209        def _clear_filter(_event: KeyPressEvent) -> None:
210            """Reset / erase active filters."""
211            self.log_view.clear_filters()
212
213        self.key_bindings: KeyBindingsBase = key_bindings
214
215    def is_focusable(self) -> bool:
216        return True
217
218    def get_key_bindings(self) -> Optional[KeyBindingsBase]:
219        return self.key_bindings
220
221    def preferred_width(self, max_available_width: int) -> int:
222        """Return the width of the longest line."""
223        line_lengths = [len(l) for l in self.lines]
224        return max(line_lengths)
225
226    def preferred_height(
227        self,
228        width: int,
229        max_available_height: int,
230        wrap_lines: bool,
231        get_line_prefix,
232    ) -> Optional[int]:
233        """Return the preferred height for the log lines."""
234        content = self.create_content(width, None)
235        return content.line_count
236
237    def create_content(self, width: int, height: Optional[int]) -> UIContent:
238        # Update lines to render
239        self.lines = self.log_view.render_content()
240
241        # Create a UIContent instance if none exists
242        if self.uicontent is None:
243            self.uicontent = UIContent(get_line=lambda i: self.lines[i],
244                                       line_count=len(self.lines),
245                                       show_cursor=False)
246
247        # Update line_count
248        self.uicontent.line_count = len(self.lines)
249
250        return self.uicontent
251
252    def mouse_handler(self, mouse_event: MouseEvent):
253        """Mouse handler for this control."""
254        mouse_position = mouse_event.position
255
256        # Left mouse button release should:
257        # 1. check if a mouse drag just completed.
258        # 2. If not in focus, switch focus to this log pane
259        #    If in focus, move the cursor to that position.
260        if (mouse_event.event_type == MouseEventType.MOUSE_UP
261                and mouse_event.button == MouseButton.LEFT):
262
263            # If a drag was in progress and this is the first mouse release
264            # press, set the stop flag.
265            if (self.visual_select_mode_drag_start
266                    and not self.visual_select_mode_drag_stop):
267                self.visual_select_mode_drag_stop = True
268
269            if not has_focus(self)():
270                # Focus the save as dialog if open.
271                if self.log_pane.saveas_dialog_active:
272                    get_app().layout.focus(self.log_pane.saveas_dialog)
273                # Focus the search bar if open.
274                elif self.log_pane.search_bar_active:
275                    get_app().layout.focus(self.log_pane.search_toolbar)
276                # Otherwise, focus on the log pane content.
277                else:
278                    get_app().layout.focus(self)
279                # Mouse event handled, return None.
280                return None
281
282            # Log pane in focus already, move the cursor to the position of the
283            # mouse click.
284            self.log_pane.log_view.scroll_to_position(mouse_position)
285            # Mouse event handled, return None.
286            return None
287
288        # Mouse drag with left button should start selecting lines.
289        # The log pane does not need to be in focus to start this.
290        if (mouse_event.event_type == MouseEventType.MOUSE_MOVE
291                and mouse_event.button == MouseButton.LEFT):
292            # If a previous mouse drag was completed, clear the selection.
293            if (self.visual_select_mode_drag_start
294                    and self.visual_select_mode_drag_stop):
295                self.log_pane.log_view.clear_visual_selection()
296            # Drag select in progress, set flags accordingly.
297            self.visual_select_mode_drag_start = True
298            self.visual_select_mode_drag_stop = False
299
300            self.log_pane.log_view.visual_select_line(mouse_position)
301            # Mouse event handled, return None.
302            return None
303
304        # Mouse wheel events should move the cursor +/- some amount of lines
305        # even if this pane is not in focus.
306        if mouse_event.event_type == MouseEventType.SCROLL_DOWN:
307            self.log_pane.log_view.scroll_down(lines=_LOG_OUTPUT_SCROLL_AMOUNT)
308            # Mouse event handled, return None.
309            return None
310
311        if mouse_event.event_type == MouseEventType.SCROLL_UP:
312            self.log_pane.log_view.scroll_up(lines=_LOG_OUTPUT_SCROLL_AMOUNT)
313            # Mouse event handled, return None.
314            return None
315
316        # Mouse event not handled, return NotImplemented.
317        return NotImplemented
318
319
320class LogPane(WindowPane):
321    """LogPane class."""
322
323    # pylint: disable=too-many-instance-attributes,too-many-public-methods
324
325    def __init__(
326        self,
327        application: Any,
328        pane_title: str = 'Logs',
329        log_store: Optional[LogStore] = None,
330    ):
331        super().__init__(application, pane_title)
332
333        # TODO(tonymd): Read these settings from a project (or user) config.
334        self.wrap_lines = False
335        self._table_view = True
336        self.is_a_duplicate = False
337
338        # Create the log container which stores and handles incoming logs.
339        self.log_view: LogView = LogView(self,
340                                         self.application,
341                                         log_store=log_store)
342
343        # Log pane size variables. These are updated just befor rendering the
344        # pane by the LogLineHSplit class.
345        self.current_log_pane_width = 0
346        self.current_log_pane_height = 0
347        self.last_log_pane_width = None
348        self.last_log_pane_height = None
349
350        # Search tracking
351        self.search_bar_active = False
352        self.search_toolbar = SearchToolbar(self)
353        self.filter_toolbar = FilterToolbar(self)
354
355        self.saveas_dialog = LogPaneSaveAsDialog(self)
356        self.saveas_dialog_active = False
357        self.visual_selection_dialog = LogPaneSelectionDialog(self)
358
359        # Table header bar, only shown if table view is active.
360        self.table_header_toolbar = TableToolbar(self)
361
362        # Create the bottom toolbar for the whole log pane.
363        self.bottom_toolbar = WindowPaneToolbar(self)
364        self.bottom_toolbar.add_button(
365            ToolbarButton('/', 'Search', self.start_search))
366        self.bottom_toolbar.add_button(
367            ToolbarButton('Ctrl-o', 'Save', self.start_saveas))
368        self.bottom_toolbar.add_button(
369            ToolbarButton('f',
370                          'Follow',
371                          self.toggle_follow,
372                          is_checkbox=True,
373                          checked=lambda: self.log_view.follow))
374        self.bottom_toolbar.add_button(
375            ToolbarButton('t',
376                          'Table',
377                          self.toggle_table_view,
378                          is_checkbox=True,
379                          checked=lambda: self.table_view))
380        self.bottom_toolbar.add_button(
381            ToolbarButton('w',
382                          'Wrap',
383                          self.toggle_wrap_lines,
384                          is_checkbox=True,
385                          checked=lambda: self.wrap_lines))
386        self.bottom_toolbar.add_button(
387            ToolbarButton('C', 'Clear', self.clear_history))
388
389        self.log_content_control = LogContentControl(self)
390
391        self.log_display_window = Window(
392            content=self.log_content_control,
393            # Scrolling is handled by LogScreen
394            allow_scroll_beyond_bottom=False,
395            # Line wrapping is handled by LogScreen
396            wrap_lines=False,
397            # Selected line highlighting is handled by LogScreen
398            cursorline=False,
399            # Don't make the window taller to fill the parent split container.
400            # Window should match the height of the log line content. This will
401            # also allow the parent HSplit to justify the content to the bottom
402            dont_extend_height=True,
403            # Window width should be extended to make backround highlighting
404            # extend to the end of the container. Otherwise backround colors
405            # will only appear until the end of the log line.
406            dont_extend_width=False,
407            # Needed for log lines ANSI sequences that don't specify foreground
408            # or background colors.
409            style=functools.partial(pw_console.style.get_pane_style, self),
410        )
411
412        # Root level container
413        self.container = ConditionalContainer(
414            FloatContainer(
415                # Horizonal split containing the log lines and the toolbar.
416                WindowPaneHSplit(
417                    self,  # LogPane reference
418                    [
419                        self.table_header_toolbar,
420                        self.log_display_window,
421                        self.filter_toolbar,
422                        self.search_toolbar,
423                        self.bottom_toolbar,
424                    ],
425                    # Align content with the bottom of the container.
426                    align=VerticalAlign.BOTTOM,
427                    height=lambda: self.height,
428                    width=lambda: self.width,
429                    style=functools.partial(pw_console.style.get_pane_style,
430                                            self),
431                ),
432                floats=[
433                    Float(top=0, right=0, height=1, content=LineInfoBar(self)),
434                    Float(top=0,
435                          right=0,
436                          height=LogPaneSelectionDialog.DIALOG_HEIGHT,
437                          content=self.visual_selection_dialog),
438                    Float(top=3,
439                          left=2,
440                          right=2,
441                          height=LogPaneSaveAsDialog.DIALOG_HEIGHT + 2,
442                          content=self.saveas_dialog),
443                ]),
444            filter=Condition(lambda: self.show_pane))
445
446    @property
447    def table_view(self):
448        return self._table_view
449
450    @table_view.setter
451    def table_view(self, table_view):
452        self._table_view = table_view
453
454    def menu_title(self):
455        """Return the title to display in the Window menu."""
456        title = self.pane_title()
457
458        # List active filters
459        if self.log_view.filtering_on:
460            title += ' (FILTERS: '
461            title += ' '.join([
462                log_filter.pattern()
463                for log_filter in self.log_view.filters.values()
464            ])
465            title += ')'
466        return title
467
468    def append_pane_subtitle(self, text):
469        if not self._pane_subtitle:
470            self._pane_subtitle = text
471        else:
472            self._pane_subtitle = self._pane_subtitle + ', ' + text
473
474    def pane_subtitle(self) -> str:
475        if not self._pane_subtitle:
476            return ', '.join(self.log_view.log_store.channel_counts.keys())
477        logger_names = self._pane_subtitle.split(', ')
478        additional_text = ''
479        if len(logger_names) > 1:
480            additional_text = ' + {} more'.format(len(logger_names))
481
482        return logger_names[0] + additional_text
483
484    def start_search(self):
485        """Show the search bar to begin a search."""
486        # Show the search bar
487        self.search_bar_active = True
488        # Focus on the search bar
489        self.application.focus_on_container(self.search_toolbar)
490
491    def start_saveas(self, **export_kwargs) -> bool:
492        """Show the saveas bar to begin saving logs to a file."""
493        # Show the search bar
494        self.saveas_dialog_active = True
495        # Set export options if any
496        self.saveas_dialog.set_export_options(**export_kwargs)
497        # Focus on the search bar
498        self.application.focus_on_container(self.saveas_dialog)
499        return True
500
501    def pane_resized(self) -> bool:
502        """Return True if the current window size has changed."""
503        return (self.last_log_pane_width != self.current_log_pane_width
504                or self.last_log_pane_height != self.current_log_pane_height)
505
506    def update_pane_size(self, width, height):
507        """Save width and height of the log pane for the current UI render
508        pass."""
509        if width:
510            self.last_log_pane_width = self.current_log_pane_width
511            self.current_log_pane_width = width
512        if height:
513            # Subtract the height of the bottom toolbar
514            height -= WindowPaneToolbar.TOOLBAR_HEIGHT
515            if self._table_view:
516                height -= TableToolbar.TOOLBAR_HEIGHT
517            if self.search_bar_active:
518                height -= SearchToolbar.TOOLBAR_HEIGHT
519            if self.log_view.filtering_on:
520                height -= FilterToolbar.TOOLBAR_HEIGHT
521            self.last_log_pane_height = self.current_log_pane_height
522            self.current_log_pane_height = height
523
524    def toggle_table_view(self):
525        """Enable or disable table view."""
526        self._table_view = not self._table_view
527        self.log_view.view_mode_changed()
528        self.redraw_ui()
529
530    def toggle_wrap_lines(self):
531        """Enable or disable line wraping/truncation."""
532        self.wrap_lines = not self.wrap_lines
533        self.log_view.view_mode_changed()
534        self.redraw_ui()
535
536    def toggle_follow(self):
537        """Enable or disable following log lines."""
538        self.log_view.toggle_follow()
539        self.redraw_ui()
540
541    def clear_history(self):
542        """Erase stored log lines."""
543        self.log_view.clear_scrollback()
544        self.redraw_ui()
545
546    def get_all_key_bindings(self) -> List:
547        """Return all keybinds for this pane."""
548        # Return log content control keybindings
549        return [self.log_content_control.get_key_bindings()]
550
551    def get_all_menu_options(self) -> List:
552        """Return all menu options for the log pane."""
553
554        options = [
555            # Menu separator
556            ('-', None),
557            (
558                'Save/Export a copy',
559                self.start_saveas,
560            ),
561            ('-', None),
562            (
563                '{check} Line wrapping'.format(
564                    check=pw_console.widgets.checkbox.to_checkbox_text(
565                        self.wrap_lines, end='')),
566                self.toggle_wrap_lines,
567            ),
568            (
569                '{check} Table view'.format(
570                    check=pw_console.widgets.checkbox.to_checkbox_text(
571                        self._table_view, end='')),
572                self.toggle_table_view,
573            ),
574            (
575                '{check} Follow'.format(
576                    check=pw_console.widgets.checkbox.to_checkbox_text(
577                        self.log_view.follow, end='')),
578                self.toggle_follow,
579            ),
580            # Menu separator
581            ('-', None),
582            (
583                'Clear history',
584                self.clear_history,
585            ),
586            (
587                'Duplicate pane',
588                self.duplicate,
589            ),
590        ]
591        if self.is_a_duplicate:
592            options += [(
593                'Remove/Delete pane',
594                functools.partial(self.application.window_manager.remove_pane,
595                                  self),
596            )]
597
598        # Search / Filter section
599        options += [
600            # Menu separator
601            ('-', None),
602            (
603                'Hide search highlighting',
604                self.log_view.disable_search_highlighting,
605            ),
606            (
607                'Create filter from search results',
608                self.log_view.apply_filter,
609            ),
610            (
611                'Clear/Reset active filters',
612                self.log_view.clear_filters,
613            ),
614        ]
615
616        return options
617
618    def apply_filters_from_config(self, window_options) -> None:
619        if 'filters' not in window_options:
620            return
621
622        for field, criteria in window_options['filters'].items():
623            for matcher_name, search_string in criteria.items():
624                inverted = matcher_name.endswith('-inverted')
625                matcher_name = re.sub(r'-inverted$', '', matcher_name)
626                if field == 'all':
627                    field = None
628                if self.log_view.new_search(
629                        search_string,
630                        invert=inverted,
631                        field=field,
632                        search_matcher=matcher_name,
633                        interactive=False,
634                ):
635                    self.log_view.install_new_filter()
636
637    def create_duplicate(self) -> 'LogPane':
638        """Create a duplicate of this LogView."""
639        new_pane = LogPane(self.application, pane_title=self.pane_title())
640        # Set the log_store
641        log_store = self.log_view.log_store
642        new_pane.log_view.log_store = log_store
643        # Register the duplicate pane as a viewer
644        log_store.register_viewer(new_pane.log_view)
645
646        # Set any existing search state.
647        new_pane.log_view.search_text = self.log_view.search_text
648        new_pane.log_view.search_filter = self.log_view.search_filter
649        new_pane.log_view.search_matcher = self.log_view.search_matcher
650        new_pane.log_view.search_highlight = self.log_view.search_highlight
651
652        # Mark new pane as a duplicate so it can be deleted.
653        new_pane.is_a_duplicate = True
654        return new_pane
655
656    def duplicate(self) -> None:
657        new_pane = self.create_duplicate()
658        # Add the new pane.
659        self.application.window_manager.add_pane(new_pane)
660
661    def add_log_handler(self,
662                        logger: Union[str, logging.Logger],
663                        level_name: Optional[str] = None) -> None:
664        """Add a log handlers to this LogPane."""
665
666        if isinstance(logger, logging.Logger):
667            logger_instance = logger
668        elif isinstance(logger, str):
669            logger_instance = logging.getLogger(logger)
670
671        if level_name:
672            if not hasattr(logging, level_name):
673                raise Exception(f'Unknown log level: {level_name}')
674            logger_instance.level = getattr(logging, level_name, logging.INFO)
675        logger_instance.addHandler(self.log_view.log_store  # type: ignore
676                                   )
677        self.append_pane_subtitle(  # type: ignore
678            logger_instance.name)
679