xref: /aosp_15_r20/external/pigweed/pw_console/py/pw_console/help_window.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"""Help window container class."""
15
16from __future__ import annotations
17
18import functools
19import importlib.resources
20import inspect
21import logging
22from typing import TYPE_CHECKING
23
24from prompt_toolkit.document import Document
25from prompt_toolkit.filters import Condition
26from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
27from prompt_toolkit.layout import (
28    ConditionalContainer,
29    DynamicContainer,
30    FormattedTextControl,
31    HSplit,
32    VSplit,
33    Window,
34    WindowAlign,
35)
36from prompt_toolkit.layout.dimension import Dimension
37from prompt_toolkit.lexers import PygmentsLexer
38from prompt_toolkit.widgets import Box, TextArea
39
40from pygments.lexers.markup import RstLexer  # type: ignore
41from pygments.lexers.data import YamlLexer  # type: ignore
42
43from pw_console.style import (
44    get_pane_indicator,
45)
46from pw_console.widgets import (
47    mouse_handlers,
48    to_keybind_indicator,
49)
50
51if TYPE_CHECKING:
52    from pw_console.console_app import ConsoleApp
53
54_LOG = logging.getLogger(__package__)
55
56_PW_CONSOLE_MODULE = 'pw_console'
57
58
59def _longest_line_length(text):
60    """Return the longest line in the given text."""
61    max_line_length = 0
62    for line in text.splitlines():
63        if len(line) > max_line_length:
64            max_line_length = len(line)
65    return max_line_length
66
67
68class HelpWindow(ConditionalContainer):
69    """Help window container for displaying keybindings."""
70
71    # pylint: disable=too-many-instance-attributes
72
73    def _create_help_text_area(self, **kwargs):
74        help_text_area = TextArea(
75            focusable=True,
76            focus_on_click=True,
77            scrollbar=True,
78            style='class:help_window_content',
79            wrap_lines=False,
80            **kwargs,
81        )
82
83        # Additional keybindings for the text area.
84        key_bindings = KeyBindings()
85        register = self.application.prefs.register_keybinding
86
87        @register('help-window.close', key_bindings)
88        def _close_window(_event: KeyPressEvent) -> None:
89            """Close the current dialog window."""
90            self.toggle_display()
91
92        if not self.disable_ctrl_c:
93
94            @register('help-window.copy-all', key_bindings)
95            def _copy_all(_event: KeyPressEvent) -> None:
96                """Close the current dialog window."""
97                self.copy_all_text()
98
99        help_text_area.control.key_bindings = key_bindings
100        return help_text_area
101
102    def __init__(
103        self,
104        application: ConsoleApp,
105        preamble: str = '',
106        additional_help_text: str = '',
107        title: str = '',
108        disable_ctrl_c: bool = False,
109    ) -> None:
110        # Dict containing key = section title and value = list of key bindings.
111        self.application: ConsoleApp = application
112        self.show_window: bool = False
113        self.help_text_sections: dict[str, dict] = {}
114        self._pane_title: str = title
115        self.disable_ctrl_c = disable_ctrl_c
116
117        # Tracks the last focused container, to enable restoring focus after
118        # closing the dialog.
119        self.last_focused_pane = None
120
121        # Generated keybinding text
122        self.preamble: str = preamble
123        self.additional_help_text: str = additional_help_text
124        self.help_text: str = ''
125
126        self.max_additional_help_text_width: int = (
127            _longest_line_length(self.additional_help_text)
128            if additional_help_text
129            else 0
130        )
131        self.max_description_width: int = 0
132        self.max_key_list_width: int = 0
133        self.max_line_length: int = 0
134
135        self.help_text_area: TextArea = self._create_help_text_area()
136
137        close_mouse_handler = functools.partial(
138            mouse_handlers.on_click, self.toggle_display
139        )
140        copy_mouse_handler = functools.partial(
141            mouse_handlers.on_click, self.copy_all_text
142        )
143
144        toolbar_padding = 1
145        toolbar_title = ' ' * toolbar_padding
146        toolbar_title += self.pane_title()
147
148        buttons = []
149        if not self.disable_ctrl_c:
150            buttons.extend(
151                to_keybind_indicator(
152                    'Ctrl-c',
153                    'Copy All',
154                    copy_mouse_handler,
155                    base_style='class:toolbar-button-active',
156                )
157            )
158            buttons.append(('', '  '))
159
160        buttons.extend(
161            to_keybind_indicator(
162                'q',
163                'Close',
164                close_mouse_handler,
165                base_style='class:toolbar-button-active',
166            )
167        )
168        top_toolbar = VSplit(
169            [
170                Window(
171                    content=FormattedTextControl(
172                        # [('', toolbar_title)]
173                        functools.partial(
174                            get_pane_indicator,
175                            self,
176                            toolbar_title,
177                        )
178                    ),
179                    align=WindowAlign.LEFT,
180                    dont_extend_width=True,
181                ),
182                Window(
183                    content=FormattedTextControl([]),
184                    align=WindowAlign.LEFT,
185                    dont_extend_width=False,
186                ),
187                Window(
188                    content=FormattedTextControl(buttons),
189                    align=WindowAlign.RIGHT,
190                    dont_extend_width=True,
191                ),
192            ],
193            height=1,
194            style='class:toolbar_active',
195        )
196
197        self.container = HSplit(
198            [
199                top_toolbar,
200                Box(
201                    body=DynamicContainer(lambda: self.help_text_area),
202                    padding=Dimension(preferred=1, max=1),
203                    padding_bottom=0,
204                    padding_top=0,
205                    char=' ',
206                    style='class:frame.border',  # Same style used for Frame.
207                ),
208            ]
209        )
210
211        super().__init__(
212            self.container,
213            filter=Condition(lambda: self.show_window),
214        )
215
216    def pane_title(self):
217        return self._pane_title
218
219    def menu_title(self):
220        """Return the title to display in the Window menu."""
221        return self.pane_title()
222
223    def __pt_container__(self):
224        """Return the prompt_toolkit container for displaying this HelpWindow.
225
226        This allows self to be used wherever prompt_toolkit expects a container
227        object."""
228        return self.container
229
230    def copy_all_text(self):
231        """Copy all text in the Python input to the system clipboard."""
232        self.application.set_system_clipboard(self.help_text_area.buffer.text)
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: PygmentsLexer | None = 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