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