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