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