• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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