blob: fb31bbfa66b2efdc573db968c4990801bf1868e6 [file] [log] [blame]
# 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, Tuple, 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,
'snippets': {},
'user_snippets': {},
}
_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._snippet_completions: List[Tuple[str, str]] = []
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', '')
def set_code_theme(self, theme_name: str):
self._config['code_theme'] = theme_name
@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', {})
def set_windows(self, new_config: Dict) -> None:
self._config['windows'] = new_config
@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
@property
def snippets(self) -> Dict:
return self._config.get('snippets', {})
@property
def user_snippets(self) -> Dict:
return self._config.get('user_snippets', {})
def snippet_completions(self) -> List[Tuple[str, str]]:
if self._snippet_completions:
return self._snippet_completions
all_descriptions: List[str] = []
all_descriptions.extend(self.user_snippets.keys())
all_descriptions.extend(self.snippets.keys())
if not all_descriptions:
return []
max_description_width = max(
len(description) for description in all_descriptions)
all_snippets: List[Tuple[str, str]] = []
all_snippets.extend(self.user_snippets.items())
all_snippets.extend(self.snippets.items())
self._snippet_completions = [
(
description.ljust(max_description_width) + ' : ' +
# Flatten linebreaks in the text.
' '.join([line.lstrip() for line in text.splitlines()]),
# Pass original text as the completion result.
text,
) for description, text in all_snippets
]
return self._snippet_completions