xref: /aosp_15_r20/external/pigweed/pw_console/py/pw_console/window_list.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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