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