| # Copyright 2021 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| """pw_console preferences""" |
| |
| import os |
| from pathlib import Path |
| from typing import Dict, Callable, List, Union |
| |
| from prompt_toolkit.key_binding import KeyBindings |
| import yaml |
| |
| from pw_console.style import get_theme_colors |
| from pw_console.key_bindings import DEFAULT_KEY_BINDINGS |
| from pw_console.yaml_config_loader_mixin import YamlConfigLoaderMixin |
| |
| _DEFAULT_REPL_HISTORY: Path = Path.home() / '.pw_console_history' |
| _DEFAULT_SEARCH_HISTORY: Path = Path.home() / '.pw_console_search' |
| |
| _DEFAULT_CONFIG = { |
| # History files |
| 'repl_history': _DEFAULT_REPL_HISTORY, |
| 'search_history': _DEFAULT_SEARCH_HISTORY, |
| # Appearance |
| 'ui_theme': 'dark', |
| 'code_theme': 'pigweed-code', |
| 'swap_light_and_dark': False, |
| 'spaces_between_columns': 2, |
| 'column_order_omit_unspecified_columns': False, |
| 'column_order': [], |
| 'column_colors': {}, |
| 'show_python_file': False, |
| 'show_python_logger': False, |
| 'show_source_file': False, |
| 'hide_date_from_log_time': False, |
| # Window arrangement |
| 'windows': {}, |
| 'window_column_split_method': 'vertical', |
| 'command_runner': { |
| 'width': 80, |
| 'height': 10, |
| 'position': { |
| 'top': 3 |
| }, |
| }, |
| 'key_bindings': DEFAULT_KEY_BINDINGS, |
| } |
| |
| _DEFAULT_PROJECT_FILE = Path('$PW_PROJECT_ROOT/.pw_console.yaml') |
| _DEFAULT_PROJECT_USER_FILE = Path('$PW_PROJECT_ROOT/.pw_console.user.yaml') |
| _DEFAULT_USER_FILE = Path('$HOME/.pw_console.yaml') |
| |
| |
| class UnknownWindowTitle(Exception): |
| """Exception for window titles not present in the window manager layout.""" |
| |
| |
| class EmptyWindowList(Exception): |
| """Exception for window lists with no content.""" |
| |
| |
| def error_unknown_window(window_title: str, |
| existing_pane_titles: List[str]) -> None: |
| """Raise an error when the window config has an unknown title. |
| |
| If a window title does not already exist on startup it must have a loggers: |
| or duplicate_of: option set.""" |
| |
| pane_title_text = ' ' + '\n '.join(existing_pane_titles) |
| existing_pane_title_example = 'Window Title' |
| if existing_pane_titles: |
| existing_pane_title_example = existing_pane_titles[0] |
| raise UnknownWindowTitle( |
| f'\n\n"{window_title}" does not exist.\n' |
| 'Existing windows include:\n' |
| f'{pane_title_text}\n' |
| 'If this window should be a duplicate of one of the above,\n' |
| f'add "duplicate_of: {existing_pane_title_example}" to your config.\n' |
| 'If this is a brand new window, include a "loggers:" section.\n' |
| 'See also: ' |
| 'https://pigweed.dev/pw_console/docs/user_guide.html#example-config') |
| |
| |
| def error_empty_window_list(window_list_title: str, ) -> None: |
| """Raise an error if a window list is empty.""" |
| |
| raise EmptyWindowList( |
| f'\n\nError: The window layout heading "{window_list_title}" contains ' |
| 'no windows.\n' |
| 'See also: ' |
| 'https://pigweed.dev/pw_console/docs/user_guide.html#example-config') |
| |
| |
| class ConsolePrefs(YamlConfigLoaderMixin): |
| """Pigweed Console preferences storage class.""" |
| |
| # pylint: disable=too-many-public-methods |
| |
| def __init__( |
| self, |
| project_file: Union[Path, bool] = _DEFAULT_PROJECT_FILE, |
| project_user_file: Union[Path, bool] = _DEFAULT_PROJECT_USER_FILE, |
| user_file: Union[Path, bool] = _DEFAULT_USER_FILE, |
| ) -> None: |
| self.config_init( |
| config_section_title='pw_console', |
| project_file=project_file, |
| project_user_file=project_user_file, |
| user_file=user_file, |
| default_config=_DEFAULT_CONFIG, |
| environment_var='PW_CONSOLE_CONFIG_FILE', |
| ) |
| |
| self.registered_commands = DEFAULT_KEY_BINDINGS |
| self.registered_commands.update(self.user_key_bindings) |
| |
| @property |
| def ui_theme(self) -> str: |
| return self._config.get('ui_theme', '') |
| |
| def set_ui_theme(self, theme_name: str): |
| self._config['ui_theme'] = theme_name |
| |
| @property |
| def theme_colors(self): |
| return get_theme_colors(self.ui_theme) |
| |
| @property |
| def code_theme(self) -> str: |
| return self._config.get('code_theme', '') |
| |
| @property |
| def swap_light_and_dark(self) -> bool: |
| return self._config.get('swap_light_and_dark', False) |
| |
| @property |
| def repl_history(self) -> Path: |
| history = Path(self._config['repl_history']) |
| history = Path(os.path.expandvars(str(history.expanduser()))) |
| return history |
| |
| @property |
| def search_history(self) -> Path: |
| history = Path(self._config['search_history']) |
| history = Path(os.path.expandvars(str(history.expanduser()))) |
| return history |
| |
| @property |
| def spaces_between_columns(self) -> int: |
| spaces = self._config.get('spaces_between_columns', 2) |
| assert isinstance(spaces, int) and spaces > 0 |
| return spaces |
| |
| @property |
| def omit_unspecified_columns(self) -> bool: |
| return self._config.get('column_order_omit_unspecified_columns', False) |
| |
| @property |
| def hide_date_from_log_time(self) -> bool: |
| return self._config.get('hide_date_from_log_time', False) |
| |
| @property |
| def show_python_file(self) -> bool: |
| return self._config.get('show_python_file', False) |
| |
| @property |
| def show_source_file(self) -> bool: |
| return self._config.get('show_source_file', False) |
| |
| @property |
| def show_python_logger(self) -> bool: |
| return self._config.get('show_python_logger', False) |
| |
| def toggle_bool_option(self, name: str): |
| existing_setting = self._config[name] |
| assert isinstance(existing_setting, bool) |
| self._config[name] = not existing_setting |
| |
| @property |
| def column_order(self) -> list: |
| return self._config.get('column_order', []) |
| |
| def column_style(self, |
| column_name: str, |
| column_value: str, |
| default='') -> str: |
| column_colors = self._config.get('column_colors', {}) |
| column_style = default |
| |
| if column_name in column_colors: |
| # If key exists but doesn't have any values. |
| if not column_colors[column_name]: |
| return default |
| # Check for user supplied default. |
| column_style = column_colors[column_name].get('default', default) |
| # Check for value specific color, otherwise use the default. |
| column_style = column_colors[column_name].get( |
| column_value, column_style) |
| return column_style |
| |
| @property |
| def window_column_split_method(self) -> str: |
| return self._config.get('window_column_split_method', 'vertical') |
| |
| @property |
| def windows(self) -> dict: |
| return self._config.get('windows', {}) |
| |
| @property |
| def window_column_modes(self) -> list: |
| return list(column_type for column_type in self.windows.keys()) |
| |
| @property |
| def command_runner_position(self) -> Dict[str, int]: |
| position = self._config.get('command_runner', |
| {}).get('position', {'top': 3}) |
| return { |
| key: value |
| for key, value in position.items() |
| if key in ['top', 'bottom', 'left', 'right'] |
| } |
| |
| @property |
| def command_runner_width(self) -> int: |
| return self._config.get('command_runner', {}).get('width', 80) |
| |
| @property |
| def command_runner_height(self) -> int: |
| return self._config.get('command_runner', {}).get('height', 10) |
| |
| @property |
| def user_key_bindings(self) -> Dict[str, List[str]]: |
| return self._config.get('key_bindings', {}) |
| |
| def current_config_as_yaml(self) -> str: |
| yaml_options = dict(sort_keys=True, |
| default_style='', |
| default_flow_style=False) |
| |
| title = {'config_title': 'pw_console'} |
| text = '\n' |
| text += yaml.safe_dump(title, **yaml_options) # type: ignore |
| |
| keys = {'key_bindings': self.registered_commands} |
| text += '\n' |
| text += yaml.safe_dump(keys, **yaml_options) # type: ignore |
| |
| return text |
| |
| @property |
| def unique_window_titles(self) -> set: |
| titles = [] |
| for window_list_title, column in self.windows.items(): |
| if not column: |
| error_empty_window_list(window_list_title) |
| |
| for window_key_title, window_dict in column.items(): |
| window_options = window_dict if window_dict else {} |
| # Use 'duplicate_of: Title' if it exists, otherwise use the key. |
| titles.append( |
| window_options.get('duplicate_of', window_key_title)) |
| return set(titles) |
| |
| def get_function_keys(self, name: str) -> List: |
| """Return the keys for the named function.""" |
| try: |
| return self.registered_commands[name] |
| except KeyError as error: |
| raise KeyError('Unbound key function: {}'.format(name)) from error |
| |
| def register_named_key_function(self, name: str, |
| default_bindings: List[str]) -> None: |
| self.registered_commands[name] = default_bindings |
| |
| def register_keybinding(self, name: str, key_bindings: KeyBindings, |
| **kwargs) -> Callable: |
| """Apply registered keys for the given named function.""" |
| def decorator(handler: Callable) -> Callable: |
| "`handler` is a callable or Binding." |
| for keys in self.get_function_keys(name): |
| key_bindings.add(*keys.split(' '), **kwargs)(handler) |
| return handler |
| |
| return decorator |