xref: /aosp_15_r20/external/pigweed/pw_console/py/pw_console/search_toolbar.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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