• 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, 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