• 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
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