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, 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: int | None, 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: int | None = 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 old_display_mode = self.display_mode 288 self.display_mode = mode 289 290 if self.display_mode == DisplayMode.TABBED: 291 # Default to focusing on the first window / tab. 292 self.focused_pane_index = 0 293 # Hide all other panes so log redraw events are not triggered. 294 for pane in self.active_panes: 295 pane.show_pane = False 296 # Keep the selected tab visible 297 self.active_panes[self.focused_pane_index].show_pane = True 298 elif ( 299 old_display_mode == DisplayMode.TABBED 300 and self.display_mode == DisplayMode.STACK 301 ): 302 # Un-hide all panes if switching from tabbed back to stacked. 303 for pane in self.active_panes: 304 pane.show_pane = True 305 306 self.application.focus_main_menu() 307 self.refresh_ui() 308 309 def refresh_ui(self): 310 self.window_manager.update_root_container_body() 311 # Update menu after the window manager rebuilds the root container. 312 self.application.update_menu_items() 313 314 if self.display_mode == DisplayMode.TABBED: 315 self.application.focus_on_container( 316 self.active_panes[self.focused_pane_index] 317 ) 318 319 self.application.redraw_ui() 320 321 def _set_window_heights(self, new_heights: list[int]): 322 for pane in self.active_panes: 323 if not pane.show_pane: 324 continue 325 pane.height = Dimension(preferred=new_heights[0]) 326 new_heights = new_heights[1:] 327 328 def rebalance_window_heights(self): 329 available_height = self.current_window_list_height 330 331 old_values = [ 332 p.height.preferred for p in self.active_panes if p.show_pane 333 ] 334 # Make sure the old total is not zero. 335 old_total = max(sum(old_values), 1) 336 percentages = [value / old_total for value in old_values] 337 new_heights = [ 338 int(available_height * percentage) for percentage in percentages 339 ] 340 341 self._set_window_heights(new_heights) 342 343 def update_window_list_size( 344 self, width, height, xposition, yposition 345 ) -> None: 346 """Save width and height of the repl pane for the current UI render 347 pass.""" 348 if width: 349 self.last_window_list_width = self.current_window_list_width 350 self.current_window_list_width = width 351 if height: 352 self.last_window_list_height = self.current_window_list_height 353 self.current_window_list_height = height 354 if xposition: 355 self.last_window_list_xposition = self.current_window_list_xposition 356 self.current_window_list_xposition = xposition 357 if yposition: 358 self.last_window_list_yposition = self.current_window_list_yposition 359 self.current_window_list_yposition = yposition 360 361 if ( 362 self.current_window_list_width != self.last_window_list_width 363 or self.current_window_list_height != self.last_window_list_height 364 ): 365 self.rebalance_window_heights() 366 367 def mouse_handler(self, mouse_event: MouseEvent): 368 mouse_position = mouse_event.position 369 370 if ( 371 mouse_event.event_type == MouseEventType.MOUSE_MOVE 372 and mouse_event.button == MouseButton.LEFT 373 ): 374 self.mouse_resize(mouse_position.x, mouse_position.y) 375 elif mouse_event.event_type == MouseEventType.MOUSE_UP: 376 self.stop_resize() 377 # Mouse event handled, return None. 378 return None 379 else: 380 self.stop_resize() 381 382 # Mouse event not handled, return NotImplemented. 383 return NotImplemented 384 385 def update_container(self): 386 """Re-create the window list split depending on the display mode.""" 387 388 if self.display_mode == DisplayMode.STACK: 389 content_split = WindowListHSplit( 390 self, 391 list(pane for pane in self.active_panes if pane.show_pane), 392 height=lambda: self.height, 393 width=lambda: self.width, 394 ) 395 396 elif self.display_mode == DisplayMode.TABBED: 397 content_split = WindowListHSplit( 398 self, 399 [ 400 self._create_window_tab_toolbar(), 401 self.get_tab_mode_active_pane(), 402 ], 403 height=lambda: self.height, 404 width=lambda: self.width, 405 ) 406 407 self.container = content_split 408 409 def _create_window_tab_toolbar(self): 410 tab_bar_control = FormattedTextControl( 411 functools.partial( 412 self.get_pane_titles, omit_subtitles=True, use_menu_title=False 413 ) 414 ) 415 tab_bar_window = Window( 416 content=tab_bar_control, 417 align=WindowAlign.LEFT, 418 dont_extend_width=True, 419 ) 420 421 spacer = Window( 422 content=FormattedTextControl([('', '')]), 423 align=WindowAlign.LEFT, 424 dont_extend_width=False, 425 ) 426 427 tab_toolbar = VSplit( 428 [ 429 tab_bar_window, 430 spacer, 431 ], 432 style='class:toolbar_dim_inactive', 433 height=1, 434 align=HorizontalAlign.LEFT, 435 ) 436 return tab_toolbar 437 438 def empty(self) -> bool: 439 return len(self.active_panes) == 0 440 441 def pane_index(self, pane): 442 pane_index = None 443 try: 444 pane_index = self.active_panes.index(pane) 445 except ValueError: 446 # Ignore ValueError which can be raised by the self.active_panes 447 # deque if existing_pane can't be found. 448 pass 449 return pane_index 450 451 def add_pane_no_checks(self, pane: Any, add_at_beginning=False): 452 if add_at_beginning: 453 self.active_panes.appendleft(pane) 454 else: 455 self.active_panes.append(pane) 456 457 def add_pane(self, new_pane, existing_pane=None, add_at_beginning=False): 458 existing_pane_index = self.pane_index(existing_pane) 459 if existing_pane_index is not None: 460 self.active_panes.insert(new_pane, existing_pane_index + 1) 461 else: 462 if add_at_beginning: 463 self.active_panes.appendleft(new_pane) 464 else: 465 self.active_panes.append(new_pane) 466 467 self.refresh_ui() 468 469 def remove_pane_no_checks(self, pane: Any): 470 try: 471 self.active_panes.remove(pane) 472 except ValueError: 473 # ValueError will be raised if the the pane is not found 474 pass 475 return pane 476 477 def remove_pane(self, existing_pane): 478 existing_pane_index = self.pane_index(existing_pane) 479 if existing_pane_index is None: 480 return 481 482 self.active_panes.remove(existing_pane) 483 self.refresh_ui() 484 485 # Set focus to the previous window pane 486 if len(self.active_panes) > 0: 487 existing_pane_index -= 1 488 try: 489 self.application.focus_on_container( 490 self.active_panes[existing_pane_index] 491 ) 492 except ValueError: 493 # ValueError will be raised if the the pane at 494 # existing_pane_index can't be accessed. 495 # Focus on the main menu if the existing pane is hidden. 496 self.application.focus_main_menu() 497 498 self.application.redraw_ui() 499 500 def enlarge_pane(self): 501 """Enlarge the currently focused window pane.""" 502 pane = self.get_current_active_pane() 503 if pane: 504 self.adjust_pane_size(pane, _WINDOW_HEIGHT_ADJUST) 505 506 def shrink_pane(self): 507 """Shrink the currently focused window pane.""" 508 pane = self.get_current_active_pane() 509 if pane: 510 self.adjust_pane_size(pane, -_WINDOW_HEIGHT_ADJUST) 511 512 def mouse_resize(self, _xpos, ypos) -> None: 513 if self.resize_target_pane_index is None: 514 return 515 516 target_pane = self.active_panes[self.resize_target_pane_index] 517 518 diff = ypos - self.resize_current_row 519 if not self.window_manager.vertical_window_list_spliting(): 520 # The mouse ypos value includes rows from other window lists. If 521 # horizontal splitting is active we need to check the diff relative 522 # to the starting y position row. Subtract the start y position and 523 # an additional 1 for the top menu bar. 524 diff -= self.current_window_list_yposition - 1 525 526 if diff == 0: 527 return 528 self.adjust_pane_size(target_pane, diff) 529 self._update_resize_current_row() 530 self.application.redraw_ui() 531 532 def adjust_pane_size(self, pane, diff: int = _WINDOW_HEIGHT_ADJUST) -> None: 533 """Increase or decrease a given pane's height.""" 534 # Placeholder next_pane value to allow setting width and height without 535 # any consequences if there is no next visible pane. 536 next_pane = HSplit( 537 [], height=Dimension(preferred=10), width=Dimension(preferred=10) 538 ) # type: ignore 539 # Try to get the next visible pane to subtract a weight value from. 540 next_visible_pane = self._get_next_visible_pane_after(pane) 541 if next_visible_pane: 542 next_pane = next_visible_pane 543 544 # If the last pane is selected, and there are at least 2 panes, make 545 # next_pane the previous pane. 546 try: 547 if len(self.active_panes) >= 2 and ( 548 self.active_panes.index(pane) == len(self.active_panes) - 1 549 ): 550 next_pane = self.active_panes[-2] 551 except ValueError: 552 # Ignore ValueError raised if self.active_panes[-2] doesn't exist. 553 pass 554 555 old_height = pane.height.preferred 556 if diff < 0 and old_height <= 1: 557 return 558 next_old_height = next_pane.height.preferred # type: ignore 559 560 # Add to the current pane 561 new_height = old_height + diff 562 if new_height <= 0: 563 new_height = old_height 564 565 # Subtract from the next pane 566 next_new_height = next_old_height - diff 567 if next_new_height <= 0: 568 next_new_height = next_old_height 569 570 # If new height is too small or no change, make no adjustments. 571 if new_height < 3 or next_new_height < 3 or old_height == new_height: 572 return 573 574 # Set new heigts of the target pane and next pane. 575 pane.height.preferred = new_height 576 next_pane.height.preferred = next_new_height # type: ignore 577 578 def reset_pane_sizes(self): 579 """Reset all active pane heights evenly.""" 580 581 available_height = self.current_window_list_height 582 old_values = [ 583 p.height.preferred for p in self.active_panes if p.show_pane 584 ] 585 new_heights = [int(available_height / len(old_values))] * len( 586 old_values 587 ) 588 589 self._set_window_heights(new_heights) 590 591 def move_pane_up(self): 592 pane = self.get_current_active_pane() 593 pane_index = self.pane_index(pane) 594 if pane_index is None or pane_index <= 0: 595 # Already at the beginning 596 return 597 598 # Swap with the previous pane 599 previous_pane = self.active_panes[pane_index - 1] 600 self.active_panes[pane_index - 1] = pane 601 self.active_panes[pane_index] = previous_pane 602 603 self.refresh_ui() 604 605 def move_pane_down(self): 606 pane = self.get_current_active_pane() 607 pane_index = self.pane_index(pane) 608 pane_count = len(self.active_panes) 609 if pane_index is None or pane_index + 1 >= pane_count: 610 # Already at the end 611 return 612 613 # Swap with the next pane 614 next_pane = self.active_panes[pane_index + 1] 615 self.active_panes[pane_index + 1] = pane 616 self.active_panes[pane_index] = next_pane 617 618 self.refresh_ui() 619 620 def _get_next_visible_pane_after(self, target_pane): 621 """Return the next visible pane that appears after the target pane.""" 622 try: 623 target_pane_index = self.active_panes.index(target_pane) 624 except ValueError: 625 # If pane can't be found, focus on the main menu. 626 return None 627 628 # Loop through active panes (not including the target_pane). 629 for i in range(1, len(self.active_panes)): 630 next_pane_index = (target_pane_index + i) % len(self.active_panes) 631 next_pane = self.active_panes[next_pane_index] 632 if next_pane.show_pane: 633 return next_pane 634 return None 635