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