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