%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/preferences/ |
Current File : //lib/calibre/calibre/gui2/preferences/tweaks.py |
#!/usr/bin/env python3 # License: GPLv3 Copyright: 2010, Kovid Goyal <kovid at kovidgoyal.net> import textwrap from collections import OrderedDict from functools import partial from operator import attrgetter from calibre import isbytestring, prepare_string_for_xml from calibre.gui2 import error_dialog, info_dialog from calibre.gui2.preferences import AbortCommit, ConfigWidgetBase, test_widget from calibre.gui2.search_box import SearchBox2 from calibre.gui2.widgets import PythonHighlighter from calibre.utils.config_base import (default_tweaks_raw, exec_tweaks, normalize_tweak, read_custom_tweaks, write_custom_tweaks) from calibre.utils.icu import lower from calibre.utils.search_query_parser import ParseException, SearchQueryParser from polyglot.builtins import iteritems from qt.core import (QAbstractItemView, QAbstractListModel, QApplication, QComboBox, QDialog, QDialogButtonBox, QFont, QGridLayout, QGroupBox, QIcon, QItemSelectionModel, QLabel, QListView, QMenu, QModelIndex, QPlainTextEdit, QPushButton, QSizePolicy, QSplitter, Qt, QVBoxLayout, QWidget, pyqtSignal) ROOT = QModelIndex() def format_doc(doc): current_indent = default_indent = None lines = [''] for line in doc.splitlines(): if not line.strip(): lines.append('') continue line = line[1:] indent = len(line) - len(line.lstrip()) if indent != current_indent: lines.append('') if default_indent is None: default_indent = indent current_indent = indent if indent == default_indent: if lines and lines[-1]: lines[-1] += ' ' + line else: lines.append(line) else: lines.append(' ' + line.strip()) return '\n'.join(lines).lstrip() class AdaptSQP(SearchQueryParser): def __init__(self, *args, **kwargs): pass class Tweak: # {{{ def __init__(self, name, doc, var_names, defaults, custom): translate = _ self.name = translate(name) self.doc = doc.strip() self.doc = ' ' + self.doc self.var_names = var_names if self.var_names: self.doc = "%s: %s\n\n%s"%(_('ID'), self.var_names[0], format_doc(self.doc)) self.default_values = OrderedDict() for x in var_names: self.default_values[x] = defaults[x] self.custom_values = OrderedDict() for x in var_names: if x in custom: self.custom_values[x] = custom[x] def __str__(self): ans = ['#: ' + self.name] for line in self.doc.splitlines(): if line: ans.append('# ' + line) for key, val in iteritems(self.default_values): val = self.custom_values.get(key, val) ans.append('%s = %r'%(key, val)) ans = '\n'.join(ans) return ans @property def sort_key(self): return 0 if self.is_customized else 1 @property def is_customized(self): for x, val in iteritems(self.default_values): cval = self.custom_values.get(x, val) if normalize_tweak(cval) != normalize_tweak(val): return True return False @property def edit_text(self): from pprint import pformat ans = ['# %s'%self.name] for x, val in iteritems(self.default_values): val = self.custom_values.get(x, val) if isinstance(val, (list, tuple, dict, set, frozenset)): ans.append(f'{x} = {pformat(val)}') else: ans.append('%s = %r'%(x, val)) return '\n\n'.join(ans) def restore_to_default(self): self.custom_values.clear() def update(self, varmap): self.custom_values.update(varmap) # }}} class Tweaks(QAbstractListModel, AdaptSQP): # {{{ def __init__(self, parent=None): QAbstractListModel.__init__(self, parent) SearchQueryParser.__init__(self, ['all']) self.parse_tweaks() def rowCount(self, *args): return len(self.tweaks) def data(self, index, role): row = index.row() try: tweak = self.tweaks[row] except: return None if role == Qt.ItemDataRole.DisplayRole: return tweak.name if role == Qt.ItemDataRole.FontRole and tweak.is_customized: ans = QFont() ans.setBold(True) return ans if role == Qt.ItemDataRole.ToolTipRole: tt = _('This tweak has its default value') if tweak.is_customized: tt = '<p>'+_('This tweak has been customized') tt += '<pre>' for varn, val in iteritems(tweak.custom_values): tt += '%s = %r\n\n'%(varn, val) return textwrap.fill(tt) if role == Qt.ItemDataRole.UserRole: return tweak return None def parse_tweaks(self): try: custom_tweaks = read_custom_tweaks() except: print('Failed to load custom tweaks file') import traceback traceback.print_exc() custom_tweaks = {} default_tweaks = exec_tweaks(default_tweaks_raw()) defaults = default_tweaks_raw().decode('utf-8') lines = defaults.splitlines() pos = 0 self.tweaks = [] while pos < len(lines): line = lines[pos] if line.startswith('#:'): pos = self.read_tweak(lines, pos, default_tweaks, custom_tweaks) pos += 1 self.tweaks.sort(key=attrgetter('sort_key')) default_keys = set(default_tweaks) custom_keys = set(custom_tweaks) self.plugin_tweaks = {} for key in custom_keys - default_keys: self.plugin_tweaks[key] = custom_tweaks[key] def read_tweak(self, lines, pos, defaults, custom): name = lines[pos][2:].strip() doc, stripped_doc, leading, var_names = [], [], [], [] while True: pos += 1 line = lines[pos] if not line.startswith('#'): break line = line[1:] doc.append(line.rstrip()) stripped_doc.append(line.strip()) leading.append(line[:len(line) - len(line.lstrip())]) translate = _ stripped_doc = translate('\n'.join(stripped_doc).strip()) final_doc = [] for prefix, line in zip(leading, stripped_doc.splitlines()): final_doc.append(prefix + line) doc = '\n'.join(final_doc) while True: try: line = lines[pos] except IndexError: break if not line.strip(): break spidx1 = line.find(' ') spidx2 = line.find('=') spidx = spidx1 if spidx1 > 0 and (spidx2 == 0 or spidx2 > spidx1) else spidx2 if spidx > 0: var = line[:spidx] if var not in defaults: raise ValueError('%r not in default tweaks dict'%var) var_names.append(var) pos += 1 if not var_names: raise ValueError('Failed to find any variables for %r'%name) self.tweaks.append(Tweak(name, doc, var_names, defaults, custom)) return pos def restore_to_default(self, idx): tweak = self.data(idx, Qt.ItemDataRole.UserRole) if tweak is not None: tweak.restore_to_default() self.dataChanged.emit(idx, idx) def restore_to_defaults(self): for r in range(self.rowCount()): self.restore_to_default(self.index(r)) self.plugin_tweaks = {} def update_tweak(self, idx, varmap): tweak = self.data(idx, Qt.ItemDataRole.UserRole) if tweak is not None: tweak.update(varmap) self.dataChanged.emit(idx, idx) def to_string(self): ans = ['#!/usr/bin/env python', '# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai', '', '# This file was automatically generated by calibre, do not' ' edit it unless you know what you are doing.', '', ] for tweak in self.tweaks: ans.extend(['', str(tweak), '']) if self.plugin_tweaks: ans.extend(['', '', '# The following are tweaks for installed plugins', '']) for key, val in iteritems(self.plugin_tweaks): ans.extend(['%s = %r'%(key, val), '', '']) return '\n'.join(ans) @property def plugin_tweaks_string(self): ans = [] for key, val in iteritems(self.plugin_tweaks): ans.extend(['%s = %r'%(key, val), '', '']) ans = '\n'.join(ans) if isbytestring(ans): ans = ans.decode('utf-8') return ans def set_plugin_tweaks(self, d): self.plugin_tweaks = d def universal_set(self): return set(range(self.rowCount())) def get_matches(self, location, query, candidates=None): if candidates is None: candidates = self.universal_set() ans = set() if not query: return ans query = lower(query) for r in candidates: dat = self.data(self.index(r), Qt.ItemDataRole.UserRole) var_names = ' '.join(dat.default_values) if query in lower(dat.name) or query in lower(var_names): ans.add(r) return ans def find(self, query): query = query.strip() if not query: return ROOT matches = self.parse(query) if not matches: return ROOT matches = list(sorted(matches)) return self.index(matches[0]) def find_next(self, idx, query, backwards=False): query = query.strip() if not query: return idx matches = self.parse(query) if not matches: return idx loc = idx.row() if loc not in matches: return self.find(query) if len(matches) == 1: return ROOT matches = list(sorted(matches)) i = matches.index(loc) if backwards: ans = i - 1 if i - 1 >= 0 else len(matches)-1 else: ans = i + 1 if i + 1 < len(matches) else 0 ans = matches[ans] return self.index(ans) # }}} class PluginTweaks(QDialog): # {{{ def __init__(self, raw, parent=None): QDialog.__init__(self, parent) self.setWindowTitle(_('Plugin tweaks')) self.edit = QPlainTextEdit(self) self.highlighter = PythonHighlighter(self.edit.document()) self.l = QVBoxLayout() self.setLayout(self.l) self.msg = QLabel( _('Add/edit tweaks for any custom plugins you have installed. ' 'Documentation for these tweaks should be available ' 'on the website from where you downloaded the plugins.')) self.msg.setWordWrap(True) self.l.addWidget(self.msg) self.l.addWidget(self.edit) self.edit.setPlainText(raw) self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel, Qt.Orientation.Horizontal, self) self.bb.accepted.connect(self.accept) self.bb.rejected.connect(self.reject) self.l.addWidget(self.bb) self.resize(550, 300) # }}} class TweaksView(QListView): current_changed = pyqtSignal(object, object) def __init__(self, parent=None): QListView.__init__(self, parent) self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) self.setAlternatingRowColors(True) self.setSpacing(5) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setMinimumWidth(300) self.setWordWrap(True) def currentChanged(self, cur, prev): QListView.currentChanged(self, cur, prev) self.current_changed.emit(cur, prev) class ConfigWidget(ConfigWidgetBase): def setupUi(self, x): self.l = l = QVBoxLayout(self) self.la1 = la = QLabel( _("Values for the tweaks are shown below. Edit them to change the behavior of calibre." " Your changes will only take effect <b>after a restart</b> of calibre.")) l.addWidget(la), la.setWordWrap(True) self.splitter = s = QSplitter(self) s.setChildrenCollapsible(False) l.addWidget(s, 10) self.lv = lv = QWidget(self) lv.l = l2 = QVBoxLayout(lv) l2.setContentsMargins(0, 0, 0, 0) self.tweaks_view = tv = TweaksView(self) l2.addWidget(tv) self.plugin_tweaks_button = b = QPushButton(self) b.setToolTip(_("Edit tweaks for any custom plugins you have installed")) b.setText(_("&Plugin tweaks")) l2.addWidget(b) s.addWidget(lv) self.lv1 = lv = QWidget(self) s.addWidget(lv) lv.g = g = QGridLayout(lv) g.setContentsMargins(0, 0, 0, 0) self.search = sb = SearchBox2(self) sb.sizePolicy().setHorizontalStretch(10) sb.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLength) sb.setMinimumContentsLength(10) g.setColumnStretch(0, 100) g.addWidget(self.search, 0, 0, 1, 1) self.next_button = b = QPushButton(self) b.setIcon(QIcon(I("arrow-down.png"))) b.setText(_("&Next")) g.addWidget(self.next_button, 0, 1, 1, 1) self.previous_button = b = QPushButton(self) b.setIcon(QIcon(I("arrow-up.png"))) b.setText(_("&Previous")) g.addWidget(self.previous_button, 0, 2, 1, 1) self.hb = hb = QGroupBox(self) hb.setTitle(_("Help")) hb.l = l2 = QVBoxLayout(hb) self.help = h = QPlainTextEdit(self) l2.addWidget(h) h.setReadOnly(True) g.addWidget(hb, 1, 0, 1, 3) self.eb = eb = QGroupBox(self) g.addWidget(eb, 2, 0, 1, 3) eb.setTitle(_("Edit tweak")) eb.g = ebg = QGridLayout(eb) self.edit_tweak = et = QPlainTextEdit(self) et.setMinimumWidth(400) et.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) ebg.addWidget(et, 0, 0, 1, 2) self.restore_default_button = b = QPushButton(self) b.setToolTip(_("Restore this tweak to its default value")) b.setText(_("&Reset this tweak")) ebg.addWidget(b, 1, 0, 1, 1) self.apply_button = ab = QPushButton(self) ab.setToolTip(_("Apply any changes you made to this tweak")) ab.setText(_("&Apply changes to this tweak")) ebg.addWidget(ab, 1, 1, 1, 1) def genesis(self, gui): self.gui = gui self.tweaks_view.current_changed.connect(self.current_changed) self.view = self.tweaks_view self.highlighter = PythonHighlighter(self.edit_tweak.document()) self.restore_default_button.clicked.connect(self.restore_to_default) self.apply_button.clicked.connect(self.apply_tweak) self.plugin_tweaks_button.clicked.connect(self.plugin_tweaks) self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 100) self.next_button.clicked.connect(self.find_next) self.previous_button.clicked.connect(self.find_previous) self.search.initialize('tweaks_search_history', help_text=_('Search for tweak')) self.search.search.connect(self.find) self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.view.customContextMenuRequested.connect(self.show_context_menu) self.copy_icon = QIcon(I('edit-copy.png')) def show_context_menu(self, point): idx = self.tweaks_view.currentIndex() if not idx.isValid(): return True tweak = self.tweaks.data(idx, Qt.ItemDataRole.UserRole) self.context_menu = QMenu(self) self.context_menu.addAction(self.copy_icon, _('Copy to clipboard'), partial(self.copy_item_to_clipboard, val="%s (%s: %s)"%(tweak.name, _('ID'), tweak.var_names[0]))) self.context_menu.popup(self.mapToGlobal(point)) return True def copy_item_to_clipboard(self, val): cb = QApplication.clipboard() cb.clear() cb.setText(val) def plugin_tweaks(self): raw = self.tweaks.plugin_tweaks_string d = PluginTweaks(raw, self) if d.exec() == QDialog.DialogCode.Accepted: g, l = {}, {} try: exec(str(d.edit.toPlainText()), g, l) except: import traceback return error_dialog(self, _('Failed'), _('There was a syntax error in your tweak. Click ' 'the "Show details" button for details.'), show=True, det_msg=traceback.format_exc()) self.tweaks.set_plugin_tweaks(l) self.changed() def current_changed(self, current, previous): self.tweaks_view.scrollTo(current) tweak = self.tweaks.data(current, Qt.ItemDataRole.UserRole) self.help.setPlainText(tweak.doc) self.edit_tweak.setPlainText(tweak.edit_text) def changed(self, *args): self.changed_signal.emit() def initialize(self): self.tweaks = self._model = Tweaks() self.tweaks_view.setModel(self.tweaks) def restore_to_default(self, *args): idx = self.tweaks_view.currentIndex() if idx.isValid(): self.tweaks.restore_to_default(idx) tweak = self.tweaks.data(idx, Qt.ItemDataRole.UserRole) self.edit_tweak.setPlainText(tweak.edit_text) self.changed() def restore_defaults(self): ConfigWidgetBase.restore_defaults(self) self.tweaks.restore_to_defaults() self.changed() def apply_tweak(self): idx = self.tweaks_view.currentIndex() if idx.isValid(): l, g = {}, {} try: exec(str(self.edit_tweak.toPlainText()), g, l) except: import traceback error_dialog(self.gui, _('Failed'), _('There was a syntax error in your tweak. Click ' 'the "Show details" button for details.'), det_msg=traceback.format_exc(), show=True) return self.tweaks.update_tweak(idx, l) self.changed() def commit(self): raw = self.tweaks.to_string() if not isinstance(raw, bytes): raw = raw.encode('utf-8') try: custom_tweaks = exec_tweaks(raw) except: import traceback error_dialog(self, _('Invalid tweaks'), _('The tweaks you entered are invalid, try resetting the' ' tweaks to default and changing them one by one until' ' you find the invalid setting.'), det_msg=traceback.format_exc(), show=True) raise AbortCommit('abort') write_custom_tweaks(custom_tweaks) ConfigWidgetBase.commit(self) return True def find(self, query): if not query: return try: idx = self._model.find(query) except ParseException: self.search.search_done(False) return self.search.search_done(True) if not idx.isValid(): info_dialog(self, _('No matches'), _('Could not find any tweaks matching <i>{}</i>').format(prepare_string_for_xml(query)), show=True, show_copy_button=False) return self.highlight_index(idx) def highlight_index(self, idx): if not idx.isValid(): return self.view.scrollTo(idx) self.view.selectionModel().select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect) self.view.setCurrentIndex(idx) def find_next(self, *args): idx = self.view.currentIndex() if not idx.isValid(): idx = self._model.index(0) idx = self._model.find_next(idx, str(self.search.currentText())) self.highlight_index(idx) def find_previous(self, *args): idx = self.view.currentIndex() if not idx.isValid(): idx = self._model.index(0) idx = self._model.find_next(idx, str(self.search.currentText()), backwards=True) self.highlight_index(idx) if __name__ == '__main__': from calibre.gui2 import Application app = Application([]) # Tweaks() # test_widget test_widget('Advanced', 'Tweaks')