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