blob: e3a0f0583c36754db259d4f45961f1dbe4c19a5b [file] [log] [blame]
# 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()