• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2022 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"""CommandRunner dialog classes."""
15
16from __future__ import annotations
17import dataclasses
18import functools
19import logging
20import re
21from typing import (
22    Callable,
23    Iterable,
24    Iterator,
25    TYPE_CHECKING,
26)
27
28from prompt_toolkit.buffer import Buffer
29from prompt_toolkit.document import Document
30from prompt_toolkit.filters import Condition
31from prompt_toolkit.formatted_text import StyleAndTextTuples
32from prompt_toolkit.formatted_text.utils import fragment_list_to_text
33from prompt_toolkit.layout.utils import explode_text_fragments
34from prompt_toolkit.history import InMemoryHistory
35from prompt_toolkit.key_binding import (
36    KeyBindings,
37    KeyBindingsBase,
38    KeyPressEvent,
39)
40from prompt_toolkit.layout import (
41    AnyContainer,
42    ConditionalContainer,
43    DynamicContainer,
44    FormattedTextControl,
45    HSplit,
46    VSplit,
47    Window,
48    WindowAlign,
49)
50from prompt_toolkit.lexers import PygmentsLexer
51from prompt_toolkit.widgets import MenuItem, TextArea
52
53from pygments.lexers.markup import MarkdownLexer  # type: ignore
54
55from pw_console.widgets import (
56    create_border,
57    mouse_handlers,
58    to_keybind_indicator,
59)
60
61if TYPE_CHECKING:
62    from pw_console.console_app import ConsoleApp
63
64_LOG = logging.getLogger(__package__)
65
66
67@dataclasses.dataclass
68class CommandRunnerItem:
69    title: str
70    handler: Callable
71    description: str | None = None
72
73
74def flatten_menu_items(
75    items: list[MenuItem], prefix: str = ''
76) -> Iterator[CommandRunnerItem]:
77    """Flatten nested prompt_toolkit MenuItems into text and callable tuples."""
78    for item in items:
79        new_text = []
80        if prefix:
81            new_text.append(prefix)
82        new_text.append(item.text)
83        new_prefix = ' > '.join(new_text)
84
85        if item.children:
86            yield from flatten_menu_items(item.children, new_prefix)
87        elif item.handler:
88            # Skip this item if it's a separator or disabled.
89            if item.text == '-' or item.disabled:
90                continue
91            yield CommandRunnerItem(title=new_prefix, handler=item.handler)
92
93
94def highlight_matches(
95    regexes: Iterable[re.Pattern], line_fragments: StyleAndTextTuples
96) -> StyleAndTextTuples:
97    """Highlight regex matches in prompt_toolkit FormattedTextTuples."""
98    line_text = fragment_list_to_text(line_fragments)
99    exploded_fragments = explode_text_fragments(line_fragments)
100
101    def apply_highlighting(
102        fragments: StyleAndTextTuples, index: int, matching_regex_index: int = 0
103    ) -> None:
104        # Expand all fragments and apply the highlighting style.
105        old_style, _text, *_ = fragments[index]
106        # There are 6 fuzzy-highlight styles defined in style.py. Get an index
107        # from 0-5 to use one style after the other in turn.
108        style_index = matching_regex_index % 6
109        fragments[index] = (
110            old_style + f' class:command-runner-fuzzy-highlight-{style_index} ',
111            fragments[index][1],
112        )
113
114    # Highlight each non-overlapping search match.
115    for regex_i, regex in enumerate(regexes):
116        for match in regex.finditer(line_text):
117            for fragment_i in range(match.start(), match.end()):
118                apply_highlighting(exploded_fragments, fragment_i, regex_i)
119
120    return exploded_fragments
121
122
123class CommandRunner:
124    """CommandRunner dialog box."""
125
126    # pylint: disable=too-many-instance-attributes
127
128    def __init__(
129        self,
130        application: ConsoleApp,
131        window_title: str | None = None,
132        load_completions: Callable[[], list[CommandRunnerItem]] | None = None,
133        width: int = 80,
134        height: int = 10,
135    ):
136        # Parent pw_console application
137        self.application = application
138        # Visibility toggle
139        self.show_dialog = False
140        # Tracks the last focused container, to enable restoring focus after
141        # closing the dialog.
142        self.last_focused_pane = None
143
144        # List of all possible completion items
145        self.completions: list[CommandRunnerItem] = []
146        # Formatted text fragments of matched items
147        self.completion_fragments: list[StyleAndTextTuples] = []
148
149        # Current selected item tracking variables
150        self.selected_item: int = 0
151        self.selected_item_title: str = ''
152        self.selected_item_handler: Callable | None = None
153        self.selected_item_description: str | None = None
154        # Previous input text
155        self.last_input_field_text: str = 'EMPTY'
156        # Previous selected item
157        self.last_selected_item: int = 0
158
159        # Dialog width, height and title
160        self.width = width
161        self.height = height
162        self.window_title: str
163
164        # Callable to fetch completion items
165        self.load_completions: Callable[[], list[CommandRunnerItem]]
166
167        # Command runner text input field
168        self.input_field = TextArea(
169            prompt=[
170                (
171                    'class:command-runner-setting',
172                    '> ',
173                    functools.partial(
174                        mouse_handlers.on_click,
175                        self.focus_self,
176                    ),
177                )
178            ],
179            focusable=True,
180            focus_on_click=True,
181            scrollbar=False,
182            multiline=False,
183            height=1,
184            dont_extend_height=True,
185            dont_extend_width=False,
186            accept_handler=self._command_accept_handler,
187            history=InMemoryHistory(),
188        )
189        # Set additional keybindings for the input field
190        self.input_field.control.key_bindings = self._create_key_bindings()
191
192        # Container for the Cancel and Run buttons
193        input_field_buttons_container = ConditionalContainer(
194            Window(
195                content=FormattedTextControl(
196                    self._get_input_field_button_fragments,
197                    focusable=False,
198                    show_cursor=False,
199                ),
200                height=1,
201                align=WindowAlign.RIGHT,
202                dont_extend_width=True,
203            ),
204            filter=Condition(lambda: self.content_width() > 40),
205        )
206
207        # Container for completion matches
208        command_items_window = Window(
209            content=FormattedTextControl(
210                self.render_completion_items,
211                show_cursor=False,
212                focusable=False,
213            ),
214            align=WindowAlign.LEFT,
215            dont_extend_width=False,
216            height=self.height,
217        )
218
219        self.selected_item_description_text_area = TextArea(
220            focusable=False,
221            focus_on_click=False,
222            scrollbar=False,
223            style='class:help_window_content',
224            wrap_lines=False,
225            lexer=PygmentsLexer(MarkdownLexer),
226            text='empty',
227        )
228
229        # Main content HSplit
230        self.command_runner_content = HSplit(
231            [
232                # Input field and buttons on the same line
233                VSplit(
234                    [
235                        self.input_field,
236                        input_field_buttons_container,
237                    ]
238                ),
239                # Completion items below
240                command_items_window,
241                # Selected item description / help text.
242                ConditionalContainer(
243                    create_border(
244                        self.selected_item_description_text_area,
245                        title=self._snippet_description_pane_title,
246                        border_style='class:command-runner-border',
247                        left_margin_columns=0,
248                        right_margin_columns=0,
249                        bottom=False,
250                        left=False,
251                        right=False,
252                    ),
253                    filter=Condition(
254                        lambda: bool(self.selected_item_description)
255                    ),
256                ),
257            ],
258            style='class:command-runner class:theme-fg-default',
259        )
260
261        # Set completions if passed in.
262        self.set_completions(window_title, load_completions)
263
264        # bordered_content wraps the above command_runner_content in a border.
265        self.bordered_content: AnyContainer
266        # Root prompt_toolkit container
267        self.container = ConditionalContainer(
268            DynamicContainer(lambda: self.bordered_content),
269            filter=Condition(lambda: self.show_dialog),
270        )
271
272    def _snippet_description_pane_title(self) -> StyleAndTextTuples:
273        return [
274            # Left padding
275            ('', '━━ '),
276            # Snippet title in yellow
277            ('class:theme-fg-yellow', self.selected_item_title),
278            # right padding
279            ('', ' '),
280        ]
281
282    def _create_bordered_content(self) -> None:
283        """Wrap self.command_runner_content in a border."""
284        # This should be called whenever the window_title changes.
285        self.bordered_content = create_border(
286            self.command_runner_content,
287            title=self.window_title,
288            border_style='class:command-runner-border',
289            left_margin_columns=1,
290            right_margin_columns=1,
291        )
292
293    def __pt_container__(self) -> AnyContainer:
294        """Return the prompt_toolkit root container for this dialog."""
295        return self.container
296
297    def _create_key_bindings(self) -> KeyBindingsBase:
298        """Create additional key bindings for the command input field."""
299        key_bindings = KeyBindings()
300        register = self.application.prefs.register_keybinding
301
302        @register('command-runner.cancel', key_bindings)
303        def _cancel(_event: KeyPressEvent) -> None:
304            """Clear input or close command."""
305            if self._get_input_field_text() != '':
306                self._reset_selected_item()
307                return
308
309            self.close_dialog()
310
311        @register('command-runner.select-previous-item', key_bindings)
312        def _select_previous_item(_event: KeyPressEvent) -> None:
313            """Select previous completion item."""
314            self._previous_item()
315
316        @register('command-runner.select-next-item', key_bindings)
317        def _select_next_item(_event: KeyPressEvent) -> None:
318            """Select next completion item."""
319            self._next_item()
320
321        return key_bindings
322
323    def content_width(self) -> int:
324        """Return the smaller value of self.width and the available width."""
325        window_manager_width = (
326            self.application.window_manager.current_window_manager_width
327        )
328        if not window_manager_width:
329            window_manager_width = self.width
330        return min(self.width, window_manager_width)
331
332    def focus_self(self) -> None:
333        self.application.layout.focus(self)
334
335    def close_dialog(self) -> None:
336        """Close command runner dialog box."""
337        self.show_dialog = False
338        self._reset_selected_item()
339
340        # Restore original focus if possible.
341        if self.last_focused_pane:
342            self.application.focus_on_container(self.last_focused_pane)
343        else:
344            # Fallback to focusing on the main menu.
345            self.application.focus_main_menu()
346
347    def open_dialog(self) -> None:
348        self.show_dialog = True
349        self.last_focused_pane = self.application.focused_window()
350        self.focus_self()
351        self.application.redraw_ui()
352
353    def set_completions(
354        self,
355        window_title: str | None = None,
356        load_completions: Callable[[], list[CommandRunnerItem]] | None = None,
357    ) -> None:
358        """Set window title and callable to fetch possible completions.
359
360        Call this function whenever new completion items need to be loaded.
361        """
362        self.window_title = window_title if window_title else 'Menu Items'
363        self.load_completions = (
364            load_completions if load_completions else self.load_menu_items
365        )
366        self._reset_selected_item()
367
368        self.completions = []
369        self.completion_fragments = []
370
371        # Load and filter completions
372        self.filter_completions()
373
374        # (Re)create the bordered content with the window_title set.
375        self._create_bordered_content()
376
377    def reload_completions(self) -> None:
378        self.completions = self.load_completions()
379
380    def load_menu_items(self) -> list[CommandRunnerItem]:
381        # pylint: disable=no-self-use
382        return list(flatten_menu_items(self.application.menu_items))
383
384    def _get_input_field_text(self) -> str:
385        return self.input_field.buffer.text
386
387    def _make_regexes(self, input_text) -> list[re.Pattern]:
388        # pylint: disable=no-self-use
389        regexes: list[re.Pattern] = []
390        if not input_text:
391            return regexes
392
393        text_tokens = input_text.split(' ')
394        if len(text_tokens) > 0:
395            regexes = [
396                re.compile(re.escape(text), re.IGNORECASE)
397                for text in text_tokens
398            ]
399
400        return regexes
401
402    def _matches_orderless(self, regexes: list[re.Pattern], text) -> bool:
403        """Check if all supplied regexs match the input text."""
404        # pylint: disable=no-self-use
405        return all(regex.search(text) for regex in regexes)
406
407    def filter_completions(self) -> None:
408        """Filter completion items if new user input detected."""
409        if not self.input_text_changed() and not self.selected_item_changed():
410            return
411
412        self.reload_completions()
413
414        input_text = self._get_input_field_text()
415        self.completion_fragments = []
416
417        regexes = self._make_regexes(input_text)
418        check_match = self._matches_orderless
419
420        i = 0
421        for item in self.completions:
422            title = item.title
423            if not (input_text == '' or check_match(regexes, item.title)):
424                continue
425            style = ''
426            if i == self.selected_item:
427                style = 'class:command-runner-selected-item'
428                self.selected_item_title = title
429                self.selected_item_handler = item.handler
430                self.selected_item_description = item.description
431                if self.selected_item_description:
432                    self.selected_item_description_text_area.buffer.document = (
433                        Document(
434                            text=self.selected_item_description,
435                            cursor_position=0,
436                        )
437                    )
438
439                title = item.title.ljust(self.content_width())
440            fragments: StyleAndTextTuples = highlight_matches(
441                regexes, [(style, title + '\n')]
442            )
443            self.completion_fragments.append(fragments)
444            i += 1
445
446    def input_text_changed(self) -> bool:
447        """Return True if text in the input field has changed."""
448        input_text = self._get_input_field_text()
449        if input_text != self.last_input_field_text:
450            self.last_input_field_text = input_text
451            self.selected_item = 0
452            return True
453        return False
454
455    def selected_item_changed(self) -> bool:
456        """Check if the user pressed up or down to select a different item."""
457        return self.last_selected_item != self.selected_item
458
459    def _next_item(self) -> None:
460        self.last_selected_item = self.selected_item
461        self.selected_item = min(
462            # Don't move past the height of the window or the length of possible
463            # items.
464            min(self.height, len(self.completion_fragments)) - 1,
465            self.selected_item + 1,
466        )
467        self.application.redraw_ui()
468
469    def _previous_item(self) -> None:
470        self.last_selected_item = self.selected_item
471        self.selected_item = max(0, self.selected_item - 1)
472        self.application.redraw_ui()
473
474    def _get_input_field_button_fragments(self) -> StyleAndTextTuples:
475        # Mouse handlers
476        focus = functools.partial(mouse_handlers.on_click, self.focus_self)
477        cancel = functools.partial(mouse_handlers.on_click, self.close_dialog)
478        select_item = functools.partial(
479            mouse_handlers.on_click, self._run_selected_item
480        )
481
482        separator_text = ('', ' ', focus)
483
484        # Default button style
485        button_style = 'class:toolbar-button-inactive'
486
487        fragments: StyleAndTextTuples = []
488
489        # Cancel button
490        fragments.extend(
491            to_keybind_indicator(
492                key='Ctrl-c',
493                description='Cancel',
494                mouse_handler=cancel,
495                base_style=button_style,
496            )
497        )
498        fragments.append(separator_text)
499
500        # Run button
501        fragments.extend(
502            to_keybind_indicator(
503                'Enter', 'Run', select_item, base_style=button_style
504            )
505        )
506        return fragments
507
508    def render_completion_items(self) -> StyleAndTextTuples:
509        """Render completion items."""
510        fragments: StyleAndTextTuples = []
511
512        # Update completions if any state change since the last render (new text
513        # entered or arrow keys pressed).
514        self.filter_completions()
515
516        for completion_item in self.completion_fragments:
517            fragments.extend(completion_item)
518
519        return fragments
520
521    def _reset_selected_item(self) -> None:
522        self.selected_item = 0
523        self.last_selected_item = 0
524        self.selected_item_title = ''
525        self.selected_item_handler = None
526        self.selected_item_description = None
527        self.last_input_field_text = 'EMPTY'
528        self.input_field.buffer.reset()
529
530    def _run_selected_item(self) -> None:
531        """Run the selected action."""
532        if not self.selected_item_handler:
533            return
534        # Save the selected item handler. This is reset by self.close_dialog()
535        handler = self.selected_item_handler
536
537        # Depending on what action is run, the command runner dialog may need to
538        # be closed, left open, or closed before running the selected action.
539        close_dialog = True
540        close_dialog_first = False
541
542        # Actions that launch new command runners, close_dialog should not run.
543        for command_text in [
544            '[File] > Insert Repl Snippet',
545            '[File] > Insert Repl History',
546            '[File] > Open Logger',
547        ]:
548            if command_text in self.selected_item_title:
549                close_dialog = False
550                break
551
552        # Actions that change what is in focus should be run after closing the
553        # command runner dialog.
554        for command_text in [
555            '[File] > Games > ',
556            '[View] > Focus Next Window/Tab',
557            '[View] > Focus Prev Window/Tab',
558            # All help menu entries open popup windows.
559            '[Help] > ',
560            # This focuses on a save dialog bor.
561            'Save/Export a copy',
562            '[Windows] > Floating ',
563        ]:
564            if command_text in self.selected_item_title:
565                close_dialog_first = True
566                break
567
568        # Close first if needed
569        if close_dialog and close_dialog_first:
570            self.close_dialog()
571
572        # Run the selected item handler
573        handler()
574
575        # If not already closed earlier.
576        if close_dialog and not close_dialog_first:
577            self.close_dialog()
578
579    def _command_accept_handler(self, _buff: Buffer) -> bool:
580        """Function run when pressing Enter in the command runner input box."""
581        # If at least one match is available
582        if len(self.completion_fragments) > 0:
583            self._run_selected_item()
584            # Erase input text
585            return False
586        # Keep input text
587        return True
588