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' __license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>' __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.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.complete2 import EditWithComplete
from calibre.gui2.tweak_book import dictionaries 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.config import JSONConfig
from calibre.utils.icu import capitalize, upper, lower, swapcase from calibre.utils.icu import capitalize, upper, lower, swapcase
from calibre.utils.titlecase import titlecase from calibre.utils.titlecase import titlecase
@ -24,6 +29,8 @@ def compile_code(src):
match = re.search(r'coding[:=]\s*([-\w.]+)', src[:200]) match = re.search(r'coding[:=]\s*([-\w.]+)', src[:200])
enc = match.group(1) if match else 'utf-8' enc = match.group(1) if match else 'utf-8'
src = src.decode(enc) 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 # Python complains if there is a coding declaration in a unicode string
src = re.sub(r'^#.*coding\s*[:=]\s*([-\w.]+)', '#', src, flags=re.MULTILINE) src = re.sub(r'^#.*coding\s*[:=]\s*([-\w.]+)', '#', src, flags=re.MULTILINE)
# Translate newlines to \n # Translate newlines to \n
@ -53,6 +60,7 @@ class Function(object):
self.context_name = name or '' self.context_name = name or ''
self.match_index = 0 self.match_index = 0
self.boss = get_boss() self.boss = get_boss()
self.data = {}
def __hash__(self): def __hash__(self):
return hash(self.name) return hash(self.name)
@ -65,7 +73,7 @@ class Function(object):
def __call__(self, match): def __call__(self, match):
self.match_index += 1 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 @property
def source(self): def source(self):
@ -86,8 +94,6 @@ def functions(refresh=False):
ans = _functions = {} ans = _functions = {}
for func in builtin_functions(): for func in builtin_functions():
ans[func.name] = Function(func.name, func=func) ans[func.name] = Function(func.name, func=func)
if refresh:
user_functions.refresh()
for name, source in user_functions.iteritems(): for name, source in user_functions.iteritems():
try: try:
f = Function(name, source=source) f = Function(name, source=source)
@ -96,6 +102,30 @@ def functions(refresh=False):
ans[f.name] = f ans[f.name] = f
return _functions 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): class FunctionBox(EditWithComplete):
save_search = pyqtSignal() save_search = pyqtSignal()
@ -107,6 +137,7 @@ class FunctionBox(EditWithComplete):
self.show_saved_search_actions = show_saved_search_actions self.show_saved_search_actions = show_saved_search_actions
self.refresh() self.refresh()
self.setToolTip(_('Choose a function to run on matched text (by name)')) self.setToolTip(_('Choose a function to run on matched text (by name)'))
boxes.append(weakref.ref(self))
def refresh(self): def refresh(self):
self.update_items_cache(set(functions())) self.update_items_cache(set(functions()))
@ -119,6 +150,76 @@ class FunctionBox(EditWithComplete):
menu.addAction(_('Show saved searches'), self.show_saved_searches.emit) menu.addAction(_('Show saved searches'), self.show_saved_searches.emit)
menu.exec_(event.globalPos()) 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 ########################################################## # Builtin functions ##########################################################
def builtin(name, *args): def builtin(name, *args):
@ -128,37 +229,48 @@ def builtin(name, *args):
return func return func
return f 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) @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, '''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 only the text in the groups will be changed, otherwise the entire text is
changed.''' changed.'''
return apply_func_to_match_groups(match, upper) return apply_func_to_match_groups(match, upper)
@builtin('Lower-case text', lower, apply_func_to_match_groups) @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, '''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 only the text in the groups will be changed, otherwise the entire text is
changed.''' changed.'''
return apply_func_to_match_groups(match, lower) return apply_func_to_match_groups(match, lower)
@builtin('Capitalize text', capitalize, apply_func_to_match_groups) @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, '''Capitalize matched text. If the regular expression contains groups,
only the text in the groups will be changed, otherwise the entire text is only the text in the groups will be changed, otherwise the entire text is
changed.''' changed.'''
return apply_func_to_match_groups(match, capitalize) return apply_func_to_match_groups(match, capitalize)
@builtin('Title-case text', titlecase, apply_func_to_match_groups) @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, '''Title-case matched text. If the regular expression contains groups,
only the text in the groups will be changed, otherwise the entire text is only the text in the groups will be changed, otherwise the entire text is
changed.''' changed.'''
return apply_func_to_match_groups(match, titlecase) return apply_func_to_match_groups(match, titlecase)
@builtin('Swap the case of text', swapcase, apply_func_to_match_groups) @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, '''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 only the text in the groups will be changed, otherwise the entire text is
changed.''' changed.'''
return apply_func_to_match_groups(match, swapcase) 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.dialogs.message_box import MessageBox
from calibre.gui2.widgets2 import HistoryComboBox from calibre.gui2.widgets2 import HistoryComboBox
from calibre.gui2.tweak_book import tprefs, editors, current_container 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.gui2.tweak_book.widgets import BusyCursor
from calibre.utils.icu import primary_contains from calibre.utils.icu import primary_contains
@ -235,10 +236,12 @@ class SearchWidget(QWidget):
fhl.setContentsMargins(0, 0, 0, 0) fhl.setContentsMargins(0, 0, 0, 0)
fhl.addWidget(fb, stretch=10, alignment=Qt.AlignVCenter) fhl.addWidget(fb, stretch=10, alignment=Qt.AlignVCenter)
self.ae_func = b = QPushButton(_('Create/&edit'), self) 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')) b.setToolTip(_('Create a new function, or edit an existing function'))
fhl.addWidget(b) fhl.addWidget(b)
self.rm_func = b = QPushButton(_('Remo&ve'), self) self.rm_func = b = QPushButton(_('Remo&ve'), self)
b.setToolTip(_('Remove this function')) b.setToolTip(_('Remove this function'))
b.clicked.connect(self.remove_function)
fhl.addWidget(b) fhl.addWidget(b)
self.fsep = f = QFrame(self) self.fsep = f = QFrame(self)
f.setFrameShape(f.VLine) f.setFrameShape(f.VLine)
@ -290,6 +293,17 @@ class SearchWidget(QWidget):
ol.addStretch(10) 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): def mode_changed(self, idx):
self.da.setVisible(idx > 0) self.da.setVisible(idx > 0)
function_mode = idx == 2 function_mode = idx == 2
@ -557,10 +571,12 @@ class EditSearch(QFrame): # {{{
la.setBuddy(f) la.setBuddy(f)
self.ae_func = b = QPushButton(_('Create/&edit'), self) self.ae_func = b = QPushButton(_('Create/&edit'), self)
b.setToolTip(_('Create a new function, or edit an existing function')) b.setToolTip(_('Create a new function, or edit an existing function'))
b.clicked.connect(self.edit_function)
g.addWidget(b, 1, 1) g.addWidget(b, 1, 1)
g.setColumnStretch(0, 10) g.setColumnStretch(0, 10)
self.rm_func = b = QPushButton(_('Remo&ve'), self) self.rm_func = b = QPushButton(_('Remo&ve'), self)
b.setToolTip(_('Remove this function')) b.setToolTip(_('Remove this function'))
b.clicked.connect(self.remove_function)
g.addWidget(b, 1, 2) g.addWidget(b, 1, 2)
self.case_sensitive = c = QCheckBox(_('Case sensitive')) 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_box.currentIndexChanged[int].connect(self.mode_changed)
self.mode_changed(self.mode_box.currentIndex()) 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): def mode_changed(self, idx):
self.dot_all.setVisible(idx > 0) self.dot_all.setVisible(idx > 0)
self.functions_container.setVisible(idx == 2) self.functions_container.setVisible(idx == 2)
@ -599,19 +626,25 @@ class EditSearch(QFrame): # {{{
self.original_name = self.search.get('name', None) self.original_name = self.search.get('name', None)
self.search_index = search_index self.search_index = search_index
self.mode_box.mode = self.search.get('mode', 'regex')
self.search_name.setText(self.search.get('name', '')) self.search_name.setText(self.search.get('name', ''))
self.find.setPlainText(self.search.get('find', '')) 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.replace.setPlainText(self.search.get('replace', ''))
self.case_sensitive.setChecked(self.search.get('case_sensitive', SearchWidget.DEFAULT_STATE['case_sensitive'])) 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.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: if state is not None:
self.find.setPlainText(state['find']) 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.replace.setPlainText(state['replace'])
self.case_sensitive.setChecked(state['case_sensitive']) self.case_sensitive.setChecked(state['case_sensitive'])
self.dot_all.setChecked(state['dot_all']) self.dot_all.setChecked(state['dot_all'])
self.mode_box.mode = state.get('mode')
def emit_done(self): def emit_done(self):
self.done.emit(True) self.done.emit(True)
@ -1147,6 +1180,8 @@ def get_search_function(search):
try: try:
return replace_functions()[ans] return replace_functions()[ans]
except KeyError: except KeyError:
if not ans:
return Function('empty-function', '')
raise NoSuchFunction(ans) raise NoSuchFunction(ans)
return ans return ans