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