# -*- 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()