pw_console: Add LogPane class
No-Docs-Update-Reason: prompt_toolkit UI boilerplate
Change-Id: I8b6527587b14ecd490e9c91bca391114416345f2
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/48804
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Reviewed-by: Joe Ethier <jethier@google.com>
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn
index f6e54f0..98ec217 100644
--- a/pw_console/py/BUILD.gn
+++ b/pw_console/py/BUILD.gn
@@ -24,6 +24,7 @@
"pw_console/console_app.py",
"pw_console/help_window.py",
"pw_console/key_bindings.py",
+ "pw_console/log_pane.py",
"pw_console/repl_pane.py",
"pw_console/style.py",
]
diff --git a/pw_console/py/pw_console/log_pane.py b/pw_console/py/pw_console/log_pane.py
new file mode 100644
index 0000000..bf39a39
--- /dev/null
+++ b/pw_console/py/pw_console/log_pane.py
@@ -0,0 +1,332 @@
+# 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."""