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