1# Copyright 2021 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"""SearchToolbar class used by LogPanes.""" 15 16from __future__ import annotations 17import functools 18from typing import TYPE_CHECKING 19 20from prompt_toolkit.buffer import Buffer 21from prompt_toolkit.filters import Condition, has_focus 22from prompt_toolkit.formatted_text import StyleAndTextTuples 23from prompt_toolkit.key_binding import ( 24 KeyBindings, 25 KeyBindingsBase, 26 KeyPressEvent, 27) 28from prompt_toolkit.layout import ( 29 ConditionalContainer, 30 FormattedTextControl, 31 HSplit, 32 VSplit, 33 Window, 34 WindowAlign, 35) 36from prompt_toolkit.widgets import TextArea 37from prompt_toolkit.validation import DynamicValidator 38 39from pw_console.log_view import RegexValidator, SearchMatcher 40from pw_console.widgets import ( 41 mouse_handlers, 42 to_checkbox_with_keybind_indicator, 43 to_keybind_indicator, 44) 45 46if TYPE_CHECKING: 47 from pw_console.log_pane import LogPane 48 49 50class SearchToolbar(ConditionalContainer): 51 """Toolbar for entering search text and viewing match counts.""" 52 53 TOOLBAR_HEIGHT = 2 54 55 def __init__(self, log_pane: LogPane): 56 self.log_pane = log_pane 57 self.log_view = log_pane.log_view 58 self.search_validator = RegexValidator() 59 self._search_successful = False 60 self._search_invert = False 61 self._search_field = None 62 63 self.input_field = TextArea( 64 prompt=[ 65 ( 66 'class:search-bar-setting', 67 '/', 68 functools.partial( 69 mouse_handlers.on_click, 70 self.focus_self, 71 ), 72 ) 73 ], 74 focusable=True, 75 focus_on_click=True, 76 scrollbar=False, 77 multiline=False, 78 height=1, 79 dont_extend_height=True, 80 dont_extend_width=False, 81 accept_handler=self._search_accept_handler, 82 validator=DynamicValidator(self.get_search_matcher), 83 history=self.log_pane.application.search_history, 84 ) 85 86 self.input_field.control.key_bindings = self._create_key_bindings() 87 88 match_count_window = Window( 89 content=FormattedTextControl(self.get_match_count_fragments), 90 height=1, 91 align=WindowAlign.LEFT, 92 dont_extend_width=True, 93 style='class:search-match-count-dialog', 94 ) 95 96 match_buttons_window = Window( 97 content=FormattedTextControl(self.get_button_fragments), 98 height=1, 99 align=WindowAlign.LEFT, 100 dont_extend_width=False, 101 style='class:search-match-count-dialog', 102 ) 103 104 input_field_buttons_window = Window( 105 content=FormattedTextControl(self.get_search_help_fragments), 106 height=1, 107 align=WindowAlign.RIGHT, 108 dont_extend_width=True, 109 ) 110 111 settings_bar_window = Window( 112 content=FormattedTextControl(self.get_search_settings_fragments), 113 height=1, 114 align=WindowAlign.LEFT, 115 dont_extend_width=False, 116 ) 117 118 super().__init__( 119 HSplit( 120 [ 121 # Top row 122 VSplit( 123 [ 124 # Search Settings toggles, only show if the search 125 # input field is in focus. 126 ConditionalContainer( 127 settings_bar_window, 128 filter=has_focus(self.input_field), 129 ), 130 # Match count numbers and buttons, only show if the 131 # search input is NOT in focus. 132 # pylint: disable=invalid-unary-operand-type 133 ConditionalContainer( 134 match_count_window, 135 filter=~has_focus(self.input_field), 136 ), 137 ConditionalContainer( 138 match_buttons_window, 139 filter=~has_focus(self.input_field), 140 ), 141 # pylint: enable=invalid-unary-operand-type 142 ] 143 ), 144 # Bottom row 145 VSplit( 146 [ 147 self.input_field, 148 ConditionalContainer( 149 input_field_buttons_window, 150 filter=has_focus(self), 151 ), 152 ] 153 ), 154 ], 155 height=SearchToolbar.TOOLBAR_HEIGHT, 156 style='class:search-bar', 157 ), 158 filter=Condition(lambda: log_pane.search_bar_active), 159 ) 160 161 def _create_key_bindings(self) -> KeyBindingsBase: 162 """Create additional key bindings for the search input.""" 163 # Clear filter keybind is handled by the parent log_pane. 164 165 key_bindings = KeyBindings() 166 register = self.log_pane.application.prefs.register_keybinding 167 168 @register('search-toolbar.cancel', key_bindings) 169 def _close_search_bar(_event: KeyPressEvent) -> None: 170 """Close search bar.""" 171 self.cancel_search() 172 173 @register('search-toolbar.toggle-matcher', key_bindings) 174 def _select_next_search_matcher(_event: KeyPressEvent) -> None: 175 """Select the next search matcher.""" 176 self.log_pane.log_view.select_next_search_matcher() 177 178 @register('search-toolbar.create-filter', key_bindings) 179 def _create_filter(_event: KeyPressEvent) -> None: 180 """Create a filter.""" 181 self.create_filter() 182 183 @register('search-toolbar.toggle-invert', key_bindings) 184 def _toggle_search_invert(_event: KeyPressEvent) -> None: 185 """Toggle inverted search matching.""" 186 self._invert_search() 187 188 @register('search-toolbar.toggle-column', key_bindings) 189 def _select_next_field(_event: KeyPressEvent) -> None: 190 """Select next search field/column.""" 191 self._next_field() 192 193 return key_bindings 194 195 def focus_self(self) -> None: 196 self.log_pane.application.application.layout.focus(self) 197 198 def focus_log_pane(self) -> None: 199 self.log_pane.application.focus_on_container(self.log_pane) 200 201 def _create_filter(self) -> None: 202 self.input_field.buffer.reset() 203 self.close_search_bar() 204 self.log_view.apply_filter() 205 206 def _next_match(self) -> None: 207 self.log_view.search_forwards() 208 209 def _previous_match(self) -> None: 210 self.log_view.search_backwards() 211 212 def cancel_search(self) -> None: 213 self.input_field.buffer.reset() 214 self.close_search_bar() 215 self.log_view.clear_search() 216 217 def close_search_bar(self) -> None: 218 """Close search bar.""" 219 # Reset invert setting for the next search 220 self._search_invert = False 221 self.log_view.follow_search_match = False 222 # Hide the search bar 223 self.log_pane.search_bar_active = False 224 # Focus on the log_pane. 225 self.log_pane.application.focus_on_container(self.log_pane) 226 self.log_pane.redraw_ui() 227 228 def _start_search(self) -> None: 229 self.input_field.buffer.validate_and_handle() 230 231 def _invert_search(self) -> None: 232 self._search_invert = not self._search_invert 233 234 def _toggle_search_follow(self) -> None: 235 self.log_view.follow_search_match = ( 236 not self.log_view.follow_search_match 237 ) 238 # If automatically jumping to the next search match, disable normal 239 # follow mode. 240 if self.log_view.follow_search_match: 241 self.log_view.follow = False 242 243 def _next_field(self) -> None: 244 fields = self.log_pane.log_view.log_store.table.all_column_names() 245 fields.append(None) 246 current_index = fields.index(self._search_field) 247 next_index = (current_index + 1) % len(fields) 248 self._search_field = fields[next_index] 249 250 def create_filter(self) -> None: 251 self._start_search() 252 if self._search_successful: 253 self.log_pane.log_view.apply_filter() 254 255 def _search_accept_handler(self, buff: Buffer) -> bool: 256 """Function run when hitting Enter in the search bar.""" 257 self._search_successful = False 258 if len(buff.text) == 0: 259 self.close_search_bar() 260 # Don't apply an empty search. 261 return False 262 263 if self.log_pane.log_view.new_search( 264 buff.text, invert=self._search_invert, field=self._search_field 265 ): 266 self._search_successful = True 267 268 # Don't close the search bar, instead focus on the log content. 269 self.log_pane.application.focus_on_container( 270 self.log_pane.log_display_window 271 ) 272 # Keep existing search text. 273 return True 274 275 # Keep existing text if regex error 276 return True 277 278 def get_search_help_fragments(self): 279 """Return FormattedText with search general help keybinds.""" 280 focus = functools.partial(mouse_handlers.on_click, self.focus_self) 281 start_search = functools.partial( 282 mouse_handlers.on_click, self._start_search 283 ) 284 close_search = functools.partial( 285 mouse_handlers.on_click, self.cancel_search 286 ) 287 288 # Search toolbar is darker than pane toolbars, use the darker button 289 # style here. 290 button_style = 'class:toolbar-button-inactive' 291 292 separator_text = [('', ' ', focus)] 293 294 # Empty text matching the width of the search bar title. 295 fragments = [ 296 ('', ' ', focus), 297 ] 298 fragments.extend(separator_text) 299 300 fragments.extend( 301 to_keybind_indicator( 302 'Enter', 'Search', start_search, base_style=button_style 303 ) 304 ) 305 fragments.extend(separator_text) 306 307 fragments.extend( 308 to_keybind_indicator( 309 'Ctrl-c', 'Cancel', close_search, base_style=button_style 310 ) 311 ) 312 313 return fragments 314 315 def get_search_settings_fragments(self): 316 """Return FormattedText with current search settings and keybinds.""" 317 focus = functools.partial(mouse_handlers.on_click, self.focus_self) 318 next_field = functools.partial( 319 mouse_handlers.on_click, self._next_field 320 ) 321 toggle_invert = functools.partial( 322 mouse_handlers.on_click, self._invert_search 323 ) 324 next_matcher = functools.partial( 325 mouse_handlers.on_click, 326 self.log_pane.log_view.select_next_search_matcher, 327 ) 328 329 separator_text = [('', ' ', focus)] 330 331 # Search toolbar is darker than pane toolbars, use the darker button 332 # style here. 333 button_style = 'class:toolbar-button-inactive' 334 335 fragments = [ 336 # Title 337 ('class:search-bar-title', ' Search ', focus), 338 ] 339 fragments.extend(separator_text) 340 341 selected_column_text = [ 342 ( 343 button_style + ' class:search-bar-setting', 344 (self._search_field.title() if self._search_field else 'All'), 345 next_field, 346 ), 347 ] 348 fragments.extend( 349 to_keybind_indicator( 350 'Ctrl-t', 351 'Column:', 352 next_field, 353 middle_fragments=selected_column_text, 354 base_style=button_style, 355 ) 356 ) 357 fragments.extend(separator_text) 358 359 fragments.extend( 360 to_checkbox_with_keybind_indicator( 361 self._search_invert, 362 'Ctrl-v', 363 'Invert', 364 toggle_invert, 365 base_style=button_style, 366 ) 367 ) 368 fragments.extend(separator_text) 369 370 # Matching Method 371 current_matcher_text = [ 372 ( 373 button_style + ' class:search-bar-setting', 374 str(self.log_pane.log_view.search_matcher.name), 375 next_matcher, 376 ) 377 ] 378 fragments.extend( 379 to_keybind_indicator( 380 'Ctrl-n', 381 'Matcher:', 382 next_matcher, 383 middle_fragments=current_matcher_text, 384 base_style=button_style, 385 ) 386 ) 387 fragments.extend(separator_text) 388 389 return fragments 390 391 def get_search_matcher(self): 392 if self.log_pane.log_view.search_matcher == SearchMatcher.REGEX: 393 return self.log_pane.log_view.search_validator 394 return False 395 396 def get_match_count_fragments(self): 397 """Return formatted text for the match count indicator.""" 398 focus = functools.partial(mouse_handlers.on_click, self.focus_log_pane) 399 two_spaces = ('', ' ', focus) 400 401 # Check if this line is a search match 402 match_number = self.log_view.search_matched_lines.get( 403 self.log_view.log_index, -1 404 ) 405 406 # If valid, increment the zero indexed value by one for better human 407 # readability. 408 if match_number >= 0: 409 match_number += 1 410 # If no match, mark as zero 411 else: 412 match_number = 0 413 414 return [ 415 ('class:search-match-count-dialog-title', ' Match ', focus), 416 ( 417 '', 418 '{} / {}'.format( 419 match_number, len(self.log_view.search_matched_lines) 420 ), 421 focus, 422 ), 423 two_spaces, 424 ] 425 426 def get_button_fragments(self) -> StyleAndTextTuples: 427 """Return formatted text for the action buttons.""" 428 focus = functools.partial(mouse_handlers.on_click, self.focus_log_pane) 429 430 one_space = ('', ' ', focus) 431 two_spaces = ('', ' ', focus) 432 cancel = functools.partial(mouse_handlers.on_click, self.cancel_search) 433 create_filter = functools.partial( 434 mouse_handlers.on_click, self._create_filter 435 ) 436 next_match = functools.partial( 437 mouse_handlers.on_click, self._next_match 438 ) 439 previous_match = functools.partial( 440 mouse_handlers.on_click, self._previous_match 441 ) 442 toggle_search_follow = functools.partial( 443 mouse_handlers.on_click, 444 self._toggle_search_follow, 445 ) 446 447 button_style = 'class:toolbar-button-inactive' 448 449 fragments = [] 450 fragments.extend( 451 to_keybind_indicator( 452 key='n', 453 description='Next', 454 mouse_handler=next_match, 455 base_style=button_style, 456 ) 457 ) 458 fragments.append(two_spaces) 459 460 fragments.extend( 461 to_keybind_indicator( 462 key='N', 463 description='Previous', 464 mouse_handler=previous_match, 465 base_style=button_style, 466 ) 467 ) 468 fragments.append(two_spaces) 469 470 fragments.extend( 471 to_keybind_indicator( 472 key='Ctrl-c', 473 description='Cancel', 474 mouse_handler=cancel, 475 base_style=button_style, 476 ) 477 ) 478 fragments.append(two_spaces) 479 480 fragments.extend( 481 to_keybind_indicator( 482 key='Ctrl-Alt-f', 483 description='Add Filter', 484 mouse_handler=create_filter, 485 base_style=button_style, 486 ) 487 ) 488 fragments.append(two_spaces) 489 490 fragments.extend( 491 to_checkbox_with_keybind_indicator( 492 checked=self.log_view.follow_search_match, 493 key='', 494 description='Jump to new matches', 495 mouse_handler=toggle_search_follow, 496 base_style=button_style, 497 ) 498 ) 499 fragments.append(one_space) 500 501 return fragments 502