# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """ Text snippets configuration widgets. """ # Standard library imports import bisect import json # Third party imports from jsonschema.exceptions import ValidationError from jsonschema import validate as json_validate from qtpy.compat import to_qvariant from qtpy.QtCore import Qt, Slot, QAbstractTableModel, QModelIndex, QSize from qtpy.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QGroupBox, QGridLayout, QLabel, QLineEdit, QTableView, QVBoxLayout) # Local imports from spyder.config.base import _ from spyder.config.manager import CONF from spyder.config.gui import get_font from spyder.plugins.completion.api import SUPPORTED_LANGUAGES from spyder.utils.snippets.ast import build_snippet_ast from spyder.widgets.helperwidgets import ItemDelegate from spyder.widgets.simplecodeeditor import SimpleCodeEditor # Languages supported by the text snippets extension LANGUAGE_NAMES = {x.lower(): x for x in SUPPORTED_LANGUAGES} LANGUAGE_SET = {lang.lower() for lang in SUPPORTED_LANGUAGES} PYTHON_POS = bisect.bisect_left(SUPPORTED_LANGUAGES, 'Python') SUPPORTED_LANGUAGES_PY = list(SUPPORTED_LANGUAGES) SUPPORTED_LANGUAGES_PY.insert(PYTHON_POS, 'Python') LANGUAGE, ADDR, CMD = [0, 1, 2] SNIPPETS_SCHEMA = { 'type': 'array', 'title': 'Snippets', 'items': { 'type': 'object', 'required': ['language', 'triggers'], 'properties': { 'language': { 'type': 'string', 'description': 'Programming language', 'enum': [lang.lower() for lang in SUPPORTED_LANGUAGES_PY] }, 'triggers': { 'type': 'array', 'description': ( 'List of snippet triggers defined for this language'), 'items': { 'type': 'object', 'description': '', 'required': ['trigger', 'descriptions'], 'properties': { 'trigger': { 'type': 'string', 'description': ( 'Text that triggers a snippet family'), }, 'descriptions': { 'type': 'array', 'items': { 'type': 'object', 'description': 'Snippet information', 'required': ['description', 'snippet'], 'properties': { 'description': { 'type': 'string', 'description': ( 'Description of the snippet') }, 'snippet': { 'type': 'object', 'description': 'Snippet information', 'required': ['text', 'remove_trigger'], 'properties': { 'text': { 'type': 'string', 'description': ( 'Snippet to insert') }, 'remove_trigger': { 'type': 'boolean', 'description': ( 'If true, the snippet ' 'should remove the text ' 'that triggers it') } } } } } } } } } } } } def iter_snippets(language, get_option, set_option, snippets=None): language_snippets = [] load_snippets = snippets is None if load_snippets: snippets = get_option(language.lower(), default={}) for trigger in snippets: trigger_descriptions = snippets[trigger] for description in trigger_descriptions: if load_snippets: this_snippet = Snippet(language=language, trigger_text=trigger, description=description, get_option=get_option, set_option=set_option) this_snippet.load() else: current_snippet = trigger_descriptions[description] text = current_snippet['text'] remove_trigger = current_snippet['remove_trigger'] this_snippet = Snippet(language=language, trigger_text=trigger, description=description, snippet_text=text, remove_trigger=remove_trigger, get_option=get_option, set_option=set_option) language_snippets.append(this_snippet) return language_snippets class Snippet: """Convenience class to store user snippets.""" def __init__(self, language=None, trigger_text="", description="", snippet_text="", remove_trigger=False, get_option=None, set_option=None): self.index = 0 self.language = language if self.language in LANGUAGE_NAMES: self.language = LANGUAGE_NAMES[self.language] self.trigger_text = trigger_text self.snippet_text = snippet_text self.description = description self.remove_trigger = remove_trigger self.initial_trigger_text = trigger_text self.initial_description = description self.set_option = set_option self.get_option = get_option def __repr__(self): return '[{0}] {1} ({2}): {3}'.format( self.language, self.trigger_text, self.description, repr(self.snippet_text)) def __str__(self): return self.__repr__() def update(self, trigger_text, description_text, snippet_text, remove_trigger): self.trigger_text = trigger_text self.description = description_text self.snippet_text = snippet_text self.remove_trigger = remove_trigger def load(self): if self.language is not None and self.trigger_text != '': state = self.get_option(self.language.lower()) trigger_info = state[self.trigger_text] snippet_info = trigger_info[self.description] self.snippet_text = snippet_info['text'] self.remove_trigger = snippet_info['remove_trigger'] def save(self): if self.language is not None: language = self.language.lower() current_state = self.get_option(language, default={}) new_state = { 'text': self.snippet_text, 'remove_trigger': self.remove_trigger } if (self.initial_trigger_text != self.trigger_text or self.initial_description != self.description): # Delete previous entry if self.initial_trigger_text in current_state: trigger = current_state[self.initial_trigger_text] trigger.pop(self.initial_description) if len(trigger) == 0: current_state.pop(self.initial_trigger_text) trigger_info = current_state.get(self.trigger_text, {}) trigger_info[self.description] = new_state current_state[self.trigger_text] = trigger_info self.set_option(language, current_state, recursive_notification=False) def delete(self): if self.language is not None: language = self.language.lower() current_state = self.get_option(language, default={}) trigger = current_state[self.trigger_text] trigger.pop(self.description) if len(trigger) == 0: current_state.pop(self.trigger_text) self.set_option(language, current_state, recursive_notification=False) class SnippetEditor(QDialog): SNIPPET_VALID = _('Valid snippet') SNIPPET_INVALID = _('Invalid snippet') INVALID_CB_CSS = "QComboBox {border: 1px solid red;}" VALID_CB_CSS = "QComboBox {border: 1px solid green;}" INVALID_LINE_CSS = "QLineEdit {border: 1px solid red;}" VALID_LINE_CSS = "QLineEdit {border: 1px solid green;}" MIN_SIZE = QSize(850, 600) def __init__(self, parent, language=None, trigger_text='', description='', snippet_text='', remove_trigger=False, trigger_texts=[], descriptions=[], get_option=None, set_option=None): super(SnippetEditor, self).__init__(parent) snippet_description = _( "To add a new text snippet, you need to define the text " "that triggers it, a short description (two words maximum) " "of the snippet and if it should delete the trigger text when " "inserted. Finally, you need to define the snippet body to insert." ) self.parent = parent self.trigger_text = trigger_text self.description = description self.remove_trigger = remove_trigger self.snippet_text = snippet_text self.descriptions = descriptions self.base_snippet = Snippet( language=language, trigger_text=trigger_text, snippet_text=snippet_text, description=description, remove_trigger=remove_trigger, get_option=get_option, set_option=set_option) # Widgets self.snippet_settings_description = QLabel(snippet_description) self.snippet_settings_description.setFixedWidth(450) # Trigger text self.trigger_text_label = QLabel(_('Trigger text:')) self.trigger_text_cb = QComboBox(self) self.trigger_text_cb.setEditable(True) # Description self.description_label = QLabel(_('Description:')) self.description_input = QLineEdit(self) # Remove trigger self.remove_trigger_cb = QCheckBox( _('Remove trigger text on insertion'), self) self.remove_trigger_cb.setToolTip(_('Check if the text that triggers ' 'this snippet should be removed ' 'when inserting it')) self.remove_trigger_cb.setChecked(self.remove_trigger) # Snippet body input self.snippet_label = QLabel(_('Snippet text:')) self.snippet_valid_label = QLabel(self.SNIPPET_INVALID, self) self.snippet_input = SimpleCodeEditor(None) # Dialog buttons self.bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.button_ok = self.bbox.button(QDialogButtonBox.Ok) self.button_cancel = self.bbox.button(QDialogButtonBox.Cancel) # Widget setup self.setWindowTitle(_('Snippet editor')) self.snippet_settings_description.setWordWrap(True) self.trigger_text_cb.setToolTip( _('Trigger text for the current snippet')) self.trigger_text_cb.addItems(trigger_texts) if self.trigger_text != '': idx = trigger_texts.index(self.trigger_text) self.trigger_text_cb.setCurrentIndex(idx) self.description_input.setText(self.description) self.description_input.textChanged.connect(lambda _x: self.validate()) text_inputs = (self.trigger_text, self.description, self.snippet_text) non_empty_text = all([x != '' for x in text_inputs]) if non_empty_text: self.button_ok.setEnabled(True) self.snippet_input.setup_editor( language=language, color_scheme=get_option('selected', section='appearance'), wrap=False, highlight_current_line=True, font=get_font() ) self.snippet_input.set_language(language) self.snippet_input.setToolTip(_('Snippet text completion to insert')) self.snippet_input.set_text(snippet_text) # Layout setup general_layout = QVBoxLayout() general_layout.addWidget(self.snippet_settings_description) snippet_settings_group = QGroupBox(_('Trigger information')) settings_layout = QGridLayout() settings_layout.addWidget(self.trigger_text_label, 0, 0) settings_layout.addWidget(self.trigger_text_cb, 0, 1) settings_layout.addWidget(self.description_label, 1, 0) settings_layout.addWidget(self.description_input, 1, 1) all_settings_layout = QVBoxLayout() all_settings_layout.addLayout(settings_layout) all_settings_layout.addWidget(self.remove_trigger_cb) snippet_settings_group.setLayout(all_settings_layout) general_layout.addWidget(snippet_settings_group) text_layout = QVBoxLayout() text_layout.addWidget(self.snippet_label) text_layout.addWidget(self.snippet_input) text_layout.addWidget(self.snippet_valid_label) general_layout.addLayout(text_layout) general_layout.addWidget(self.bbox) self.setLayout(general_layout) # Signals self.trigger_text_cb.editTextChanged.connect(self.validate) self.description_input.textChanged.connect(self.validate) self.snippet_input.textChanged.connect(self.validate) self.bbox.accepted.connect(self.accept) self.bbox.rejected.connect(self.reject) # Final setup if trigger_text != '' or snippet_text != '': self.validate() @Slot() def validate(self): trigger_text = self.trigger_text_cb.currentText() description_text = self.description_input.text() snippet_text = self.snippet_input.toPlainText() invalid = False try: build_snippet_ast(snippet_text) self.snippet_valid_label.setText(self.SNIPPET_VALID) except SyntaxError: invalid = True self.snippet_valid_label.setText(self.SNIPPET_INVALID) if trigger_text == '': invalid = True self.trigger_text_cb.setStyleSheet(self.INVALID_CB_CSS) else: self.trigger_text_cb.setStyleSheet(self.VALID_CB_CSS) if trigger_text in self.descriptions: if self.trigger_text != trigger_text: if description_text in self.descriptions[trigger_text]: invalid = True self.description_input.setStyleSheet( self.INVALID_LINE_CSS) else: self.description_input.setStyleSheet( self.VALID_LINE_CSS) else: if description_text != self.description: if description_text in self.descriptions[trigger_text]: invalid = True self.description_input.setStyleSheet( self.INVALID_LINE_CSS) else: self.description_input.setStyleSheet( self.VALID_LINE_CSS) else: self.description_input.setStyleSheet( self.VALID_LINE_CSS) self.button_ok.setEnabled(not invalid) def get_options(self): trigger_text = self.trigger_text_cb.currentText() description_text = self.description_input.text() snippet_text = self.snippet_input.toPlainText() remove_trigger = self.remove_trigger_cb.isChecked() self.base_snippet.update( trigger_text, description_text, snippet_text, remove_trigger) return self.base_snippet class SnippetsModel(QAbstractTableModel): TRIGGER = 0 DESCRIPTION = 1 def __init__(self, parent, text_color=None, text_color_highlight=None): super(QAbstractTableModel, self).__init__() self.parent = parent self.snippets = [] self.delete_queue = [] self.snippet_map = {} self.rich_text = [] self.normal_text = [] self.letters = '' self.label = QLabel() self.widths = [] # Needed to compensate for the HTMLDelegate color selection unawareness palette = parent.palette() if text_color is None: self.text_color = palette.text().color().name() else: self.text_color = text_color if text_color_highlight is None: self.text_color_highlight = \ palette.highlightedText().color().name() else: self.text_color_highlight = text_color_highlight def sortByName(self): self.snippets = sorted(self.snippets, key=lambda x: x.trigger_text) self.reset() def flags(self, index): if not index.isValid(): return Qt.ItemIsEnabled return Qt.ItemFlags(QAbstractTableModel.flags(self, index)) def data(self, index, role=Qt.DisplayRole): row = index.row() if not index.isValid() or not (0 <= row < len(self.snippets)): return to_qvariant() snippet = self.snippets[row] column = index.column() if role == Qt.DisplayRole: if column == self.TRIGGER: return to_qvariant(snippet.trigger_text) elif column == self.DESCRIPTION: return to_qvariant(snippet.description) elif role == Qt.TextAlignmentRole: return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) elif role == Qt.ToolTipRole: return to_qvariant(_("Double-click to view or edit")) return to_qvariant() def headerData(self, section, orientation, role=Qt.DisplayRole): if role == Qt.TextAlignmentRole: if orientation == Qt.Horizontal: return to_qvariant(int(Qt.AlignHCenter | Qt.AlignVCenter)) return to_qvariant(int(Qt.AlignRight | Qt.AlignVCenter)) if role != Qt.DisplayRole: return to_qvariant() if orientation == Qt.Horizontal: if section == self.TRIGGER: return to_qvariant(_('Trigger text')) elif section == self.DESCRIPTION: return to_qvariant(_('Description')) return to_qvariant() def rowCount(self, index=QModelIndex()): return len(self.snippets) def columnCount(self, index=QModelIndex()): return 2 def row(self, row_num): return self.snippets[row_num] def reset(self): self.beginResetModel() self.endResetModel() class SnippetModelsProxy: def __init__(self, parent): self.models = {} self.awaiting_queue = {} self.parent = parent def get_model(self, table, language, text_color=None): if language not in self.models: language_model = SnippetsModel(table, text_color=text_color) to_add = self.awaiting_queue.pop(language, []) self.load_snippets(language, language_model, to_add=to_add) self.models[language] = language_model language_model = self.models[language] return language_model def reload_model(self, language, defaults): if language in self.models: model = self.models[language] model.delete_queue = list(model.snippets) self.load_snippets(language, model, defaults) def load_snippets(self, language, model, snippets=None, to_add=[]): snippets = iter_snippets(language, self.parent.get_option, self.parent.set_option, snippets=snippets) for i, snippet in enumerate(snippets): snippet.index = i snippet_map = {(x.trigger_text, x.description): x for x in snippets} # Merge loaded snippets for snippet in to_add: key = (snippet.trigger_text, snippet.description) if key in snippet_map: to_replace = snippet_map[key] snippet.index = to_replace.index snippet_map[key] = snippet else: snippet.index = len(snippet_map) snippet_map[key] = snippet model.snippets = list(snippet_map.values()) model.snippet_map = snippet_map def save_snippets(self): language_changes = set({}) for language in self.models: language_changes |= {language} language_model = self.models[language] while len(language_model.delete_queue) > 0: snippet = language_model.delete_queue.pop(0) snippet.delete() for snippet in language_model.snippets: snippet.save() for language in list(self.awaiting_queue.keys()): language_changes |= {language} language_queue = self.awaiting_queue.pop(language) for snippet in language_queue: snippet.save() return language_changes def update_or_enqueue(self, language, trigger, description, snippet): new_snippet = Snippet( language=language, trigger_text=trigger, description=description, snippet_text=snippet['text'], remove_trigger=snippet['remove_trigger'], get_option=self.parent.get_option, set_option=self.parent.set_option) if language in self.models: language_model = self.models[language] snippet_map = language_model.snippet_map key = (trigger, description) if key in snippet_map: old_snippet = snippet_map[key] new_snippet.index = old_snippet.index snippet_map[key] = new_snippet else: new_snippet.index = len(snippet_map) snippet_map[key] = new_snippet language_model.snippets = list(snippet_map.values()) language_model.snippet_map = snippet_map language_model.reset() else: language_queue = self.awaiting_queue.get(language, []) language_queue.append(new_snippet) self.awaiting_queue[language] = language_queue def export_snippets(self, filename): snippets = [] for language in self.models: language_model = self.models[language] language_snippets = { 'language': language, 'triggers': [] } triggers = {} for snippet in language_model.snippets: default_trigger = { 'trigger': snippet.trigger_text, 'descriptions': [] } snippet_info = triggers.get( snippet.trigger_text, default_trigger) snippet_info['descriptions'].append({ 'description': snippet.description, 'snippet': { 'text': snippet.snippet_text, 'remove_trigger': snippet.remove_trigger } }) triggers[snippet.trigger_text] = snippet_info language_snippets['triggers'] = list(triggers.values()) snippets.append(language_snippets) with open(filename, 'w') as f: json.dump(snippets, f) def import_snippets(self, filename): errors = {} total_snippets = 0 valid_snippets = 0 with open(filename, 'r') as f: try: snippets = json.load(f) except ValueError as e: errors['loading'] = e.msg if len(errors) == 0: try: json_validate(instance=snippets, schema=SNIPPETS_SCHEMA) except ValidationError as e: index_path = ['snippets'] for part in e.absolute_path: index_path.append('[{0}]'.format(part)) full_message = '{0} on instance {1}:
{2}'.format( e.message, ''.join(index_path), e.instance ) errors['validation'] = full_message if len(errors) == 0: for language_info in snippets: language = language_info['language'] triggers = language_info['triggers'] for trigger_info in triggers: trigger = trigger_info['trigger'] descriptions = trigger_info['descriptions'] for description_info in descriptions: description = description_info['description'] snippet = description_info['snippet'] snippet_text = snippet['text'] total_snippets += 1 try: build_snippet_ast(snippet_text) self.update_or_enqueue( language, trigger, description, snippet) valid_snippets += 1 except SyntaxError as e: syntax_errors = errors.get('syntax', {}) key = '{0}/{1}/{2}'.format( language, trigger, description) syntax_errors[key] = e.msg errors['syntax'] = syntax_errors return valid_snippets, total_snippets, errors class SnippetTable(QTableView): def __init__(self, parent, proxy, language=None, text_color=None): super(SnippetTable, self).__init__() self._parent = parent self.language = language self.proxy = proxy self.source_model = proxy.get_model( self, language.lower(), text_color=text_color) self.setModel(self.source_model) self.setItemDelegateForColumn(CMD, ItemDelegate(self)) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSelectionMode(QAbstractItemView.SingleSelection) self.setSortingEnabled(True) self.setEditTriggers(QAbstractItemView.AllEditTriggers) self.selectionModel().selectionChanged.connect(self.selection) self.verticalHeader().hide() self.reset_plain() def focusOutEvent(self, e): """Qt Override.""" # self.source_model.update_active_row() # self._parent.delete_btn.setEnabled(False) super(SnippetTable, self).focusOutEvent(e) def focusInEvent(self, e): """Qt Override.""" super(SnippetTable, self).focusInEvent(e) self.selectRow(self.currentIndex().row()) def selection(self, index): self.update() self.isActiveWindow() self._parent.delete_snippet_btn.setEnabled(True) def adjust_cells(self): """Adjust column size based on contents.""" self.resizeColumnsToContents() fm = self.horizontalHeader().fontMetrics() names = [fm.width(s.description) for s in self.source_model.snippets] if names: self.setColumnWidth(CMD, max(names)) self.horizontalHeader().setStretchLastSection(True) def reset_plain(self): self.source_model.reset() self.adjust_cells() self.sortByColumn(self.source_model.TRIGGER, Qt.AscendingOrder) self.selectionModel().selectionChanged.connect(self.selection) def update_language_model(self, language): self.language = language.lower() self.source_model = self.proxy.get_model(self, language.lower()) self.setModel(self.source_model) self._parent.delete_snippet_btn.setEnabled(False) self.reset_plain() def delete_snippet(self, idx): snippet = self.source_model.snippets.pop(idx) self.source_model.delete_queue.append(snippet) self.source_model.snippet_map.pop( (snippet.trigger_text, snippet.description)) self.source_model.reset() self.adjust_cells() self.sortByColumn(self.source_model.TRIGGER, Qt.AscendingOrder) def show_editor(self, new_snippet=False): snippet = Snippet(get_option=self._parent.get_option, set_option=self._parent.set_option) if not new_snippet: idx = self.currentIndex().row() snippet = self.source_model.row(idx) snippets_keys = list(self.source_model.snippet_map.keys()) trigger_texts = list({x[0] for x in snippets_keys}) descriptions = {} for trigger, description in snippets_keys: trigger_descriptions = descriptions.get(trigger, set({})) trigger_descriptions |= {description} descriptions[trigger] = trigger_descriptions dialog = SnippetEditor(self, language=self.language.lower(), trigger_text=snippet.trigger_text, description=snippet.description, remove_trigger=snippet.remove_trigger, snippet_text=snippet.snippet_text, trigger_texts=trigger_texts, descriptions=descriptions, get_option=self._parent.get_option, set_option=self._parent.set_option) if dialog.exec_(): snippet = dialog.get_options() key = (snippet.trigger_text, snippet.description) self.source_model.snippet_map[key] = snippet self.source_model.snippets = list( self.source_model.snippet_map.values()) self.source_model.reset() self.adjust_cells() self.sortByColumn(LANGUAGE, Qt.AscendingOrder) self._parent.set_modified(True) def next_row(self): """Move to next row from currently selected row.""" row = self.currentIndex().row() rows = self.source_model.rowCount() if row + 1 == rows: row = -1 self.selectRow(row + 1) def previous_row(self): """Move to previous row from currently selected row.""" row = self.currentIndex().row() rows = self.source_model.rowCount() if row == 0: row = rows self.selectRow(row - 1) def keyPressEvent(self, event): """Qt Override.""" key = event.key() if key in [Qt.Key_Enter, Qt.Key_Return]: self.show_editor() elif key in [Qt.Key_Backtab]: self.parent().reset_btn.setFocus() elif key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right]: super(SnippetTable, self).keyPressEvent(event) else: super(SnippetTable, self).keyPressEvent(event) def mouseDoubleClickEvent(self, event): """Qt Override.""" self.show_editor()