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