blob: 286e342c45d59bc1dc18bef82d041d682a4ab39e [file] [log] [blame]
#!/usr/bin/env python
# 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.
""" Prompt toolkit application for pw watch. """
import asyncio
import functools
import logging
import os
import re
import time
from typing import Callable, Iterable, NoReturn
from prompt_toolkit.application import Application
from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
from prompt_toolkit.filters import Condition
from prompt_toolkit.history import (
InMemoryHistory,
History,
ThreadedHistory,
)
from prompt_toolkit.key_binding import (
KeyBindings,
KeyBindingsBase,
merge_key_bindings,
)
from prompt_toolkit.layout import (
DynamicContainer,
Float,
FloatContainer,
FormattedTextControl,
HSplit,
Layout,
Window,
)
from prompt_toolkit.layout.controls import BufferControl
from prompt_toolkit.styles import (
ConditionalStyleTransformation,
DynamicStyle,
SwapLightAndDarkStyleTransformation,
merge_style_transformations,
merge_styles,
style_from_pygments_cls,
)
from prompt_toolkit.formatted_text import StyleAndTextTuples
from prompt_toolkit.lexers import PygmentsLexer
from pygments.lexers.markup import MarkdownLexer # type: ignore
from pw_config_loader import yaml_config_loader_mixin
from pw_console.console_app import get_default_colordepth, MIN_REDRAW_INTERVAL
from pw_console.get_pw_console_app import PW_CONSOLE_APP_CONTEXTVAR
from pw_console.help_window import HelpWindow
from pw_console.key_bindings import DEFAULT_KEY_BINDINGS
from pw_console.log_pane import LogPane
from pw_console.plugin_mixin import PluginMixin
import pw_console.python_logging
from pw_console.quit_dialog import QuitDialog
from pw_console.style import generate_styles, get_theme_colors
from pw_console.pigweed_code_style import PigweedCodeStyle
from pw_console.widgets import (
FloatingWindowPane,
ToolbarButton,
WindowPaneToolbar,
create_border,
mouse_handlers,
to_checkbox,
)
from pw_console.window_list import DisplayMode
from pw_console.window_manager import WindowManager
from pw_build.project_builder_prefs import ProjectBuilderPrefs
from pw_build.project_builder_context import get_project_builder_context
_LOG = logging.getLogger('pw_build.watch')
BUILDER_CONTEXT = get_project_builder_context()
_HELP_TEXT = """
Mouse Keys
==========
- Click on a line in the bottom progress bar to switch to that tab.
- Click on any tab, or button to activate.
- Scroll wheel in the the log windows moves back through the history.
Global Keys
===========
Quit with confirmation dialog. -------------------- Ctrl-D
Quit without confirmation. ------------------------ Ctrl-X Ctrl-C
Toggle user guide window. ------------------------- F1
Trigger a rebuild. -------------------------------- Enter
Window Management Keys
======================
Switch focus to the next window pane or tab. ------ Ctrl-Alt-N
Switch focus to the previous window pane or tab. -- Ctrl-Alt-P
Move window pane left. ---------------------------- Ctrl-Alt-Left
Move window pane right. --------------------------- Ctrl-Alt-Right
Move window pane down. ---------------------------- Ctrl-Alt-Down
Move window pane up. ------------------------------ Ctrl-Alt-Up
Balance all window sizes. ------------------------- Ctrl-U
Bottom Toolbar Controls
=======================
Rebuild Enter --------------- Click or press Enter to trigger a rebuild.
[x] Auto Rebuild ------------ Click to globaly enable or disable automatic
rebuilding when files change.
Help F1 --------------------- Click or press F1 to open this help window.
Quit Ctrl-d ----------------- Click or press Ctrl-d to quit pw_watch.
Next Tab Ctrl-Alt-n --------- Switch to the next log tab.
Previous Tab Ctrl-Alt-p ----- Switch to the previous log tab.
Build Status Bar
================
The build status bar shows the current status of all build directories outlined
in a colored frame.
┏━━ BUILDING ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ [✓] out_directory Building Last line of standard out. ┃
┃ [✓] out_dir2 Waiting Last line of standard out. ┃
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
Each checkbox on the far left controls whether that directory is built when
files change and manual builds are run.
Copying Text
============
- Click drag will select whole lines in the log windows.
- `Ctrl-c` will copy selected lines to your system clipboard.
If running over SSH you will need to use your terminal's built in text
selection.
Linux
-----
- Holding `Shift` and dragging the mouse in most terminals.
Mac
---
- Apple Terminal:
Hold `Fn` and drag the mouse
- iTerm2:
Hold `Cmd+Option` and drag the mouse
Windows
-------
- Git CMD (included in `Git for Windows)
1. Click on the Git window icon in the upper left of the title bar
2. Click `Edit` then `Mark`
3. Drag the mouse to select text and press Enter to copy.
- Windows Terminal
1. Hold `Shift` and drag the mouse to select text
2. Press `Ctrl-Shift-C` to copy.
"""
class WatchAppPrefs(ProjectBuilderPrefs):
"""Add pw_console specific prefs standard ProjectBuilderPrefs."""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.registered_commands = DEFAULT_KEY_BINDINGS
self.registered_commands.update(self.user_key_bindings)
new_config_settings = {
'key_bindings': DEFAULT_KEY_BINDINGS,
'show_python_logger': True,
}
self.default_config.update(new_config_settings)
self._update_config(
new_config_settings,
yaml_config_loader_mixin.Stage.DEFAULT,
)
# Required pw_console preferences for key bindings and themes
@property
def user_key_bindings(self) -> dict[str, list[str]]:
return self._config.get('key_bindings', {})
@property
def ui_theme(self) -> str:
return self._config.get('ui_theme', '')
@ui_theme.setter
def ui_theme(self, new_ui_theme: str) -> None:
self._config['ui_theme'] = new_ui_theme
@property
def theme_colors(self):
return get_theme_colors(self.ui_theme)
@property
def swap_light_and_dark(self) -> bool:
return self._config.get('swap_light_and_dark', False)
def get_function_keys(self, name: str) -> list:
"""Return the keys for the named function."""
try:
return self.registered_commands[name]
except KeyError as error:
raise KeyError('Unbound key function: {}'.format(name)) from error
def register_named_key_function(
self, name: str, default_bindings: list[str]
) -> None:
self.registered_commands[name] = default_bindings
def register_keybinding(
self, name: str, key_bindings: KeyBindings, **kwargs
) -> Callable:
"""Apply registered keys for the given named function."""
def decorator(handler: Callable) -> Callable:
"`handler` is a callable or Binding."
for keys in self.get_function_keys(name):
key_bindings.add(*keys.split(' '), **kwargs)(handler)
return handler
return decorator
# Required pw_console preferences for using a log window pane.
@property
def spaces_between_columns(self) -> int:
return 2
@property
def window_column_split_method(self) -> str:
return 'vertical'
@property
def hide_date_from_log_time(self) -> bool:
return True
@property
def column_order(self) -> list:
return []
def column_style( # pylint: disable=no-self-use
self,
_column_name: str,
_column_value: str,
default='',
) -> str:
return default
@property
def show_python_file(self) -> bool:
return self._config.get('show_python_file', False)
@property
def show_source_file(self) -> bool:
return self._config.get('show_source_file', False)
@property
def show_python_logger(self) -> bool:
return self._config.get('show_python_logger', False)
class WatchWindowManager(WindowManager):
def update_root_container_body(self):
self.application.window_manager_container = self.create_root_container()
class WatchApp(PluginMixin):
"""Pigweed Watch main window application."""
# pylint: disable=too-many-instance-attributes
NINJA_FAILURE_TEXT = '\033[31mFAILED: '
NINJA_BUILD_STEP = re.compile(
r"^\[(?P<step>[0-9]+)/(?P<total_steps>[0-9]+)\] (?P<action>.*)$"
)
def __init__(
self,
event_handler,
prefs: WatchAppPrefs,
):
self.event_handler = event_handler
self.color_depth = get_default_colordepth()
# Necessary for some of pw_console's window manager features to work
# such as mouse drag resizing.
PW_CONSOLE_APP_CONTEXTVAR.set(self) # type: ignore
self.prefs = prefs
self.quit_dialog = QuitDialog(self, self.exit) # type: ignore
self.search_history: History = ThreadedHistory(InMemoryHistory())
self.window_manager = WatchWindowManager(self)
self._build_error_count = 0
self._errors_in_output = False
self.log_ui_update_frequency = 0.1 # 10 FPS
self._last_ui_update_time = time.time()
self.recipe_name_to_log_pane: dict[str, LogPane] = {}
self.recipe_index_to_log_pane: dict[int, LogPane] = {}
debug_logging = (
event_handler.project_builder.default_log_level == logging.DEBUG
)
level_name = 'DEBUG' if debug_logging else 'INFO'
no_propagation_loggers = []
if event_handler.separate_logfiles:
pane_index = len(event_handler.project_builder.build_recipes) - 1
for recipe in reversed(event_handler.project_builder.build_recipes):
log_pane = self.add_build_log_pane(
recipe.display_name,
loggers=[recipe.log],
level_name=level_name,
)
if recipe.log.propagate is False:
no_propagation_loggers.append(recipe.log)
self.recipe_name_to_log_pane[recipe.display_name] = log_pane
self.recipe_index_to_log_pane[pane_index] = log_pane
pane_index -= 1
pw_console.python_logging.setup_python_logging(
loggers_with_no_propagation=no_propagation_loggers
)
self.root_log_pane = self.add_build_log_pane(
'Root Log',
loggers=[
logging.getLogger('pw_build'),
],
level_name=level_name,
)
# Repeat the Attaching filesystem watcher message for the full screen
# interface. The original log in watch.py will be hidden from view.
_LOG.info('Attaching filesystem watcher...')
self.window_manager.window_lists[0].display_mode = DisplayMode.TABBED
self.window_manager_container = (
self.window_manager.create_root_container()
)
self.status_bar_border_style = 'class:command-runner-border'
self.status_bar_control = FormattedTextControl(self.get_status_bar_text)
self.status_bar_container = create_border(
HSplit(
[
# Result Toolbar.
Window(
content=self.status_bar_control,
height=len(self.event_handler.project_builder),
wrap_lines=False,
style='class:pane_active',
),
]
),
content_height=len(self.event_handler.project_builder),
title=BUILDER_CONTEXT.get_title_bar_text,
border_style=(BUILDER_CONTEXT.get_title_style),
base_style='class:pane_active',
left_margin_columns=1,
right_margin_columns=1,
)
self.floating_window_plugins: list[FloatingWindowPane] = []
self.user_guide_window = HelpWindow(
self, # type: ignore
title='Pigweed Watch',
disable_ctrl_c=True,
)
self.user_guide_window.set_help_text(
_HELP_TEXT, lexer=PygmentsLexer(MarkdownLexer)
)
self.help_toolbar = WindowPaneToolbar(
title='Pigweed Watch',
include_resize_handle=False,
focus_action_callable=self.switch_to_root_log,
click_to_focus_text='',
)
self.help_toolbar.add_button(
ToolbarButton('Enter', 'Rebuild', self.run_build)
)
self.help_toolbar.add_button(
ToolbarButton(
description='Auto Rebuild',
mouse_handler=self.toggle_restart_on_filechange,
is_checkbox=True,
checked=lambda: self.restart_on_changes,
)
)
self.help_toolbar.add_button(
ToolbarButton('F1', 'Help', self.user_guide_window.toggle_display)
)
self.help_toolbar.add_button(ToolbarButton('Ctrl-d', 'Quit', self.exit))
self.help_toolbar.add_button(
ToolbarButton(
'Ctrl-Alt-n', 'Next Tab', self.window_manager.focus_next_pane
)
)
self.help_toolbar.add_button(
ToolbarButton(
'Ctrl-Alt-p',
'Previous Tab',
self.window_manager.focus_previous_pane,
)
)
self.root_container = FloatContainer(
HSplit(
[
# Window pane content:
DynamicContainer(lambda: self.window_manager_container),
self.status_bar_container,
self.help_toolbar,
]
),
floats=[
Float(
content=self.user_guide_window,
top=2,
left=4,
bottom=4,
width=self.user_guide_window.content_width,
),
Float(
content=self.quit_dialog,
top=2,
left=2,
),
],
)
key_bindings = KeyBindings()
@key_bindings.add('enter', filter=self.input_box_not_focused())
def _run_build(_event):
"Rebuild."
self.run_build()
register = self.prefs.register_keybinding
@register('global.exit-no-confirmation', key_bindings)
def _quit_no_confirm(_event):
"""Quit without confirmation."""
_LOG.info('Got quit signal; exiting...')
self.exit(0)
@register('global.exit-with-confirmation', key_bindings)
def _quit_with_confirm(_event):
"""Quit with confirmation dialog."""
self.quit_dialog.open_dialog()
@register(
'global.open-user-guide',
key_bindings,
filter=Condition(lambda: not self.modal_window_is_open()),
)
def _show_help(_event):
"""Toggle user guide window."""
self.user_guide_window.toggle_display()
self.key_bindings = merge_key_bindings(
[
self.window_manager.key_bindings,
key_bindings,
]
)
self.current_theme = generate_styles(self.prefs.ui_theme)
self.style_transformation = merge_style_transformations(
[
ConditionalStyleTransformation(
SwapLightAndDarkStyleTransformation(),
filter=Condition(lambda: self.prefs.swap_light_and_dark),
),
]
)
self.code_theme = style_from_pygments_cls(PigweedCodeStyle)
self.layout = Layout(
self.root_container,
focused_element=self.root_log_pane,
)
self.application: Application = Application(
layout=self.layout,
key_bindings=self.key_bindings,
mouse_support=True,
color_depth=self.color_depth,
clipboard=PyperclipClipboard(),
style=DynamicStyle(
lambda: merge_styles(
[
self.current_theme,
self.code_theme,
]
)
),
style_transformation=self.style_transformation,
full_screen=True,
min_redraw_interval=MIN_REDRAW_INTERVAL,
)
self.plugin_init(
plugin_callback=self.check_build_status,
plugin_callback_frequency=0.5,
plugin_logger_name='pw_watch_stdout_checker',
)
def add_build_log_pane(
self, title: str, loggers: list[logging.Logger], level_name: str
) -> LogPane:
"""Setup a new build log pane."""
new_log_pane = LogPane(application=self, pane_title=title)
for logger in loggers:
new_log_pane.add_log_handler(logger, level_name=level_name)
# Set python log format to just the message itself.
new_log_pane.log_view.log_store.formatter = logging.Formatter(
'%(message)s'
)
new_log_pane.table_view = False
# Disable line wrapping for improved error visibility.
if new_log_pane.wrap_lines:
new_log_pane.toggle_wrap_lines()
# Blank right side toolbar text
new_log_pane._pane_subtitle = ' ' # pylint: disable=protected-access
# Make tab and shift-tab search for next and previous error
next_error_bindings = KeyBindings()
@next_error_bindings.add('s-tab')
def _previous_error(_event):
self.jump_to_error(backwards=True)
@next_error_bindings.add('tab')
def _next_error(_event):
self.jump_to_error()
existing_log_bindings: (
KeyBindingsBase | None
) = new_log_pane.log_content_control.key_bindings
key_binding_list: list[KeyBindingsBase] = []
if existing_log_bindings:
key_binding_list.append(existing_log_bindings)
key_binding_list.append(next_error_bindings)
new_log_pane.log_content_control.key_bindings = merge_key_bindings(
key_binding_list
)
# Only show a few buttons in the log pane toolbars.
new_buttons = []
for button in new_log_pane.bottom_toolbar.buttons:
if button.description in [
'Search',
'Save',
'Follow',
'Wrap',
'Clear',
]:
new_buttons.append(button)
new_log_pane.bottom_toolbar.buttons = new_buttons
self.window_manager.add_pane(new_log_pane)
return new_log_pane
def logs_redraw(self):
emit_time = time.time()
# Has enough time passed since last UI redraw due to new logs?
if emit_time > self._last_ui_update_time + self.log_ui_update_frequency:
# Update last log time
self._last_ui_update_time = emit_time
# Trigger Prompt Toolkit UI redraw.
self.redraw_ui()
def jump_to_error(self, backwards: bool = False) -> None:
if not self.root_log_pane.log_view.search_text:
self.root_log_pane.log_view.set_search_regex(
'^FAILED: ', False, None
)
if backwards:
self.root_log_pane.log_view.search_backwards()
else:
self.root_log_pane.log_view.search_forwards()
self.root_log_pane.log_view.log_screen.reset_logs(
log_index=self.root_log_pane.log_view.log_index
)
self.root_log_pane.log_view.move_selected_line_to_top()
def refresh_layout(self) -> None:
self.window_manager.update_root_container_body()
def update_menu_items(self):
"""Required by the Window Manager Class."""
def redraw_ui(self):
"""Redraw the prompt_toolkit UI."""
if hasattr(self, 'application'):
self.application.invalidate()
def focus_on_container(self, pane):
"""Set application focus to a specific container."""
# Try to focus on the given pane
try:
self.application.layout.focus(pane)
except ValueError:
# If the container can't be focused, focus on the first visible
# window pane.
self.window_manager.focus_first_visible_pane()
def focused_window(self):
"""Return the currently focused window."""
return self.application.layout.current_window
def focus_main_menu(self):
"""Focus on the main menu.
Currently pw_watch has no main menu so focus on the first visible pane
instead."""
self.window_manager.focus_first_visible_pane()
def switch_to_root_log(self) -> None:
(
window_list,
pane_index,
) = self.window_manager.find_window_list_and_pane_index(
self.root_log_pane
)
window_list.switch_to_tab(pane_index)
def switch_to_build_log(self, log_index: int) -> None:
pane = self.recipe_index_to_log_pane.get(log_index, None)
if not pane:
return
(
window_list,
pane_index,
) = self.window_manager.find_window_list_and_pane_index(pane)
window_list.switch_to_tab(pane_index)
def command_runner_is_open(self) -> bool:
# pylint: disable=no-self-use
return False
def all_log_panes(self) -> Iterable[LogPane]:
for pane in self.window_manager.active_panes():
if isinstance(pane, LogPane):
yield pane
def clear_log_panes(self) -> None:
"""Erase all log pane content and turn on follow.
This is called whenever rebuilds occur. Either a manual build from
self.run_build or on file changes called from
pw_watch._handle_matched_event."""
for pane in self.all_log_panes():
pane.log_view.clear_visual_selection()
pane.log_view.clear_filters()
pane.log_view.log_store.clear_logs()
pane.log_view.view_mode_changed()
# Re-enable follow if needed
if not pane.log_view.follow:
pane.log_view.toggle_follow()
def run_build(self) -> None:
"""Manually trigger a rebuild from the UI."""
self.clear_log_panes()
self.event_handler.rebuild()
@property
def restart_on_changes(self) -> bool:
return self.event_handler.restart_on_changes
def toggle_restart_on_filechange(self) -> None:
self.event_handler.restart_on_changes = (
not self.event_handler.restart_on_changes
)
def get_status_bar_text(self) -> StyleAndTextTuples:
"""Return formatted text for build status bar."""
formatted_text: StyleAndTextTuples = []
separator = ('', ' ')
name_width = self.event_handler.project_builder.max_name_width
# pylint: disable=protected-access
(
_window_list,
pane,
) = self.window_manager._get_active_window_list_and_pane()
# pylint: enable=protected-access
restarting = BUILDER_CONTEXT.restart_flag
for i, cfg in enumerate(self.event_handler.project_builder):
# The build directory
name_style = ''
if not pane:
formatted_text.append(('', '\n'))
continue
# Dim the build name if disabled
if not cfg.enabled:
name_style = 'class:theme-fg-inactive'
# If this build tab is selected, highlight with cyan.
if pane.pane_title() == cfg.display_name:
name_style = 'class:theme-fg-cyan'
formatted_text.append(
to_checkbox(
cfg.enabled,
functools.partial(
mouse_handlers.on_click,
cfg.toggle_enabled,
),
end=' ',
unchecked_style='class:checkbox',
checked_style='class:checkbox-checked',
)
)
formatted_text.append(
(
name_style,
f'{cfg.display_name}'.ljust(name_width),
functools.partial(
mouse_handlers.on_click,
functools.partial(self.switch_to_build_log, i),
),
)
)
formatted_text.append(separator)
# Status
formatted_text.append(cfg.status.status_slug(restarting=restarting))
formatted_text.append(separator)
# Current stdout line
formatted_text.extend(cfg.status.current_step_formatted())
formatted_text.append(('', '\n'))
if not formatted_text:
formatted_text = [('', 'Loading...')]
self.set_tab_bar_colors()
return formatted_text
def set_tab_bar_colors(self) -> None:
restarting = BUILDER_CONTEXT.restart_flag
for cfg in BUILDER_CONTEXT.recipes:
pane = self.recipe_name_to_log_pane.get(cfg.display_name, None)
if not pane:
continue
pane.extra_tab_style = None
if not restarting and cfg.status.failed():
pane.extra_tab_style = 'class:theme-fg-red'
def exit(
self,
exit_code: int = 1,
log_after_shutdown: Callable[[], None] | None = None,
) -> None:
_LOG.info('Exiting...')
BUILDER_CONTEXT.ctrl_c_pressed = True
# Shut everything down after the prompt_toolkit app exits.
def _really_exit(future: asyncio.Future) -> NoReturn:
BUILDER_CONTEXT.restore_logging_and_shutdown(log_after_shutdown)
os._exit(future.result()) # pylint: disable=protected-access
if self.application.future:
self.application.future.add_done_callback(_really_exit)
self.application.exit(result=exit_code)
def check_build_status(self) -> bool:
if not self.event_handler.current_stdout:
return False
if self._errors_in_output:
return True
if self.event_handler.current_build_errors > self._build_error_count:
self._errors_in_output = True
self.jump_to_error()
return True
def run(self):
self.plugin_start()
# Run the prompt_toolkit application
self.application.run(set_exception_handler=True)
def input_box_not_focused(self) -> Condition:
"""Condition checking the focused control is not a text input field."""
@Condition
def _test() -> bool:
"""Check if the currently focused control is an input buffer.
Returns:
bool: True if the currently focused control is not a text input
box. For example if the user presses enter when typing in
the search box, return False.
"""
return not isinstance(
self.application.layout.current_control, BufferControl
)
return _test
def modal_window_is_open(self):
"""Return true if any modal window or dialog is open."""
floating_window_is_open = (
self.user_guide_window.show_window or self.quit_dialog.show_dialog
)
floating_plugin_is_open = any(
plugin.show_pane for plugin in self.floating_window_plugins
)
return floating_window_is_open or floating_plugin_is_open