• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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