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