%PDF- %PDF-
| Direktori : /lib/calibre/calibre/gui2/tweak_book/editor/ |
| Current File : //lib/calibre/calibre/gui2/tweak_book/editor/snippets.py |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
import copy
import re
import weakref
from collections import OrderedDict, namedtuple
from itertools import groupby
from operator import attrgetter, itemgetter
from qt.core import (
QDialog, QDialogButtonBox, QFrame, QGridLayout, QHBoxLayout, QIcon, QLabel,
QLineEdit, QListView, QListWidget, QListWidgetItem, QObject, QPushButton, QSize,
QStackedLayout, Qt, QTextCursor, QToolButton, QVBoxLayout, QWidget
)
from calibre.constants import ismacos
from calibre.gui2 import error_dialog
from calibre.gui2.tweak_book.editor import all_text_syntaxes
from calibre.gui2.tweak_book.editor.smarts.utils import get_text_before_cursor
from calibre.gui2.tweak_book.widgets import Dialog, PlainTextEdit
from calibre.utils.config import JSONConfig
from calibre.utils.icu import string_length as strlen
from calibre.utils.localization import localize_user_manual_link
from polyglot.builtins import codepoint_to_chr, iteritems, itervalues
string_length = lambda x: strlen(str(x)) # Needed on narrow python builds, as subclasses of unicode dont work
KEY = Qt.Key.Key_J
MODIFIER = Qt.KeyboardModifier.MetaModifier if ismacos else Qt.KeyboardModifier.ControlModifier
SnipKey = namedtuple('SnipKey', 'trigger syntaxes')
def snip_key(trigger, *syntaxes):
if '*' in syntaxes:
syntaxes = all_text_syntaxes
return SnipKey(trigger, frozenset(syntaxes))
def contains(l1, r1, l2, r2):
# True iff (l2, r2) if contained in (l1, r1)
return l2 > l1 and r2 < r1
builtin_snippets = { # {{{
snip_key('Lorem', 'html', 'xml'): {
'description': _('Insert filler text'),
'template': '''\
<p>The actual teachings of the great explorer of the truth, the master-builder
of human happiness. No one rejects, dislikes, or avoids pleasure itself,
because it is pleasure, but because those who do not know how to pursue
pleasure rationally encounter consequences that are extremely painful.</p>
<p>Nor again is there anyone who loves or pursues or desires to obtain pain of
itself, because it is pain, but because occasionally circumstances occur in
which toil and pain can procure him some great pleasure. To take a trivial
example, which of us ever undertakes laborious physical exercise, except to
obtain some advantage from it? But.</p>
''',
},
snip_key('<<', 'html', 'xml'): {
'description': _('Insert a tag'),
'template': '<$1>${2*}</$1>$3',
},
snip_key('<>', 'html', 'xml'): {
'description': _('Insert a self closing tag'),
'template': '<$1/>$2',
},
snip_key('<a', 'html'): {
'description': _('Insert a HTML link'),
'template': '<a href="${1:filename}">${2*}</a>$3',
},
snip_key('<i', 'html'): {
'description': _('Insert a HTML image'),
'template': '<img src="${1:filename}" alt="${2*:description}" />$3',
},
snip_key('<c', 'html'): {
'description': _('Insert a HTML tag with a class'),
'template': '<$1 class="${2:classname}">${3*}</$1>$4',
},
} # }}}
# Parsing of snippets {{{
escape = unescape = None
def escape_funcs():
global escape, unescape
if escape is None:
escapem = {('\\' + x):codepoint_to_chr(i+1) for i, x in enumerate('\\${}')}
escape_pat = re.compile('|'.join(map(re.escape, escapem)))
escape = lambda x: escape_pat.sub(lambda m: escapem[m.group()], x.replace(r'\\', '\x01'))
unescapem = {v:k[1] for k, v in iteritems(escapem)}
unescape_pat = re.compile('|'.join(unescapem))
unescape = lambda x:unescape_pat.sub(lambda m:unescapem[m.group()], x)
return escape, unescape
class TabStop(str):
def __new__(self, raw, start_offset, tab_stops, is_toplevel=True):
if raw.endswith('}'):
unescape = escape_funcs()[1]
num, default = raw[2:-1].partition(':')[0::2]
# Look for tab stops defined in the default text
uraw, child_stops = parse_template(unescape(default), start_offset=start_offset, is_toplevel=False, grouped=False)
for c in child_stops:
c.parent = self
tab_stops.extend(child_stops)
self = str.__new__(self, uraw)
if num.endswith('*'):
self.takes_selection = True
num = num[:-1]
else:
self.takes_selection = False
self.num = int(num)
else:
self = str.__new__(self, '')
self.num = int(raw[1:])
self.takes_selection = False
self.start = start_offset
self.is_toplevel = is_toplevel
self.is_mirror = False
self.parent = None
tab_stops.append(self)
return self
def __repr__(self):
return 'TabStop(text=%s num=%d start=%d is_mirror=%s takes_selection=%s is_toplevel=%s)' % (
str.__repr__(self), self.num, self.start, self.is_mirror, self.takes_selection, self.is_toplevel)
def parse_template(template, start_offset=0, is_toplevel=True, grouped=True):
escape, unescape = escape_funcs()
template = escape(template)
pos, parts, tab_stops = start_offset, [], []
for part in re.split(r'(\$(?:\d+|\{[^}]+\}))', template):
is_tab_stop = part.startswith('$')
if is_tab_stop:
ts = TabStop(part, pos, tab_stops, is_toplevel=is_toplevel)
parts.append(ts)
else:
parts.append(unescape(part))
pos += string_length(parts[-1])
if grouped:
key = attrgetter('num')
tab_stops.sort(key=key)
ans = OrderedDict()
for num, stops in groupby(tab_stops, key):
stops = tuple(stops)
for ts in stops[1:]:
ts.is_mirror = True
ans[num] = stops
tab_stops = ans
return ''.join(parts), tab_stops
# }}}
_snippets = None
user_snippets = JSONConfig('editor_snippets')
def snippets(refresh=False):
global _snippets
if _snippets is None or refresh:
_snippets = copy.deepcopy(builtin_snippets)
for snip in user_snippets.get('snippets', []):
if snip['trigger'] and isinstance(snip['trigger'], str):
key = snip_key(snip['trigger'], *snip['syntaxes'])
_snippets[key] = {'template':snip['template'], 'description':snip['description']}
_snippets = sorted(iteritems(_snippets), key=(lambda key_snip:string_length(key_snip[0].trigger)), reverse=True)
return _snippets
# Editor integration {{{
class EditorTabStop:
def __init__(self, left, tab_stops, editor):
self.editor = weakref.ref(editor)
tab_stop = tab_stops[0]
self.num = tab_stop.num
self.is_mirror = tab_stop.is_mirror
self.is_deleted = False
self.is_toplevel = tab_stop.is_toplevel
self.takes_selection = tab_stop.takes_selection
self.left = left + tab_stop.start
l = string_length(tab_stop)
self.right = self.left + l
self.mirrors = tuple(EditorTabStop(left, [ts], editor) for ts in tab_stops[1:])
self.ignore_position_update = False
self.join_previous_edit = False
self.transform = None
self.has_transform = self.transform is not None
def __enter__(self):
self.join_previous_edit = True
def __exit__(self, *args):
self.join_previous_edit = False
def __repr__(self):
return 'EditorTabStop(num={!r} text={!r} left={!r} right={!r} is_deleted={!r} mirrors={!r})'.format(
self.num, self.text, self.left, self.right, self.is_deleted, self.mirrors)
__str__ = __unicode__ = __repr__
def apply_selected_text(self, text):
if self.takes_selection and not self.is_deleted:
with self:
self.text = text
for m in self.mirrors:
with m:
m.text = text
@property
def text(self):
editor = self.editor()
if editor is None or self.is_deleted:
return ''
c = editor.textCursor()
c.setPosition(self.left), c.setPosition(self.right, QTextCursor.MoveMode.KeepAnchor)
return editor.selected_text_from_cursor(c)
@text.setter
def text(self, text):
editor = self.editor()
if editor is None or self.is_deleted:
return
c = editor.textCursor()
c.joinPreviousEditBlock() if self.join_previous_edit else c.beginEditBlock()
c.setPosition(self.left), c.setPosition(self.right, QTextCursor.MoveMode.KeepAnchor)
c.insertText(text)
c.endEditBlock()
def set_editor_cursor(self, editor):
if not self.is_deleted:
c = editor.textCursor()
c.setPosition(self.left), c.setPosition(self.right, QTextCursor.MoveMode.KeepAnchor)
editor.setTextCursor(c)
def contained_in(self, left, right):
return contains(left, right, self.left, self.right)
def contains(self, left, right):
return contains(self.left, self.right, left, right)
def update_positions(self, position, chars_removed, chars_added):
for m in self.mirrors:
m.update_positions(position, chars_removed, chars_added)
if position > self.right or self.is_deleted or self.ignore_position_update:
return
# First handle deletions
if chars_removed > 0:
if self.contained_in(position, position + chars_removed):
self.is_deleted = True
return
if position <= self.left:
self.left = max(self.left - chars_removed, position)
if position <= self.right:
self.right = max(self.right - chars_removed, position)
if chars_added > 0:
if position < self.left:
self.left += chars_added
if position <= self.right:
self.right += chars_added
class Template(list):
def __new__(self, tab_stops):
self = list.__new__(self)
self.left_most_ts = self.right_most_ts = None
self.extend(tab_stops)
for c in self:
if self.left_most_ts is None or self.left_most_ts.left > c.left:
self.left_most_ts = c
if self.right_most_ts is None or self.right_most_ts.right <= c.right:
self.right_most_ts = c
self.has_tab_stops = bool(self)
self.active_tab_stop = None
return self
@property
def left_most_position(self):
return getattr(self.left_most_ts, 'left', None)
@property
def right_most_position(self):
return getattr(self.right_most_ts, 'right', None)
def contains_cursor(self, cursor):
if not self.has_tab_stops:
return False
pos = cursor.position()
if self.left_most_position <= pos <= self.right_most_position:
return True
return False
def jump_to_next(self, editor):
if self.active_tab_stop is None:
self.active_tab_stop = ts = self.find_closest_tab_stop(editor.textCursor().position())
if ts is not None:
ts.set_editor_cursor(editor)
return ts
ts = self.active_tab_stop
if not ts.is_deleted:
if ts.has_transform:
ts.text = ts.transform(ts.text)
for m in ts.mirrors:
if not m.is_deleted:
m.text = ts.text
for x in self:
if x.num > ts.num and not x.is_deleted:
self.active_tab_stop = x
x.set_editor_cursor(editor)
return x
def remains_active(self):
if self.active_tab_stop is None:
return False
ts = self.active_tab_stop
for x in self:
if x.num > ts.num and not x.is_deleted:
return True
return bool(ts.mirrors) or ts.has_transform
def find_closest_tab_stop(self, position):
ans = dist = None
for c in self:
x = min(abs(c.left - position), abs(c.right - position))
if ans is None or x < dist:
dist, ans = x, c
return ans
def expand_template(editor, trigger, template):
c = editor.textCursor()
c.beginEditBlock()
c.setPosition(c.position())
right = c.position()
left = right - string_length(trigger)
text, tab_stops = parse_template(template)
c.setPosition(left), c.setPosition(right, QTextCursor.MoveMode.KeepAnchor), c.insertText(text)
editor_tab_stops = [EditorTabStop(left, ts, editor) for ts in itervalues(tab_stops)]
tl = Template(editor_tab_stops)
if tl.has_tab_stops:
tl.active_tab_stop = ts = editor_tab_stops[0]
ts.set_editor_cursor(editor)
else:
editor.setTextCursor(c)
c.endEditBlock()
return tl
def find_matching_snip(text, syntax=None, snip_func=None):
ans_snip = ans_trigger = None
for key, snip in (snip_func or snippets)():
if text.endswith(key.trigger) and (syntax in key.syntaxes or syntax is None):
ans_snip, ans_trigger = snip, key.trigger
break
return ans_snip, ans_trigger
class SnippetManager(QObject):
def __init__(self, editor):
QObject.__init__(self, editor)
self.active_templates = []
self.last_selected_text = ''
editor.document().contentsChange.connect(self.contents_changed)
self.snip_func = None
def contents_changed(self, position, chars_removed, chars_added):
for template in self.active_templates:
for ets in template:
ets.update_positions(position, chars_removed, chars_added)
def get_active_template(self, cursor):
remove = []
at = None
pos = cursor.position()
for template in self.active_templates:
if at is None and template.contains_cursor(cursor):
at = template
elif pos > template.right_most_position or pos < template.left_most_position:
remove.append(template)
for template in remove:
self.active_templates.remove(template)
return at
def handle_key_press(self, ev):
editor = self.parent()
if ev.key() == KEY and ev.modifiers() & MODIFIER:
at = self.get_active_template(editor.textCursor())
if at is not None:
if at.jump_to_next(editor) is None:
self.active_templates.remove(at)
else:
if not at.remains_active():
self.active_templates.remove(at)
ev.accept()
return True
lst, self.last_selected_text = self.last_selected_text, editor.selected_text
if self.last_selected_text:
editor.textCursor().insertText('')
ev.accept()
return True
c, text = get_text_before_cursor(editor)
snip, trigger = find_matching_snip(text, editor.syntax, self.snip_func)
if snip is None:
error_dialog(self.parent(), _('No snippet found'), _(
'No matching snippet was found'), show=True)
self.last_selected_text = self.last_selected_text or lst
return True
template = expand_template(editor, trigger, snip['template'])
if template.has_tab_stops:
self.active_templates.append(template)
if lst:
for ts in template:
ts.apply_selected_text(lst)
ev.accept()
return True
return False
# }}}
# Config {{{
class SnippetTextEdit(PlainTextEdit):
def __init__(self, text, parent=None):
PlainTextEdit.__init__(self, parent)
if text:
self.setPlainText(text)
self.snippet_manager = SnippetManager(self)
def keyPressEvent(self, ev):
if self.snippet_manager.handle_key_press(ev):
return
PlainTextEdit.keyPressEvent(self, ev)
class EditSnippet(QWidget):
def __init__(self, parent=None):
QWidget.__init__(self, parent)
self.l = l = QGridLayout(self)
def add_row(*args):
r = l.rowCount()
if len(args) == 1:
l.addWidget(args[0], r, 0, 1, 2)
else:
la = QLabel(args[0])
l.addWidget(la, r, 0, Qt.AlignmentFlag.AlignRight), l.addWidget(args[1], r, 1)
la.setBuddy(args[1])
self.heading = la = QLabel('<h2>\xa0')
add_row(la)
self.helpl = la = QLabel(_('For help with snippets, see the <a href="%s">User Manual</a>') %
localize_user_manual_link('https://manual.calibre-ebook.com/snippets.html'))
la.setOpenExternalLinks(True)
add_row(la)
self.name = n = QLineEdit(self)
n.setPlaceholderText(_('The name of this snippet'))
add_row(_('&Name:'), n)
self.trig = t = QLineEdit(self)
t.setPlaceholderText(_('The text used to trigger this snippet'))
add_row(_('Tri&gger:'), t)
self.template = t = PlainTextEdit(self)
la.setBuddy(t)
add_row(_('&Template:'), t)
self.types = t = QListWidget(self)
t.setFlow(QListView.Flow.LeftToRight)
t.setWrapping(True), t.setResizeMode(QListView.ResizeMode.Adjust), t.setSpacing(5)
fm = t.fontMetrics()
t.setMaximumHeight(2*(fm.ascent() + fm.descent()) + 25)
add_row(_('&File types:'), t)
t.setToolTip(_('Which file types this snippet should be active in'))
self.frame = f = QFrame(self)
f.setFrameShape(QFrame.Shape.HLine)
add_row(f)
self.test = d = SnippetTextEdit('', self)
d.snippet_manager.snip_func = self.snip_func
d.setToolTip(_('You can test your snippet here'))
d.setMaximumHeight(t.maximumHeight() + 15)
add_row(_('T&est:'), d)
i = QListWidgetItem(_('All'), t)
i.setData(Qt.ItemDataRole.UserRole, '*')
i.setCheckState(Qt.CheckState.Checked)
i.setFlags(i.flags() | Qt.ItemFlag.ItemIsUserCheckable)
for ftype in sorted(all_text_syntaxes):
i = QListWidgetItem(ftype, t)
i.setData(Qt.ItemDataRole.UserRole, ftype)
i.setCheckState(Qt.CheckState.Checked)
i.setFlags(i.flags() | Qt.ItemFlag.ItemIsUserCheckable)
self.creating_snippet = False
def snip_func(self):
key = snip_key(self.trig.text(), '*')
return ((key, self.snip),)
def apply_snip(self, snip, creating_snippet=None):
self.creating_snippet = not snip if creating_snippet is None else creating_snippet
self.heading.setText('<h2>' + (_('Create a snippet') if self.creating_snippet else _('Edit snippet')))
snip = snip or {}
self.name.setText(snip.get('description') or '')
self.trig.setText(snip.get('trigger') or '')
self.template.setPlainText(snip.get('template') or '')
ftypes = snip.get('syntaxes', ())
for i in range(self.types.count()):
i = self.types.item(i)
ftype = i.data(Qt.ItemDataRole.UserRole)
i.setCheckState(Qt.CheckState.Checked if ftype in ftypes else Qt.CheckState.Unchecked)
if self.creating_snippet and not ftypes:
self.types.item(0).setCheckState(Qt.CheckState.Checked)
(self.name if self.creating_snippet else self.template).setFocus(Qt.FocusReason.OtherFocusReason)
@property
def snip(self):
ftypes = []
for i in range(self.types.count()):
i = self.types.item(i)
if i.checkState() == Qt.CheckState.Checked:
ftypes.append(i.data(Qt.ItemDataRole.UserRole))
return {'description':self.name.text().strip(), 'trigger':self.trig.text(), 'template':self.template.toPlainText(), 'syntaxes':ftypes}
@snip.setter
def snip(self, snip):
self.apply_snip(snip)
def validate(self):
snip = self.snip
err = None
if not snip['description']:
err = _('You must provide a name for this snippet')
elif not snip['trigger']:
err = _('You must provide a trigger for this snippet')
elif not snip['template']:
err = _('You must provide a template for this snippet')
elif not snip['syntaxes']:
err = _('You must specify at least one file type')
return err
class UserSnippets(Dialog):
def __init__(self, parent=None):
Dialog.__init__(self, _('Create/edit snippets'), 'snippet-editor', parent=parent)
self.setWindowIcon(QIcon(I('snippets.png')))
def setup_ui(self):
self.setWindowIcon(QIcon(I('modified.png')))
self.l = l = QVBoxLayout(self)
self.stack = s = QStackedLayout()
l.addLayout(s), l.addWidget(self.bb)
self.listc = c = QWidget(self)
s.addWidget(c)
c.l = l = QVBoxLayout(c)
c.h = h = QHBoxLayout()
l.addLayout(h)
self.search_bar = sb = QLineEdit(self)
sb.setPlaceholderText(_('Search for a snippet'))
h.addWidget(sb)
self.next_button = b = QPushButton(_('&Next'))
b.clicked.connect(self.find_next)
h.addWidget(b)
c.h2 = h = QHBoxLayout()
l.addLayout(h)
self.snip_list = sl = QListWidget(self)
sl.doubleClicked.connect(self.edit_snippet)
h.addWidget(sl)
c.l2 = l = QVBoxLayout()
h.addLayout(l)
self.add_button = b = QToolButton(self)
b.setIcon(QIcon(I('plus.png'))), b.setText(_('&Add snippet')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
b.clicked.connect(self.add_snippet)
l.addWidget(b)
self.edit_button = b = QToolButton(self)
b.setIcon(QIcon(I('modified.png'))), b.setText(_('&Edit snippet')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
b.clicked.connect(self.edit_snippet)
l.addWidget(b)
self.add_button = b = QToolButton(self)
b.setIcon(QIcon(I('minus.png'))), b.setText(_('&Remove snippet')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
b.clicked.connect(self.remove_snippet)
l.addWidget(b)
self.add_button = b = QToolButton(self)
b.setIcon(QIcon(I('config.png'))), b.setText(_('Change &built-in')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
b.clicked.connect(self.change_builtin)
l.addWidget(b)
for i, snip in enumerate(sorted(user_snippets.get('snippets', []), key=itemgetter('trigger'))):
item = self.snip_to_item(snip)
if i == 0:
self.snip_list.setCurrentItem(item)
self.edit_snip = es = EditSnippet(self)
self.stack.addWidget(es)
def snip_to_text(self, snip):
return '{} - {}'.format(snip['trigger'], snip['description'])
def snip_to_item(self, snip):
i = QListWidgetItem(self.snip_to_text(snip), self.snip_list)
i.setData(Qt.ItemDataRole.UserRole, copy.deepcopy(snip))
return i
def reject(self):
if self.stack.currentIndex() > 0:
self.stack.setCurrentIndex(0)
return
return Dialog.reject(self)
def accept(self):
if self.stack.currentIndex() > 0:
err = self.edit_snip.validate()
if err is None:
self.stack.setCurrentIndex(0)
if self.edit_snip.creating_snippet:
item = self.snip_to_item(self.edit_snip.snip)
else:
item = self.snip_list.currentItem()
snip = self.edit_snip.snip
item.setText(self.snip_to_text(snip))
item.setData(Qt.ItemDataRole.UserRole, snip)
self.snip_list.setCurrentItem(item)
self.snip_list.scrollToItem(item)
else:
error_dialog(self, _('Invalid snippet'), err, show=True)
return
user_snippets['snippets'] = [self.snip_list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.snip_list.count())]
snippets(refresh=True)
return Dialog.accept(self)
def sizeHint(self):
return QSize(900, 600)
def edit_snippet(self, *args):
item = self.snip_list.currentItem()
if item is None:
return error_dialog(self, _('Cannot edit snippet'), _('No snippet selected'), show=True)
self.stack.setCurrentIndex(1)
self.edit_snip.snip = item.data(Qt.ItemDataRole.UserRole)
def add_snippet(self, *args):
self.stack.setCurrentIndex(1)
self.edit_snip.snip = None
def remove_snippet(self, *args):
item = self.snip_list.currentItem()
if item is not None:
self.snip_list.takeItem(self.snip_list.row(item))
def find_next(self, *args):
q = self.search_bar.text().strip()
if not q:
return
matches = self.snip_list.findItems(q, Qt.MatchFlag.MatchContains | Qt.MatchFlag.MatchWrap)
if len(matches) < 1:
return error_dialog(self, _('No snippets found'), _(
'No snippets found for query: %s') % q, show=True)
ci = self.snip_list.currentItem()
try:
item = matches[(matches.index(ci) + 1) % len(matches)]
except Exception:
item = matches[0]
self.snip_list.setCurrentItem(item)
self.snip_list.scrollToItem(item)
def change_builtin(self):
d = QDialog(self)
lw = QListWidget(d)
for (trigger, syntaxes), snip in iteritems(builtin_snippets):
snip = copy.deepcopy(snip)
snip['trigger'], snip['syntaxes'] = trigger, syntaxes
i = QListWidgetItem(self.snip_to_text(snip), lw)
i.setData(Qt.ItemDataRole.UserRole, snip)
d.l = l = QVBoxLayout(d)
l.addWidget(QLabel(_('Choose the built-in snippet to modify:')))
l.addWidget(lw)
lw.itemDoubleClicked.connect(d.accept)
d.bb = bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
l.addWidget(bb)
bb.accepted.connect(d.accept), bb.rejected.connect(d.reject)
if d.exec() == QDialog.DialogCode.Accepted and lw.currentItem() is not None:
self.stack.setCurrentIndex(1)
self.edit_snip.apply_snip(lw.currentItem().data(Qt.ItemDataRole.UserRole), creating_snippet=True)
# }}}
if __name__ == '__main__':
from calibre.gui2 import Application
app = Application([])
d = UserSnippets()
d.exec()
del app