| # Copyright 2021 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """LogView maintains a log pane's scrolling and searching state.""" |
| |
| from __future__ import annotations |
| import asyncio |
| import collections |
| import copy |
| from enum import Enum |
| import itertools |
| import logging |
| import operator |
| from pathlib import Path |
| import re |
| import time |
| from typing import Callable, Dict, List, Optional, Tuple, TYPE_CHECKING |
| |
| from prompt_toolkit.data_structures import Point |
| from prompt_toolkit.formatted_text import StyleAndTextTuples |
| |
| from pw_console.log_filter import ( |
| DEFAULT_SEARCH_MATCHER, |
| LogFilter, |
| RegexValidator, |
| SearchMatcher, |
| preprocess_search_regex, |
| ) |
| from pw_console.log_screen import ScreenLine, LogScreen |
| from pw_console.log_store import LogStore |
| from pw_console.text_formatting import remove_formatting |
| |
| if TYPE_CHECKING: |
| from pw_console.console_app import ConsoleApp |
| from pw_console.log_line import LogLine |
| from pw_console.log_pane import LogPane |
| |
| _LOG = logging.getLogger(__package__) |
| |
| |
| class FollowEvent(Enum): |
| """Follow mode scroll event types.""" |
| SEARCH_MATCH = 'scroll_to_bottom' |
| STICKY_FOLLOW = 'scroll_to_bottom_with_sticky_follow' |
| |
| |
| class LogView: |
| """Viewing window into a LogStore.""" |
| |
| # pylint: disable=too-many-instance-attributes,too-many-public-methods |
| |
| def __init__( |
| self, |
| log_pane: 'LogPane', |
| application: 'ConsoleApp', |
| log_store: Optional[LogStore] = None, |
| ): |
| # Parent LogPane reference. Updated by calling `set_log_pane()`. |
| self.log_pane = log_pane |
| self.log_store = log_store if log_store else LogStore( |
| prefs=application.prefs) |
| self.log_store.set_prefs(application.prefs) |
| self.log_store.register_viewer(self) |
| |
| self.marked_logs_start: Optional[int] = None |
| self.marked_logs_end: Optional[int] = None |
| |
| # Search variables |
| self.search_text: Optional[str] = None |
| self.search_filter: Optional[LogFilter] = None |
| self.search_highlight: bool = False |
| self.search_matcher = DEFAULT_SEARCH_MATCHER |
| self.search_validator = RegexValidator() |
| |
| # Container for each log_index matched by active searches. |
| self.search_matched_lines: Dict[int, int] = {} |
| # Background task to find historical matched lines. |
| self.search_match_count_task: Optional[asyncio.Task] = None |
| |
| # Flag for automatically jumping to each new search match as they |
| # appear. |
| self.follow_search_match: bool = False |
| self.last_search_matched_log: Optional[int] = None |
| |
| # Follow event flag. This is set to by the new_logs_arrived() function |
| # as a signal that the log screen should be scrolled to the bottom. |
| # This is read by render_content() whenever the screen is drawn. |
| self.follow_event: Optional[FollowEvent] = None |
| |
| self.log_screen = LogScreen( |
| get_log_source=self._get_log_lines, |
| get_line_wrapping=self.wrap_lines_enabled, |
| get_log_formatter=self._get_table_formatter, |
| get_search_filter=lambda: self.search_filter, |
| get_search_highlight=lambda: self.search_highlight, |
| ) |
| |
| # Filter |
| self.filtering_on: bool = False |
| self.filters: 'collections.OrderedDict[str, LogFilter]' = ( |
| collections.OrderedDict()) |
| self.filtered_logs: collections.deque = collections.deque() |
| self.filter_existing_logs_task: Optional[asyncio.Task] = None |
| |
| # Current log line index state variables: |
| self._last_log_index = -1 |
| self._log_index = 0 |
| self._filtered_log_index = 0 |
| self._last_start_index = 0 |
| self._last_end_index = 0 |
| self._current_start_index = 0 |
| self._current_end_index = 0 |
| self._scrollback_start_index = 0 |
| |
| # LogPane prompt_toolkit container render size. |
| self._window_height = 20 |
| self._window_width = 80 |
| self._reset_log_screen_on_next_render: bool = True |
| self._user_scroll_event: bool = False |
| |
| # Max frequency in seconds of prompt_toolkit UI redraws triggered by new |
| # log lines. |
| self._ui_update_frequency = 0.05 |
| self._last_ui_update_time = time.time() |
| self._last_log_store_index = 0 |
| self._new_logs_since_last_render = True |
| |
| # Should new log lines be tailed? |
| self.follow: bool = True |
| |
| self.visual_select_mode: bool = False |
| |
| # Cache of formatted text tuples used in the last UI render. |
| self._line_fragment_cache: List[StyleAndTextTuples] = [] |
| |
| def view_mode_changed(self) -> None: |
| self._reset_log_screen_on_next_render = True |
| |
| @property |
| def log_index(self): |
| if self.filtering_on: |
| return self._filtered_log_index |
| return self._log_index |
| |
| @log_index.setter |
| def log_index(self, new_log_index): |
| # Save the old log_index |
| self._last_log_index = self.log_index |
| if self.filtering_on: |
| self._filtered_log_index = new_log_index |
| else: |
| self._log_index = new_log_index |
| |
| def _reset_log_index_changed(self) -> None: |
| self._last_log_index = self.log_index |
| |
| def log_index_changed_since_last_render(self) -> bool: |
| return self._last_log_index != self.log_index |
| |
| def _set_match_position(self, position: int): |
| self.follow = False |
| self.log_index = position |
| self.save_search_matched_line(position) |
| self.log_screen.reset_logs(log_index=self.log_index) |
| self.log_screen.shift_selected_log_to_center() |
| self._user_scroll_event = True |
| self.log_pane.application.redraw_ui() |
| |
| def select_next_search_matcher(self): |
| matchers = list(SearchMatcher) |
| index = matchers.index(self.search_matcher) |
| new_index = (index + 1) % len(matchers) |
| self.search_matcher = matchers[new_index] |
| |
| def search_forwards(self): |
| if not self.search_filter: |
| return |
| self.search_highlight = True |
| |
| log_beginning_index = self.hidden_line_count() |
| |
| starting_index = self.log_index + 1 |
| if starting_index > self.get_last_log_index(): |
| starting_index = log_beginning_index |
| |
| _, logs = self._get_log_lines() |
| |
| # From current position +1 and down |
| for i in range(starting_index, self.get_last_log_index() + 1): |
| if self.search_filter.matches(logs[i]): |
| self._set_match_position(i) |
| return |
| |
| # From the beginning to the original start |
| for i in range(log_beginning_index, starting_index): |
| if self.search_filter.matches(logs[i]): |
| self._set_match_position(i) |
| return |
| |
| def search_backwards(self): |
| if not self.search_filter: |
| return |
| self.search_highlight = True |
| |
| log_beginning_index = self.hidden_line_count() |
| |
| starting_index = self.log_index - 1 |
| if starting_index < 0: |
| starting_index = self.get_last_log_index() |
| |
| _, logs = self._get_log_lines() |
| |
| # From current position - 1 and up |
| for i in range(starting_index, log_beginning_index - 1, -1): |
| if self.search_filter.matches(logs[i]): |
| self._set_match_position(i) |
| return |
| |
| # From the end to the original start |
| for i in range(self.get_last_log_index(), starting_index, -1): |
| if self.search_filter.matches(logs[i]): |
| self._set_match_position(i) |
| return |
| |
| def set_search_regex(self, |
| text, |
| invert, |
| field, |
| matcher: Optional[SearchMatcher] = None) -> bool: |
| search_matcher = matcher if matcher else self.search_matcher |
| _LOG.debug(search_matcher) |
| |
| regex_text, regex_flags = preprocess_search_regex( |
| text, matcher=search_matcher) |
| |
| try: |
| compiled_regex = re.compile(regex_text, regex_flags) |
| self.search_filter = LogFilter( |
| regex=compiled_regex, |
| input_text=text, |
| invert=invert, |
| field=field, |
| ) |
| _LOG.debug(self.search_filter) |
| except re.error as error: |
| _LOG.debug(error) |
| return False |
| |
| self.search_highlight = True |
| self.search_text = regex_text |
| return True |
| |
| def new_search( |
| self, |
| text, |
| invert=False, |
| field: Optional[str] = None, |
| search_matcher: Optional[str] = None, |
| interactive: bool = True, |
| ) -> bool: |
| """Start a new search for the given text.""" |
| valid_matchers = list(s.name for s in SearchMatcher) |
| selected_matcher: Optional[SearchMatcher] = None |
| if (search_matcher is not None |
| and search_matcher.upper() in valid_matchers): |
| selected_matcher = SearchMatcher(search_matcher.upper()) |
| |
| if not self.set_search_regex(text, invert, field, selected_matcher): |
| return False |
| |
| # Clear matched lines |
| self.search_matched_lines = {} |
| |
| if interactive: |
| # Start count historical search matches task. |
| self.search_match_count_task = asyncio.create_task( |
| self.count_search_matches()) |
| |
| # Default search direction when hitting enter in the search bar. |
| if interactive: |
| self.search_forwards() |
| return True |
| |
| def save_search_matched_line(self, log_index: int) -> None: |
| """Save the log_index at position as a matched line.""" |
| self.search_matched_lines[log_index] = 0 |
| # Keep matched lines sorted by position |
| self.search_matched_lines = { |
| # Save this log_index and its match number. |
| log_index: match_number |
| for match_number, log_index in enumerate( |
| sorted(self.search_matched_lines.keys())) |
| } |
| |
| def disable_search_highlighting(self): |
| self.log_pane.log_view.search_highlight = False |
| |
| def _restart_filtering(self): |
| # Turn on follow |
| if not self.follow: |
| self.toggle_follow() |
| |
| # Reset filtered logs. |
| self.filtered_logs.clear() |
| # Reset scrollback start |
| self._scrollback_start_index = 0 |
| |
| # Start filtering existing log lines. |
| self.filter_existing_logs_task = asyncio.create_task( |
| self.filter_past_logs()) |
| |
| # Reset existing search |
| self.clear_search() |
| |
| # Trigger a main menu update to set log window menu titles. |
| self.log_pane.application.update_menu_items() |
| # Redraw the UI |
| self.log_pane.application.redraw_ui() |
| |
| def install_new_filter(self): |
| """Set a filter using the current search_regex.""" |
| if not self.search_filter: |
| return |
| |
| self.filtering_on = True |
| self.filters[self.search_text] = copy.deepcopy(self.search_filter) |
| |
| self.clear_search() |
| |
| def apply_filter(self): |
| """Set new filter and schedule historical log filter asyncio task.""" |
| self.install_new_filter() |
| self._restart_filtering() |
| |
| def clear_search_highlighting(self): |
| self.search_highlight = False |
| self._reset_log_screen_on_next_render = True |
| |
| def clear_search(self): |
| self.search_matched_lines = {} |
| self.search_text = None |
| self.search_filter = None |
| self.search_highlight = False |
| self._reset_log_screen_on_next_render = True |
| |
| def _get_log_lines(self) -> Tuple[int, collections.deque[LogLine]]: |
| logs = self.log_store.logs |
| if self.filtering_on: |
| logs = self.filtered_logs |
| return self._scrollback_start_index, logs |
| |
| def _get_visible_log_lines(self): |
| _, logs = self._get_log_lines() |
| if self._scrollback_start_index > 0: |
| return collections.deque( |
| itertools.islice(logs, self.hidden_line_count(), len(logs))) |
| return logs |
| |
| def _get_table_formatter(self) -> Optional[Callable]: |
| table_formatter = None |
| if self.log_pane.table_view: |
| table_formatter = self.log_store.table.formatted_row |
| return table_formatter |
| |
| def delete_filter(self, filter_text): |
| if filter_text not in self.filters: |
| return |
| |
| # Delete this filter |
| del self.filters[filter_text] |
| |
| # If no filters left, stop filtering. |
| if len(self.filters) == 0: |
| self.clear_filters() |
| else: |
| # Erase existing filtered lines. |
| self._restart_filtering() |
| |
| def clear_filters(self): |
| if not self.filtering_on: |
| return |
| self.clear_search() |
| self.filtering_on = False |
| self.filters: 'collections.OrderedDict[str, re.Pattern]' = ( |
| collections.OrderedDict()) |
| self.filtered_logs.clear() |
| # Reset scrollback start |
| self._scrollback_start_index = 0 |
| if not self.follow: |
| self.toggle_follow() |
| |
| async def count_search_matches(self): |
| """Count search matches and save their locations.""" |
| # Wait for any filter_existing_logs_task to finish. |
| if self.filtering_on and self.filter_existing_logs_task: |
| await self.filter_existing_logs_task |
| |
| starting_index = self.get_last_log_index() |
| ending_index, logs = self._get_log_lines() |
| |
| # From the end of the log store to the beginning. |
| for i in range(starting_index, ending_index - 1, -1): |
| # Is this log a match? |
| if self.search_filter.matches(logs[i]): |
| self.save_search_matched_line(i) |
| # Pause every 100 lines or so |
| if i % 100 == 0: |
| await asyncio.sleep(.1) |
| |
| async def filter_past_logs(self): |
| """Filter past log lines.""" |
| starting_index = self.log_store.get_last_log_index() |
| ending_index = -1 |
| |
| # From the end of the log store to the beginning. |
| for i in range(starting_index, ending_index, -1): |
| # Is this log a match? |
| if self.filter_scan(self.log_store.logs[i]): |
| # Add to the beginning of the deque. |
| self.filtered_logs.appendleft(self.log_store.logs[i]) |
| # TODO(tonymd): Tune these values. |
| # Pause every 100 lines or so |
| if i % 100 == 0: |
| await asyncio.sleep(.1) |
| |
| def set_log_pane(self, log_pane: 'LogPane'): |
| """Set the parent LogPane instance.""" |
| self.log_pane = log_pane |
| |
| def _update_log_index(self) -> ScreenLine: |
| line_at_cursor = self.log_screen.get_line_at_cursor_position() |
| if line_at_cursor.log_index is not None: |
| self.log_index = line_at_cursor.log_index |
| return line_at_cursor |
| |
| def get_current_line(self) -> int: |
| """Return the currently selected log event index.""" |
| return self.log_index |
| |
| def get_total_count(self): |
| """Total size of the logs store.""" |
| return (len(self.filtered_logs) |
| if self.filtering_on else self.log_store.get_total_count()) |
| |
| def get_last_log_index(self): |
| total = self.get_total_count() |
| return 0 if total < 0 else total - 1 |
| |
| def clear_scrollback(self): |
| """Hide log lines before the max length of the stored logs.""" |
| # Enable follow and scroll to the bottom, then clear. |
| if not self.follow: |
| self.toggle_follow() |
| self._scrollback_start_index = self.log_index |
| self._reset_log_screen_on_next_render = True |
| |
| def hidden_line_count(self): |
| """Return the number of hidden lines.""" |
| if self._scrollback_start_index > 0: |
| return self._scrollback_start_index + 1 |
| return 0 |
| |
| def undo_clear_scrollback(self): |
| """Reset the current scrollback start index.""" |
| self._scrollback_start_index = 0 |
| |
| def wrap_lines_enabled(self): |
| """Get the parent log pane wrap lines setting.""" |
| if not self.log_pane: |
| return False |
| return self.log_pane.wrap_lines |
| |
| def toggle_follow(self): |
| """Toggle auto line following.""" |
| self.follow = not self.follow |
| if self.follow: |
| # Disable search match follow mode. |
| self.follow_search_match = False |
| self.scroll_to_bottom() |
| |
| def filter_scan(self, log: 'LogLine'): |
| filter_match_count = 0 |
| for _filter_text, log_filter in self.filters.items(): |
| if log_filter.matches(log): |
| filter_match_count += 1 |
| else: |
| break |
| |
| if filter_match_count == len(self.filters): |
| return True |
| return False |
| |
| def new_logs_arrived(self): |
| """Check newly arrived log messages. |
| |
| Depending on where log statements occur ``new_logs_arrived`` may be in a |
| separate thread since it is triggerd by the Python log handler |
| ``emit()`` function. In this case the log handler is the LogStore |
| instance ``self.log_store``. This function should not redraw the screen |
| or scroll. |
| """ |
| latest_total = self.log_store.get_total_count() |
| |
| if self.filtering_on: |
| # Scan newly arived log lines |
| for i in range(self._last_log_store_index, latest_total): |
| if self.filter_scan(self.log_store.logs[i]): |
| self.filtered_logs.append(self.log_store.logs[i]) |
| |
| if self.search_filter: |
| last_matched_log: Optional[int] = None |
| # Scan newly arived log lines |
| for i in range(self._last_log_store_index, latest_total): |
| if self.search_filter.matches(self.log_store.logs[i]): |
| self.save_search_matched_line(i) |
| last_matched_log = i |
| if last_matched_log and self.follow_search_match: |
| # Set the follow event flag for the next render_content call. |
| self.follow_event = FollowEvent.SEARCH_MATCH |
| self.last_search_matched_log = last_matched_log |
| |
| self._last_log_store_index = latest_total |
| self._new_logs_since_last_render = True |
| |
| if self.follow: |
| # Set the follow event flag for the next render_content call. |
| self.follow_event = FollowEvent.STICKY_FOLLOW |
| |
| # Trigger a UI update |
| self._update_prompt_toolkit_ui() |
| |
| def _update_prompt_toolkit_ui(self): |
| """Update Prompt Toolkit UI if a certain amount of time has passed.""" |
| emit_time = time.time() |
| # Has enough time passed since last UI redraw? |
| if emit_time > self._last_ui_update_time + self._ui_update_frequency: |
| # Update last log time |
| self._last_ui_update_time = emit_time |
| |
| # Trigger Prompt Toolkit UI redraw. |
| self.log_pane.application.redraw_ui() |
| |
| def get_cursor_position(self) -> Point: |
| """Return the position of the cursor.""" |
| return Point(0, self.log_screen.cursor_position) |
| |
| def scroll_to_top(self): |
| """Move selected index to the beginning.""" |
| # Stop following so cursor doesn't jump back down to the bottom. |
| self.follow = False |
| # First possible log index that should be displayed |
| log_beginning_index = self.hidden_line_count() |
| self.log_index = log_beginning_index |
| self.log_screen.reset_logs(log_index=self.log_index) |
| self.log_screen.shift_selected_log_to_top() |
| self._user_scroll_event = True |
| |
| def move_selected_line_to_top(self): |
| self.follow = False |
| |
| # Update selected line |
| self._update_log_index() |
| |
| self.log_screen.reset_logs(log_index=self.log_index) |
| self.log_screen.shift_selected_log_to_top() |
| self._user_scroll_event = True |
| |
| def center_log_line(self): |
| self.follow = False |
| |
| # Update selected line |
| self._update_log_index() |
| |
| self.log_screen.reset_logs(log_index=self.log_index) |
| self.log_screen.shift_selected_log_to_center() |
| self._user_scroll_event = True |
| |
| def scroll_to_bottom(self, with_sticky_follow: bool = True): |
| """Move selected index to the end.""" |
| # Don't change following state like scroll_to_top. |
| self.log_index = max(0, self.get_last_log_index()) |
| self.log_screen.reset_logs(log_index=self.log_index) |
| |
| # Sticky follow mode |
| if with_sticky_follow: |
| self.follow = True |
| self._user_scroll_event = True |
| |
| def scroll(self, lines) -> None: |
| """Scroll up or down by plus or minus lines. |
| |
| This method is only called by user keybindings. |
| """ |
| # If the user starts scrolling, stop auto following. |
| self.follow = False |
| |
| self.log_screen.scroll_subline(lines) |
| self._user_scroll_event = True |
| |
| # Update the current log |
| current_line = self._update_log_index() |
| |
| # Don't check for sticky follow mode if selecting lines. |
| if self.visual_select_mode: |
| return |
| # Is the last log line selected? |
| if self.log_index == self.get_last_log_index(): |
| # Is the last line of the current log selected? |
| if current_line.subline + 1 == current_line.height: |
| # Sticky follow mode |
| self.follow = True |
| |
| def visual_selected_log_count(self) -> int: |
| if self.marked_logs_start is None or self.marked_logs_end is None: |
| return 0 |
| return (self.marked_logs_end - self.marked_logs_start) + 1 |
| |
| def clear_visual_selection(self) -> None: |
| self.marked_logs_start = None |
| self.marked_logs_end = None |
| self.visual_select_mode = False |
| self._user_scroll_event = True |
| self.log_pane.application.redraw_ui() |
| |
| def visual_select_all(self) -> None: |
| self.marked_logs_start = self._scrollback_start_index |
| self.marked_logs_end = self.get_total_count() - 1 |
| |
| self.visual_select_mode = True |
| self._user_scroll_event = True |
| self.log_pane.application.redraw_ui() |
| |
| def visual_select_up(self) -> None: |
| # Select the current line |
| self.visual_select_line(self.get_cursor_position(), autoscroll=False) |
| # Move the cursor by 1 |
| self.scroll_up(1) |
| # Select the new line |
| self.visual_select_line(self.get_cursor_position(), autoscroll=False) |
| |
| def visual_select_down(self) -> None: |
| # Select the current line |
| self.visual_select_line(self.get_cursor_position(), autoscroll=False) |
| # Move the cursor by 1 |
| self.scroll_down(1) |
| # Select the new line |
| self.visual_select_line(self.get_cursor_position(), autoscroll=False) |
| |
| def visual_select_line(self, |
| mouse_position: Point, |
| autoscroll: bool = True) -> None: |
| """Mark the log under mouse_position as visually selected.""" |
| # Check mouse_position is valid |
| if not 0 <= mouse_position.y < len(self.log_screen.line_buffer): |
| return |
| # Update mode flags |
| self.visual_select_mode = True |
| self.follow = False |
| # Get the ScreenLine for the cursor position |
| screen_line = self.log_screen.line_buffer[mouse_position.y] |
| if screen_line.log_index is None: |
| return |
| |
| if self.marked_logs_start is None: |
| self.marked_logs_start = screen_line.log_index |
| if self.marked_logs_end is None: |
| self.marked_logs_end = screen_line.log_index |
| |
| if screen_line.log_index < self.marked_logs_start: |
| self.marked_logs_start = screen_line.log_index |
| elif screen_line.log_index > self.marked_logs_end: |
| self.marked_logs_end = screen_line.log_index |
| |
| # Update cursor position |
| self.log_screen.move_cursor_to_position(mouse_position.y) |
| |
| # Autoscroll when mouse dragging on the top or bottom of the window. |
| if autoscroll: |
| if mouse_position.y == 0: |
| self.scroll_up(1) |
| elif mouse_position.y == self._window_height - 1: |
| self.scroll_down(1) |
| |
| # Trigger a rerender. |
| self._user_scroll_event = True |
| self.log_pane.application.redraw_ui() |
| |
| def scroll_to_position(self, mouse_position: Point): |
| """Set the selected log line to the mouse_position.""" |
| # Disable follow mode when the user clicks or mouse drags on a log line. |
| self.follow = False |
| |
| self.log_screen.move_cursor_to_position(mouse_position.y) |
| self._update_log_index() |
| |
| self._user_scroll_event = True |
| |
| def scroll_up_one_page(self): |
| """Move the selected log index up by one window height.""" |
| lines = 1 |
| if self._window_height > 0: |
| lines = self._window_height |
| self.scroll(-1 * lines) |
| |
| def scroll_down_one_page(self): |
| """Move the selected log index down by one window height.""" |
| lines = 1 |
| if self._window_height > 0: |
| lines = self._window_height |
| self.scroll(lines) |
| |
| def scroll_down(self, lines=1): |
| """Move the selected log index down by one or more lines.""" |
| self.scroll(lines) |
| |
| def scroll_up(self, lines=1): |
| """Move the selected log index up by one or more lines.""" |
| self.scroll(-1 * lines) |
| |
| def log_start_end_indexes_changed(self) -> bool: |
| return (self._last_start_index != self._current_start_index |
| or self._last_end_index != self._current_end_index) |
| |
| def render_table_header(self): |
| """Get pre-formatted table header.""" |
| return self.log_store.render_table_header() |
| |
| def render_content(self) -> list: |
| """Return logs to display on screen as a list of FormattedText tuples. |
| |
| This function determines when the log screen requires re-rendeing based |
| on user scroll events, follow mode being on, or log pane being |
| empty. The FormattedText tuples passed to prompt_toolkit are cached if |
| no updates are required. |
| """ |
| screen_update_needed = False |
| |
| # Check window size |
| if self.log_pane.pane_resized(): |
| self._window_width = self.log_pane.current_log_pane_width |
| self._window_height = self.log_pane.current_log_pane_height |
| self.log_screen.resize(self._window_width, self._window_height) |
| self._reset_log_screen_on_next_render = True |
| |
| if self.follow_event is not None: |
| if (self.follow_event == FollowEvent.SEARCH_MATCH |
| and self.last_search_matched_log): |
| self.log_index = self.last_search_matched_log |
| self.last_search_matched_log = None |
| self._reset_log_screen_on_next_render = True |
| |
| elif self.follow_event == FollowEvent.STICKY_FOLLOW: |
| # Jump to the last log message |
| self.log_index = max(0, self.get_last_log_index()) |
| |
| self.follow_event = None |
| screen_update_needed = True |
| |
| if self._reset_log_screen_on_next_render or self.log_screen.empty(): |
| # Clear the reset flag. |
| self._reset_log_screen_on_next_render = False |
| self.log_screen.reset_logs(log_index=self.log_index) |
| screen_update_needed = True |
| |
| elif self.follow and self._new_logs_since_last_render: |
| # Follow mode is on so add new logs to the screen |
| self._new_logs_since_last_render = False |
| |
| current_log_index = self.log_index |
| last_rendered_log_index = self.log_screen.last_appended_log_index |
| # If so many logs have arrived than can fit on the screen, redraw |
| # the whole screen from the new position. |
| if (current_log_index - |
| last_rendered_log_index) > self.log_screen.height: |
| self.log_screen.reset_logs(log_index=self.log_index) |
| # A small amount of logs have arrived, append them one at a time |
| # without redrawing the whole screen. |
| else: |
| for i in range(last_rendered_log_index + 1, |
| current_log_index + 1): |
| self.log_screen.append_log(i) |
| |
| screen_update_needed = True |
| |
| if self.follow: |
| # Select the last line for follow mode. |
| self.log_screen.move_cursor_to_bottom() |
| screen_update_needed = True |
| |
| if self._user_scroll_event: |
| self._user_scroll_event = False |
| screen_update_needed = True |
| |
| if screen_update_needed: |
| self._line_fragment_cache = self.log_screen.get_lines( |
| marked_logs_start=self.marked_logs_start, |
| marked_logs_end=self.marked_logs_end, |
| ) |
| return self._line_fragment_cache |
| |
| def _logs_to_text( |
| self, |
| use_table_formatting: bool = True, |
| selected_lines_only: bool = False, |
| ) -> str: |
| """Convert all or selected log messages to plaintext.""" |
| def get_table_string(log: LogLine) -> str: |
| return remove_formatting(self.log_store.table.formatted_row(log)) |
| |
| formatter: Callable[[LogLine], |
| str] = operator.attrgetter('ansi_stripped_log') |
| if use_table_formatting: |
| formatter = get_table_string |
| |
| _start_log_index, log_source = self._get_log_lines() |
| |
| log_index_range = range(self._scrollback_start_index, |
| self.get_total_count()) |
| if (selected_lines_only and self.marked_logs_start is not None |
| and self.marked_logs_end is not None): |
| log_index_range = range(self.marked_logs_start, |
| self.marked_logs_end + 1) |
| |
| text_output = '' |
| for i in log_index_range: |
| log_text = formatter(log_source[i]) |
| text_output += log_text |
| if not log_text.endswith('\n'): |
| text_output += '\n' |
| |
| return text_output |
| |
| def export_logs( |
| self, |
| use_table_formatting: bool = True, |
| selected_lines_only: bool = False, |
| file_name: Optional[str] = None, |
| to_clipboard: bool = False, |
| add_markdown_fence: bool = False, |
| ) -> bool: |
| """Export log lines to file or clipboard.""" |
| text_output = self._logs_to_text(use_table_formatting, |
| selected_lines_only) |
| |
| if file_name: |
| target_path = Path(file_name).expanduser() |
| with target_path.open('w') as output_file: |
| output_file.write(text_output) |
| _LOG.debug('Saved to file: %s', file_name) |
| |
| elif to_clipboard: |
| if add_markdown_fence: |
| text_output = '```\n' + text_output + '```\n' |
| self.log_pane.application.application.clipboard.set_text( |
| text_output) |
| _LOG.debug('Copied logs to clipboard.') |
| |
| return True |