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