blob: bf39a3918cb435f0dedbc69f67b87af97373ccab [file] [log] [blame]
# 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.
"""LogPane class."""
from functools import partial
from typing import Any, List, Optional
from prompt_toolkit.filters import (
Condition,
has_focus,
)
from prompt_toolkit.layout import (
ConditionalContainer,
Dimension,
Float,
FloatContainer,
FormattedTextControl,
HSplit,
VSplit,
Window,
WindowAlign,
VerticalAlign,
)
from prompt_toolkit.layout.dimension import AnyDimension
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
class LogPaneLineInfoBar(ConditionalContainer):
"""One line bar for showing current and total log lines."""
@staticmethod
def get_tokens(unused_log_pane):
"""Return formatted text tokens for display."""
tokens = ' Line {} / {} '.format(
# TODO: Replace fake counts with the current line (1) and total
# lines (10).
1,
10)
return [('', tokens)]
def __init__(self, log_pane):
info_bar_control = FormattedTextControl(
partial(LogPaneLineInfoBar.get_tokens, log_pane))
info_bar_window = Window(content=info_bar_control,
align=WindowAlign.RIGHT,
dont_extend_width=True)
super().__init__(
VSplit([info_bar_window],
height=1,
style='class:bottom_toolbar',
align=WindowAlign.RIGHT),
# Only show current/total line info if not auto-following
# logs. Similar to tmux behavior.
filter=Condition(
lambda: True
# TODO: replace True with log follow status.
# not log_pane.log_container.follow
))
class LogPaneBottomToolbarBar(ConditionalContainer):
"""One line toolbar for display at the bottom of the LogPane."""
TOOLBAR_HEIGHT = 1
@staticmethod
def mouse_handler_focus(log_pane, mouse_event: MouseEvent):
"""Focus this pane on click."""
if mouse_event.event_type == MouseEventType.MOUSE_UP:
log_pane.application.application.layout.focus(log_pane)
return None
return NotImplemented
@staticmethod
def mouse_handler_toggle_wrap_lines(log_pane, mouse_event: MouseEvent):
"""Toggle wrap lines on click."""
if mouse_event.event_type == MouseEventType.MOUSE_UP:
log_pane.toggle_wrap_lines()
return None
return NotImplemented
@staticmethod
def mouse_handler_clear_history(log_pane, mouse_event: MouseEvent):
"""Clear history on click."""
if mouse_event.event_type == MouseEventType.MOUSE_UP:
log_pane.clear_history()
return None
return NotImplemented
@staticmethod
def mouse_handler_toggle_follow(log_pane, mouse_event: MouseEvent):
"""Toggle follow on click."""
if mouse_event.event_type == MouseEventType.MOUSE_UP:
log_pane.toggle_follow()
return None
return NotImplemented
@staticmethod
def get_center_text_tokens(log_pane):
"""Return formatted text tokens for display in the center part of the
toolbar."""
# Create mouse handler functions.
focus = partial(LogPaneBottomToolbarBar.mouse_handler_focus, log_pane)
toggle_wrap_lines = partial(
LogPaneBottomToolbarBar.mouse_handler_toggle_wrap_lines, log_pane)
clear_history = partial(
LogPaneBottomToolbarBar.mouse_handler_clear_history, log_pane)
toggle_follow = partial(
LogPaneBottomToolbarBar.mouse_handler_toggle_follow, log_pane)
# FormattedTextTuple contents: (Style, Text, Mouse handler)
separator_text = ('', ' ') # 1 space of separaton between keybinds.
# If the log_pane is in focus, show keybinds in the toolbar.
if has_focus(log_pane.__pt_container__())():
return [
('', ' [FOCUSED]'),
separator_text,
# TODO: Indicate toggle status with a checkbox?
('class:keybind', 'w', toggle_wrap_lines),
('class:keyhelp', ':Wrap', toggle_wrap_lines),
separator_text,
('class:keybind', 'f', toggle_follow),
('class:keyhelp', ':Follow', toggle_follow),
separator_text,
('class:keybind', 'C', clear_history),
('class:keyhelp', ':Clear', clear_history),
]
# Show the click to focus button if log pane isn't in focus.
return [
('class:keyhelp', ' [click to focus] ', focus),
]
def __init__(self, log_pane):
title_section_text = FormattedTextControl([(
# Style
'class:logo',
# Text
' Logs ',
# Mouse handler
partial(LogPaneBottomToolbarBar.mouse_handler_focus, log_pane),
)])
keybind_section_text = FormattedTextControl(
# Callable to get formatted text tuples.
partial(LogPaneBottomToolbarBar.get_center_text_tokens, log_pane))
title_section_window = Window(
content=title_section_text,
align=WindowAlign.LEFT,
dont_extend_width=True,
)
keybind_section_window = Window(
content=keybind_section_text,
align=WindowAlign.LEFT,
dont_extend_width=False,
)
toolbar_vsplit = VSplit(
[
title_section_window,
keybind_section_window,
],
height=LogPaneBottomToolbarBar.TOOLBAR_HEIGHT,
style='class:bottom_toolbar',
align=WindowAlign.LEFT,
)
# ConditionalContainer init()
super().__init__(
# Contents
toolbar_vsplit,
filter=Condition(lambda: log_pane.show_bottom_toolbar),
)
class LogLineHSplit(HSplit):
"""PromptToolkit HSplit class with a write_to_screen function that saves the
width and height of the container to be rendered.
"""
def __init__(self, log_pane, *args, **kwargs):
# Save a reference to the parent LogPane.
self.log_pane = log_pane
super().__init__(*args, **kwargs)
def write_to_screen(
self,
screen,
mouse_handlers,
write_position,
parent_style: str,
erase_bg: bool,
z_index: Optional[int],
) -> None:
# Save the width and height for the current render pass. This will be
# used by the log pane to render the correct amount of log lines.
self.log_pane.update_log_pane_size(write_position.width,
write_position.height)
# Continue writing content to the screen.
super().write_to_screen(screen, mouse_handlers, write_position,
parent_style, erase_bg, z_index)
class LogPane:
"""LogPane class."""
# pylint: disable=too-many-instance-attributes
def __init__(
self,
application: Any,
show_bottom_toolbar=True,
show_line_info=True,
wrap_lines=True,
# Default width and height to 50% of the screen
height: Optional[AnyDimension] = Dimension(weight=50),
width: Optional[AnyDimension] = Dimension(weight=50),
):
self.application = application
self.show_bottom_toolbar = show_bottom_toolbar
self.show_line_info = show_line_info
self.wrap_lines = wrap_lines
self.height = height
self.width = width
# Log pane size variables. These are updated just befor rendering the
# pane by the LogLineHSplit class.
self.current_log_pane_width = 0
self.current_log_pane_height = 0
self.last_log_pane_width = 0
self.last_log_pane_height = 0
# Create the bottom toolbar for the whole log pane.
self.bottom_toolbar = LogPaneBottomToolbarBar(self)
# TODO: Render logs here
self.log_content_control = FormattedTextControl(
[('', 'Logs appear here')],
focusable=True,
)
self.log_display_window = Window(
content=self.log_content_control,
allow_scroll_beyond_bottom=True,
wrap_lines=Condition(lambda: self.wrap_lines),
cursorline=False,
# Don't make the window taller to fill the parent split container.
# Window should match the height of the log line content. This will
# also allow the parent HSplit to justify the content to the bottom
dont_extend_height=True,
# Window width should be extended to make backround highlighting
# extend to the end of the container. Otherwise backround colors
# will only appear until the end of the log line.
dont_extend_width=False,
)
# Root level container
self.container = FloatContainer(
# Horizonal split containing the log lines and the toolbar.
LogLineHSplit(
self, # LogPane reference
[
self.log_display_window,
self.bottom_toolbar,
],
# Align content with the bottom of the container.
align=VerticalAlign.BOTTOM,
height=self.height,
width=self.width,
),
floats=[
# Floating LogPaneLineInfoBar
Float(top=0,
right=0,
height=1,
content=LogPaneLineInfoBar(self)),
])
def update_log_pane_size(self, width, height):
"""Save width and height of the log pane for the current UI render
pass."""
if width:
self.last_log_pane_width = self.current_log_pane_width
self.current_log_pane_width = width
if height:
# Subtract the height of the LogPaneBottomToolbarBar
height -= LogPaneBottomToolbarBar.TOOLBAR_HEIGHT
self.last_log_pane_height = self.current_log_pane_height
self.current_log_pane_height = height
def redraw_ui(self):
"""Trigger a prompt_toolkit UI redraw."""
self.application.redraw_ui()
def toggle_wrap_lines(self):
"""Toggle line wraping/truncation."""
self.wrap_lines = not self.wrap_lines
self.redraw_ui()
def toggle_follow(self):
"""Toggle following log lines."""
# TODO: self.log_container.toggle_follow()
self.redraw_ui()
def clear_history(self):
"""Erase stored log lines."""
# TODO: self.log_container.clear_logs()
self.redraw_ui()
def __pt_container__(self):
"""Return the prompt_toolkit root container for this log pane."""
return self.container
# pylint: disable=no-self-use
def get_all_key_bindings(self) -> List:
"""Return all keybinds for this pane."""
# TODO: return log content control keybindings
return []
def after_render_hook(self):
"""Run tasks after the last UI render."""