• 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"""Help window container class."""
15
16import functools
17import importlib.resources
18import inspect
19import logging
20from typing import Dict, Optional, TYPE_CHECKING
21
22from prompt_toolkit.document import Document
23from prompt_toolkit.filters import Condition
24from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
25from prompt_toolkit.layout import (
26    ConditionalContainer,
27    DynamicContainer,
28    FormattedTextControl,
29    HSplit,
30    VSplit,
31    Window,
32    WindowAlign,
33)
34from prompt_toolkit.layout.dimension import Dimension
35from prompt_toolkit.lexers import PygmentsLexer
36from prompt_toolkit.widgets import Box, TextArea
37
38from pygments.lexers.markup import RstLexer  # type: ignore
39from pygments.lexers.data import YamlLexer  # type: ignore
40
41from pw_console.style import (
42    get_pane_indicator,
43)
44from pw_console.widgets import (
45    mouse_handlers,
46    to_keybind_indicator,
47)
48
49if TYPE_CHECKING:
50    from pw_console.console_app import ConsoleApp
51
52_LOG = logging.getLogger(__package__)
53
54_PW_CONSOLE_MODULE = 'pw_console'
55
56
57def _longest_line_length(text):
58    """Return the longest line in the given text."""
59    max_line_length = 0
60    for line in text.splitlines():
61        if len(line) > max_line_length:
62            max_line_length = len(line)
63    return max_line_length
64
65
66class HelpWindow(ConditionalContainer):
67    """Help window container for displaying keybindings."""
68
69    # pylint: disable=too-many-instance-attributes
70
71    def _create_help_text_area(self, **kwargs):
72        help_text_area = TextArea(
73            focusable=True,
74            focus_on_click=True,
75            scrollbar=True,
76            style='class:help_window_content',
77            wrap_lines=False,
78            **kwargs,
79        )
80
81        # Additional keybindings for the text area.
82        key_bindings = KeyBindings()
83        register = self.application.prefs.register_keybinding
84
85        @register('help-window.close', key_bindings)
86        def _close_window(_event: KeyPressEvent) -> None:
87            """Close the current dialog window."""
88            self.toggle_display()
89
90        if not self.disable_ctrl_c:
91
92            @register('help-window.copy-all', key_bindings)
93            def _copy_all(_event: KeyPressEvent) -> None:
94                """Close the current dialog window."""
95                self.copy_all_text()
96
97        help_text_area.control.key_bindings = key_bindings
98        return help_text_area
99
100    def __init__(
101        self,
102        application: 'ConsoleApp',
103        preamble: str = '',
104        additional_help_text: str = '',
105        title: str = '',
106        disable_ctrl_c: bool = False,
107    ) -> None:
108        # Dict containing key = section title and value = list of key bindings.
109        self.application: 'ConsoleApp' = application
110        self.show_window: bool = False
111        self.help_text_sections: Dict[str, Dict] = {}
112        self._pane_title: str = title
113        self.disable_ctrl_c = disable_ctrl_c
114
115        # Tracks the last focused container, to enable restoring focus after
116        # closing the dialog.
117        self.last_focused_pane = None
118
119        # Generated keybinding text
120        self.preamble: str = preamble
121        self.additional_help_text: str = additional_help_text
122        self.help_text: str = ''
123
124        self.max_additional_help_text_width: int = (
125            _longest_line_length(self.additional_help_text)
126            if additional_help_text
127            else 0
128        )
129        self.max_description_width: int = 0
130        self.max_key_list_width: int = 0
131        self.max_line_length: int = 0
132
133        self.help_text_area: TextArea = self._create_help_text_area()
134
135        close_mouse_handler = functools.partial(
136            mouse_handlers.on_click, self.toggle_display
137        )
138        copy_mouse_handler = functools.partial(
139            mouse_handlers.on_click, self.copy_all_text
140        )
141
142        toolbar_padding = 1
143        toolbar_title = ' ' * toolbar_padding
144        toolbar_title += self.pane_title()
145
146        buttons = []
147        if not self.disable_ctrl_c:
148            buttons.extend(
149                to_keybind_indicator(
150                    'Ctrl-c',
151                    'Copy All',
152                    copy_mouse_handler,
153                    base_style='class:toolbar-button-active',
154                )
155            )
156            buttons.append(('', '  '))
157
158        buttons.extend(
159            to_keybind_indicator(
160                'q',
161                'Close',
162                close_mouse_handler,
163                base_style='class:toolbar-button-active',
164            )
165        )
166        top_toolbar = VSplit(
167            [
168                Window(
169                    content=FormattedTextControl(
170                        # [('', toolbar_title)]
171                        functools.partial(
172                            get_pane_indicator,
173                            self,
174                            toolbar_title,
175                        )
176                    ),
177                    align=WindowAlign.LEFT,
178                    dont_extend_width=True,
179                ),
180                Window(
181                    content=FormattedTextControl([]),
182                    align=WindowAlign.LEFT,
183                    dont_extend_width=False,
184                ),
185                Window(
186                    content=FormattedTextControl(buttons),
187                    align=WindowAlign.RIGHT,
188                    dont_extend_width=True,
189                ),
190            ],
191            height=1,
192            style='class:toolbar_active',
193        )
194
195        self.container = HSplit(
196            [
197                top_toolbar,
198                Box(
199                    body=DynamicContainer(lambda: self.help_text_area),
200                    padding=Dimension(preferred=1, max=1),
201                    padding_bottom=0,
202                    padding_top=0,
203                    char=' ',
204                    style='class:frame.border',  # Same style used for Frame.
205                ),
206            ]
207        )
208
209        super().__init__(
210            self.container,
211            filter=Condition(lambda: self.show_window),
212        )
213
214    def pane_title(self):
215        return self._pane_title
216
217    def menu_title(self):
218        """Return the title to display in the Window menu."""
219        return self.pane_title()
220
221    def __pt_container__(self):
222        """Return the prompt_toolkit container for displaying this HelpWindow.
223
224        This allows self to be used wherever prompt_toolkit expects a container
225        object."""
226        return self.container
227
228    def copy_all_text(self):
229        """Copy all text in the Python input to the system clipboard."""
230        self.application.application.clipboard.set_text(
231            self.help_text_area.buffer.text
232        )
233
234    def toggle_display(self):
235        """Toggle visibility of this help window."""
236        # Toggle state variable.
237        self.show_window = not self.show_window
238
239        if self.show_window:
240            # Save previous focus
241            self.last_focused_pane = self.application.focused_window()
242            # Set the help window in focus.
243            self.application.layout.focus(self.help_text_area)
244        else:
245            # Restore original focus if possible.
246            if self.last_focused_pane:
247                self.application.layout.focus(self.last_focused_pane)
248            else:
249                # Fallback to focusing on the first window pane.
250                self.application.focus_main_menu()
251
252    def content_width(self) -> int:
253        """Return total width of help window."""
254        # Widths of UI elements
255        frame_width = 1
256        padding_width = 1
257        left_side_frame_and_padding_width = frame_width + padding_width
258        right_side_frame_and_padding_width = frame_width + padding_width
259        scrollbar_padding = 1
260        scrollbar_width = 1
261
262        desired_width = self.max_line_length + (
263            left_side_frame_and_padding_width
264            + right_side_frame_and_padding_width
265            + scrollbar_padding
266            + scrollbar_width
267        )
268        desired_width = max(60, desired_width)
269
270        window_manager_width = (
271            self.application.window_manager.current_window_manager_width
272        )
273        if not window_manager_width:
274            window_manager_width = 80
275        return min(desired_width, window_manager_width)
276
277    def load_user_guide(self):
278        rstdoc_text = importlib.resources.read_text(
279            f'{_PW_CONSOLE_MODULE}.docs', 'user_guide.rst'
280        )
281        max_line_length = 0
282        rst_text = ''
283        for line in rstdoc_text.splitlines():
284            if 'https://' not in line and len(line) > max_line_length:
285                max_line_length = len(line)
286            rst_text += line + '\n'
287        self.max_line_length = max_line_length
288
289        self.help_text_area = self._create_help_text_area(
290            lexer=PygmentsLexer(RstLexer),
291            text=rst_text,
292        )
293
294    def load_yaml_text(self, content: str):
295        max_line_length = 0
296        for line in content.splitlines():
297            if 'https://' not in line and len(line) > max_line_length:
298                max_line_length = len(line)
299        self.max_line_length = max_line_length
300
301        self.help_text_area = self._create_help_text_area(
302            lexer=PygmentsLexer(YamlLexer),
303            text=content,
304        )
305
306    def set_help_text(
307        self, text: str, lexer: Optional[PygmentsLexer] = None
308    ) -> None:
309        self.help_text_area = self._create_help_text_area(
310            lexer=lexer,
311            text=text,
312        )
313        self._update_help_text_area(text)
314
315    def generate_keybind_help_text(self) -> str:
316        """Generate help text based on added key bindings."""
317
318        template = self.application.get_template('keybind_list.jinja')
319
320        text = template.render(
321            sections=self.help_text_sections,
322            max_additional_help_text_width=self.max_additional_help_text_width,
323            max_description_width=self.max_description_width,
324            max_key_list_width=self.max_key_list_width,
325            preamble=self.preamble,
326            additional_help_text=self.additional_help_text,
327        )
328
329        self._update_help_text_area(text)
330        return text
331
332    def _update_help_text_area(self, text: str) -> None:
333        self.help_text = text
334
335        # Find the longest line in the rendered template.
336        self.max_line_length = _longest_line_length(self.help_text)
337
338        # Replace the TextArea content.
339        self.help_text_area.buffer.document = Document(
340            text=self.help_text, cursor_position=0
341        )
342
343    def add_custom_keybinds_help_text(self, section_name, key_bindings: Dict):
344        """Add hand written key_bindings."""
345        self.help_text_sections[section_name] = key_bindings
346
347    def add_keybind_help_text(self, section_name, key_bindings: KeyBindings):
348        """Append formatted key binding text to this help window."""
349
350        # Create a new keybind section, erasing any old section with thesame
351        # title.
352        self.help_text_sections[section_name] = {}
353
354        # Loop through passed in prompt_toolkit key_bindings.
355        for binding in key_bindings.bindings:
356            # Skip this keybind if the method name ends in _hidden.
357            if binding.handler.__name__.endswith('_hidden'):
358                continue
359
360            # Get the key binding description from the function doctstring.
361            docstring = binding.handler.__doc__
362            if not docstring:
363                docstring = ''
364            description = inspect.cleandoc(docstring)
365            description = description.replace('\n', ' ')
366
367            # Save the length of the description.
368            if len(description) > self.max_description_width:
369                self.max_description_width = len(description)
370
371            # Get the existing list of keys for this function or make a new one.
372            key_list = self.help_text_sections[section_name].get(
373                description, list()
374            )
375
376            # Save the name of the key e.g. F1, q, ControlQ, ControlUp
377            key_name = ' '.join(
378                [getattr(key, 'name', str(key)) for key in binding.keys]
379            )
380            key_name = key_name.replace('Control', 'Ctrl-')
381            key_name = key_name.replace('Shift', 'Shift-')
382            key_name = key_name.replace('Escape ', 'Alt-')
383            key_name = key_name.replace('Alt-Ctrl-', 'Ctrl-Alt-')
384            key_name = key_name.replace('BackTab', 'Shift-Tab')
385            key_list.append(key_name)
386
387            key_list_width = len(', '.join(key_list))
388            # Save the length of the key list.
389            if key_list_width > self.max_key_list_width:
390                self.max_key_list_width = key_list_width
391
392            # Update this functions key_list
393            self.help_text_sections[section_name][description] = key_list
394