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