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"""pw_console preferences""" 15 16import os 17from pathlib import Path 18from typing import Dict, Callable, List, Tuple, Union 19 20from prompt_toolkit.key_binding import KeyBindings 21import yaml 22 23from pw_cli.yaml_config_loader_mixin import YamlConfigLoaderMixin 24 25from pw_console.style import get_theme_colors, generate_styles 26from pw_console.key_bindings import DEFAULT_KEY_BINDINGS 27 28_DEFAULT_REPL_HISTORY: Path = Path.home() / '.pw_console_history' 29_DEFAULT_SEARCH_HISTORY: Path = Path.home() / '.pw_console_search' 30 31_DEFAULT_CONFIG = { 32 # History files 33 'repl_history': _DEFAULT_REPL_HISTORY, 34 'search_history': _DEFAULT_SEARCH_HISTORY, 35 # Appearance 36 'ui_theme': 'dark', 37 'code_theme': 'pigweed-code', 38 'swap_light_and_dark': False, 39 'spaces_between_columns': 2, 40 'column_order_omit_unspecified_columns': False, 41 'column_order': [], 42 'column_colors': {}, 43 'show_python_file': False, 44 'show_python_logger': False, 45 'show_source_file': False, 46 'hide_date_from_log_time': False, 47 # Window arrangement 48 'windows': {}, 49 'window_column_split_method': 'vertical', 50 'command_runner': { 51 'width': 80, 52 'height': 10, 53 'position': {'top': 3}, 54 }, 55 'key_bindings': DEFAULT_KEY_BINDINGS, 56 'snippets': {}, 57 'user_snippets': {}, 58} 59 60_DEFAULT_PROJECT_FILE = Path('$PW_PROJECT_ROOT/.pw_console.yaml') 61_DEFAULT_PROJECT_USER_FILE = Path('$PW_PROJECT_ROOT/.pw_console.user.yaml') 62_DEFAULT_USER_FILE = Path('$HOME/.pw_console.yaml') 63 64 65class UnknownWindowTitle(Exception): 66 """Exception for window titles not present in the window manager layout.""" 67 68 69class EmptyWindowList(Exception): 70 """Exception for window lists with no content.""" 71 72 73def error_unknown_window( 74 window_title: str, existing_pane_titles: List[str] 75) -> None: 76 """Raise an error when the window config has an unknown title. 77 78 If a window title does not already exist on startup it must have a loggers: 79 or duplicate_of: option set.""" 80 81 pane_title_text = ' ' + '\n '.join(existing_pane_titles) 82 existing_pane_title_example = 'Window Title' 83 if existing_pane_titles: 84 existing_pane_title_example = existing_pane_titles[0] 85 raise UnknownWindowTitle( 86 f'\n\n"{window_title}" does not exist.\n' 87 'Existing windows include:\n' 88 f'{pane_title_text}\n' 89 'If this window should be a duplicate of one of the above,\n' 90 f'add "duplicate_of: {existing_pane_title_example}" to your config.\n' 91 'If this is a brand new window, include a "loggers:" section.\n' 92 'See also: ' 93 'https://pigweed.dev/pw_console/docs/user_guide.html#example-config' 94 ) 95 96 97def error_empty_window_list( 98 window_list_title: str, 99) -> None: 100 """Raise an error if a window list is empty.""" 101 102 raise EmptyWindowList( 103 f'\n\nError: The window layout heading "{window_list_title}" contains ' 104 'no windows.\n' 105 'See also: ' 106 'https://pigweed.dev/pw_console/docs/user_guide.html#example-config' 107 ) 108 109 110class ConsolePrefs(YamlConfigLoaderMixin): 111 """Pigweed Console preferences storage class.""" 112 113 # pylint: disable=too-many-public-methods 114 115 def __init__( 116 self, 117 project_file: Union[Path, bool] = _DEFAULT_PROJECT_FILE, 118 project_user_file: Union[Path, bool] = _DEFAULT_PROJECT_USER_FILE, 119 user_file: Union[Path, bool] = _DEFAULT_USER_FILE, 120 ) -> None: 121 self.config_init( 122 config_section_title='pw_console', 123 project_file=project_file, 124 project_user_file=project_user_file, 125 user_file=user_file, 126 default_config=_DEFAULT_CONFIG, 127 environment_var='PW_CONSOLE_CONFIG_FILE', 128 ) 129 130 self._snippet_completions: List[Tuple[str, str]] = [] 131 self.registered_commands = DEFAULT_KEY_BINDINGS 132 self.registered_commands.update(self.user_key_bindings) 133 134 @property 135 def ui_theme(self) -> str: 136 return self._config.get('ui_theme', '') 137 138 def set_ui_theme(self, theme_name: str): 139 self._config['ui_theme'] = theme_name 140 141 @property 142 def theme_colors(self): 143 return get_theme_colors(self.ui_theme) 144 145 @property 146 def code_theme(self) -> str: 147 return self._config.get('code_theme', '') 148 149 def set_code_theme(self, theme_name: str): 150 self._config['code_theme'] = theme_name 151 152 @property 153 def swap_light_and_dark(self) -> bool: 154 return self._config.get('swap_light_and_dark', False) 155 156 @property 157 def repl_history(self) -> Path: 158 history = Path(self._config['repl_history']) 159 history = Path(os.path.expandvars(str(history.expanduser()))) 160 return history 161 162 @property 163 def search_history(self) -> Path: 164 history = Path(self._config['search_history']) 165 history = Path(os.path.expandvars(str(history.expanduser()))) 166 return history 167 168 @property 169 def spaces_between_columns(self) -> int: 170 spaces = self._config.get('spaces_between_columns', 2) 171 assert isinstance(spaces, int) and spaces > 0 172 return spaces 173 174 @property 175 def omit_unspecified_columns(self) -> bool: 176 return self._config.get('column_order_omit_unspecified_columns', False) 177 178 @property 179 def hide_date_from_log_time(self) -> bool: 180 return self._config.get('hide_date_from_log_time', False) 181 182 @property 183 def show_python_file(self) -> bool: 184 return self._config.get('show_python_file', False) 185 186 @property 187 def show_source_file(self) -> bool: 188 return self._config.get('show_source_file', False) 189 190 @property 191 def show_python_logger(self) -> bool: 192 return self._config.get('show_python_logger', False) 193 194 def toggle_bool_option(self, name: str): 195 existing_setting = self._config[name] 196 assert isinstance(existing_setting, bool) 197 self._config[name] = not existing_setting 198 199 @property 200 def column_order(self) -> List: 201 return self._config.get('column_order', []) 202 203 def column_style( 204 self, column_name: str, column_value: str, default='' 205 ) -> str: 206 column_colors = self._config.get('column_colors', {}) 207 column_style = default 208 209 if column_name in column_colors: 210 # If key exists but doesn't have any values. 211 if not column_colors[column_name]: 212 return default 213 # Check for user supplied default. 214 column_style = column_colors[column_name].get('default', default) 215 # Check for value specific color, otherwise use the default. 216 column_style = column_colors[column_name].get( 217 column_value, column_style 218 ) 219 return column_style 220 221 def pw_console_color_config(self) -> Dict[str, Dict]: 222 column_colors = self._config.get('column_colors', {}) 223 theme_styles = generate_styles(self.ui_theme) 224 style_classes = dict(theme_styles.style_rules) 225 226 color_config = {} 227 color_config['classes'] = style_classes 228 color_config['column_values'] = column_colors 229 return {'__pw_console_colors': color_config} 230 231 @property 232 def window_column_split_method(self) -> str: 233 return self._config.get('window_column_split_method', 'vertical') 234 235 @property 236 def windows(self) -> Dict: 237 return self._config.get('windows', {}) 238 239 def set_windows(self, new_config: Dict) -> None: 240 self._config['windows'] = new_config 241 242 @property 243 def window_column_modes(self) -> List: 244 return list(column_type for column_type in self.windows.keys()) 245 246 @property 247 def command_runner_position(self) -> Dict[str, int]: 248 position = self._config.get('command_runner', {}).get( 249 'position', {'top': 3} 250 ) 251 return { 252 key: value 253 for key, value in position.items() 254 if key in ['top', 'bottom', 'left', 'right'] 255 } 256 257 @property 258 def command_runner_width(self) -> int: 259 return self._config.get('command_runner', {}).get('width', 80) 260 261 @property 262 def command_runner_height(self) -> int: 263 return self._config.get('command_runner', {}).get('height', 10) 264 265 @property 266 def user_key_bindings(self) -> Dict[str, List[str]]: 267 return self._config.get('key_bindings', {}) 268 269 def current_config_as_yaml(self) -> str: 270 yaml_options = dict( 271 sort_keys=True, default_style='', default_flow_style=False 272 ) 273 274 title = {'config_title': 'pw_console'} 275 text = '\n' 276 text += yaml.safe_dump(title, **yaml_options) # type: ignore 277 278 keys = {'key_bindings': self.registered_commands} 279 text += '\n' 280 text += yaml.safe_dump(keys, **yaml_options) # type: ignore 281 282 return text 283 284 @property 285 def unique_window_titles(self) -> set: 286 titles = [] 287 for window_list_title, column in self.windows.items(): 288 if not column: 289 error_empty_window_list(window_list_title) 290 291 for window_key_title, window_dict in column.items(): 292 window_options = window_dict if window_dict else {} 293 # Use 'duplicate_of: Title' if it exists, otherwise use the key. 294 titles.append( 295 window_options.get('duplicate_of', window_key_title) 296 ) 297 return set(titles) 298 299 def get_function_keys(self, name: str) -> List: 300 """Return the keys for the named function.""" 301 try: 302 return self.registered_commands[name] 303 except KeyError as error: 304 raise KeyError('Unbound key function: {}'.format(name)) from error 305 306 def register_named_key_function( 307 self, name: str, default_bindings: List[str] 308 ) -> None: 309 self.registered_commands[name] = default_bindings 310 311 def register_keybinding( 312 self, name: str, key_bindings: KeyBindings, **kwargs 313 ) -> Callable: 314 """Apply registered keys for the given named function.""" 315 316 def decorator(handler: Callable) -> Callable: 317 "`handler` is a callable or Binding." 318 for keys in self.get_function_keys(name): 319 key_bindings.add(*keys.split(' '), **kwargs)(handler) 320 return handler 321 322 return decorator 323 324 @property 325 def snippets(self) -> Dict: 326 return self._config.get('snippets', {}) 327 328 @property 329 def user_snippets(self) -> Dict: 330 return self._config.get('user_snippets', {}) 331 332 def snippet_completions(self) -> List[Tuple[str, str]]: 333 if self._snippet_completions: 334 return self._snippet_completions 335 336 all_descriptions: List[str] = [] 337 all_descriptions.extend(self.user_snippets.keys()) 338 all_descriptions.extend(self.snippets.keys()) 339 if not all_descriptions: 340 return [] 341 max_description_width = max( 342 len(description) for description in all_descriptions 343 ) 344 345 all_snippets: List[Tuple[str, str]] = [] 346 all_snippets.extend(self.user_snippets.items()) 347 all_snippets.extend(self.snippets.items()) 348 349 self._snippet_completions = [ 350 ( 351 description.ljust(max_description_width) + ' : ' + 352 # Flatten linebreaks in the text. 353 ' '.join([line.lstrip() for line in text.splitlines()]), 354 # Pass original text as the completion result. 355 text, 356 ) 357 for description, text in all_snippets 358 ] 359 360 return self._snippet_completions 361