1# Copyright 2021 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14"""WindowList""" 15 16import collections 17from enum import Enum 18import functools 19import logging 20from typing import Any, List, Optional, TYPE_CHECKING 21 22from prompt_toolkit.filters import has_focus 23from prompt_toolkit.layout import ( 24 Dimension, 25 FormattedTextControl, 26 HSplit, 27 HorizontalAlign, 28 VSplit, 29 Window, 30 WindowAlign, 31) 32from prompt_toolkit.mouse_events import MouseEvent, MouseEventType, MouseButton 33 34import pw_console.style 35import pw_console.widgets.mouse_handlers 36 37if TYPE_CHECKING: 38 # pylint: disable=ungrouped-imports 39 from pw_console.window_manager import WindowManager 40 41_LOG = logging.getLogger(__package__) 42 43 44class DisplayMode(Enum): 45 """WindowList display modes.""" 46 STACK = 'Stacked' 47 TABBED = 'Tabbed' 48 49 50DEFAULT_DISPLAY_MODE = DisplayMode.STACK 51 52# Weighted amount for adjusting window dimensions when enlarging and shrinking. 53_WINDOW_HEIGHT_ADJUST = 1 54 55 56class WindowListHSplit(HSplit): 57 """PromptToolkit HSplit class with some additions for size and mouse resize. 58 59 This HSplit has a write_to_screen function that saves the width and height 60 of the container for the current render pass. It also handles overriding 61 mouse handlers for triggering window resize adjustments. 62 """ 63 def __init__(self, parent_window_list, *args, **kwargs): 64 # Save a reference to the parent window pane. 65 self.parent_window_list = parent_window_list 66 super().__init__(*args, **kwargs) 67 68 def write_to_screen( 69 self, 70 screen, 71 mouse_handlers, 72 write_position, 73 parent_style: str, 74 erase_bg: bool, 75 z_index: Optional[int], 76 ) -> None: 77 new_mouse_handlers = mouse_handlers 78 # Is resize mode active? 79 if self.parent_window_list.resize_mode: 80 # Ignore future mouse_handler updates. 81 new_mouse_handlers = ( 82 pw_console.widgets.mouse_handlers.EmptyMouseHandler()) 83 # Set existing mouse_handlers to the parent_window_list's 84 # mouse_handler. This will handle triggering resize events. 85 mouse_handlers.set_mouse_handler_for_range( 86 write_position.xpos, 87 write_position.xpos + write_position.width, 88 write_position.ypos, 89 write_position.ypos + write_position.height, 90 self.parent_window_list.mouse_handler) 91 92 # Save the width, height, and draw position for the current render pass. 93 self.parent_window_list.update_window_list_size( 94 write_position.width, write_position.height, write_position.xpos, 95 write_position.ypos) 96 # Continue writing content to the screen. 97 super().write_to_screen(screen, new_mouse_handlers, write_position, 98 parent_style, erase_bg, z_index) 99 100 101class WindowList: 102 """WindowList holds a stack of windows for the WindowManager.""" 103 104 # pylint: disable=too-many-instance-attributes,too-many-public-methods 105 def __init__( 106 self, 107 window_manager: 'WindowManager', 108 ): 109 self.window_manager = window_manager 110 self.application = window_manager.application 111 112 self.current_window_list_width: int = 0 113 self.current_window_list_height: int = 0 114 self.last_window_list_width: int = 0 115 self.last_window_list_height: int = 0 116 117 self.current_window_list_xposition: int = 0 118 self.last_window_list_xposition: int = 0 119 self.current_window_list_yposition: int = 0 120 self.last_window_list_yposition: int = 0 121 122 self.display_mode = DEFAULT_DISPLAY_MODE 123 self.active_panes: collections.deque = collections.deque() 124 self.focused_pane_index: Optional[int] = None 125 126 self.height = Dimension(preferred=10) 127 self.width = Dimension(preferred=10) 128 129 self.resize_mode = False 130 self.resize_target_pane_index = None 131 self.resize_target_pane = None 132 self.resize_current_row = 0 133 134 # Reference to the current prompt_toolkit window split for the current 135 # set of active_panes. 136 self.container = None 137 138 def _calculate_actual_heights(self) -> List[int]: 139 heights = [ 140 p.height.preferred if p.show_pane else 0 for p in self.active_panes 141 ] 142 available_height = self.current_window_list_height 143 remaining_rows = available_height - sum(heights) 144 window_index = 0 145 146 # Distribute remaining unaccounted rows to each window in turn. 147 while remaining_rows > 0: 148 # 0 heights are hiden windows, only add +1 to visible windows. 149 if heights[window_index] > 0: 150 heights[window_index] += 1 151 remaining_rows -= 1 152 window_index = (window_index + 1) % len(heights) 153 154 return heights 155 156 def _update_resize_current_row(self): 157 heights = self._calculate_actual_heights() 158 start_row = 0 159 160 # Find the starting row 161 for i in range(self.resize_target_pane_index + 1): 162 # If we are past the current pane, exit the loop. 163 if i > self.resize_target_pane_index: 164 break 165 # 0 heights are hidden windows, only count visible windows. 166 if heights[i] > 0: 167 start_row += heights[i] 168 self.resize_current_row = start_row 169 170 def start_resize(self, target_pane, pane_index): 171 # Can only resize if view mode is stacked. 172 if self.display_mode != DisplayMode.STACK: 173 return 174 175 # Check the target_pane isn't the last one in the list 176 visible_panes = [pane for pane in self.active_panes if pane.show_pane] 177 if target_pane == visible_panes[-1]: 178 return 179 180 self.resize_mode = True 181 self.resize_target_pane_index = pane_index 182 self._update_resize_current_row() 183 184 def stop_resize(self): 185 self.resize_mode = False 186 self.resize_target_pane_index = None 187 self.resize_current_row = 0 188 189 def get_tab_mode_active_pane(self): 190 if self.focused_pane_index is None: 191 self.focused_pane_index = 0 192 193 pane = None 194 try: 195 pane = self.active_panes[self.focused_pane_index] 196 except IndexError: 197 # Ignore ValueError which can be raised by the self.active_panes 198 # deque if existing_pane can't be found. 199 self.focused_pane_index = 0 200 pane = self.active_panes[self.focused_pane_index] 201 return pane 202 203 def get_current_active_pane(self): 204 """Return the current active window pane.""" 205 focused_pane = None 206 207 command_runner_focused_pane = None 208 if self.application.command_runner_is_open(): 209 command_runner_focused_pane = ( 210 self.application.command_runner_last_focused_pane()) 211 212 for index, pane in enumerate(self.active_panes): 213 in_focus = False 214 if has_focus(pane)(): 215 in_focus = True 216 elif command_runner_focused_pane and pane.has_child_container( 217 command_runner_focused_pane): 218 in_focus = True 219 220 if in_focus: 221 focused_pane = pane 222 self.focused_pane_index = index 223 break 224 return focused_pane 225 226 def get_pane_titles(self, omit_subtitles=False, use_menu_title=True): 227 fragments = [] 228 separator = ('', ' ') 229 fragments.append(separator) 230 for pane_index, pane in enumerate(self.active_panes): 231 title = pane.menu_title() if use_menu_title else pane.pane_title() 232 subtitle = pane.pane_subtitle() 233 text = f' {title} {subtitle} ' 234 if omit_subtitles: 235 text = f' {title} ' 236 237 fragments.append(( 238 # Style 239 ('class:window-tab-active' if pane_index 240 == self.focused_pane_index else 'class:window-tab-inactive'), 241 # Text 242 text, 243 # Mouse handler 244 functools.partial( 245 pw_console.widgets.mouse_handlers.on_click, 246 functools.partial(self.switch_to_tab, pane_index), 247 ), 248 )) 249 fragments.append(separator) 250 return fragments 251 252 def switch_to_tab(self, index: int): 253 self.focused_pane_index = index 254 255 # refresh_ui() will focus on the new tab container. 256 self.refresh_ui() 257 258 def set_display_mode(self, mode: DisplayMode): 259 self.display_mode = mode 260 261 if self.display_mode == DisplayMode.TABBED: 262 self.focused_pane_index = 0 263 # Un-hide all panes, they must be visible to switch between tabs. 264 for pane in self.active_panes: 265 pane.show_pane = True 266 267 self.application.focus_main_menu() 268 self.refresh_ui() 269 270 def refresh_ui(self): 271 self.window_manager.update_root_container_body() 272 # Update menu after the window manager rebuilds the root container. 273 self.application.update_menu_items() 274 275 if self.display_mode == DisplayMode.TABBED: 276 self.application.focus_on_container( 277 self.active_panes[self.focused_pane_index]) 278 279 self.application.redraw_ui() 280 281 def _set_window_heights(self, new_heights: List[int]): 282 for pane in self.active_panes: 283 if not pane.show_pane: 284 continue 285 pane.height = Dimension(preferred=new_heights[0]) 286 new_heights = new_heights[1:] 287 288 def rebalance_window_heights(self): 289 available_height = self.current_window_list_height 290 291 old_values = [ 292 p.height.preferred for p in self.active_panes if p.show_pane 293 ] 294 # Make sure the old total is not zero. 295 old_total = max(sum(old_values), 1) 296 percentages = [value / old_total for value in old_values] 297 new_heights = [ 298 int(available_height * percentage) for percentage in percentages 299 ] 300 301 self._set_window_heights(new_heights) 302 303 def update_window_list_size(self, width, height, xposition, 304 yposition) -> None: 305 """Save width and height of the repl pane for the current UI render 306 pass.""" 307 if width: 308 self.last_window_list_width = self.current_window_list_width 309 self.current_window_list_width = width 310 if height: 311 self.last_window_list_height = self.current_window_list_height 312 self.current_window_list_height = height 313 if xposition: 314 self.last_window_list_xposition = ( 315 self.current_window_list_xposition) 316 self.current_window_list_xposition = xposition 317 if yposition: 318 self.last_window_list_yposition = ( 319 self.current_window_list_yposition) 320 self.current_window_list_yposition = yposition 321 322 if (self.current_window_list_width != self.last_window_list_width 323 or self.current_window_list_height != 324 self.last_window_list_height): 325 self.rebalance_window_heights() 326 327 def mouse_handler(self, mouse_event: MouseEvent): 328 mouse_position = mouse_event.position 329 330 if (mouse_event.event_type == MouseEventType.MOUSE_MOVE 331 and mouse_event.button == MouseButton.LEFT): 332 self.mouse_resize(mouse_position.x, mouse_position.y) 333 elif mouse_event.event_type == MouseEventType.MOUSE_UP: 334 self.stop_resize() 335 # Mouse event handled, return None. 336 return None 337 else: 338 self.stop_resize() 339 340 # Mouse event not handled, return NotImplemented. 341 return NotImplemented 342 343 def update_container(self): 344 """Re-create the window list split depending on the display mode.""" 345 346 if self.display_mode == DisplayMode.STACK: 347 content_split = WindowListHSplit( 348 self, 349 list(pane for pane in self.active_panes if pane.show_pane), 350 height=lambda: self.height, 351 width=lambda: self.width, 352 ) 353 354 elif self.display_mode == DisplayMode.TABBED: 355 content_split = WindowListHSplit( 356 self, 357 [ 358 self._create_window_tab_toolbar(), 359 self.get_tab_mode_active_pane(), 360 ], 361 height=lambda: self.height, 362 width=lambda: self.width, 363 ) 364 365 self.container = content_split 366 367 def _create_window_tab_toolbar(self): 368 tab_bar_control = FormattedTextControl( 369 functools.partial(self.get_pane_titles, 370 omit_subtitles=True, 371 use_menu_title=False)) 372 tab_bar_window = Window(content=tab_bar_control, 373 align=WindowAlign.LEFT, 374 dont_extend_width=True) 375 376 spacer = Window(content=FormattedTextControl([('', '')]), 377 align=WindowAlign.LEFT, 378 dont_extend_width=False) 379 380 tab_toolbar = VSplit( 381 [ 382 tab_bar_window, 383 spacer, 384 ], 385 style='class:toolbar_dim_inactive', 386 height=1, 387 align=HorizontalAlign.LEFT, 388 ) 389 return tab_toolbar 390 391 def empty(self) -> bool: 392 return len(self.active_panes) == 0 393 394 def pane_index(self, pane): 395 pane_index = None 396 try: 397 pane_index = self.active_panes.index(pane) 398 except ValueError: 399 # Ignore ValueError which can be raised by the self.active_panes 400 # deque if existing_pane can't be found. 401 pass 402 return pane_index 403 404 def add_pane_no_checks(self, pane: Any, add_at_beginning=False): 405 if add_at_beginning: 406 self.active_panes.appendleft(pane) 407 else: 408 self.active_panes.append(pane) 409 410 def add_pane(self, new_pane, existing_pane=None, add_at_beginning=False): 411 existing_pane_index = self.pane_index(existing_pane) 412 if existing_pane_index is not None: 413 self.active_panes.insert(new_pane, existing_pane_index + 1) 414 else: 415 if add_at_beginning: 416 self.active_panes.appendleft(new_pane) 417 else: 418 self.active_panes.append(new_pane) 419 420 self.refresh_ui() 421 422 def remove_pane_no_checks(self, pane: Any): 423 try: 424 self.active_panes.remove(pane) 425 except ValueError: 426 # ValueError will be raised if the the pane is not found 427 pass 428 return pane 429 430 def remove_pane(self, existing_pane): 431 existing_pane_index = self.pane_index(existing_pane) 432 if existing_pane_index is None: 433 return 434 435 self.active_panes.remove(existing_pane) 436 self.refresh_ui() 437 438 # Set focus to the previous window pane 439 if len(self.active_panes) > 0: 440 existing_pane_index -= 1 441 try: 442 self.application.focus_on_container( 443 self.active_panes[existing_pane_index]) 444 except ValueError: 445 # ValueError will be raised if the the pane at 446 # existing_pane_index can't be accessed. 447 # Focus on the main menu if the existing pane is hidden. 448 self.application.focus_main_menu() 449 450 self.application.redraw_ui() 451 452 def enlarge_pane(self): 453 """Enlarge the currently focused window pane.""" 454 pane = self.get_current_active_pane() 455 if pane: 456 self.adjust_pane_size(pane, _WINDOW_HEIGHT_ADJUST) 457 458 def shrink_pane(self): 459 """Shrink the currently focused window pane.""" 460 pane = self.get_current_active_pane() 461 if pane: 462 self.adjust_pane_size(pane, -_WINDOW_HEIGHT_ADJUST) 463 464 def mouse_resize(self, _xpos, ypos) -> None: 465 if self.resize_target_pane_index is None: 466 return 467 468 target_pane = self.active_panes[self.resize_target_pane_index] 469 470 diff = ypos - self.resize_current_row 471 if not self.window_manager.vertical_window_list_spliting(): 472 # The mouse ypos value includes rows from other window lists. If 473 # horizontal splitting is active we need to check the diff relative 474 # to the starting y position row. Subtract the start y position and 475 # an additional 1 for the top menu bar. 476 diff -= self.current_window_list_yposition - 1 477 478 if diff == 0: 479 return 480 self.adjust_pane_size(target_pane, diff) 481 self._update_resize_current_row() 482 self.application.redraw_ui() 483 484 def adjust_pane_size(self, 485 pane, 486 diff: int = _WINDOW_HEIGHT_ADJUST) -> None: 487 """Increase or decrease a given pane's height.""" 488 # Placeholder next_pane value to allow setting width and height without 489 # any consequences if there is no next visible pane. 490 next_pane = HSplit([], 491 height=Dimension(preferred=10), 492 width=Dimension(preferred=10)) # type: ignore 493 # Try to get the next visible pane to subtract a weight value from. 494 next_visible_pane = self._get_next_visible_pane_after(pane) 495 if next_visible_pane: 496 next_pane = next_visible_pane 497 498 # If the last pane is selected, and there are at least 2 panes, make 499 # next_pane the previous pane. 500 try: 501 if len(self.active_panes) >= 2 and (self.active_panes.index(pane) 502 == len(self.active_panes) - 1): 503 next_pane = self.active_panes[-2] 504 except ValueError: 505 # Ignore ValueError raised if self.active_panes[-2] doesn't exist. 506 pass 507 508 old_height = pane.height.preferred 509 if diff < 0 and old_height <= 1: 510 return 511 next_old_height = next_pane.height.preferred # type: ignore 512 513 # Add to the current pane 514 new_height = old_height + diff 515 if new_height <= 0: 516 new_height = old_height 517 518 # Subtract from the next pane 519 next_new_height = next_old_height - diff 520 if next_new_height <= 0: 521 next_new_height = next_old_height 522 523 # If new height is too small or no change, make no adjustments. 524 if new_height < 3 or next_new_height < 3 or old_height == new_height: 525 return 526 527 # Set new heigts of the target pane and next pane. 528 pane.height.preferred = new_height 529 next_pane.height.preferred = next_new_height # type: ignore 530 531 def reset_pane_sizes(self): 532 """Reset all active pane heights evenly.""" 533 534 available_height = self.current_window_list_height 535 old_values = [ 536 p.height.preferred for p in self.active_panes if p.show_pane 537 ] 538 new_heights = [int(available_height / len(old_values)) 539 ] * len(old_values) 540 541 self._set_window_heights(new_heights) 542 543 def move_pane_up(self): 544 pane = self.get_current_active_pane() 545 pane_index = self.pane_index(pane) 546 if pane_index is None or pane_index <= 0: 547 # Already at the beginning 548 return 549 550 # Swap with the previous pane 551 previous_pane = self.active_panes[pane_index - 1] 552 self.active_panes[pane_index - 1] = pane 553 self.active_panes[pane_index] = previous_pane 554 555 self.refresh_ui() 556 557 def move_pane_down(self): 558 pane = self.get_current_active_pane() 559 pane_index = self.pane_index(pane) 560 pane_count = len(self.active_panes) 561 if pane_index is None or pane_index + 1 >= pane_count: 562 # Already at the end 563 return 564 565 # Swap with the next pane 566 next_pane = self.active_panes[pane_index + 1] 567 self.active_panes[pane_index + 1] = pane 568 self.active_panes[pane_index] = next_pane 569 570 self.refresh_ui() 571 572 def _get_next_visible_pane_after(self, target_pane): 573 """Return the next visible pane that appears after the target pane.""" 574 try: 575 target_pane_index = self.active_panes.index(target_pane) 576 except ValueError: 577 # If pane can't be found, focus on the main menu. 578 return None 579 580 # Loop through active panes (not including the target_pane). 581 for i in range(1, len(self.active_panes)): 582 next_pane_index = (target_pane_index + i) % len(self.active_panes) 583 next_pane = self.active_panes[next_pane_index] 584 if next_pane.show_pane: 585 return next_pane 586 return None 587 588 def focus_next_visible_pane(self, pane): 589 """Focus on the next visible window pane if possible.""" 590 next_visible_pane = self._get_next_visible_pane_after(pane) 591 if next_visible_pane: 592 self.application.layout.focus(next_visible_pane) 593 return 594 self.application.focus_main_menu() 595