xref: /aosp_15_r20/external/pigweed/pw_console/py/pw_console/log_pane_saveas_dialog.py (revision 61c4878ac05f98d0ceed94b57d316916de578985)
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"""LogPane Save As Dialog."""
15
16from __future__ import annotations
17import functools
18from pathlib import Path
19from typing import TYPE_CHECKING
20
21from prompt_toolkit.buffer import Buffer
22from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
23from prompt_toolkit.completion import PathCompleter
24from prompt_toolkit.filters import Condition
25from prompt_toolkit.history import InMemoryHistory
26from prompt_toolkit.layout import (
27    ConditionalContainer,
28    FormattedTextControl,
29    HSplit,
30    Window,
31    WindowAlign,
32)
33from prompt_toolkit.widgets import TextArea
34from prompt_toolkit.validation import (
35    ValidationError,
36    Validator,
37)
38
39from pw_console.widgets import (
40    create_border,
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 PathValidator(Validator):
51    """Validation of file path input."""
52
53    def validate(self, document):
54        """Check input path leads to a valid parent directory."""
55        target_path = Path(document.text).expanduser()
56
57        if not target_path.parent.exists():
58            raise ValidationError(
59                # Set cursor position to the end
60                len(document.text),
61                "Directory doesn't exist: %s" % document.text,
62            )
63
64        if target_path.is_dir():
65            raise ValidationError(
66                # Set cursor position to the end
67                len(document.text),
68                "File input is an existing directory: %s" % document.text,
69            )
70
71
72class LogPaneSaveAsDialog(ConditionalContainer):
73    """Dialog box for saving logs to a file."""
74
75    # Height of the dialog box contens in lines of text.
76    DIALOG_HEIGHT = 3
77
78    def __init__(self, log_pane: LogPane):
79        self.log_pane = log_pane
80
81        self.path_validator = PathValidator()
82
83        self._export_with_table_formatting: bool = True
84        self._export_with_selected_lines_only: bool = False
85
86        self.starting_file_path: str = str(Path.cwd())
87
88        self.input_field = TextArea(
89            prompt=[
90                (
91                    'class:saveas-dialog-setting',
92                    'File: ',
93                    functools.partial(
94                        mouse_handlers.on_click,
95                        self.focus_self,
96                    ),
97                )
98            ],
99            # Pre-fill the current working directory.
100            text=self.starting_file_path,
101            focusable=True,
102            focus_on_click=True,
103            scrollbar=False,
104            multiline=False,
105            height=1,
106            dont_extend_height=True,
107            dont_extend_width=False,
108            accept_handler=self._saveas_accept_handler,
109            validator=self.path_validator,
110            history=InMemoryHistory(),
111            completer=PathCompleter(expanduser=True),
112        )
113
114        self.input_field.buffer.cursor_position = len(self.starting_file_path)
115
116        settings_bar_control = FormattedTextControl(self.get_settings_fragments)
117        settings_bar_window = Window(
118            content=settings_bar_control,
119            height=1,
120            align=WindowAlign.LEFT,
121            dont_extend_width=False,
122        )
123
124        action_bar_control = FormattedTextControl(self.get_action_fragments)
125        action_bar_window = Window(
126            content=action_bar_control,
127            height=1,
128            align=WindowAlign.RIGHT,
129            dont_extend_width=False,
130        )
131
132        # Add additional keybindings for the input_field text area.
133        key_bindings = KeyBindings()
134        register = self.log_pane.application.prefs.register_keybinding
135
136        @register('save-as-dialog.cancel', key_bindings)
137        def _close_saveas_dialog(_event: KeyPressEvent) -> None:
138            """Close save as dialog."""
139            self.close_dialog()
140
141        self.input_field.control.key_bindings = key_bindings
142
143        super().__init__(
144            create_border(
145                HSplit(
146                    [
147                        settings_bar_window,
148                        self.input_field,
149                        action_bar_window,
150                    ],
151                    height=LogPaneSaveAsDialog.DIALOG_HEIGHT,
152                    style='class:saveas-dialog',
153                ),
154                LogPaneSaveAsDialog.DIALOG_HEIGHT,
155                border_style='class:saveas-dialog-border',
156                left_margin_columns=1,
157            ),
158            filter=Condition(lambda: self.log_pane.saveas_dialog_active),
159        )
160
161    def focus_self(self):
162        self.log_pane.application.application.layout.focus(self)
163
164    def close_dialog(self):
165        """Close this dialog."""
166        self.log_pane.saveas_dialog_active = False
167        self.log_pane.application.focus_on_container(self.log_pane)
168        self.log_pane.redraw_ui()
169
170    def _toggle_table_formatting(self):
171        self._export_with_table_formatting = (
172            not self._export_with_table_formatting
173        )
174
175    def _toggle_selected_lines(self):
176        self._export_with_selected_lines_only = (
177            not self._export_with_selected_lines_only
178        )
179
180    def set_export_options(
181        self,
182        table_format: bool | None = None,
183        selected_lines_only: bool | None = None,
184    ) -> None:
185        # Allows external callers such as the line selection dialog to set
186        # export format options.
187        if table_format is not None:
188            self._export_with_table_formatting = table_format
189
190        if selected_lines_only is not None:
191            self._export_with_selected_lines_only = selected_lines_only
192
193    def save_action(self):
194        """Trigger save file execution on mouse click.
195
196        This ultimately runs LogPaneSaveAsDialog._saveas_accept_handler()."""
197        self.input_field.buffer.validate_and_handle()
198
199    def _saveas_accept_handler(self, buff: Buffer) -> bool:
200        """Function run when hitting Enter in the input_field."""
201        input_text = buff.text
202        if len(input_text) == 0:
203            self.close_dialog()
204            # Don't save anything if empty input.
205            return False
206
207        if self.log_pane.log_view.export_logs(
208            file_name=input_text,
209            use_table_formatting=self._export_with_table_formatting,
210            selected_lines_only=self._export_with_selected_lines_only,
211        ):
212            self.close_dialog()
213            # Reset selected_lines_only
214            self.set_export_options(selected_lines_only=False)
215            # Erase existing input text.
216            return False
217
218        # Keep existing text if error
219        return True
220
221    def get_settings_fragments(self):
222        """Return FormattedText with current save settings."""
223        # Mouse handlers
224        focus = functools.partial(mouse_handlers.on_click, self.focus_self)
225        toggle_table_formatting = functools.partial(
226            mouse_handlers.on_click,
227            self._toggle_table_formatting,
228        )
229        toggle_selected_lines = functools.partial(
230            mouse_handlers.on_click,
231            self._toggle_selected_lines,
232        )
233
234        # Separator should have the focus mouse handler so clicking on any
235        # whitespace focuses the input field.
236        separator_text = ('', '  ', focus)
237
238        # Default button style
239        button_style = 'class:toolbar-button-inactive'
240
241        fragments = [('class:saveas-dialog-title', 'Save as File', focus)]
242        fragments.append(separator_text)
243
244        # Table checkbox
245        fragments.extend(
246            to_checkbox_with_keybind_indicator(
247                checked=self._export_with_table_formatting,
248                key='',  # No key shortcut help text
249                description='Table Formatting',
250                mouse_handler=toggle_table_formatting,
251                base_style=button_style,
252            )
253        )
254
255        # Two space separator
256        fragments.append(separator_text)
257
258        # Selected lines checkbox
259        fragments.extend(
260            to_checkbox_with_keybind_indicator(
261                checked=self._export_with_selected_lines_only,
262                key='',  # No key shortcut help text
263                description='Selected Lines Only',
264                mouse_handler=toggle_selected_lines,
265                base_style=button_style,
266            )
267        )
268
269        # Two space separator
270        fragments.append(separator_text)
271
272        return fragments
273
274    def get_action_fragments(self):
275        """Return FormattedText with the save action buttons."""
276        # Mouse handlers
277        focus = functools.partial(mouse_handlers.on_click, self.focus_self)
278        cancel = functools.partial(mouse_handlers.on_click, self.close_dialog)
279        save = functools.partial(mouse_handlers.on_click, self.save_action)
280
281        # Separator should have the focus mouse handler so clicking on any
282        # whitespace focuses the input field.
283        separator_text = ('', '  ', focus)
284
285        # Default button style
286        button_style = 'class:toolbar-button-inactive'
287
288        fragments = [separator_text]
289        # Cancel button
290        fragments.extend(
291            to_keybind_indicator(
292                key='Ctrl-c',
293                description='Cancel',
294                mouse_handler=cancel,
295                base_style=button_style,
296            )
297        )
298
299        # Two space separator
300        fragments.append(separator_text)
301
302        # Save button
303        fragments.extend(
304            to_keybind_indicator(
305                key='Enter',
306                description='Save',
307                mouse_handler=save,
308                base_style=button_style,
309            )
310        )
311
312        # One space separator
313        fragments.append(('', ' ', focus))
314
315        return fragments
316