• 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
16from __future__ import annotations
17
18import dataclasses
19import os
20from pathlib import Path
21from typing import Callable
22
23from prompt_toolkit.key_binding import KeyBindings
24import yaml
25
26from pw_config_loader.yaml_config_loader_mixin import YamlConfigLoaderMixin
27
28from pw_console.style import get_theme_colors, generate_styles
29from pw_console.key_bindings import DEFAULT_KEY_BINDINGS
30
31_DEFAULT_REPL_HISTORY: Path = Path.home() / '.pw_console_history'
32_DEFAULT_SEARCH_HISTORY: Path = Path.home() / '.pw_console_search'
33
34_DEFAULT_CONFIG = {
35    # History files
36    'repl_history': _DEFAULT_REPL_HISTORY,
37    'search_history': _DEFAULT_SEARCH_HISTORY,
38    # Appearance
39    'ui_theme': 'dark',
40    'code_theme': 'pigweed-code',
41    'swap_light_and_dark': False,
42    'spaces_between_columns': 2,
43    'column_order_omit_unspecified_columns': False,
44    'column_order': [],
45    'column_colors': {},
46    'show_python_file': False,
47    'show_python_logger': False,
48    'show_source_file': False,
49    'hide_date_from_log_time': False,
50    # Window arrangement
51    'windows': {},
52    'window_column_split_method': 'vertical',
53    'command_runner': {
54        'width': 80,
55        'height': 10,
56        'position': {'top': 3},
57    },
58    'key_bindings': DEFAULT_KEY_BINDINGS,
59    'snippets': {},
60    'user_snippets': {},
61}
62
63_DEFAULT_PROJECT_FILE = Path('$PW_PROJECT_ROOT/.pw_console.yaml')
64_DEFAULT_PROJECT_USER_FILE = Path('$PW_PROJECT_ROOT/.pw_console.user.yaml')
65_DEFAULT_USER_FILE = Path('$HOME/.pw_console.yaml')
66
67
68class UnknownWindowTitle(Exception):
69    """Exception for window titles not present in the window manager layout."""
70
71
72class EmptyWindowList(Exception):
73    """Exception for window lists with no content."""
74
75
76class EmptyPreviousPreviousDescription(Exception):
77    """Previous snippet description is empty for 'description: USE_PREVIOUS'."""
78
79
80@dataclasses.dataclass
81class CodeSnippet:
82    """Stores a single code snippet for inserting into the Python Repl.
83
84    Attributes:
85
86        title: The displayed title in the command runner window.
87        code: Python code text to be inserted.
88        description: Optional help text to be displayed below the snippet
89            selection window.
90    """
91
92    title: str
93    code: str
94    description: str | None = None
95
96    @staticmethod
97    def from_yaml(
98        title: str,
99        value: str | dict,
100        previous_description: str | None = None,
101    ) -> CodeSnippet:
102        if isinstance(value, str):
103            return CodeSnippet(title=title, code=value)
104
105        assert isinstance(value, dict)
106
107        code = value.get('code', None)
108        description = value.get('description', None)
109        if description == 'USE_PREVIOUS':
110            if not previous_description:
111                raise EmptyPreviousPreviousDescription(
112                    f'\nERROR: pw_console.yaml snippet "{title}" has '
113                    '"description: USE_PREVIOUS" but the previous snippet '
114                    'description is empty.'
115                )
116            description = previous_description
117        return CodeSnippet(title=title, code=code, description=description)
118
119
120def error_unknown_window(
121    window_title: str, existing_pane_titles: list[str]
122) -> None:
123    """Raise an error when the window config has an unknown title.
124
125    If a window title does not already exist on startup it must have a loggers:
126    or duplicate_of: option set."""
127
128    pane_title_text = '  ' + '\n  '.join(existing_pane_titles)
129    existing_pane_title_example = 'Window Title'
130    if existing_pane_titles:
131        existing_pane_title_example = existing_pane_titles[0]
132    raise UnknownWindowTitle(
133        f'\n\n"{window_title}" does not exist.\n'
134        'Existing windows include:\n'
135        f'{pane_title_text}\n'
136        'If this window should be a duplicate of one of the above,\n'
137        f'add "duplicate_of: {existing_pane_title_example}" to your config.\n'
138        'If this is a brand new window, include a "loggers:" section.\n'
139        'See also: '
140        'https://pigweed.dev/pw_console/docs/user_guide.html#example-config'
141    )
142
143
144def error_empty_window_list(
145    window_list_title: str,
146) -> None:
147    """Raise an error if a window list is empty."""
148
149    raise EmptyWindowList(
150        f'\n\nError: The window layout heading "{window_list_title}" contains '
151        'no windows.\n'
152        'See also: '
153        'https://pigweed.dev/pw_console/docs/user_guide.html#example-config'
154    )
155
156
157class ConsolePrefs(YamlConfigLoaderMixin):
158    """Pigweed Console preferences storage class."""
159
160    # pylint: disable=too-many-public-methods
161
162    def __init__(
163        self,
164        project_file: Path | bool = _DEFAULT_PROJECT_FILE,
165        project_user_file: Path | bool = _DEFAULT_PROJECT_USER_FILE,
166        user_file: Path | bool = _DEFAULT_USER_FILE,
167    ) -> None:
168        self.config_init(
169            config_section_title='pw_console',
170            project_file=project_file,
171            project_user_file=project_user_file,
172            user_file=user_file,
173            default_config=_DEFAULT_CONFIG,
174            environment_var='PW_CONSOLE_CONFIG_FILE',
175        )
176
177        self._snippet_completions: list[CodeSnippet] = []
178        self.registered_commands = DEFAULT_KEY_BINDINGS
179        self.registered_commands.update(self.user_key_bindings)
180
181    @property
182    def ui_theme(self) -> str:
183        return self._config.get('ui_theme', '')
184
185    def set_ui_theme(self, theme_name: str):
186        self._config['ui_theme'] = theme_name
187
188    @property
189    def theme_colors(self):
190        return get_theme_colors(self.ui_theme)
191
192    @property
193    def code_theme(self) -> str:
194        return self._config.get('code_theme', '')
195
196    def set_code_theme(self, theme_name: str):
197        self._config['code_theme'] = theme_name
198
199    @property
200    def swap_light_and_dark(self) -> bool:
201        return self._config.get('swap_light_and_dark', False)
202
203    @property
204    def repl_history(self) -> Path:
205        history = Path(self._config['repl_history'])
206        history = Path(os.path.expandvars(str(history.expanduser())))
207        return history
208
209    @property
210    def search_history(self) -> Path:
211        history = Path(self._config['search_history'])
212        history = Path(os.path.expandvars(str(history.expanduser())))
213        return history
214
215    @property
216    def spaces_between_columns(self) -> int:
217        spaces = self._config.get('spaces_between_columns', 2)
218        assert isinstance(spaces, int) and spaces > 0
219        return spaces
220
221    @property
222    def omit_unspecified_columns(self) -> bool:
223        return self._config.get('column_order_omit_unspecified_columns', False)
224
225    @property
226    def hide_date_from_log_time(self) -> bool:
227        return self._config.get('hide_date_from_log_time', False)
228
229    @property
230    def show_python_file(self) -> bool:
231        return self._config.get('show_python_file', False)
232
233    @property
234    def show_source_file(self) -> bool:
235        return self._config.get('show_source_file', False)
236
237    @property
238    def show_python_logger(self) -> bool:
239        return self._config.get('show_python_logger', False)
240
241    def toggle_bool_option(self, name: str):
242        existing_setting = self._config[name]
243        assert isinstance(existing_setting, bool)
244        self._config[name] = not existing_setting
245
246    @property
247    def column_order(self) -> list:
248        return self._config.get('column_order', [])
249
250    def column_style(
251        self, column_name: str, column_value: str, default=''
252    ) -> str:
253        column_colors = self._config.get('column_colors', {})
254        column_style = default
255
256        if column_name in column_colors:
257            # If key exists but doesn't have any values.
258            if not column_colors[column_name]:
259                return default
260            # Check for user supplied default.
261            column_style = column_colors[column_name].get('default', default)
262            # Check for value specific color, otherwise use the default.
263            column_style = column_colors[column_name].get(
264                column_value, column_style
265            )
266        return column_style
267
268    def pw_console_color_config(self) -> dict[str, dict]:
269        column_colors = self._config.get('column_colors', {})
270        theme_styles = generate_styles(self.ui_theme)
271        style_classes = dict(theme_styles.style_rules)
272
273        color_config = {}
274        color_config['classes'] = style_classes
275        color_config['column_values'] = column_colors
276        return {'__pw_console_colors': color_config}
277
278    @property
279    def window_column_split_method(self) -> str:
280        return self._config.get('window_column_split_method', 'vertical')
281
282    @property
283    def windows(self) -> dict:
284        return self._config.get('windows', {})
285
286    def set_windows(self, new_config: dict) -> None:
287        self._config['windows'] = new_config
288
289    @property
290    def window_column_modes(self) -> list:
291        return list(column_type for column_type in self.windows.keys())
292
293    @property
294    def command_runner_position(self) -> dict[str, int]:
295        position = self._config.get('command_runner', {}).get(
296            'position', {'top': 3}
297        )
298        return {
299            key: value
300            for key, value in position.items()
301            if key in ['top', 'bottom', 'left', 'right']
302        }
303
304    @property
305    def command_runner_width(self) -> int:
306        return self._config.get('command_runner', {}).get('width', 80)
307
308    @property
309    def command_runner_height(self) -> int:
310        return self._config.get('command_runner', {}).get('height', 10)
311
312    @property
313    def user_key_bindings(self) -> dict[str, list[str]]:
314        return self._config.get('key_bindings', {})
315
316    def current_config_as_yaml(self) -> str:
317        yaml_options = dict(
318            sort_keys=True, default_style='', default_flow_style=False
319        )
320
321        title = {'config_title': 'pw_console'}
322        text = '\n'
323        text += yaml.safe_dump(title, **yaml_options)  # type: ignore
324
325        keys = {'key_bindings': self.registered_commands}
326        text += '\n'
327        text += yaml.safe_dump(keys, **yaml_options)  # type: ignore
328
329        return text
330
331    @property
332    def unique_window_titles(self) -> set:
333        titles = []
334        for window_list_title, column in self.windows.items():
335            if not column:
336                error_empty_window_list(window_list_title)
337
338            for window_key_title, window_dict in column.items():
339                window_options = window_dict if window_dict else {}
340                # Use 'duplicate_of: Title' if it exists, otherwise use the key.
341                titles.append(
342                    window_options.get('duplicate_of', window_key_title)
343                )
344        return set(titles)
345
346    def get_function_keys(self, name: str) -> list:
347        """Return the keys for the named function."""
348        try:
349            return self.registered_commands[name]
350        except KeyError as error:
351            raise KeyError('Unbound key function: {}'.format(name)) from error
352
353    def register_named_key_function(
354        self, name: str, default_bindings: list[str]
355    ) -> None:
356        self.registered_commands[name] = default_bindings
357
358    def register_keybinding(
359        self, name: str, key_bindings: KeyBindings, **kwargs
360    ) -> Callable:
361        """Apply registered keys for the given named function."""
362
363        def decorator(handler: Callable) -> Callable:
364            "`handler` is a callable or Binding."
365            for keys in self.get_function_keys(name):
366                key_bindings.add(*keys.split(' '), **kwargs)(handler)
367            return handler
368
369        return decorator
370
371    @property
372    def snippets(self) -> dict:
373        return self._config.get('snippets', {})
374
375    @property
376    def user_snippets(self) -> dict:
377        return self._config.get('user_snippets', {})
378
379    def snippet_completions(self) -> list[CodeSnippet]:
380        if self._snippet_completions:
381            return self._snippet_completions
382
383        all_snippets: list[CodeSnippet] = []
384
385        def previous_description() -> str | None:
386            if not all_snippets:
387                return None
388            return all_snippets[-1].description
389
390        for title, value in self.user_snippets.items():
391            all_snippets.append(
392                CodeSnippet.from_yaml(title, value, previous_description())
393            )
394        for title, value in self.snippets.items():
395            all_snippets.append(
396                CodeSnippet.from_yaml(title, value, previous_description())
397            )
398
399        self._snippet_completions = all_snippets
400
401        return self._snippet_completions
402