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