Finish implementation of function mode for the editor

This commit is contained in:
Kovid Goyal 2014-11-19 15:02:59 +05:30
parent 35cd019066
commit ef3509ebc5
2 changed files with 162 additions and 15 deletions

View File

@ -6,13 +6,18 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'
import re, io
import re, io, weakref
from PyQt5.Qt import pyqtSignal
from PyQt5.Qt import (
pyqtSignal, QVBoxLayout, QHBoxLayout, QPlainTextEdit, QLabel, QFontMetrics,
QSize)
from calibre.ebooks.oeb.polish.utils import apply_func_to_match_groups
from calibre.gui2 import error_dialog
from calibre.gui2.complete2 import EditWithComplete
from calibre.gui2.tweak_book import dictionaries
from calibre.gui2.tweak_book.widgets import Dialog
from calibre.gui2.widgets import PythonHighlighter
from calibre.utils.config import JSONConfig
from calibre.utils.icu import capitalize, upper, lower, swapcase
from calibre.utils.titlecase import titlecase
@ -24,6 +29,8 @@ def compile_code(src):
match = re.search(r'coding[:=]\s*([-\w.]+)', src[:200])
enc = match.group(1) if match else 'utf-8'
src = src.decode(enc)
if not src or not src.strip():
src = EMPTY_FUNC
# Python complains if there is a coding declaration in a unicode string
src = re.sub(r'^#.*coding\s*[:=]\s*([-\w.]+)', '#', src, flags=re.MULTILINE)
# Translate newlines to \n
@ -53,6 +60,7 @@ class Function(object):
self.context_name = name or ''
self.match_index = 0
self.boss = get_boss()
self.data = {}
def __hash__(self):
return hash(self.name)
@ -65,7 +73,7 @@ class Function(object):
def __call__(self, match):
self.match_index += 1
return self.func(match, self.match_index, self.context_name, self.boss.current_metadata, dictionaries, functions())
return self.func(match, self.match_index, self.context_name, self.boss.current_metadata, dictionaries, self.data, functions())
@property
def source(self):
@ -86,8 +94,6 @@ def functions(refresh=False):
ans = _functions = {}
for func in builtin_functions():
ans[func.name] = Function(func.name, func=func)
if refresh:
user_functions.refresh()
for name, source in user_functions.iteritems():
try:
f = Function(name, source=source)
@ -96,6 +102,30 @@ def functions(refresh=False):
ans[f.name] = f
return _functions
def remove_function(name, gui_parent=None):
funcs = functions()
if not name:
return False
if name not in funcs:
error_dialog(gui_parent, _('No such function'), _(
'There is no function named %s') % name, show=True)
return False
if name not in user_functions:
error_dialog(gui_parent, _('Cannot remove builtin function'), _(
'The function %s is a builtin function, it cannot be removed.') % name, show=True)
del user_functions[name]
functions(refresh=True)
refresh_boxes()
return True
boxes = []
def refresh_boxes():
for ref in boxes:
box = ref()
if box is not None:
box.refresh()
class FunctionBox(EditWithComplete):
save_search = pyqtSignal()
@ -107,6 +137,7 @@ class FunctionBox(EditWithComplete):
self.show_saved_search_actions = show_saved_search_actions
self.refresh()
self.setToolTip(_('Choose a function to run on matched text (by name)'))
boxes.append(weakref.ref(self))
def refresh(self):
self.update_items_cache(set(functions()))
@ -119,6 +150,76 @@ class FunctionBox(EditWithComplete):
menu.addAction(_('Show saved searches'), self.show_saved_searches.emit)
menu.exec_(event.globalPos())
class FunctionEditor(Dialog):
def __init__(self, func_name='', parent=None):
self._func_name = func_name
Dialog.__init__(self, _('Create/edit a function'), 'edit-sr-func', parent=parent)
def setup_ui(self):
self.l = l = QVBoxLayout(self)
self.h = h = QHBoxLayout()
l.addLayout(h)
self.la1 = la = QLabel(_('F&unction name:'))
h.addWidget(la)
self.fb = fb = FunctionBox(self)
la.setBuddy(fb)
h.addWidget(fb, stretch=10)
self.la3 = la = QLabel(_('&Code:'))
self.source_code = QPlainTextEdit(self)
self.highlighter = PythonHighlighter(self.source_code.document())
la.setBuddy(self.source_code)
l.addWidget(la), l.addWidget(self.source_code)
if self._func_name:
self.fb.setText(self._func_name)
func = functions().get(self._func_name)
if func is not None:
self.source_code.setPlainText(func.source or ('\n' + EMPTY_FUNC))
else:
self.source_code.setPlainText('\n' + EMPTY_FUNC)
self.la2 = la = QLabel(_(
'For help with creating functions, see the <a href="%s">User Manual</a>') %
'http://manual.calibre-ebook.com/edit.html#function-mode')
la.setOpenExternalLinks(True)
l.addWidget(la)
l.addWidget(self.bb)
def sizeHint(self):
fm = QFontMetrics(self.font())
return QSize(fm.averageCharWidth() * 120, 600)
@property
def func_name(self):
return self.fb.text().strip()
@property
def source(self):
return self.source_code.toPlainText()
def accept(self):
if not self.func_name:
return error_dialog(self, _('Must specify name'), _(
'You must specify a name for this function.'), show=True)
source = self.source
try:
mod = compile_code(source)
except Exception as err:
return error_dialog(self, _('Invalid python code'), _(
'The code you created is not valid python code, with error: %s') % err, show=True)
if not callable(mod.get('replace')):
return error_dialog(self, _('No replace function'), _(
'You must create a python function named replace in your code'), show=True)
user_functions[self.func_name] = source
functions(refresh=True)
refresh_boxes()
Dialog.accept(self)
# Builtin functions ##########################################################
def builtin(name, *args):
@ -128,37 +229,48 @@ def builtin(name, *args):
return func
return f
EMPTY_FUNC = '''\
def replace(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs):
return ''
'''
@builtin('Upper-case text', upper, apply_func_to_match_groups)
def replace_uppercase(match, number, file_name, metadata, dictionaries, functions, *args, **kwargs):
def replace_uppercase(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs):
'''Make matched text upper case. If the regular expression contains groups,
only the text in the groups will be changed, otherwise the entire text is
changed.'''
return apply_func_to_match_groups(match, upper)
@builtin('Lower-case text', lower, apply_func_to_match_groups)
def replace_lowercase(match, number, file_name, metadata, dictionaries, functions, *args, **kwargs):
def replace_lowercase(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs):
'''Make matched text lower case. If the regular expression contains groups,
only the text in the groups will be changed, otherwise the entire text is
changed.'''
return apply_func_to_match_groups(match, lower)
@builtin('Capitalize text', capitalize, apply_func_to_match_groups)
def replace_capitalize(match, number, file_name, metadata, dictionaries, functions, *args, **kwargs):
def replace_capitalize(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs):
'''Capitalize matched text. If the regular expression contains groups,
only the text in the groups will be changed, otherwise the entire text is
changed.'''
return apply_func_to_match_groups(match, capitalize)
@builtin('Title-case text', titlecase, apply_func_to_match_groups)
def replace_titlecase(match, number, file_name, metadata, dictionaries, functions, *args, **kwargs):
def replace_titlecase(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs):
'''Title-case matched text. If the regular expression contains groups,
only the text in the groups will be changed, otherwise the entire text is
changed.'''
return apply_func_to_match_groups(match, titlecase)
@builtin('Swap the case of text', swapcase, apply_func_to_match_groups)
def replace_swapcase(match, number, file_name, metadata, dictionaries, functions, *args, **kwargs):
def replace_swapcase(match, number, file_name, metadata, dictionaries, data, functions, *args, **kwargs):
'''Swap the case of the matched text. If the regular expression contains groups,
only the text in the groups will be changed, otherwise the entire text is
changed.'''
return apply_func_to_match_groups(match, swapcase)
if __name__ == '__main__':
from PyQt5.Qt import QApplication
app = QApplication([])
FunctionEditor().exec_()
del app

View File

@ -23,7 +23,8 @@ from calibre.gui2 import error_dialog, info_dialog, choose_files, choose_save_fi
from calibre.gui2.dialogs.message_box import MessageBox
from calibre.gui2.widgets2 import HistoryComboBox
from calibre.gui2.tweak_book import tprefs, editors, current_container
from calibre.gui2.tweak_book.function_replace import FunctionBox, functions as replace_functions
from calibre.gui2.tweak_book.function_replace import (
FunctionBox, functions as replace_functions, FunctionEditor, remove_function, Function)
from calibre.gui2.tweak_book.widgets import BusyCursor
from calibre.utils.icu import primary_contains
@ -235,10 +236,12 @@ class SearchWidget(QWidget):
fhl.setContentsMargins(0, 0, 0, 0)
fhl.addWidget(fb, stretch=10, alignment=Qt.AlignVCenter)
self.ae_func = b = QPushButton(_('Create/&edit'), self)
b.clicked.connect(self.edit_function)
b.setToolTip(_('Create a new function, or edit an existing function'))
fhl.addWidget(b)
self.rm_func = b = QPushButton(_('Remo&ve'), self)
b.setToolTip(_('Remove this function'))
b.clicked.connect(self.remove_function)
fhl.addWidget(b)
self.fsep = f = QFrame(self)
f.setFrameShape(f.VLine)
@ -290,6 +293,17 @@ class SearchWidget(QWidget):
ol.addStretch(10)
def edit_function(self):
d = FunctionEditor(func_name=self.functions.text().strip(), parent=self)
if d.exec_() == d.Accepted:
self.functions.setText(d.func_name)
def remove_function(self):
fname = self.functions.text().strip()
if fname:
if remove_function(fname, self):
self.functions.setText('')
def mode_changed(self, idx):
self.da.setVisible(idx > 0)
function_mode = idx == 2
@ -557,10 +571,12 @@ class EditSearch(QFrame): # {{{
la.setBuddy(f)
self.ae_func = b = QPushButton(_('Create/&edit'), self)
b.setToolTip(_('Create a new function, or edit an existing function'))
b.clicked.connect(self.edit_function)
g.addWidget(b, 1, 1)
g.setColumnStretch(0, 10)
self.rm_func = b = QPushButton(_('Remo&ve'), self)
b.setToolTip(_('Remove this function'))
b.clicked.connect(self.remove_function)
g.addWidget(b, 1, 2)
self.case_sensitive = c = QCheckBox(_('Case sensitive'))
@ -587,6 +603,17 @@ class EditSearch(QFrame): # {{{
self.mode_box.currentIndexChanged[int].connect(self.mode_changed)
self.mode_changed(self.mode_box.currentIndex())
def edit_function(self):
d = FunctionEditor(func_name=self.function.text().strip(), parent=self)
if d.exec_() == d.Accepted:
self.function.setText(d.func_name)
def remove_function(self):
fname = self.function.text().strip()
if fname:
if remove_function(fname, self):
self.function.setText('')
def mode_changed(self, idx):
self.dot_all.setVisible(idx > 0)
self.functions_container.setVisible(idx == 2)
@ -599,19 +626,25 @@ class EditSearch(QFrame): # {{{
self.original_name = self.search.get('name', None)
self.search_index = search_index
self.mode_box.mode = self.search.get('mode', 'regex')
self.search_name.setText(self.search.get('name', ''))
self.find.setPlainText(self.search.get('find', ''))
if self.mode_box.mode == 'function':
self.function.setText(self.search.get('replace', ''))
else:
self.replace.setPlainText(self.search.get('replace', ''))
self.case_sensitive.setChecked(self.search.get('case_sensitive', SearchWidget.DEFAULT_STATE['case_sensitive']))
self.dot_all.setChecked(self.search.get('dot_all', SearchWidget.DEFAULT_STATE['dot_all']))
self.mode_box.mode = self.search.get('mode', 'regex')
if state is not None:
self.find.setPlainText(state['find'])
self.mode_box.mode = state.get('mode')
if self.mode_box.mode == 'function':
self.function.setText(state['replace'])
else:
self.replace.setPlainText(state['replace'])
self.case_sensitive.setChecked(state['case_sensitive'])
self.dot_all.setChecked(state['dot_all'])
self.mode_box.mode = state.get('mode')
def emit_done(self):
self.done.emit(True)
@ -1147,6 +1180,8 @@ def get_search_function(search):
try:
return replace_functions()[ans]
except KeyError:
if not ans:
return Function('empty-function', '')
raise NoSuchFunction(ans)
return ans