blob: 3fe48a33bbe5fbb8838ec9ff8bd1cb19563bdc82 [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.
"""WindowManager"""
import collections
import copy
import functools
from itertools import chain
import operator
from typing import Any, Dict, Iterable
from prompt_toolkit.layout import (
Dimension,
HSplit,
VSplit,
)
from prompt_toolkit.widgets import MenuItem
from pw_console.console_prefs import ConsolePrefs, error_unknown_window
from pw_console.log_pane import LogPane
import pw_console.widgets.checkbox
from pw_console.window_list import WindowList, DisplayMode
# Weighted amount for adjusting window dimensions when enlarging and shrinking.
_WINDOW_SPLIT_ADJUST = 2
class WindowManager:
"""WindowManager class
This class handles adding/removing/resizing windows and rendering the
prompt_toolkit split layout."""
# pylint: disable=too-many-public-methods
def __init__(
self,
application: Any,
):
self.application = application
self.window_lists: collections.deque = collections.deque()
self.window_lists.append(WindowList(self))
def delete_empty_window_lists(self):
empty_lists = [
window_list for window_list in self.window_lists
if window_list.empty()
]
for empty_list in empty_lists:
self.window_lists.remove(empty_list)
def create_root_container(self):
"""Create a vertical or horizontal split container for all active
panes."""
self.delete_empty_window_lists()
for window_list in self.window_lists:
window_list.update_container()
window_containers = [
window_list.container for window_list in self.window_lists
]
if self.application.prefs.window_column_split_method == 'horizontal':
split = HSplit(
window_containers,
padding=1,
padding_char='─',
padding_style='class:pane_separator',
)
else: # vertical
split = VSplit(
window_containers,
padding=1,
padding_char='│',
padding_style='class:pane_separator',
)
return HSplit([split])
def update_root_container_body(self):
# Replace the root MenuContainer body with the new split.
self.application.root_container.container.content.children[
1] = self.create_root_container()
def _get_active_window_list_and_pane(self):
active_pane = None
active_window_list = None
for window_list in self.window_lists:
active_pane = window_list.get_current_active_pane()
if active_pane:
active_window_list = window_list
break
return active_window_list, active_pane
def window_list_index(self, window_list: WindowList):
index = None
try:
index = self.window_lists.index(window_list)
except ValueError:
# Ignore ValueError which can be raised by the self.window_lists
# deque if the window_list can't be found.
pass
return index
def run_action_on_active_pane(self, function_name):
_active_window_list, active_pane = (
self._get_active_window_list_and_pane())
if not hasattr(active_pane, function_name):
return
method_to_call = getattr(active_pane, function_name)
method_to_call()
return
def move_pane_left(self):
active_window_list, active_pane = (
self._get_active_window_list_and_pane())
if not active_window_list:
return
window_list_index = self.window_list_index(active_window_list)
# Move left should pick the previous window_list
target_window_list_index = window_list_index - 1
# Check if a new WindowList should be created on the left
if target_window_list_index == -1:
# Add the new WindowList
target_window_list = WindowList(self)
self.window_lists.appendleft(target_window_list)
# New index is 0
target_window_list_index = 0
# Get the destination window_list
target_window_list = self.window_lists[target_window_list_index]
# Move the pane
active_window_list.remove_pane_no_checks(active_pane)
target_window_list.add_pane(active_pane, add_at_beginning=True)
self.delete_empty_window_lists()
def move_pane_right(self):
active_window_list, active_pane = (
self._get_active_window_list_and_pane())
if not active_window_list:
return
window_list_index = self.window_list_index(active_window_list)
# Move right should pick the next window_list
target_window_list_index = window_list_index + 1
# Check if a new WindowList should be created
if target_window_list_index == len(self.window_lists):
# Add a new WindowList
target_window_list = WindowList(self)
self.window_lists.append(target_window_list)
# Get the destination window_list
target_window_list = self.window_lists[target_window_list_index]
# Move the pane
active_window_list.remove_pane_no_checks(active_pane)
target_window_list.add_pane(active_pane, add_at_beginning=True)
self.delete_empty_window_lists()
def move_pane_up(self):
active_window_list, _active_pane = (
self._get_active_window_list_and_pane())
if not active_window_list:
return
active_window_list.move_pane_up()
def move_pane_down(self):
active_window_list, _active_pane = (
self._get_active_window_list_and_pane())
if not active_window_list:
return
active_window_list.move_pane_down()
def shrink_pane(self):
active_window_list, _active_pane = (
self._get_active_window_list_and_pane())
if not active_window_list:
return
active_window_list.shrink_pane()
def enlarge_pane(self):
active_window_list, _active_pane = (
self._get_active_window_list_and_pane())
if not active_window_list:
return
active_window_list.enlarge_pane()
def shrink_split(self):
if len(self.window_lists) < 2:
return
active_window_list, _active_pane = (
self._get_active_window_list_and_pane())
if not active_window_list:
return
self.adjust_split_size(active_window_list, -_WINDOW_SPLIT_ADJUST)
def enlarge_split(self):
active_window_list, _active_pane = (
self._get_active_window_list_and_pane())
if not active_window_list:
return
self.adjust_split_size(active_window_list, _WINDOW_SPLIT_ADJUST)
def balance_window_sizes(self):
"""Reset all splits and pane sizes."""
self.reset_pane_sizes()
self.reset_split_sizes()
def reset_split_sizes(self):
"""Reset all active pane width and height to 50%"""
for window_list in self.window_lists:
window_list.height = Dimension(weight=50)
window_list.width = Dimension(weight=50)
def adjust_split_size(self,
window_list: WindowList,
diff: int = _WINDOW_SPLIT_ADJUST):
"""Increase or decrease a given window_list's vertical split width."""
# No need to resize if only one split.
if len(self.window_lists) < 2:
return
# Get the next split to subtract a weight value from.
window_list_index = self.window_list_index(window_list)
next_window_list_index = ((window_list_index + 1) %
len(self.window_lists))
# Use the previous window if we are on the last split
if window_list_index == len(self.window_lists) - 1:
next_window_list_index = window_list_index - 1
next_window_list = self.window_lists[next_window_list_index]
# Get current weight values
old_weight = window_list.width.weight
next_old_weight = next_window_list.width.weight # type: ignore
# Add to the current split
new_weight = old_weight + diff
if new_weight <= 0:
new_weight = old_weight
# Subtract from the next split
next_new_weight = next_old_weight - diff
if next_new_weight <= 0:
next_new_weight = next_old_weight
# Set new weight values
window_list.width.weight = new_weight
next_window_list.width.weight = next_new_weight # type: ignore
def toggle_pane(self, pane):
"""Toggle a pane on or off."""
window_list, _pane_index = (
self._find_window_list_and_pane_index(pane))
# Don't hide if tabbed mode is enabled, the container can't be rendered.
if window_list.display_mode == DisplayMode.TABBED:
return
pane.show_pane = not pane.show_pane
self.update_root_container_body()
self.application.update_menu_items()
# Set focus to the top level menu. This has the effect of keeping the
# menu open if it's already open.
self.application.focus_main_menu()
def focus_first_visible_pane(self):
"""Focus on the first visible container."""
for pane in self.active_panes():
if pane.show_pane:
self.application.focus_on_container(pane)
break
def check_for_all_hidden_panes_and_unhide(self) -> None:
"""Scan for window_lists containing only hidden panes."""
for window_list in self.window_lists:
all_hidden = all(not pane.show_pane
for pane in window_list.active_panes)
if all_hidden:
# Unhide the first pane
self.toggle_pane(window_list.active_panes[0])
def add_pane_no_checks(self, pane: Any):
self.window_lists[0].add_pane_no_checks(pane)
def add_pane(self, pane: Any):
self.window_lists[0].add_pane(pane, add_at_beginning=True)
def first_window_list(self):
return self.window_lists[0]
def active_panes(self):
"""Return all active panes from all window lists."""
return chain.from_iterable(
map(operator.attrgetter('active_panes'), self.window_lists))
def _find_window_list_and_pane_index(self, pane: Any):
pane_index = None
parent_window_list = None
for window_list in self.window_lists:
pane_index = window_list.pane_index(pane)
if pane_index is not None:
parent_window_list = window_list
break
return parent_window_list, pane_index
def remove_pane(self, existing_pane: Any):
window_list, _pane_index = (
self._find_window_list_and_pane_index(existing_pane))
if window_list:
window_list.remove_pane(existing_pane)
# Reset focus if this list is empty
if len(window_list.active_panes) == 0:
self.application.focus_main_menu()
def reset_pane_sizes(self):
for window_list in self.window_lists:
window_list.reset_pane_sizes()
def _remove_panes_from_layout(
self, pane_titles: Iterable[str]) -> Dict[str, Any]:
# Gather pane objects and remove them from the window layout.
collected_panes = {}
for window_list in self.window_lists:
# Make a copy of active_panes to prevent mutating the while
# iterating.
for pane in copy.copy(window_list.active_panes):
if pane.pane_title() in pane_titles:
collected_panes[pane.pane_title()] = (
window_list.remove_pane_no_checks(pane))
return collected_panes
def _set_pane_options(self, pane, options: dict) -> None: # pylint: disable=no-self-use
if options.get('hidden', False):
# Hide this pane
pane.show_pane = False
if options.get('height', False):
# Apply new height
new_height = options['height']
assert isinstance(new_height, int)
pane.height.weight = new_height
def _set_window_list_display_modes(self, prefs: ConsolePrefs) -> None:
# Set column display modes
for column_index, column_type in enumerate(prefs.window_column_modes):
mode = DisplayMode.STACK
if 'tabbed' in column_type:
mode = DisplayMode.TABBED
self.window_lists[column_index].set_display_mode(mode)
def _create_new_log_pane_with_loggers(self, window_title, window_options,
existing_pane_titles) -> LogPane:
if 'loggers' not in window_options:
error_unknown_window(window_title, existing_pane_titles)
new_pane = LogPane(application=self.application,
pane_title=window_title)
# Add logger handlers
for logger_name, logger_options in window_options.get('loggers',
{}).items():
log_level_name = logger_options.get('level', None)
new_pane.add_log_handler(logger_name, level_name=log_level_name)
return new_pane
# TODO(tonymd): Split this large function up.
def apply_config(self, prefs: ConsolePrefs) -> None:
"""Apply window configuration from loaded ConsolePrefs."""
if not prefs.windows:
return
unique_titles = prefs.unique_window_titles
collected_panes = self._remove_panes_from_layout(unique_titles)
existing_pane_titles = [
p.pane_title() for p in collected_panes.values()
if isinstance(p, LogPane)
]
# Keep track of original non-duplicated pane titles
already_added_panes = []
for column_index, column in enumerate(prefs.windows.items()): # pylint: disable=too-many-nested-blocks
_column_type, windows = column
# Add a new window_list if needed
if column_index >= len(self.window_lists):
self.window_lists.append(WindowList(self))
# Set column display mode to stacked by default.
self.window_lists[column_index].display_mode = DisplayMode.STACK
# Add windows to the this column (window_list)
for window_title, window_options in windows.items():
new_pane = None
desired_window_title = window_title
# Check for duplicate_of: Title value
window_title = window_options.get('duplicate_of', window_title)
# Check if this pane is brand new, ready to be added, or should
# be duplicated.
if (window_title not in already_added_panes
and window_title not in collected_panes):
# New pane entirely
new_pane = self._create_new_log_pane_with_loggers(
window_title, window_options, existing_pane_titles)
elif window_title not in already_added_panes:
# First time adding this pane
already_added_panes.append(window_title)
new_pane = collected_panes[window_title]
elif window_title in collected_panes:
# Pane added once, duplicate it
new_pane = collected_panes[window_title].create_duplicate()
# Rename this duplicate pane
assert isinstance(new_pane, LogPane)
new_pane.set_pane_title(desired_window_title)
if new_pane:
# Set window size and visibility
self._set_pane_options(new_pane, window_options)
# Add the new pane
self.window_lists[column_index].add_pane_no_checks(
new_pane)
# Apply log filters
if isinstance(new_pane, LogPane):
new_pane.apply_filters_from_config(window_options)
# Update column display modes.
self._set_window_list_display_modes(prefs)
# Check for columns where all panes are hidden and unhide at least one.
self.check_for_all_hidden_panes_and_unhide()
# Update prompt_toolkit containers.
self.update_root_container_body()
self.application.update_menu_items()
# Focus on the first visible pane.
self.focus_first_visible_pane()
def create_window_menu(self):
"""Build the [Window] menu for the current set of window lists."""
root_menu_items = []
for window_list_index, window_list in enumerate(self.window_lists):
menu_items = []
menu_items.append(
MenuItem(
'Column {index} View Modes'.format(
index=window_list_index + 1),
children=[
MenuItem(
'{check} {display_mode} Windows'.format(
display_mode=display_mode.value,
check=pw_console.widgets.checkbox.
to_checkbox_text(
window_list.display_mode == display_mode,
end='',
)),
handler=functools.partial(
window_list.set_display_mode, display_mode),
) for display_mode in DisplayMode
],
))
menu_items.extend(
MenuItem(
'{index}: {title} {subtitle}'.format(
index=pane_index + 1,
title=pane.menu_title(),
subtitle=pane.pane_subtitle()),
children=[
MenuItem(
'{check} Show Window'.format(
check=pw_console.widgets.checkbox.
to_checkbox_text(pane.show_pane, end='')),
handler=functools.partial(self.toggle_pane, pane),
),
] + [
MenuItem(text,
handler=functools.partial(
self.application.run_pane_menu_option,
handler))
for text, handler in pane.get_all_menu_options()
],
) for pane_index, pane in enumerate(window_list.active_panes))
if window_list_index + 1 < len(self.window_lists):
menu_items.append(MenuItem('-'))
root_menu_items.extend(menu_items)
menu = MenuItem(
'[Windows]',
children=root_menu_items,
)
return [menu]