| # Copyright 2022 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """CommandRunner dialog classes.""" |
| |
| from __future__ import annotations |
| import functools |
| import logging |
| import re |
| from typing import ( |
| Callable, |
| Iterable, |
| Iterator, |
| List, |
| Optional, |
| TYPE_CHECKING, |
| Tuple, |
| ) |
| |
| from prompt_toolkit.buffer import Buffer |
| from prompt_toolkit.filters import Condition |
| from prompt_toolkit.formatted_text import StyleAndTextTuples |
| from prompt_toolkit.formatted_text.utils import fragment_list_to_text |
| from prompt_toolkit.layout.utils import explode_text_fragments |
| from prompt_toolkit.history import InMemoryHistory |
| from prompt_toolkit.key_binding import ( |
| KeyBindings, |
| KeyBindingsBase, |
| KeyPressEvent, |
| ) |
| from prompt_toolkit.layout import ( |
| AnyContainer, |
| ConditionalContainer, |
| DynamicContainer, |
| FormattedTextControl, |
| HSplit, |
| VSplit, |
| Window, |
| WindowAlign, |
| ) |
| from prompt_toolkit.widgets import MenuItem |
| from prompt_toolkit.widgets import TextArea |
| |
| import pw_console.widgets.border |
| import pw_console.widgets.checkbox |
| import pw_console.widgets.mouse_handlers |
| |
| if TYPE_CHECKING: |
| from pw_console.console_app import ConsoleApp |
| |
| _LOG = logging.getLogger(__package__) |
| |
| |
| def flatten_menu_items(items: List[MenuItem], |
| prefix: str = '') -> Iterator[Tuple[str, Callable]]: |
| """Flatten nested prompt_toolkit MenuItems into text and callable tuples.""" |
| for item in items: |
| new_text = [] |
| if prefix: |
| new_text.append(prefix) |
| new_text.append(item.text) |
| new_prefix = ' > '.join(new_text) |
| |
| if item.children: |
| yield from flatten_menu_items(item.children, new_prefix) |
| elif item.handler: |
| # Skip this item if it's a separator or disabled. |
| if item.text == '-' or item.disabled: |
| continue |
| yield (new_prefix, item.handler) |
| |
| |
| def highlight_matches( |
| regexes: Iterable[re.Pattern], |
| line_fragments: StyleAndTextTuples) -> StyleAndTextTuples: |
| """Highlight regex matches in prompt_toolkit FormattedTextTuples.""" |
| line_text = fragment_list_to_text(line_fragments) |
| exploded_fragments = explode_text_fragments(line_fragments) |
| |
| def apply_highlighting(fragments: StyleAndTextTuples, |
| index: int, |
| matching_regex_index: int = 0) -> None: |
| # Expand all fragments and apply the highlighting style. |
| old_style, _text, *_ = fragments[index] |
| # There are 6 fuzzy-highlight styles defined in style.py. Get an index |
| # from 0-5 to use one style after the other in turn. |
| style_index = matching_regex_index % 6 |
| fragments[index] = ( |
| old_style + |
| f' class:command-runner-fuzzy-highlight-{style_index} ', |
| fragments[index][1], |
| ) |
| |
| # Highlight each non-overlapping search match. |
| for regex_i, regex in enumerate(regexes): |
| for match in regex.finditer(line_text): |
| for fragment_i in range(match.start(), match.end()): |
| apply_highlighting(exploded_fragments, fragment_i, regex_i) |
| |
| return exploded_fragments |
| |
| |
| class CommandRunner: |
| """CommandRunner dialog box.""" |
| |
| # pylint: disable=too-many-instance-attributes |
| |
| def __init__( |
| self, |
| application: ConsoleApp, |
| window_title: str = None, |
| load_completions: Optional[Callable[[], |
| List[Tuple[str, |
| Callable]]]] = None, |
| width: int = 80, |
| height: int = 10): |
| # Parent pw_console application |
| self.application = application |
| # Visibility toggle |
| self.show_dialog = False |
| # Tracks the last focused container, to enable restoring focus after |
| # closing the dialog. |
| self.last_focused_pane = None |
| |
| # List of all possible completion items |
| self.completions: List[Tuple[str, Callable]] = [] |
| # Formatted text fragments of matched items |
| self.completion_fragments: List[StyleAndTextTuples] = [] |
| |
| # Current selected item tracking variables |
| self.selected_item: int = 0 |
| self.selected_item_text: str = '' |
| self.selected_item_handler: Optional[Callable] = None |
| # Previous input text |
| self.last_input_field_text: str = 'EMPTY' |
| # Previous selected item |
| self.last_selected_item: int = 0 |
| |
| # Dialog width, height and title |
| self.width = width |
| self.height = height |
| self.window_title: str |
| |
| # Callable to fetch completion items |
| self.load_completions: Callable[[], List[Tuple[str, Callable]]] |
| |
| # Command runner text input field |
| self.input_field = TextArea( |
| prompt=[ |
| ('class:command-runner-setting', '> ', |
| functools.partial(pw_console.widgets.mouse_handlers.on_click, |
| self.focus_self)) |
| ], |
| focusable=True, |
| focus_on_click=True, |
| scrollbar=False, |
| multiline=False, |
| height=1, |
| dont_extend_height=True, |
| dont_extend_width=False, |
| accept_handler=self._command_accept_handler, |
| history=InMemoryHistory(), |
| ) |
| # Set additional keybindings for the input field |
| self.input_field.control.key_bindings = self._create_key_bindings() |
| |
| # Container for the Cancel and Run buttons |
| input_field_buttons_container = ConditionalContainer( |
| Window( |
| content=FormattedTextControl( |
| self._get_input_field_button_fragments, |
| focusable=False, |
| show_cursor=False, |
| ), |
| height=1, |
| align=WindowAlign.RIGHT, |
| dont_extend_width=True, |
| ), |
| filter=Condition(lambda: self.content_width() > 40), |
| ) |
| |
| # Container for completion matches |
| command_items_window = Window( |
| content=FormattedTextControl( |
| self.render_completion_items, |
| show_cursor=False, |
| focusable=False, |
| ), |
| align=WindowAlign.LEFT, |
| dont_extend_width=False, |
| height=self.height, |
| ) |
| |
| # Main content HSplit |
| self.command_runner_content = HSplit( |
| [ |
| # Input field and buttons on the same line |
| VSplit([ |
| self.input_field, |
| input_field_buttons_container, |
| ]), |
| # Completion items below |
| command_items_window, |
| ], |
| style='class:command-runner class:theme-fg-default', |
| ) |
| |
| # Set completions if passed in. |
| self.set_completions(window_title, load_completions) |
| |
| # bordered_content wraps the above command_runner_content in a border. |
| self.bordered_content: AnyContainer |
| # Root prompt_toolkit container |
| self.container = ConditionalContainer( |
| DynamicContainer(lambda: self.bordered_content), |
| filter=Condition(lambda: self.show_dialog), |
| ) |
| |
| def _create_bordered_content(self) -> None: |
| """Wrap self.command_runner_content in a border.""" |
| # This should be called whenever the window_title changes. |
| self.bordered_content = pw_console.widgets.border.create_border( |
| self.command_runner_content, |
| title=self.window_title, |
| border_style='class:command-runner-border', |
| left_margin_columns=1, |
| right_margin_columns=1, |
| ) |
| |
| def __pt_container__(self) -> AnyContainer: |
| """Return the prompt_toolkit root container for this dialog.""" |
| return self.container |
| |
| def _create_key_bindings(self) -> KeyBindingsBase: |
| """Create additional key bindings for the command input field.""" |
| key_bindings = KeyBindings() |
| register = self.application.prefs.register_keybinding |
| |
| @register('command-runner.cancel', key_bindings) |
| def _cancel(_event: KeyPressEvent) -> None: |
| """Clear input or close command.""" |
| if self._get_input_field_text() != '': |
| self._reset_selected_item() |
| return |
| |
| self.close_dialog() |
| |
| @register('command-runner.select-previous-item', key_bindings) |
| def _select_previous_item(_event: KeyPressEvent) -> None: |
| """Select previous completion item.""" |
| self._previous_item() |
| |
| @register('command-runner.select-next-item', key_bindings) |
| def _select_next_item(_event: KeyPressEvent) -> None: |
| """Select next completion item.""" |
| self._next_item() |
| |
| return key_bindings |
| |
| def content_width(self) -> int: |
| """Return the smaller value of self.width and the available width.""" |
| window_manager_width = ( |
| self.application.window_manager.current_window_manager_width) |
| if not window_manager_width: |
| window_manager_width = self.width |
| return min(self.width, window_manager_width) |
| |
| def focus_self(self) -> None: |
| self.application.layout.focus(self) |
| |
| def close_dialog(self) -> None: |
| """Close command runner dialog box.""" |
| self.show_dialog = False |
| self._reset_selected_item() |
| |
| # Restore original focus if possible. |
| if self.last_focused_pane: |
| self.application.focus_on_container(self.last_focused_pane) |
| else: |
| # Fallback to focusing on the main menu. |
| self.application.focus_main_menu() |
| |
| def open_dialog(self) -> None: |
| self.show_dialog = True |
| self.last_focused_pane = self.application.focused_window() |
| self.focus_self() |
| self.application.redraw_ui() |
| |
| def set_completions( |
| self, |
| window_title: str = None, |
| load_completions: Optional[Callable[[], List[Tuple[str, |
| Callable]]]] = None, |
| ) -> None: |
| """Set window title and callable to fetch possible completions. |
| |
| Call this function whenever new completion items need to be loaded. |
| """ |
| self.window_title = window_title if window_title else 'Menu Items' |
| self.load_completions = (load_completions |
| if load_completions else self.load_menu_items) |
| self._reset_selected_item() |
| |
| self.completions = [] |
| self.completion_fragments = [] |
| |
| # Load and filter completions |
| self.filter_completions() |
| |
| # (Re)create the bordered content with the window_title set. |
| self._create_bordered_content() |
| |
| def reload_completions(self) -> None: |
| self.completions = self.load_completions() |
| |
| def load_menu_items(self) -> List[Tuple[str, Callable]]: |
| # pylint: disable=no-self-use |
| return list(flatten_menu_items(self.application.menu_items)) |
| |
| def _get_input_field_text(self) -> str: |
| return self.input_field.buffer.text |
| |
| def _make_regexes(self, input_text) -> List[re.Pattern]: |
| # pylint: disable=no-self-use |
| regexes: List[re.Pattern] = [] |
| if not input_text: |
| return regexes |
| |
| text_tokens = input_text.split(' ') |
| if len(text_tokens) > 0: |
| regexes = [ |
| re.compile(re.escape(text), re.IGNORECASE) |
| for text in text_tokens |
| ] |
| |
| return regexes |
| |
| def _matches_orderless(self, regexes: List[re.Pattern], text) -> bool: |
| """Check if all supplied regexs match the input text.""" |
| # pylint: disable=no-self-use |
| return all(regex.search(text) for regex in regexes) |
| |
| def filter_completions(self) -> None: |
| """Filter completion items if new user input detected.""" |
| if not self.input_text_changed() and not self.selected_item_changed(): |
| return |
| |
| self.reload_completions() |
| |
| input_text = self._get_input_field_text() |
| self.completion_fragments = [] |
| |
| regexes = self._make_regexes(input_text) |
| check_match = self._matches_orderless |
| |
| i = 0 |
| for text, handler in self.completions: |
| if not (input_text == '' or check_match(regexes, text)): |
| continue |
| style = '' |
| if i == self.selected_item: |
| style = 'class:command-runner-selected-item' |
| self.selected_item_text = text |
| self.selected_item_handler = handler |
| text = text.ljust(self.content_width()) |
| fragments: StyleAndTextTuples = highlight_matches( |
| regexes, [(style, text + '\n')]) |
| self.completion_fragments.append(fragments) |
| i += 1 |
| |
| def input_text_changed(self) -> bool: |
| """Return True if text in the input field has changed.""" |
| input_text = self._get_input_field_text() |
| if input_text != self.last_input_field_text: |
| self.last_input_field_text = input_text |
| self.selected_item = 0 |
| return True |
| return False |
| |
| def selected_item_changed(self) -> bool: |
| """Check if the user pressed up or down to select a different item.""" |
| return self.last_selected_item != self.selected_item |
| |
| def _next_item(self) -> None: |
| self.last_selected_item = self.selected_item |
| self.selected_item = min( |
| # Don't move past the height of the window or the length of possible |
| # items. |
| min(self.height, len(self.completion_fragments)) - 1, |
| self.selected_item + 1) |
| self.application.redraw_ui() |
| |
| def _previous_item(self) -> None: |
| self.last_selected_item = self.selected_item |
| self.selected_item = max(0, self.selected_item - 1) |
| self.application.redraw_ui() |
| |
| def _get_input_field_button_fragments(self) -> StyleAndTextTuples: |
| # Mouse handlers |
| focus = functools.partial(pw_console.widgets.mouse_handlers.on_click, |
| self.focus_self) |
| cancel = functools.partial(pw_console.widgets.mouse_handlers.on_click, |
| self.close_dialog) |
| select_item = functools.partial( |
| pw_console.widgets.mouse_handlers.on_click, |
| self._run_selected_item) |
| |
| separator_text = ('', ' ', focus) |
| |
| # Default button style |
| button_style = 'class:toolbar-button-inactive' |
| |
| fragments: StyleAndTextTuples = [] |
| |
| # Cancel button |
| fragments.extend( |
| pw_console.widgets.checkbox.to_keybind_indicator( |
| key='Ctrl-c', |
| description='Cancel', |
| mouse_handler=cancel, |
| base_style=button_style, |
| )) |
| fragments.append(separator_text) |
| |
| # Run button |
| fragments.extend( |
| pw_console.widgets.checkbox.to_keybind_indicator( |
| 'Enter', 'Run', select_item, base_style=button_style)) |
| return fragments |
| |
| def render_completion_items(self) -> StyleAndTextTuples: |
| """Render completion items.""" |
| fragments: StyleAndTextTuples = [] |
| |
| # Update completions if any state change since the last render (new text |
| # entered or arrow keys pressed). |
| self.filter_completions() |
| |
| for completion_item in self.completion_fragments: |
| fragments.extend(completion_item) |
| |
| return fragments |
| |
| def _reset_selected_item(self) -> None: |
| self.selected_item = 0 |
| self.last_selected_item = 0 |
| self.selected_item_text = '' |
| self.selected_item_handler = None |
| self.last_input_field_text = 'EMPTY' |
| self.input_field.buffer.reset() |
| |
| def _run_selected_item(self) -> None: |
| """Run the selected action.""" |
| if not self.selected_item_handler: |
| return |
| # Save the selected item handler. This is reset by self.close_dialog() |
| handler = self.selected_item_handler |
| |
| # Depending on what action is run, the command runner dialog may need to |
| # be closed, left open, or closed before running the selected action. |
| close_dialog = True |
| close_dialog_first = False |
| |
| # Actions that launch new command runners, close_dialog should not run. |
| for command_text in [ |
| '[File] > Open Logger', |
| ]: |
| if command_text in self.selected_item_text: |
| close_dialog = False |
| break |
| |
| # Actions that change what is in focus should be run after closing the |
| # command runner dialog. |
| for command_text in [ |
| '[View] > Focus Next Window/Tab', |
| '[View] > Focus Prev Window/Tab', |
| # All help menu entries open popup windows. |
| '[Help] > ', |
| # This focuses on a save dialog bor. |
| 'Save/Export a copy', |
| ]: |
| if command_text in self.selected_item_text: |
| close_dialog_first = True |
| break |
| |
| # Close first if needed |
| if close_dialog and close_dialog_first: |
| self.close_dialog() |
| |
| # Run the selected item handler |
| handler() |
| |
| # If not already closed earlier. |
| if close_dialog and not close_dialog_first: |
| self.close_dialog() |
| |
| def _command_accept_handler(self, _buff: Buffer) -> bool: |
| """Function run when pressing Enter in the command runner input box.""" |
| # If at least one match is available |
| if len(self.completion_fragments) > 0: |
| self._run_selected_item() |
| # Erase input text |
| return False |
| # Keep input text |
| return True |