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."""