| # Copyright 2022 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. |
| """LogScreen tracks lines to display on screen with a set of ScreenLines.""" |
| |
| from __future__ import annotations |
| import collections |
| import dataclasses |
| import logging |
| from typing import Callable, List, Optional, Tuple, TYPE_CHECKING |
| |
| from prompt_toolkit.formatted_text import ( |
| to_formatted_text, |
| StyleAndTextTuples, |
| ) |
| |
| from pw_console.log_filter import LogFilter |
| from pw_console.text_formatting import ( |
| fill_character_width, |
| insert_linebreaks, |
| split_lines, |
| ) |
| |
| if TYPE_CHECKING: |
| from pw_console.log_line import LogLine |
| from pw_console.log_pane import LogPane |
| |
| _LOG = logging.getLogger(__package__) |
| |
| |
| @dataclasses.dataclass |
| class ScreenLine: |
| """A single line of text for displaying on screen. |
| |
| Instances of ScreenLine are stored in a LogScreen's line_buffer deque. When |
| a new log message is added it may be converted into multiple ScreenLine |
| instances if the text is wrapped across multiple lines. |
| |
| For example: say our screen is 80 characters wide and a log message 240 |
| characters long needs to be displayed. With line wrapping on we will need |
| 240/80 = 3 lines to show the full message. Say also that this single log |
| message is at index #5 in the LogStore classes deque, this is the log_index |
| value. This single log message will then be split into 3 separate |
| ScreenLine instances: |
| |
| :: |
| ScreenLine(fragments=[('', 'Log message text line one')], |
| log_index=5, subline=0, height=3) |
| ScreenLine(fragments=[('', 'Log message text line two')], |
| log_index=5, subline=1, height=3) |
| ScreenLine(fragments=[('', 'Log message text line three')], |
| log_index=5, subline=2, height=3) |
| |
| Each `fragments` attribute will store the formatted text indended to be |
| direcly drawn to the screen. Since these three lines are all displaying the |
| same log message their `log_index` reference will be the same. The `subline` |
| attribute is the zero-indexed number for this log's wrapped line count and |
| `height` is the total ScreenLines needed to show this log message. |
| |
| Continuing with this example say the next two log messages to display both |
| fit on screen with no wrapping. They will both be represented with one |
| ScreenLine each: |
| |
| :: |
| ScreenLine(fragments=[('', 'Another log message')], |
| log_index=6, subline=0, height=1) |
| ScreenLine(fragments=[('', 'Yet another log message')], |
| log_index=7, subline=0, height=1) |
| |
| The `log_index` is different for each since these are both separate |
| logs. The subline is 0 since each line is the first one for this log. Both |
| have a height of 1 since no line wrapping was performed. |
| """ |
| # The StyleAndTextTuples for this line ending with a '\n'. These are the raw |
| # prompt_toolkit formatted text tuples to display on screen. The colors and |
| # spacing can change depending on the formatters used in the |
| # LogScreen._get_fragments_per_line() function. |
| fragments: StyleAndTextTuples |
| |
| # Log index reference for this screen line. This is the index to where the |
| # log message resides in the parent LogStore.logs deque. It is set to None |
| # if this is an empty ScreenLine. If a log message requires line wrapping |
| # then each resulting ScreenLine instance will have the same log_index |
| # value. |
| # |
| # This log_index may also be the integer index into a LogView.filtered_logs |
| # deque depending on if log messages are being filtered by the user. The |
| # LogScreen class below doesn't need to do anything different in either |
| # case. It's the responsibility of LogScreen.get_log_source() to return the |
| # correct source. |
| # |
| # Note this is NOT an index into LogScreen.line_buffer. |
| log_index: Optional[int] = None |
| |
| # Keep track the total height and subline number for this log message. |
| # For example this line could be subline (0, 1, or 2) of a log message with |
| # a total height 3. |
| |
| # Subline index. |
| subline: int = 0 |
| # Total height in lines of text (also ScreenLine count) that the log message |
| # referred to by log_index requires. When a log message is split across |
| # multiple lines height will be set to the same value for each ScreenLine |
| # instance. |
| height: int = 1 |
| |
| # Empty lines will have no log_index |
| def empty(self) -> bool: |
| return self.log_index is None |
| |
| |
| @dataclasses.dataclass |
| class LogScreen: |
| """LogScreen maintains the state of visible logs on screen. |
| |
| It is responsible for moving the cursor_position, prepending and appending |
| log lines as the user moves the cursor.""" |
| # Callable functions to retrieve logs and display formatting. |
| get_log_source: Callable[[], Tuple[int, collections.deque[LogLine]]] |
| get_line_wrapping: Callable[[], bool] |
| get_log_formatter: Callable[[], Optional[Callable[[LogLine], |
| StyleAndTextTuples]]] |
| get_search_filter: Callable[[], Optional[LogFilter]] |
| get_search_highlight: Callable[[], bool] |
| |
| # Window row of the current cursor position |
| cursor_position: int = 0 |
| # Screen width and height in number of characters. |
| width: int = 0 |
| height: int = 0 |
| # Buffer of literal text lines to be displayed on screen. Each visual line |
| # is represented by a ScreenLine instance and will have a max width equal |
| # to the screen's width. If any given whole log message requires line |
| # wrapping to be displayed it will be represented by multiple ScreenLine |
| # instances in this deque. |
| line_buffer: collections.deque[ScreenLine] = dataclasses.field( |
| default_factory=collections.deque) |
| |
| def __post_init__(self) -> None: |
| # Empty screen flag. Will be true if the screen contains only newlines. |
| self._empty: bool = True |
| # Save the last log index when appending. Useful for tracking how many |
| # new lines need appending in follow mode. |
| self.last_appended_log_index: int = 0 |
| |
| def _fill_top_with_empty_lines(self) -> None: |
| """Add empty lines to fill the remaining empty screen space.""" |
| for _ in range(self.height - len(self.line_buffer)): |
| self.line_buffer.appendleft(ScreenLine([('', '')])) |
| |
| def clear_screen(self) -> None: |
| """Erase all lines and fill with empty lines.""" |
| self.line_buffer.clear() |
| self._fill_top_with_empty_lines() |
| self._empty = True |
| |
| def empty(self) -> bool: |
| """Return True if the screen has no lines with content.""" |
| return self._empty |
| |
| def reset_logs( |
| self, |
| log_index: int = 0, |
| ) -> None: |
| """Erase the screen and append logs starting from log_index.""" |
| self.clear_screen() |
| |
| start_log_index, log_source = self.get_log_source() |
| if len(log_source) == 0: |
| return |
| |
| # Append at most at most the window height number worth of logs. If the |
| # number of available logs is less, use that amount. |
| max_log_messages_to_fetch = min(self.height, len(log_source)) |
| |
| # Including the target log_index, fetch the desired logs. |
| # For example if we are rendering log_index 10 and the window height is |
| # 6 the range below will be: |
| # >>> list(i for i in range((10 - 6) + 1, 10 + 1)) |
| # [5, 6, 7, 8, 9, 10] |
| for i in range((log_index - max_log_messages_to_fetch) + 1, |
| log_index + 1): |
| # If i is < 0 it's an invalid log, skip to the next line. The next |
| # index could be 0 or higher since we are traversing in increasing |
| # order. |
| if i < start_log_index: |
| continue |
| self.append_log(i) |
| # Make sure the bottom line is highlighted. |
| self.move_cursor_to_bottom() |
| |
| def resize(self, width, height) -> None: |
| """Update screen width and height. |
| |
| Following a resize the caller should run reset_logs().""" |
| self.width = width |
| self.height = height |
| |
| def get_lines( |
| self, |
| marked_logs_start: Optional[int] = None, |
| marked_logs_end: Optional[int] = None, |
| ) -> List[StyleAndTextTuples]: |
| """Return lines for final display. |
| |
| Styling is added for the line under the cursor.""" |
| if not marked_logs_start: |
| marked_logs_start = -1 |
| if not marked_logs_end: |
| marked_logs_end = -1 |
| |
| all_lines: List[StyleAndTextTuples] = [] |
| # Loop through a copy of the line_buffer in case it is mutated before |
| # this function is complete. |
| for i, line in enumerate(list(self.line_buffer)): |
| |
| # Is this line the cursor_position? Apply line highlighting |
| if (i == self.cursor_position |
| and (self.cursor_position < len(self.line_buffer)) |
| and not self.line_buffer[self.cursor_position].empty()): |
| # Fill in empty charaters to the width of the screen. This |
| # ensures the backgound is highlighted to the edge of the |
| # screen. |
| new_fragments = fill_character_width( |
| line.fragments, |
| len(line.fragments) - 1, # -1 for the ending line break |
| self.width, |
| ) |
| |
| # Apply a style to highlight this line. |
| all_lines.append( |
| to_formatted_text(new_fragments, |
| style='class:selected-log-line')) |
| elif line.log_index is not None and ( |
| marked_logs_start <= line.log_index <= marked_logs_end): |
| new_fragments = fill_character_width( |
| line.fragments, |
| len(line.fragments) - 1, # -1 for the ending line break |
| self.width, |
| ) |
| |
| # Apply a style to highlight this line. |
| all_lines.append( |
| to_formatted_text(new_fragments, |
| style='class:marked-log-line')) |
| |
| else: |
| all_lines.append(line.fragments) |
| |
| return all_lines |
| |
| def _prepend_line(self, line: ScreenLine) -> None: |
| """Add a line to the top of the screen.""" |
| self.line_buffer.appendleft(line) |
| self._empty = False |
| |
| def _append_line(self, line: ScreenLine) -> None: |
| """Add a line to the bottom of the screen.""" |
| self.line_buffer.append(line) |
| self._empty = False |
| |
| def _trim_top_lines(self) -> None: |
| """Remove lines from the top if larger than the screen height.""" |
| overflow_amount = len(self.line_buffer) - self.height |
| for _ in range(overflow_amount): |
| self.line_buffer.popleft() |
| |
| def _trim_bottom_lines(self) -> None: |
| """Remove lines from the bottom if larger than the screen height.""" |
| overflow_amount = len(self.line_buffer) - self.height |
| for _ in range(overflow_amount): |
| self.line_buffer.pop() |
| |
| def move_cursor_up(self, line_count: int) -> int: |
| """Move the cursor up as far as it can go without fetching new lines. |
| |
| Args: |
| line_count: A negative number of lines to move the cursor by. |
| |
| Returns: |
| int: The remaining line count that was not moved. This is the number |
| of new lines that need to be fetched and prepended to the screen |
| line buffer.""" |
| remaining_lines = line_count |
| |
| # Loop from a negative line_count value to zero. |
| # For example if line_count is -5 the loop will traverse: |
| # >>> list(i for i in range(-5, 0, 1)) |
| # [-5, -4, -3, -2, -1] |
| for _ in range(line_count, 0, 1): |
| new_index = self.cursor_position - 1 |
| if new_index < 0: |
| break |
| if (new_index < len(self.line_buffer) |
| and self.line_buffer[new_index].empty()): |
| # The next line is empty and has no content. |
| break |
| self.cursor_position -= 1 |
| remaining_lines += 1 |
| return remaining_lines |
| |
| def move_cursor_down(self, line_count: int) -> int: |
| """Move the cursor down as far as it can go without fetching new lines. |
| |
| Args: |
| line_count: A positive number of lines to move the cursor down by. |
| |
| Returns: |
| int: The remaining line count that was not moved. This is the number |
| of new lines that need to be fetched and appended to the screen line |
| buffer.""" |
| remaining_lines = line_count |
| for _ in range(line_count): |
| new_index = self.cursor_position + 1 |
| if new_index >= self.height: |
| break |
| if (new_index < len(self.line_buffer) |
| and self.line_buffer[new_index].empty()): |
| # The next line is empty and has no content. |
| break |
| self.cursor_position += 1 |
| remaining_lines -= 1 |
| return remaining_lines |
| |
| def move_cursor_to_bottom(self) -> None: |
| """Move the cursor to the bottom of the screen. |
| |
| Only use this for movement not initiated by users. For example if new |
| logs were just added to the bottom of the screen in follow |
| mode. The LogScreen class does not allow scrolling beyond the bottom of |
| the content so the cursor will fall on a log message as long as there |
| are some log messages. If there are no log messages the line is not |
| highlighted by get_lines().""" |
| self.cursor_position = self.height - 1 |
| |
| def move_cursor_to_position(self, window_row: int) -> None: |
| """Move the cursor to a line if there is a log message there.""" |
| if window_row >= len(self.line_buffer): |
| return |
| if 0 <= window_row < self.height: |
| current_line = self.line_buffer[window_row] |
| if current_line.log_index is not None: |
| self.cursor_position = window_row |
| |
| def _move_selection_to_log(self, log_index: int, subline: int) -> None: |
| """Move the cursor to the location of log_index.""" |
| for i, line in enumerate(self.line_buffer): |
| if line.log_index == log_index and line.subline == subline: |
| self.cursor_position = i |
| return |
| |
| def shift_selected_log_to_top(self) -> None: |
| """Shift the selected line to the top. |
| |
| This moves the lines on screen and keeps the originally selected line |
| highlighted. Example use case: when jumping to a search match the |
| matched line will be shown at the top of the screen.""" |
| if not 0 <= self.cursor_position < len(self.line_buffer): |
| return |
| |
| current_line = self.line_buffer[self.cursor_position] |
| amount = max(self.cursor_position, current_line.height) |
| amount -= current_line.subline |
| remaining_lines = self.scroll_subline(amount) |
| if remaining_lines != 0 and current_line.log_index is not None: |
| # Restore original selected line. |
| self._move_selection_to_log(current_line.log_index, |
| current_line.subline) |
| return |
| # Lines scrolled as expected, set cursor_position to top. |
| self.cursor_position = 0 |
| |
| def shift_selected_log_to_center(self) -> None: |
| """Shift the selected line to the center. |
| |
| This moves the lines on screen and keeps the originally selected line |
| highlighted. Example use case: when jumping to a search match the |
| matched line will be shown at the center of the screen.""" |
| if not 0 <= self.cursor_position < len(self.line_buffer): |
| return |
| |
| half_height = int(self.height / 2) |
| current_line = self.line_buffer[self.cursor_position] |
| |
| amount = max(self.cursor_position - half_height, current_line.height) |
| amount -= current_line.subline |
| |
| remaining_lines = self.scroll_subline(amount) |
| if remaining_lines != 0 and current_line.log_index is not None: |
| # Restore original selected line. |
| self._move_selection_to_log(current_line.log_index, |
| current_line.subline) |
| return |
| |
| # Lines scrolled as expected, set cursor_position to center. |
| self.cursor_position -= amount |
| self.cursor_position -= (current_line.height - 1) |
| |
| def scroll_subline(self, line_count: int = 1) -> int: |
| """Move the cursor down or up by positive or negative lines. |
| |
| Args: |
| line_count: A positive or negative number of lines the cursor should |
| move. Positive for down, negative for up. |
| |
| Returns: |
| int: The remaining line count that was not moved. This is the number |
| of new lines that could not be fetched in the case that the top or |
| bottom of available log message lines was reached.""" |
| # Move self.cursor_position as far as it can go on screen without |
| # fetching new log message lines. |
| if line_count > 0: |
| remaining_lines = self.move_cursor_down(line_count) |
| else: |
| remaining_lines = self.move_cursor_up(line_count) |
| |
| if remaining_lines == 0: |
| # No more lines needed, return |
| return remaining_lines |
| |
| # Top or bottom of the screen was reached, fetch and add new log lines. |
| if remaining_lines < 0: |
| return self.fetch_subline_up(remaining_lines) |
| return self.fetch_subline_down(remaining_lines) |
| |
| def fetch_subline_up(self, line_count: int = -1) -> int: |
| """Fetch new lines from the top in order of decreasing log_indexes. |
| |
| Args: |
| line_count: A negative number of lines that should be fetched and |
| added to the top of the screen. |
| |
| Returns: |
| int: The number of lines that were not fetched. Returns 0 if the |
| desired number of lines were fetched successfully.""" |
| start_log_index, _log_source = self.get_log_source() |
| remaining_lines = line_count |
| for _ in range(line_count, 0, 1): |
| current_line = self.get_line_at_cursor_position() |
| if current_line.log_index is None: |
| return remaining_lines + 1 |
| |
| target_log_index: int |
| target_subline: int |
| |
| # If the current subline is at the start of this log, fetch the |
| # previous log message's last subline. |
| if current_line.subline == 0: |
| target_log_index = current_line.log_index - 1 |
| # Set -1 to signal fetching the previous log's last subline |
| target_subline = -1 |
| else: |
| # Get previous sub line of current log |
| target_log_index = current_line.log_index |
| target_subline = current_line.subline - 1 |
| |
| if target_log_index < start_log_index: |
| # Invalid log_index, don't scroll further |
| return remaining_lines + 1 |
| |
| self.prepend_log(target_log_index, subline=target_subline) |
| remaining_lines += 1 |
| |
| return remaining_lines |
| |
| def get_line_at_cursor_position(self) -> ScreenLine: |
| """Returns the ScreenLine under the cursor.""" |
| if (self.cursor_position >= len(self.line_buffer) |
| or self.cursor_position < 0): |
| return ScreenLine([('', '')]) |
| return self.line_buffer[self.cursor_position] |
| |
| def fetch_subline_down(self, line_count: int = 1) -> int: |
| """Fetch new lines from the bottom in order of increasing log_indexes. |
| |
| Args: |
| line_count: A positive number of lines that should be fetched and |
| added to the bottom of the screen. |
| |
| Returns: |
| int: The number of lines that were not fetched. Returns 0 if the |
| desired number of lines were fetched successfully.""" |
| _start_log_index, log_source = self.get_log_source() |
| remaining_lines = line_count |
| for _ in range(line_count): |
| # Skip this line if not at the bottom |
| if self.cursor_position < self.height - 1: |
| self.cursor_position += 1 |
| continue |
| |
| current_line = self.get_line_at_cursor_position() |
| if current_line.log_index is None: |
| return remaining_lines - 1 |
| |
| target_log_index: int |
| target_subline: int |
| |
| # If the current subline is at the height of this log, fetch the |
| # next log message. |
| if current_line.subline == current_line.height - 1: |
| # Get next log's first subline |
| target_log_index = current_line.log_index + 1 |
| target_subline = 0 |
| else: |
| # Get next sub line of current log |
| target_log_index = current_line.log_index |
| target_subline = current_line.subline + 1 |
| |
| if target_log_index >= len(log_source): |
| # Invalid log_index, don't scroll further |
| return remaining_lines - 1 |
| |
| self.append_log(target_log_index, subline=target_subline) |
| remaining_lines -= 1 |
| |
| return remaining_lines |
| |
| def first_rendered_log_index(self) -> Optional[int]: |
| """Scan the screen for the first valid log_index and return it.""" |
| log_index = None |
| for i in range(self.height): |
| if i >= len(self.line_buffer): |
| break |
| if self.line_buffer[i].log_index is not None: |
| log_index = self.line_buffer[i].log_index |
| break |
| return log_index |
| |
| def last_rendered_log_index(self) -> Optional[int]: |
| """Return the last log_index shown on screen.""" |
| log_index = None |
| if len(self.line_buffer) == 0: |
| return None |
| if self.line_buffer[-1].log_index is not None: |
| log_index = self.line_buffer[-1].log_index |
| return log_index |
| |
| def _get_fragments_per_line(self, |
| log_index: int) -> List[StyleAndTextTuples]: |
| """Return a list of lines wrapped to the screen width for a log. |
| |
| Before fetching the log message this function updates the log_source and |
| formatting options.""" |
| _start_log_index, log_source = self.get_log_source() |
| if log_index >= len(log_source): |
| return [] |
| log = log_source[log_index] |
| table_formatter = self.get_log_formatter() |
| truncate_lines = not self.get_line_wrapping() |
| search_filter = self.get_search_filter() |
| search_highlight = self.get_search_highlight() |
| |
| # Select the log display formatter; table or standard. |
| fragments: StyleAndTextTuples = [] |
| if table_formatter: |
| fragments = table_formatter(log) |
| else: |
| fragments = log.get_fragments() |
| |
| # Apply search term highlighting. |
| if search_filter and search_highlight and search_filter.matches(log): |
| fragments = search_filter.highlight_search_matches(fragments) |
| |
| # Word wrap the log message or truncate to screen width |
| line_fragments, _log_line_height = insert_linebreaks( |
| fragments, |
| max_line_width=self.width, |
| truncate_long_lines=truncate_lines) |
| # Convert the existing flattened fragments to a list of lines. |
| fragments_per_line = split_lines(line_fragments) |
| |
| return fragments_per_line |
| |
| def prepend_log( |
| self, |
| log_index: int, |
| subline: Optional[int] = None, |
| ) -> None: |
| """Add a log message or a single line to the top of the screen. |
| |
| Args: |
| log_index: The index of the log message to fetch. |
| subline: The desired subline of the log message. When displayed on |
| screen the log message may take up more than one line. If |
| subline is 0 or higher that line will be added. If subline is -1 |
| the last subline will be prepended regardless of the total log |
| message height. |
| """ |
| fragments_per_line = self._get_fragments_per_line(log_index) |
| |
| # Target the last subline if the subline arg is set to -1. |
| fetch_last_subline = (subline == -1) |
| |
| for line_index, line in enumerate(fragments_per_line): |
| # If we are looking for a specific subline and this isn't it, skip. |
| if subline is not None: |
| # If subline is set to -1 we need to append the last subline of |
| # this log message. Skip this line if it isn't the last one. |
| if fetch_last_subline and (line_index != |
| len(fragments_per_line) - 1): |
| continue |
| # If subline is not -1 (0 or higher) and this isn't the desired |
| # line, skip to the next one. |
| if not fetch_last_subline and line_index != subline: |
| continue |
| |
| self._prepend_line( |
| ScreenLine( |
| fragments=line, |
| log_index=log_index, |
| subline=line_index, |
| height=len(fragments_per_line), |
| )) |
| |
| # Remove lines from the bottom if over the screen height. |
| if len(self.line_buffer) > self.height: |
| self._trim_bottom_lines() |
| |
| def append_log( |
| self, |
| log_index: int, |
| subline: Optional[int] = None, |
| ) -> None: |
| """Add a log message or a single line to the bottom of the screen.""" |
| # Save this log_index |
| self.last_appended_log_index = log_index |
| fragments_per_line = self._get_fragments_per_line(log_index) |
| |
| for line_index, line in enumerate(fragments_per_line): |
| # If we are looking for a specific subline and this isn't it, skip. |
| if subline is not None and line_index != subline: |
| continue |
| |
| self._append_line( |
| ScreenLine( |
| fragments=line, |
| log_index=log_index, |
| subline=line_index, |
| height=len(fragments_per_line), |
| )) |
| |
| # Remove lines from the top if over the screen height. |
| if len(self.line_buffer) > self.height: |
| self._trim_top_lines() |