blob: ec21b58103df779184ee4379c0043e871100e97b [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.
"""WindowList"""
import collections
from enum import Enum
import functools
import logging
from typing import Any, List, Optional, TYPE_CHECKING
from prompt_toolkit.filters import has_focus
from prompt_toolkit.layout import (
Dimension,
FormattedTextControl,
HSplit,
HorizontalAlign,
VSplit,
Window,
WindowAlign,
)
from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseButton
import pw_console.style
import pw_console.widgets.mouse_handlers
if TYPE_CHECKING:
# pylint: disable=ungrouped-imports
from pw_console.window_manager import WindowManager
_LOG = logging.getLogger(__package__)
class DisplayMode(Enum):
"""WindowList display modes."""
STACK = 'Stacked'
TABBED = 'Tabbed'
DEFAULT_DISPLAY_MODE = DisplayMode.STACK
# Weighted amount for adjusting window dimensions when enlarging and shrinking.
_WINDOW_HEIGHT_ADJUST = 1
class WindowListHSplit(HSplit):
"""PromptToolkit HSplit class with some additions for size and mouse resize.
This HSplit has a write_to_screen function that saves the width and height
of the container for the current render pass. It also handles overriding
mouse handlers for triggering window resize adjustments.
"""
def __init__(self, parent_window_list, *args, **kwargs):
# Save a reference to the parent window pane.
self.parent_window_list = parent_window_list
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:
new_mouse_handlers = mouse_handlers
# Is resize mode active?
if self.parent_window_list.resize_mode:
# Ignore future mouse_handler updates.
new_mouse_handlers = (
pw_console.widgets.mouse_handlers.EmptyMouseHandler())
# Set existing mouse_handlers to the parent_window_list's
# mouse_handler. This will handle triggering resize events.
mouse_handlers.set_mouse_handler_for_range(
write_position.xpos,
write_position.xpos + write_position.width,
write_position.ypos,
write_position.ypos + write_position.height,
self.parent_window_list.mouse_handler)
# Save the width, height, and draw position for the current render pass.
self.parent_window_list.update_window_list_size(
write_position.width, write_position.height, write_position.xpos,
write_position.ypos)
# Continue writing content to the screen.
super().write_to_screen(screen, new_mouse_handlers, write_position,
parent_style, erase_bg, z_index)
class WindowList:
"""WindowList holds a stack of windows for the WindowManager."""
# pylint: disable=too-many-instance-attributes,too-many-public-methods
def __init__(
self,
window_manager: 'WindowManager',
):
self.window_manager = window_manager
self.application = window_manager.application
self.current_window_list_width: int = 0
self.current_window_list_height: int = 0
self.last_window_list_width: int = 0
self.last_window_list_height: int = 0
self.current_window_list_xposition: int = 0
self.last_window_list_xposition: int = 0
self.current_window_list_yposition: int = 0
self.last_window_list_yposition: int = 0
self.display_mode = DEFAULT_DISPLAY_MODE
self.active_panes: collections.deque = collections.deque()
self.focused_pane_index: Optional[int] = None
self.height = Dimension(preferred=10)
self.width = Dimension(preferred=10)
self.resize_mode = False
self.resize_target_pane_index = None
self.resize_target_pane = None
self.resize_current_row = 0
# Reference to the current prompt_toolkit window split for the current
# set of active_panes.
self.container = None
def _calculate_actual_heights(self) -> List[int]:
heights = [
p.height.preferred if p.show_pane else 0 for p in self.active_panes
]
available_height = self.current_window_list_height
remaining_rows = available_height - sum(heights)
window_index = 0
# Distribute remaining unaccounted rows to each window in turn.
while remaining_rows > 0:
# 0 heights are hiden windows, only add +1 to visible windows.
if heights[window_index] > 0:
heights[window_index] += 1
remaining_rows -= 1
window_index = (window_index + 1) % len(heights)
return heights
def _update_resize_current_row(self):
heights = self._calculate_actual_heights()
start_row = 0
# Find the starting row
for i in range(self.resize_target_pane_index + 1):
# If we are past the current pane, exit the loop.
if i > self.resize_target_pane_index:
break
# 0 heights are hidden windows, only count visible windows.
if heights[i] > 0:
start_row += heights[i]
self.resize_current_row = start_row
def start_resize(self, target_pane, pane_index):
# Can only resize if view mode is stacked.
if self.display_mode != DisplayMode.STACK:
return
# Check the target_pane isn't the last one in the list
visible_panes = [pane for pane in self.active_panes if pane.show_pane]
if target_pane == visible_panes[-1]:
return
self.resize_mode = True
self.resize_target_pane_index = pane_index
self._update_resize_current_row()
def stop_resize(self):
self.resize_mode = False
self.resize_target_pane_index = None
self.resize_current_row = 0
def get_tab_mode_active_pane(self):
if self.focused_pane_index is None:
self.focused_pane_index = 0
pane = None
try:
pane = self.active_panes[self.focused_pane_index]
except IndexError:
# Ignore ValueError which can be raised by the self.active_panes
# deque if existing_pane can't be found.
self.focused_pane_index = 0
pane = self.active_panes[self.focused_pane_index]
return pane
def get_current_active_pane(self):
"""Return the current active window pane."""
focused_pane = None
command_runner_focused_pane = None
if self.application.command_runner_is_open():
command_runner_focused_pane = (
self.application.command_runner_last_focused_pane())
for index, pane in enumerate(self.active_panes):
in_focus = False
if has_focus(pane)():
in_focus = True
elif command_runner_focused_pane and pane.has_child_container(
command_runner_focused_pane):
in_focus = True
if in_focus:
focused_pane = pane
self.focused_pane_index = index
break
return focused_pane
def get_pane_titles(self, omit_subtitles=False, use_menu_title=True):
fragments = []
separator = ('', ' ')
fragments.append(separator)
for pane_index, pane in enumerate(self.active_panes):
title = pane.menu_title() if use_menu_title else pane.pane_title()
subtitle = pane.pane_subtitle()
text = f' {title} {subtitle} '
if omit_subtitles:
text = f' {title} '
fragments.append((
# Style
('class:window-tab-active' if pane_index
== self.focused_pane_index else 'class:window-tab-inactive'),
# Text
text,
# Mouse handler
functools.partial(
pw_console.widgets.mouse_handlers.on_click,
functools.partial(self.switch_to_tab, pane_index),
),
))
fragments.append(separator)
return fragments
def switch_to_tab(self, index: int):
self.focused_pane_index = index
# refresh_ui() will focus on the new tab container.
self.refresh_ui()
def set_display_mode(self, mode: DisplayMode):
self.display_mode = mode
if self.display_mode == DisplayMode.TABBED:
self.focused_pane_index = 0
# Un-hide all panes, they must be visible to switch between tabs.
for pane in self.active_panes:
pane.show_pane = True
self.application.focus_main_menu()
self.refresh_ui()
def refresh_ui(self):
self.window_manager.update_root_container_body()
# Update menu after the window manager rebuilds the root container.
self.application.update_menu_items()
if self.display_mode == DisplayMode.TABBED:
self.application.focus_on_container(
self.active_panes[self.focused_pane_index])
self.application.redraw_ui()
def _set_window_heights(self, new_heights: List[int]):
for pane in self.active_panes:
if not pane.show_pane:
continue
pane.height = Dimension(preferred=new_heights[0])
new_heights = new_heights[1:]
def rebalance_window_heights(self):
available_height = self.current_window_list_height
old_values = [
p.height.preferred for p in self.active_panes if p.show_pane
]
# Make sure the old total is not zero.
old_total = max(sum(old_values), 1)
percentages = [value / old_total for value in old_values]
new_heights = [
int(available_height * percentage) for percentage in percentages
]
self._set_window_heights(new_heights)
def update_window_list_size(self, width, height, xposition,
yposition) -> None:
"""Save width and height of the repl pane for the current UI render
pass."""
if width:
self.last_window_list_width = self.current_window_list_width
self.current_window_list_width = width
if height:
self.last_window_list_height = self.current_window_list_height
self.current_window_list_height = height
if xposition:
self.last_window_list_xposition = (
self.current_window_list_xposition)
self.current_window_list_xposition = xposition
if yposition:
self.last_window_list_yposition = (
self.current_window_list_yposition)
self.current_window_list_yposition = yposition
if (self.current_window_list_width != self.last_window_list_width
or self.current_window_list_height !=
self.last_window_list_height):
self.rebalance_window_heights()
def mouse_handler(self, mouse_event: MouseEvent):
mouse_position = mouse_event.position
if (mouse_event.event_type == MouseEventType.MOUSE_MOVE
and mouse_event.button == MouseButton.LEFT):
self.mouse_resize(mouse_position.x, mouse_position.y)
elif mouse_event.event_type == MouseEventType.MOUSE_UP:
self.stop_resize()
# Mouse event handled, return None.
return None
else:
self.stop_resize()
# Mouse event not handled, return NotImplemented.
return NotImplemented
def update_container(self):
"""Re-create the window list split depending on the display mode."""
if self.display_mode == DisplayMode.STACK:
content_split = WindowListHSplit(
self,
list(pane for pane in self.active_panes if pane.show_pane),
height=lambda: self.height,
width=lambda: self.width,
)
elif self.display_mode == DisplayMode.TABBED:
content_split = WindowListHSplit(
self,
[
self._create_window_tab_toolbar(),
self.get_tab_mode_active_pane(),
],
height=lambda: self.height,
width=lambda: self.width,
)
self.container = content_split
def _create_window_tab_toolbar(self):
tab_bar_control = FormattedTextControl(
functools.partial(self.get_pane_titles,
omit_subtitles=True,
use_menu_title=False))
tab_bar_window = Window(content=tab_bar_control,
align=WindowAlign.LEFT,
dont_extend_width=True)
spacer = Window(content=FormattedTextControl([('', '')]),
align=WindowAlign.LEFT,
dont_extend_width=False)
tab_toolbar = VSplit(
[
tab_bar_window,
spacer,
],
style='class:toolbar_dim_inactive',
height=1,
align=HorizontalAlign.LEFT,
)
return tab_toolbar
def empty(self) -> bool:
return len(self.active_panes) == 0
def pane_index(self, pane):
pane_index = None
try:
pane_index = self.active_panes.index(pane)
except ValueError:
# Ignore ValueError which can be raised by the self.active_panes
# deque if existing_pane can't be found.
pass
return pane_index
def add_pane_no_checks(self, pane: Any, add_at_beginning=False):
if add_at_beginning:
self.active_panes.appendleft(pane)
else:
self.active_panes.append(pane)
def add_pane(self, new_pane, existing_pane=None, add_at_beginning=False):
existing_pane_index = self.pane_index(existing_pane)
if existing_pane_index is not None:
self.active_panes.insert(new_pane, existing_pane_index + 1)
else:
if add_at_beginning:
self.active_panes.appendleft(new_pane)
else:
self.active_panes.append(new_pane)
self.refresh_ui()
def remove_pane_no_checks(self, pane: Any):
try:
self.active_panes.remove(pane)
except ValueError:
# ValueError will be raised if the the pane is not found
pass
return pane
def remove_pane(self, existing_pane):
existing_pane_index = self.pane_index(existing_pane)
if existing_pane_index is None:
return
self.active_panes.remove(existing_pane)
self.refresh_ui()
# Set focus to the previous window pane
if len(self.active_panes) > 0:
existing_pane_index -= 1
try:
self.application.focus_on_container(
self.active_panes[existing_pane_index])
except ValueError:
# ValueError will be raised if the the pane at
# existing_pane_index can't be accessed.
# Focus on the main menu if the existing pane is hidden.
self.application.focus_main_menu()
self.application.redraw_ui()
def enlarge_pane(self):
"""Enlarge the currently focused window pane."""
pane = self.get_current_active_pane()
if pane:
self.adjust_pane_size(pane, _WINDOW_HEIGHT_ADJUST)
def shrink_pane(self):
"""Shrink the currently focused window pane."""
pane = self.get_current_active_pane()
if pane:
self.adjust_pane_size(pane, -_WINDOW_HEIGHT_ADJUST)
def mouse_resize(self, _xpos, ypos) -> None:
if self.resize_target_pane_index is None:
return
target_pane = self.active_panes[self.resize_target_pane_index]
diff = ypos - self.resize_current_row
if not self.window_manager.vertical_window_list_spliting():
# The mouse ypos value includes rows from other window lists. If
# horizontal splitting is active we need to check the diff relative
# to the starting y position row. Subtract the start y position and
# an additional 1 for the top menu bar.
diff -= self.current_window_list_yposition - 1
if diff == 0:
return
self.adjust_pane_size(target_pane, diff)
self._update_resize_current_row()
self.application.redraw_ui()
def adjust_pane_size(self,
pane,
diff: int = _WINDOW_HEIGHT_ADJUST) -> None:
"""Increase or decrease a given pane's height."""
# Placeholder next_pane value to allow setting width and height without
# any consequences if there is no next visible pane.
next_pane = HSplit([],
height=Dimension(preferred=10),
width=Dimension(preferred=10)) # type: ignore
# Try to get the next visible pane to subtract a weight value from.
next_visible_pane = self._get_next_visible_pane_after(pane)
if next_visible_pane:
next_pane = next_visible_pane
# If the last pane is selected, and there are at least 2 panes, make
# next_pane the previous pane.
try:
if len(self.active_panes) >= 2 and (self.active_panes.index(pane)
== len(self.active_panes) - 1):
next_pane = self.active_panes[-2]
except ValueError:
# Ignore ValueError raised if self.active_panes[-2] doesn't exist.
pass
old_height = pane.height.preferred
if diff < 0 and old_height <= 1:
return
next_old_height = next_pane.height.preferred # type: ignore
# Add to the current pane
new_height = old_height + diff
if new_height <= 0:
new_height = old_height
# Subtract from the next pane
next_new_height = next_old_height - diff
if next_new_height <= 0:
next_new_height = next_old_height
# If new height is too small or no change, make no adjustments.
if new_height < 3 or next_new_height < 3 or old_height == new_height:
return
# Set new heigts of the target pane and next pane.
pane.height.preferred = new_height
next_pane.height.preferred = next_new_height # type: ignore
def reset_pane_sizes(self):
"""Reset all active pane heights evenly."""
available_height = self.current_window_list_height
old_values = [
p.height.preferred for p in self.active_panes if p.show_pane
]
new_heights = [int(available_height / len(old_values))
] * len(old_values)
self._set_window_heights(new_heights)
def move_pane_up(self):
pane = self.get_current_active_pane()
pane_index = self.pane_index(pane)
if pane_index is None or pane_index <= 0:
# Already at the beginning
return
# Swap with the previous pane
previous_pane = self.active_panes[pane_index - 1]
self.active_panes[pane_index - 1] = pane
self.active_panes[pane_index] = previous_pane
self.refresh_ui()
def move_pane_down(self):
pane = self.get_current_active_pane()
pane_index = self.pane_index(pane)
pane_count = len(self.active_panes)
if pane_index is None or pane_index + 1 >= pane_count:
# Already at the end
return
# Swap with the next pane
next_pane = self.active_panes[pane_index + 1]
self.active_panes[pane_index + 1] = pane
self.active_panes[pane_index] = next_pane
self.refresh_ui()
def _get_next_visible_pane_after(self, target_pane):
"""Return the next visible pane that appears after the target pane."""
try:
target_pane_index = self.active_panes.index(target_pane)
except ValueError:
# If pane can't be found, focus on the main menu.
return None
# Loop through active panes (not including the target_pane).
for i in range(1, len(self.active_panes)):
next_pane_index = (target_pane_index + i) % len(self.active_panes)
next_pane = self.active_panes[next_pane_index]
if next_pane.show_pane:
return next_pane
return None
def focus_next_visible_pane(self, pane):
"""Focus on the next visible window pane if possible."""
next_visible_pane = self._get_next_visible_pane_after(pane)
if next_visible_pane:
self.application.layout.focus(next_visible_pane)
return
self.application.focus_main_menu()