class ItemView(QStackedWidget): # {{{ add_new_item = pyqtSignal(object, object) delete_item = pyqtSignal() flatten_item = pyqtSignal() go_to_root = pyqtSignal() create_from_xpath = pyqtSignal(object, object) create_from_links = pyqtSignal() create_from_files = pyqtSignal() flatten_toc = pyqtSignal() def __init__(self, parent, prefs): QStackedWidget.__init__(self, parent) self.prefs = prefs self.setMinimumWidth(250) self.root_pane = rp = QWidget(self) self.item_pane = ip = QWidget(self) self.current_item = None sa = QScrollArea(self) sa.setWidgetResizable(True) sa.setWidget(rp) self.addWidget(sa) sa = QScrollArea(self) sa.setWidgetResizable(True) sa.setWidget(ip) self.addWidget(sa) self.l1 = la = QLabel('<p>' + _( 'You can edit existing entries in the Table of Contents by clicking them' ' in the panel to the left.' ) + '<p>' + _( 'Entries with a green tick next to them point to a location that has ' 'been verified to exist. Entries with a red dot are broken and may need' ' to be fixed.')) la.setStyleSheet('QLabel { margin-bottom: 20px }') la.setWordWrap(True) l = rp.l = QVBoxLayout() rp.setLayout(l) l.addWidget(la) self.add_new_to_root_button = b = QPushButton(_('Create a &new entry')) b.clicked.connect(self.add_new_to_root) l.addWidget(b) l.addStretch() self.cfmhb = b = QPushButton(_('Generate ToC from &major headings')) b.clicked.connect(self.create_from_major_headings) b.setToolTip( textwrap.fill( _('Generate a Table of Contents from the major headings in the book.' ' This will work if the book identifies its headings using HTML' ' heading tags. Uses the <h1>, <h2> and <h3> tags.'))) l.addWidget(b) self.cfmab = b = QPushButton(_('Generate ToC from &all headings')) b.clicked.connect(self.create_from_all_headings) b.setToolTip( textwrap.fill( _('Generate a Table of Contents from all the headings in the book.' ' This will work if the book identifies its headings using HTML' ' heading tags. Uses the <h1-6> tags.'))) l.addWidget(b) self.lb = b = QPushButton(_('Generate ToC from &links')) b.clicked.connect(self.create_from_links) b.setToolTip( textwrap.fill( _('Generate a Table of Contents from all the links in the book.' ' Links that point to destinations that do not exist in the book are' ' ignored. Also multiple links with the same destination or the same' ' text are ignored.'))) l.addWidget(b) self.cfb = b = QPushButton(_('Generate ToC from &files')) b.clicked.connect(self.create_from_files) b.setToolTip( textwrap.fill( _('Generate a Table of Contents from individual files in the book.' ' Each entry in the ToC will point to the start of the file, the' ' text of the entry will be the "first line" of text from the file.' ))) l.addWidget(b) self.xpb = b = QPushButton(_('Generate ToC from &XPath')) b.clicked.connect(self.create_from_user_xpath) b.setToolTip( textwrap.fill( _('Generate a Table of Contents from arbitrary XPath expressions.' ))) l.addWidget(b) self.fal = b = QPushButton(_('&Flatten the ToC')) b.clicked.connect(self.flatten_toc) b.setToolTip( textwrap.fill( _('Flatten the Table of Contents, putting all entries at the top level' ))) l.addWidget(b) l.addStretch() self.w1 = la = QLabel( _('<b>WARNING:</b> calibre only supports the ' 'creation of linear ToCs in AZW3 files. In a ' 'linear ToC every entry must point to a ' 'location after the previous entry. If you ' 'create a non-linear ToC it will be ' 'automatically re-arranged inside the AZW3 file.')) la.setWordWrap(True) l.addWidget(la) l = ip.l = QGridLayout() ip.setLayout(l) la = ip.heading = QLabel('') l.addWidget(la, 0, 0, 1, 2) la.setWordWrap(True) la = ip.la = QLabel( _('You can move this entry around the Table of Contents by drag ' 'and drop or using the up and down buttons to the left')) la.setWordWrap(True) l.addWidget(la, 1, 0, 1, 2) # Item status ip.hl1 = hl = QFrame() hl.setFrameShape(QFrame.Shape.HLine) l.addWidget(hl, l.rowCount(), 0, 1, 2) self.icon_label = QLabel() self.status_label = QLabel() self.status_label.setWordWrap(True) l.addWidget(self.icon_label, l.rowCount(), 0) l.addWidget(self.status_label, l.rowCount() - 1, 1) ip.hl2 = hl = QFrame() hl.setFrameShape(QFrame.Shape.HLine) l.addWidget(hl, l.rowCount(), 0, 1, 2) # Edit/remove item rs = l.rowCount() ip.b1 = b = QPushButton(QIcon(I('edit_input.png')), _('Change the &location this entry points to'), self) b.clicked.connect(self.edit_item) l.addWidget(b, l.rowCount() + 1, 0, 1, 2) ip.b2 = b = QPushButton(QIcon(I('trash.png')), _('&Remove this entry'), self) l.addWidget(b, l.rowCount(), 0, 1, 2) b.clicked.connect(self.delete_item) ip.hl3 = hl = QFrame() hl.setFrameShape(QFrame.Shape.HLine) l.addWidget(hl, l.rowCount(), 0, 1, 2) l.setRowMinimumHeight(rs, 20) # Add new item rs = l.rowCount() ip.b3 = b = QPushButton(QIcon(I('plus.png')), _('New entry &inside this entry')) connect_lambda(b.clicked, self, lambda self: self.add_new('inside')) l.addWidget(b, l.rowCount() + 1, 0, 1, 2) ip.b4 = b = QPushButton(QIcon(I('plus.png')), _('New entry &above this entry')) connect_lambda(b.clicked, self, lambda self: self.add_new('before')) l.addWidget(b, l.rowCount(), 0, 1, 2) ip.b5 = b = QPushButton(QIcon(I('plus.png')), _('New entry &below this entry')) connect_lambda(b.clicked, self, lambda self: self.add_new('after')) l.addWidget(b, l.rowCount(), 0, 1, 2) # Flatten entry ip.b3 = b = QPushButton(QIcon(I('heuristics.png')), _('&Flatten this entry')) b.clicked.connect(self.flatten_item) b.setToolTip( _('All children of this entry are brought to the same ' 'level as this entry.')) l.addWidget(b, l.rowCount() + 1, 0, 1, 2) ip.hl4 = hl = QFrame() hl.setFrameShape(QFrame.Shape.HLine) l.addWidget(hl, l.rowCount(), 0, 1, 2) l.setRowMinimumHeight(rs, 20) # Return to welcome rs = l.rowCount() ip.b4 = b = QPushButton(QIcon(I('back.png')), _('&Return to welcome screen')) b.clicked.connect(self.go_to_root) b.setToolTip(_('Go back to the top level view')) l.addWidget(b, l.rowCount() + 1, 0, 1, 2) l.setRowMinimumHeight(rs, 20) l.addWidget(QLabel(), l.rowCount(), 0, 1, 2) l.setColumnStretch(1, 10) l.setRowStretch(l.rowCount() - 1, 10) self.w2 = la = QLabel(self.w1.text()) self.w2.setWordWrap(True) l.addWidget(la, l.rowCount(), 0, 1, 2) def ask_if_duplicates_should_be_removed(self): return not question_dialog( self, _('Remove duplicates'), _('Should headings with the same text at the same level be included?' ), yes_text=_('&Include duplicates'), no_text=_('&Remove duplicates')) def create_from_major_headings(self): self.create_from_xpath.emit(['//h:h%d' % i for i in range(1, 4)], self.ask_if_duplicates_should_be_removed()) def create_from_all_headings(self): self.create_from_xpath.emit(['//h:h%d' % i for i in range(1, 7)], self.ask_if_duplicates_should_be_removed()) def create_from_user_xpath(self): d = XPathDialog(self, self.prefs) if d.exec_() == QDialog.DialogCode.Accepted and d.xpaths: self.create_from_xpath.emit(d.xpaths, d.remove_duplicates_cb.isChecked()) def hide_azw3_warning(self): self.w1.setVisible(False), self.w2.setVisible(False) def add_new_to_root(self): self.add_new_item.emit(None, None) def add_new(self, where): self.add_new_item.emit(self.current_item, where) def edit_item(self): self.add_new_item.emit(self.current_item, None) def __call__(self, item): if item is None: self.current_item = None self.setCurrentIndex(0) else: self.current_item = item self.setCurrentIndex(1) self.populate_item_pane() def populate_item_pane(self): item = self.current_item name = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '') self.item_pane.heading.setText('<h2>%s</h2>' % name) self.icon_label.setPixmap( item.data(0, Qt.ItemDataRole.DecorationRole).pixmap(32, 32)) tt = _('This entry points to an existing destination') toc = item.data(0, Qt.ItemDataRole.UserRole) if toc.dest_exists is False: tt = _('The location this entry points to does not exist') elif toc.dest_exists is None: tt = '' self.status_label.setText(tt) def data_changed(self, item): if item is self.current_item: self.populate_item_pane()
class Editor(QMainWindow): has_line_numbers = False modification_state_changed = pyqtSignal(object) undo_redo_state_changed = pyqtSignal(object, object) data_changed = pyqtSignal(object) cursor_position_changed = pyqtSignal() # dummy copy_available_state_changed = pyqtSignal(object) def __init__(self, syntax, parent=None): QMainWindow.__init__(self, parent) if parent is None: self.setWindowFlags(Qt.WindowType.Widget) self.is_synced_to_container = False self.syntax = syntax self._is_modified = False self.copy_available = self.cut_available = False self.quality = 90 self.canvas = Canvas(self) self.setCentralWidget(self.canvas) self.create_toolbars() self.canvas.image_changed.connect(self.image_changed) self.canvas.undo_redo_state_changed.connect(self.undo_redo_state_changed) self.canvas.selection_state_changed.connect(self.update_clipboard_actions) @property def is_modified(self): return self._is_modified @is_modified.setter def is_modified(self, val): self._is_modified = val self.modification_state_changed.emit(val) @property def current_editing_state(self): return {} @current_editing_state.setter def current_editing_state(self, val): pass @property def undo_available(self): return self.canvas.undo_action.isEnabled() @property def redo_available(self): return self.canvas.redo_action.isEnabled() @property def current_line(self): return 0 @current_line.setter def current_line(self, val): pass @property def number_of_lines(self): return 0 def pretty_print(self, name): return False def change_document_name(self, newname): pass def get_raw_data(self): return self.canvas.get_image_data(quality=self.quality) @property def data(self): return self.get_raw_data() @data.setter def data(self, val): self.canvas.load_image(val) self._is_modified = False # The image_changed signal will have been triggered causing this editor to be incorrectly marked as modified def replace_data(self, raw, only_if_different=True): # We ignore only_if_different as it is useless in our case, and # there is no easy way to check two images for equality self.data = raw def apply_settings(self, prefs=None, dictionaries_changed=False): pass def go_to_line(self, *args, **kwargs): pass def save_state(self): for bar in self.bars: if bar.isFloating(): return tprefs['image-editor-state'] = bytearray(self.saveState()) def restore_state(self): state = tprefs.get('image-editor-state', None) if state is not None: self.restoreState(state) def set_focus(self): self.canvas.setFocus(Qt.FocusReason.OtherFocusReason) def undo(self): self.canvas.undo_action.trigger() def redo(self): self.canvas.redo_action.trigger() def copy(self): self.canvas.copy() def cut(self): return error_dialog(self, _('Not allowed'), _( 'Cutting of images is not allowed. If you want to delete the image, use' ' the files browser to do it.'), show=True) def paste(self): self.canvas.paste() # Search and replace {{{ def mark_selected_text(self, *args, **kwargs): pass def find(self, *args, **kwargs): return False def replace(self, *args, **kwargs): return False def all_in_marked(self, *args, **kwargs): return 0 @property def selected_text(self): return '' # }}} def image_changed(self, new_image): self.is_synced_to_container = False self._is_modified = True self.copy_available = self.canvas.is_valid self.copy_available_state_changed.emit(self.copy_available) self.data_changed.emit(self) self.modification_state_changed.emit(True) self.fmt_label.setText(' ' + (self.canvas.original_image_format or '').upper()) im = self.canvas.current_image self.size_label.setText('{} x {}{}'.format(im.width(), im.height(), ' px')) def break_cycles(self): self.canvas.break_cycles() self.canvas.image_changed.disconnect() self.canvas.undo_redo_state_changed.disconnect() self.canvas.selection_state_changed.disconnect() self.modification_state_changed.disconnect() self.undo_redo_state_changed.disconnect() self.data_changed.disconnect() self.cursor_position_changed.disconnect() self.copy_available_state_changed.disconnect() def contextMenuEvent(self, ev): ev.ignore() def create_toolbars(self): self.action_bar = b = self.addToolBar(_('File actions tool bar')) b.setObjectName('action_bar') # Needed for saveState for x in ('undo', 'redo'): b.addAction(getattr(self.canvas, '%s_action' % x)) self.edit_bar = b = self.addToolBar(_('Edit actions tool bar')) b.setObjectName('edit-actions-bar') for x in ('copy', 'paste'): ac = actions['editor-%s' % x] setattr(self, 'action_' + x, b.addAction(ac.icon(), x, getattr(self, x))) self.update_clipboard_actions() b.addSeparator() self.action_trim = ac = b.addAction(QIcon(I('trim.png')), _('Trim image'), self.canvas.trim_image) self.action_rotate = ac = b.addAction(QIcon(I('rotate-right.png')), _('Rotate image'), self.canvas.rotate_image) self.action_resize = ac = b.addAction(QIcon(I('resize.png')), _('Resize image'), self.resize_image) b.addSeparator() self.action_filters = ac = b.addAction(QIcon(I('filter.png')), _('Image filters')) b.widgetForAction(ac).setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) self.filters_menu = m = QMenu(self) ac.setMenu(m) m.addAction(_('Auto-trim image'), self.canvas.autotrim_image) m.addAction(_('Sharpen image'), self.sharpen_image) m.addAction(_('Blur image'), self.blur_image) m.addAction(_('De-speckle image'), self.canvas.despeckle_image) m.addAction(_('Improve contrast (normalize image)'), self.canvas.normalize_image) m.addAction(_('Make image look like an oil painting'), self.oilify_image) self.info_bar = b = self.addToolBar(_('Image information bar')) b.setObjectName('image_info_bar') self.fmt_label = QLabel('') b.addWidget(self.fmt_label) b.addSeparator() self.size_label = QLabel('') b.addWidget(self.size_label) self.bars = [self.action_bar, self.edit_bar, self.info_bar] for x in self.bars: x.setFloatable(False) x.topLevelChanged.connect(self.toolbar_floated) x.setIconSize(QSize(tprefs['toolbar_icon_size'], tprefs['toolbar_icon_size'])) self.restore_state() def toolbar_floated(self, floating): if not floating: self.save_state() for ed in itervalues(editors): if ed is not self: ed.restore_state() def update_clipboard_actions(self, *args): if self.canvas.has_selection: self.action_copy.setText(_('Copy selected region')) self.action_paste.setText(_('Paste into selected region')) else: self.action_copy.setText(_('Copy image')) self.action_paste.setText(_('Paste image')) def resize_image(self): im = self.canvas.current_image d = ResizeDialog(im.width(), im.height(), self) if d.exec() == QDialog.DialogCode.Accepted: self.canvas.resize_image(d.width, d.height) def sharpen_image(self): val, ok = QInputDialog.getInt(self, _('Sharpen image'), _( 'The standard deviation for the Gaussian sharpen operation (higher means more sharpening)'), value=3, min=1, max=20) if ok: self.canvas.sharpen_image(sigma=val) def blur_image(self): val, ok = QInputDialog.getInt(self, _('Blur image'), _( 'The standard deviation for the Gaussian blur operation (higher means more blurring)'), value=3, min=1, max=20) if ok: self.canvas.blur_image(sigma=val) def oilify_image(self): val, ok = QInputDialog.getDouble(self, _('Oilify image'), _( 'The strength of the operation (higher numbers have larger effects)'), value=4, min=0.1, max=20) if ok: self.canvas.oilify_image(radius=val)
class BookInfo(HTMLDisplay): link_clicked = pyqtSignal(object) remove_format = pyqtSignal(int, object) remove_item = pyqtSignal(int, object, object) save_format = pyqtSignal(int, object) restore_format = pyqtSignal(int, object) compare_format = pyqtSignal(int, object) set_cover_format = pyqtSignal(int, object) copy_link = pyqtSignal(object) manage_category = pyqtSignal(object, object) open_fmt_with = pyqtSignal(int, object, object) edit_book = pyqtSignal(int, object) edit_identifiers = pyqtSignal() find_in_tag_browser = pyqtSignal(object, object) def __init__(self, vertical, parent=None): HTMLDisplay.__init__(self, parent) self.vertical = vertical self.anchor_clicked.connect(self.link_activated) for x, icon in [ ('remove_format', 'trash.png'), ('save_format', 'save.png'), ('restore_format', 'edit-undo.png'), ('copy_link','edit-copy.png'), ('compare_format', 'diff.png'), ('set_cover_format', 'default_cover.png'), ('find_in_tag_browser', 'search.png') ]: ac = QAction(QIcon(I(icon)), '', self) ac.current_fmt = None ac.current_url = None ac.triggered.connect(getattr(self, '%s_triggerred'%x)) setattr(self, '%s_action'%x, ac) self.manage_action = QAction(self) self.manage_action.current_fmt = self.manage_action.current_url = None self.manage_action.triggered.connect(self.manage_action_triggered) self.edit_identifiers_action = QAction(QIcon(I('identifiers.png')), _('Edit identifiers for this book'), self) self.edit_identifiers_action.triggered.connect(self.edit_identifiers) self.remove_item_action = ac = QAction(QIcon(I('minus.png')), '...', self) ac.data = (None, None, None) ac.triggered.connect(self.remove_item_triggered) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.setDefaultStyleSheet(css()) def refresh_css(self): self.setDefaultStyleSheet(css(True)) def remove_item_triggered(self): field, value, book_id = self.remove_item_action.data if field and confirm(_('Are you sure you want to delete <b>{}</b> from the book?').format(value), 'book_details_remove_item'): self.remove_item.emit(book_id, field, value) def context_action_triggered(self, which): f = getattr(self, '%s_action'%which).current_fmt url = getattr(self, '%s_action'%which).current_url if f and 'format' in which: book_id, fmt = f getattr(self, which).emit(book_id, fmt) if url and 'link' in which: getattr(self, which).emit(url) def remove_format_triggerred(self): self.context_action_triggered('remove_format') def save_format_triggerred(self): self.context_action_triggered('save_format') def restore_format_triggerred(self): self.context_action_triggered('restore_format') def compare_format_triggerred(self): self.context_action_triggered('compare_format') def set_cover_format_triggerred(self): self.context_action_triggered('set_cover_format') def copy_link_triggerred(self): self.context_action_triggered('copy_link') def find_in_tag_browser_triggerred(self): if self.find_in_tag_browser_action.current_fmt: self.find_in_tag_browser.emit(*self.find_in_tag_browser_action.current_fmt) def manage_action_triggered(self): if self.manage_action.current_fmt: self.manage_category.emit(*self.manage_action.current_fmt) def link_activated(self, link): if unicode_type(link.scheme()) in ('http', 'https'): return safe_open_url(link) link = unicode_type(link.toString(NO_URL_FORMATTING)) self.link_clicked.emit(link) def show_data(self, mi): html = render_html(mi, self.vertical, self.parent()) set_html(mi, html, self) def mouseDoubleClickEvent(self, ev): v = self.viewport() if v.rect().contains(self.mapFromGlobal(ev.globalPos())): ev.ignore() else: return HTMLDisplay.mouseDoubleClickEvent(self, ev) def contextMenuEvent(self, ev): details_context_menu_event(self, ev, self, True) def open_with(self, book_id, fmt, entry): self.open_fmt_with.emit(book_id, fmt, entry) def choose_open_with(self, book_id, fmt): from calibre.gui2.open_with import choose_program entry = choose_program(fmt, self) if entry is not None: self.open_with(book_id, fmt, entry) def edit_fmt(self, book_id, fmt): self.edit_book.emit(book_id, fmt)
class CoversView(QListView): # {{{ chosen = pyqtSignal() def __init__(self, current_cover, parent=None): QListView.__init__(self, parent) self.m = CoversModel(current_cover, self) self.setModel(self.m) self.setFlow(QListView.Flow.LeftToRight) self.setWrapping(True) self.setResizeMode(QListView.ResizeMode.Adjust) self.setGridSize(QSize(190, 260)) self.setIconSize(QSize(*CoverDelegate.ICON_SIZE)) self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.setViewMode(QListView.ViewMode.IconMode) self.delegate = CoverDelegate(self) self.setItemDelegate(self.delegate) self.delegate.needs_redraw.connect( self.redraw_spinners, type=Qt.ConnectionType.QueuedConnection) self.doubleClicked.connect(self.chosen, type=Qt.ConnectionType.QueuedConnection) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) def redraw_spinners(self): m = self.model() for r in range(m.rowCount()): idx = m.index(r) if bool(m.data(idx, Qt.ItemDataRole.UserRole)): m.dataChanged.emit(idx, idx) def select(self, num): current = self.model().index(num) sm = self.selectionModel() sm.select(current, QItemSelectionModel.SelectionFlag.SelectCurrent) def start(self): self.select(0) self.delegate.start_animation() def stop(self): self.delegate.stop_animation() def reset_covers(self): self.m.reset_covers() def clear_failed(self): pointer = self.m.pointer_from_index(self.currentIndex()) self.m.clear_failed() if pointer is None: self.select(0) else: self.select(self.m.index_from_pointer(pointer).row()) def show_context_menu(self, point): idx = self.currentIndex() if idx and idx.isValid() and not idx.data(Qt.ItemDataRole.UserRole): m = QMenu(self) m.addAction(QIcon(I('view.png')), _('View this cover at full size'), self.show_cover) m.addAction(QIcon(I('edit-copy.png')), _('Copy this cover to clipboard'), self.copy_cover) m.exec(QCursor.pos()) def show_cover(self): idx = self.currentIndex() pmap = self.model().cover_pixmap(idx) if pmap is None and idx.row() == 0: pmap = self.model().cc if pmap is not None: from calibre.gui2.image_popup import ImageView d = ImageView(self, pmap, str(idx.data(Qt.ItemDataRole.DisplayRole) or ''), geom_name='metadata_download_cover_popup_geom') d(use_exec=True) def copy_cover(self): idx = self.currentIndex() pmap = self.model().cover_pixmap(idx) if pmap is None and idx.row() == 0: pmap = self.model().cc if pmap is not None: QApplication.clipboard().setPixmap(pmap) def keyPressEvent(self, ev): if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): self.chosen.emit() ev.accept() return return QListView.keyPressEvent(self, ev)
class SaveTemplate(QWidget, Ui_Form): changed_signal = pyqtSignal() def __init__(self, *args): QWidget.__init__(self, *args) Ui_Form.__init__(self) self.setupUi(self) self.orig_help_text = self.help_label.text() def initialize(self, name, default, help, field_metadata): variables = sorted(FORMAT_ARG_DESCS.keys()) if name == 'send_to_device': self.help_label.setText(self.orig_help_text + ' ' + _( 'This setting can be overridden for <b>individual devices</b>,' ' by clicking the device icon and choosing "Configure this device".' )) rows = [] for var in variables: rows.append('<tr><td>%s</td><td> </td><td>%s</td></tr>' % (var, FORMAT_ARG_DESCS[var])) rows.append('<tr><td>%s </td><td> </td><td>%s</td></tr>' % ( _('Any custom field'), _('The lookup name of any custom field (these names begin with "#").' ))) table = '<table>%s</table>' % ('\n'.join(rows)) self.template_variables.setText(table) self.field_metadata = field_metadata self.opt_template.initialize(name + '_template_history', default, help) self.opt_template.editTextChanged.connect(self.changed) self.opt_template.currentIndexChanged.connect(self.changed) self.option_name = name self.open_editor.clicked.connect(self.do_open_editor) def do_open_editor(self): t = TemplateDialog(self, self.opt_template.text(), fm=self.field_metadata) t.setWindowTitle(_('Edit template')) if t.exec_(): self.opt_template.set_value(t.rule[1]) def changed(self, *args): self.changed_signal.emit() def validate(self): ''' Do a syntax check on the format string. Doing a semantic check (verifying that the fields exist) is not useful in the presence of custom fields, because they may or may not exist. ''' tmpl = preprocess_template(self.opt_template.text()) try: t = validation_formatter.validate(tmpl) if t.find(validation_formatter._validation_string) < 0: return question_dialog( self, _('Constant template'), _('The template contains no {fields}, so all ' 'books will have the same name. Is this OK?')) except Exception as err: error_dialog(self, _('Invalid template'), '<p>' + _('The template %s is invalid:') % tmpl + '<br>' + str(err), show=True) return False return True def set_value(self, val): self.opt_template.set_value(val) def save_settings(self, config, name): val = str(self.opt_template.text()) config.set(name, val) self.opt_template.save_history(self.option_name + '_template_history')
class Highlights(QTreeWidget): jump_to_highlight = pyqtSignal(object) current_highlight_changed = pyqtSignal(object) delete_requested = pyqtSignal() edit_requested = pyqtSignal() edit_notes_requested = pyqtSignal() def __init__(self, parent=None): QTreeWidget.__init__(self, parent) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) self.default_decoration = QIcon(I('blank.png')) self.setHeaderHidden(True) self.num_of_items = 0 self.setSelectionMode( QAbstractItemView.SelectionMode.ExtendedSelection) set_no_activate_on_click(self) self.itemActivated.connect(self.item_activated) self.currentItemChanged.connect(self.current_item_changed) self.uuid_map = {} self.section_font = QFont(self.font()) self.section_font.setItalic(True) def show_context_menu(self, point): index = self.indexAt(point) h = index.data(Qt.ItemDataRole.UserRole) self.context_menu = m = QMenu(self) if h is not None: m.addAction(QIcon(I('edit_input.png')), _('Modify this highlight'), self.edit_requested.emit) m.addAction(QIcon(I('modified.png')), _('Edit notes for this highlight'), self.edit_notes_requested.emit) m.addAction( QIcon(I('trash.png')), ngettext('Delete this highlight', 'Delete selected highlights', len(self.selectedItems())), self.delete_requested.emit) m.addSeparator() m.addAction(_('Expand all'), self.expandAll) m.addAction(_('Collapse all'), self.collapseAll) self.context_menu.popup(self.mapToGlobal(point)) return True def current_item_changed(self, current, previous): self.current_highlight_changed.emit( current.data(0, Qt.ItemDataRole.UserRole ) if current is not None else None) def load(self, highlights, preserve_state=False): s = self.style() expanded_chapters = set() if preserve_state: root = self.invisibleRootItem() for i in range(root.childCount()): chapter = root.child(i) if chapter.isExpanded(): expanded_chapters.add( chapter.data(0, Qt.ItemDataRole.DisplayRole)) icon_size = s.pixelMetric(QStyle.PixelMetric.PM_SmallIconSize, None, self) dpr = self.devicePixelRatioF() is_dark = is_dark_theme() self.clear() self.uuid_map = {} highlights = (h for h in highlights if not h.get('removed') and h.get('highlighted_text')) section_map = defaultdict(list) section_tt_map = {} for h in self.sorted_highlights(highlights): tfam = h.get('toc_family_titles') or () if tfam: tsec = tfam[0] lsec = tfam[-1] else: tsec = h.get('top_level_section_title') lsec = h.get('lowest_level_section_title') sec = lsec or tsec or _('Unknown') if len(tfam) > 1: lines = [] for i, node in enumerate(tfam): lines.append('\xa0\xa0' * i + '➤ ' + node) tt = ngettext('Table of Contents section:', 'Table of Contents sections:', len(lines)) tt += '\n' + '\n'.join(lines) section_tt_map[sec] = tt section_map[sec].append(h) for secnum, (sec, items) in enumerate(section_map.items()): section = QTreeWidgetItem([sec], 1) section.setFlags(Qt.ItemFlag.ItemIsEnabled) section.setFont(0, self.section_font) tt = section_tt_map.get(sec) if tt: section.setToolTip(0, tt) self.addTopLevelItem(section) section.setExpanded(not preserve_state or sec in expanded_chapters) for itemnum, h in enumerate(items): txt = h.get('highlighted_text') txt = txt.replace('\n', ' ') if h.get('notes'): txt = '•' + txt if len(txt) > 100: txt = txt[:100] + '…' item = QTreeWidgetItem(section, [txt], 2) item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemNeverHasChildren) item.setData(0, Qt.ItemDataRole.UserRole, h) try: dec = decoration_for_style(self.palette(), h.get('style') or {}, icon_size, dpr, is_dark) except Exception: import traceback traceback.print_exc() dec = None if dec is None: dec = self.default_decoration item.setData(0, Qt.ItemDataRole.DecorationRole, dec) self.uuid_map[h['uuid']] = secnum, itemnum self.num_of_items += 1 def sorted_highlights(self, highlights): defval = 999999999999999, cfi_sort_key('/99999999') def cfi_key(h): cfi = h.get('start_cfi') return (h.get('spine_index') or defval[0], cfi_sort_key(cfi)) if cfi else defval return sorted(highlights, key=cfi_key) def refresh(self, highlights): h = self.current_highlight self.load(highlights, preserve_state=True) if h is not None: idx = self.uuid_map.get(h['uuid']) if idx is not None: sec_idx, item_idx = idx self.set_current_row(sec_idx, item_idx) def iteritems(self): root = self.invisibleRootItem() for i in range(root.childCount()): sec = root.child(i) for k in range(sec.childCount()): yield sec.child(k) def count(self): return self.num_of_items def find_query(self, query): pat = query.regex items = tuple(self.iteritems()) count = len(items) cr = -1 ch = self.current_highlight if ch: q = ch['uuid'] for i, item in enumerate(items): h = item.data(0, Qt.ItemDataRole.UserRole) if h['uuid'] == q: cr = i if query.backwards: if cr < 0: cr = count indices = chain(range(cr - 1, -1, -1), range(count - 1, cr, -1)) else: if cr < 0: cr = -1 indices = chain(range(cr + 1, count), range(0, cr + 1)) for i in indices: h = items[i].data(0, Qt.ItemDataRole.UserRole) if pat.search(h['highlighted_text']) is not None or pat.search( h.get('notes') or '') is not None: self.set_current_row(*self.uuid_map[h['uuid']]) return True return False def find_annot_id(self, annot_id): q = self.uuid_map.get(annot_id) if q is not None: self.set_current_row(*q) return True return False def set_current_row(self, sec_idx, item_idx): sec = self.topLevelItem(sec_idx) if sec is not None: item = sec.child(item_idx) if item is not None: self.setCurrentItem( item, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect) return True return False def item_activated(self, item): h = item.data(0, Qt.ItemDataRole.UserRole) if h is not None: self.jump_to_highlight.emit(h) @property def current_highlight(self): i = self.currentItem() if i is not None: return i.data(0, Qt.ItemDataRole.UserRole) @property def all_highlights(self): for item in self.iteritems(): yield item.data(0, Qt.ItemDataRole.UserRole) @property def selected_highlights(self): for item in self.selectedItems(): yield item.data(0, Qt.ItemDataRole.UserRole) def keyPressEvent(self, ev): if ev.matches(QKeySequence.StandardKey.Delete): self.delete_requested.emit() ev.accept() return if ev.key() == Qt.Key.Key_F2: self.edit_requested.emit() ev.accept() return return super().keyPressEvent(ev)
class ResultsView(QTableView): # {{{ show_details_signal = pyqtSignal(object) book_selected = pyqtSignal(object) def __init__(self, parent=None): QTableView.__init__(self, parent) self.rt_delegate = RichTextDelegate(self) self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.setAlternatingRowColors(True) self.setSelectionBehavior( QAbstractItemView.SelectionBehavior.SelectRows) self.setIconSize(QSize(24, 24)) self.clicked.connect(self.show_details) self.doubleClicked.connect(self.select_index) self.setSortingEnabled(True) def show_results(self, results): self._model = ResultsModel(results, self) self.setModel(self._model) for i in self._model.HTML_COLS: self.setItemDelegateForColumn(i, self.rt_delegate) self.resizeRowsToContents() self.resizeColumnsToContents() self.setFocus(Qt.FocusReason.OtherFocusReason) idx = self.model().index(0, 0) if idx.isValid() and self.model().rowCount() > 0: self.show_details(idx) sm = self.selectionModel() sm.select( idx, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows) def resize_delegate(self): self.rt_delegate.max_width = int(self.width() / 2.1) self.resizeColumnsToContents() def resizeEvent(self, ev): ret = super().resizeEvent(ev) self.resize_delegate() return ret def currentChanged(self, current, previous): ret = QTableView.currentChanged(self, current, previous) self.show_details(current) return ret def show_details(self, index): f = rating_font() book = self.model().data(index, Qt.ItemDataRole.UserRole) parts = [ '<center>', '<h2>%s</h2>' % book.title, '<div><i>%s</i></div>' % authors_to_string(book.authors), ] if not book.is_null('series'): series = book.format_field('series') if series[1]: parts.append('<div>%s: %s</div>' % series) if not book.is_null('rating'): style = 'style=\'font-family:"%s"\'' % f parts.append('<div %s>%s</div>' % (style, rating_to_stars(int(2 * book.rating)))) parts.append('</center>') if book.identifiers: urls = urls_from_identifiers(book.identifiers) ids = [ '<a href="%s">%s</a>' % (url, name) for name, ign, ign, url in urls ] if ids: parts.append('<div><b>%s:</b> %s</div><br>' % (_('See at'), ', '.join(ids))) if book.tags: parts.append('<div>%s</div><div>\u00a0</div>' % ', '.join(book.tags)) if book.comments: parts.append(comments_to_html(book.comments)) self.show_details_signal.emit(''.join(parts)) def select_index(self, index): if self.model() is None: return if not index.isValid(): index = self.model().index(0, 0) book = self.model().data(index, Qt.ItemDataRole.UserRole) self.book_selected.emit(book) def get_result(self): self.select_index(self.currentIndex()) def keyPressEvent(self, ev): if ev.key() in (Qt.Key.Key_Left, Qt.Key.Key_Right): ac = QAbstractItemView.CursorAction.MoveDown if ev.key( ) == Qt.Key.Key_Right else QAbstractItemView.CursorAction.MoveUp index = self.moveCursor(ac, ev.modifiers()) if index.isValid() and index != self.currentIndex(): m = self.selectionModel() m.select( index, QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Current | QItemSelectionModel.SelectionFlag.Rows) self.setCurrentIndex(index) ev.accept() return return QTableView.keyPressEvent(self, ev)
class CharView(QListView): show_name = pyqtSignal(object) char_selected = pyqtSignal(object) def __init__(self, parent=None): self.last_mouse_idx = -1 QListView.__init__(self, parent) self._model = CharModel(self) self.setModel(self._model) self.delegate = CharDelegate(self) self.setResizeMode(QListView.ResizeMode.Adjust) self.setItemDelegate(self.delegate) self.setFlow(QListView.Flow.LeftToRight) self.setWrapping(True) self.setMouseTracking(True) self.setSpacing(2) self.setUniformItemSizes(True) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.context_menu) self.showing_favorites = False set_no_activate_on_click(self) self.activated.connect(self.item_activated) self.clicked.connect(self.item_activated) def item_activated(self, index): try: char_code = int(self.model().data(index, Qt.ItemDataRole.UserRole)) except (TypeError, ValueError): pass else: self.char_selected.emit(codepoint_to_chr(char_code)) def set_allow_drag_and_drop(self, enabled): if not enabled: self.setDragEnabled(False) self.viewport().setAcceptDrops(False) self.setDropIndicatorShown(True) self._model.allow_dnd = False else: self.setSelectionMode( QAbstractItemView.SelectionMode.ExtendedSelection) self.viewport().setAcceptDrops(True) self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(False) self._model.allow_dnd = True def show_chars(self, name, codes): self.showing_favorites = name == _('Favorites') self._model.beginResetModel() self._model.chars = codes self._model.endResetModel() self.scrollToTop() def mouseMoveEvent(self, ev): index = self.indexAt(ev.pos()) if index.isValid(): row = index.row() if row != self.last_mouse_idx: self.last_mouse_idx = row try: char_code = int(self.model().data( index, Qt.ItemDataRole.UserRole)) except (TypeError, ValueError): pass else: self.show_name.emit(char_code) self.setCursor(Qt.CursorShape.PointingHandCursor) else: self.setCursor(Qt.CursorShape.ArrowCursor) self.show_name.emit(-1) self.last_mouse_idx = -1 return QListView.mouseMoveEvent(self, ev) def context_menu(self, pos): index = self.indexAt(pos) if index.isValid(): try: char_code = int(self.model().data(index, Qt.ItemDataRole.UserRole)) except (TypeError, ValueError): pass else: m = QMenu(self) m.addAction( QIcon(I('edit-copy.png')), _('Copy %s to clipboard') % codepoint_to_chr(char_code), partial(self.copy_to_clipboard, char_code)) m.addAction( QIcon(I('rating.png')), (_('Remove %s from favorites') if self.showing_favorites else _('Add %s to favorites')) % codepoint_to_chr(char_code), partial(self.remove_from_favorites, char_code)) if self.showing_favorites: m.addAction(_('Restore favorites to defaults'), self.restore_defaults) m.exec(self.mapToGlobal(pos)) def restore_defaults(self): del tprefs['charmap_favorites'] self.model().beginResetModel() self.model().chars = list(tprefs['charmap_favorites']) self.model().endResetModel() def copy_to_clipboard(self, char_code): c = QApplication.clipboard() c.setText(codepoint_to_chr(char_code)) def remove_from_favorites(self, char_code): existing = tprefs['charmap_favorites'] if not self.showing_favorites: if char_code not in existing: tprefs['charmap_favorites'] = [char_code] + existing elif char_code in existing: existing.remove(char_code) tprefs['charmap_favorites'] = existing self.model().beginResetModel() self.model().chars.remove(char_code) self.model().endResetModel()
class Splitter(QSplitter): state_changed = pyqtSignal(object) reapply_sizes = pyqtSignal(object) def __init__(self, name, label, icon, initial_show=True, initial_side_size=120, connect_button=True, orientation=Qt.Orientation.Horizontal, side_index=0, parent=None, shortcut=None, hide_handle_on_single_panel=True): QSplitter.__init__(self, parent) self.reapply_sizes.connect(self.setSizes, type=Qt.ConnectionType.QueuedConnection) self.hide_handle_on_single_panel = hide_handle_on_single_panel if hide_handle_on_single_panel: self.state_changed.connect(self.update_handle_width) self.original_handle_width = self.handleWidth() self.resize_timer = QTimer(self) self.resize_timer.setSingleShot(True) self.desired_side_size = initial_side_size self.desired_show = initial_show self.resize_timer.setInterval(5) self.resize_timer.timeout.connect(self.do_resize) self.setOrientation(orientation) self.side_index = side_index self._name = name self.label = label self.initial_side_size = initial_side_size self.initial_show = initial_show self.splitterMoved.connect(self.splitter_moved, type=Qt.ConnectionType.QueuedConnection) self.button = LayoutButton(icon, label, self, shortcut=shortcut) if connect_button: self.button.clicked.connect(self.double_clicked) if shortcut is not None: self.action_toggle = QAction(QIcon(icon), _('Toggle') + ' ' + label, self) self.action_toggle.changed.connect(self.update_shortcut) self.action_toggle.triggered.connect(self.toggle_triggered) if parent is not None: parent.addAction(self.action_toggle) if hasattr(parent, 'keyboard'): parent.keyboard.register_shortcut( 'splitter %s %s' % (name, label), str(self.action_toggle.text()), default_keys=(shortcut, ), action=self.action_toggle) else: self.action_toggle.setShortcut(shortcut) else: self.action_toggle.setShortcut(shortcut) def update_shortcut(self): self.button.update_shortcut(self.action_toggle) def toggle_triggered(self, *args): self.toggle_side_pane() def createHandle(self): return SplitterHandle(self.orientation(), self) def initialize(self): for i in range(self.count()): h = self.handle(i) if h is not None: h.splitter_moved() self.state_changed.emit(not self.is_side_index_hidden) def splitter_moved(self, *args): self.desired_side_size = self.side_index_size self.state_changed.emit(not self.is_side_index_hidden) def update_handle_width(self, not_one_panel): self.setHandleWidth(self.original_handle_width if not_one_panel else 0) @property def is_side_index_hidden(self): sizes = list(self.sizes()) try: return sizes[self.side_index] == 0 except IndexError: return True @property def save_name(self): ori = 'horizontal' if self.orientation() == Qt.Orientation.Horizontal \ else 'vertical' return self._name + '_' + ori def print_sizes(self): if self.count() > 1: print(self.save_name, 'side:', self.side_index_size, 'other:', end=' ') print(list(self.sizes())[self.other_index]) @property def side_index_size(self): if self.count() < 2: return 0 return self.sizes()[self.side_index] @side_index_size.setter def side_index_size(self, val): if self.count() < 2: return side_index_hidden = self.is_side_index_hidden if val == 0 and not side_index_hidden: self.save_state() sizes = list(self.sizes()) for i in range(len(sizes)): sizes[i] = val if i == self.side_index else 10 self.setSizes(sizes) sizes = list(self.sizes()) total = sum(sizes) total_needs_adjustment = self.hide_handle_on_single_panel and side_index_hidden if total_needs_adjustment: total -= self.original_handle_width for i in range(len(sizes)): sizes[i] = val if i == self.side_index else total - val self.setSizes(sizes) self.initialize() if total_needs_adjustment: # the handle visibility and therefore size distribution will change # when the event loop ticks self.reapply_sizes.emit(sizes) def do_resize(self, *args): orig = self.desired_side_size QSplitter.resizeEvent(self, self._resize_ev) if orig > 20 and self.desired_show: c = 0 while abs(self.side_index_size - orig) > 10 and c < 5: self.apply_state(self.get_state(), save_desired=False) c += 1 def resizeEvent(self, ev): if self.resize_timer.isActive(): self.resize_timer.stop() self._resize_ev = ev self.resize_timer.start() def get_state(self): if self.count() < 2: return (False, 200) return (self.desired_show, self.desired_side_size) def apply_state(self, state, save_desired=True): if state[0]: self.side_index_size = state[1] if save_desired: self.desired_side_size = self.side_index_size else: self.side_index_size = 0 self.desired_show = state[0] def default_state(self): return (self.initial_show, self.initial_side_size) # Public API {{{ def update_desired_state(self): self.desired_show = not self.is_side_index_hidden def save_state(self): if self.count() > 1: gprefs[self.save_name + '_state'] = self.get_state() @property def other_index(self): return (self.side_index + 1) % 2 def restore_state(self): if self.count() > 1: state = gprefs.get(self.save_name + '_state', self.default_state()) self.apply_state(state, save_desired=False) self.desired_side_size = state[1] def toggle_side_pane(self, hide=None): if hide is None: action = 'show' if self.is_side_index_hidden else 'hide' else: action = 'hide' if hide else 'show' getattr(self, action + '_side_pane')() def show_side_pane(self): if self.count() < 2 or not self.is_side_index_hidden: return if self.desired_side_size == 0: self.desired_side_size = self.initial_side_size self.apply_state((True, self.desired_side_size)) def hide_side_pane(self): if self.count() < 2 or self.is_side_index_hidden: return self.apply_state((False, self.desired_side_size)) def double_clicked(self, *args): self.toggle_side_pane()
class JobError(QDialog): # {{{ WIDTH = 600 do_pop = pyqtSignal() def __init__(self, parent): QDialog.__init__(self, parent) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False) self.queue = [] self.do_pop.connect(self.pop, type=Qt.ConnectionType.QueuedConnection) self._layout = l = QGridLayout() self.setLayout(l) self.icon = QIcon(I('dialog_error.png')) self.setWindowIcon(self.icon) self.icon_widget = Icon(self) self.icon_widget.set_icon(self.icon) self.msg_label = QLabel('<p> ') self.msg_label.setStyleSheet('QLabel { margin-top: 1ex; }') self.msg_label.setWordWrap(True) self.msg_label.setTextFormat(Qt.TextFormat.RichText) self.det_msg = QPlainTextEdit(self) self.det_msg.setVisible(False) self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self) self.bb.accepted.connect(self.accept) self.bb.rejected.connect(self.reject) self.ctc_button = self.bb.addButton( _('&Copy to clipboard'), QDialogButtonBox.ButtonRole.ActionRole) self.ctc_button.clicked.connect(self.copy_to_clipboard) self.retry_button = self.bb.addButton( _('&Retry'), QDialogButtonBox.ButtonRole.ActionRole) self.retry_button.clicked.connect(self.retry) self.retry_func = None self.show_det_msg = _('Show &details') self.hide_det_msg = _('Hide &details') self.det_msg_toggle = self.bb.addButton( self.show_det_msg, QDialogButtonBox.ButtonRole.ActionRole) self.det_msg_toggle.clicked.connect(self.toggle_det_msg) self.det_msg_toggle.setToolTip( _('Show detailed information about this error')) self.suppress = QCheckBox(self) l.addWidget(self.icon_widget, 0, 0, 1, 1) l.addWidget(self.msg_label, 0, 1, 1, 1) l.addWidget(self.det_msg, 1, 0, 1, 2) l.addWidget(self.suppress, 2, 0, 1, 2, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom) l.addWidget(self.bb, 3, 0, 1, 2, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom) l.setColumnStretch(1, 100) self.setModal(False) self.suppress.setVisible(False) self.do_resize() def retry(self): if self.retry_func is not None: self.accept() self.retry_func() def update_suppress_state(self): self.suppress.setText( ngettext('Hide the remaining error message', 'Hide the {} remaining error messages', len(self.queue)).format(len(self.queue))) self.suppress.setVisible(len(self.queue) > 3) self.do_resize() def copy_to_clipboard(self, *args): d = QTextDocument() d.setHtml(self.msg_label.text()) QApplication.clipboard().setText( 'calibre, version %s (%s, embedded-python: %s)\n%s: %s\n\n%s' % (__version__, sys.platform, isfrozen, unicode_type(self.windowTitle()), unicode_type( d.toPlainText()), unicode_type(self.det_msg.toPlainText()))) if hasattr(self, 'ctc_button'): self.ctc_button.setText(_('Copied')) def toggle_det_msg(self, *args): vis = unicode_type(self.det_msg_toggle.text()) == self.hide_det_msg self.det_msg_toggle.setText( self.show_det_msg if vis else self.hide_det_msg) self.det_msg.setVisible(not vis) self.do_resize() def do_resize(self): h = self.sizeHint().height() self.setMinimumHeight(0) # Needed as this gets set if det_msg is shown # Needed otherwise re-showing the box after showing det_msg causes the box # to not reduce in height self.setMaximumHeight(h) self.resize(QSize(self.WIDTH, h)) def showEvent(self, ev): ret = QDialog.showEvent(self, ev) self.bb.button(QDialogButtonBox.StandardButton.Close).setFocus( Qt.FocusReason.OtherFocusReason) return ret def show_error(self, title, msg, det_msg='', retry_func=None): self.queue.append((title, msg, det_msg, retry_func)) self.update_suppress_state() self.pop() def pop(self): if not self.queue or self.isVisible(): return title, msg, det_msg, retry_func = self.queue.pop(0) self.setWindowTitle(title) self.msg_label.setText(msg) self.det_msg.setPlainText(det_msg) self.det_msg.setVisible(False) self.det_msg_toggle.setText(self.show_det_msg) self.det_msg_toggle.setVisible(True) self.suppress.setChecked(False) self.update_suppress_state() if not det_msg: self.det_msg_toggle.setVisible(False) self.retry_button.setVisible(retry_func is not None) self.retry_func = retry_func self.do_resize() self.show() def done(self, r): if self.suppress.isChecked(): self.queue = [] QDialog.done(self, r) self.do_pop.emit()
class Widget(QWidget): TITLE = _('Unknown') ICON = I('config.png') HELP = '' COMMIT_NAME = None # If True, leading and trailing spaces are removed from line and text edit # fields STRIP_TEXT_FIELDS = True changed_signal = pyqtSignal() set_help_signal = pyqtSignal(object) def __init__(self, parent, options): options = list(options) QWidget.__init__(self, parent) self.setupUi(self) self._options = options self._name = self.commit_name = self.COMMIT_NAME assert self._name is not None self._icon = QIcon(self.ICON) for name in self._options: if not hasattr(self, 'opt_' + name): raise Exception('Option %s missing in %s' % (name, self.__class__.__name__)) self.connect_gui_obj(getattr(self, 'opt_' + name)) def initialize_options(self, get_option, get_help, db=None, book_id=None): ''' :param get_option: A callable that takes one argument: the option name and returns the corresponding OptionRecommendation. :param get_help: A callable that takes the option name and return a help string. ''' defaults = load_defaults(self._name) defaults.merge_recommendations(get_option, OptionRecommendation.LOW, self._options) if db is not None: specifics = load_specifics(db, book_id) specifics.merge_recommendations(get_option, OptionRecommendation.HIGH, self._options, only_existing=True) defaults.update(specifics) self.apply_recommendations(defaults) self.setup_help(get_help) def process_child(child): for g in child.children(): if isinstance(g, QLabel): buddy = g.buddy() if buddy is not None and hasattr(buddy, '_help'): g._help = buddy._help htext = str(buddy.toolTip()).strip() g.setToolTip(htext) g.setWhatsThis(htext) g.__class__.enterEvent = lambda obj, event: self.set_help( getattr(obj, '_help', obj.toolTip())) else: process_child(g) process_child(self) def restore_defaults(self, get_option): defaults = GuiRecommendations() defaults.merge_recommendations(get_option, OptionRecommendation.LOW, self._options) self.apply_recommendations(defaults) def commit_options(self, save_defaults=False): recs = self.create_recommendations() if save_defaults: save_defaults_(self.commit_name, recs) return recs def create_recommendations(self): recs = GuiRecommendations() for name in self._options: gui_opt = getattr(self, 'opt_' + name, None) if gui_opt is None: continue recs[name] = self.get_value(gui_opt) return recs def apply_recommendations(self, recs): for name, val in recs.items(): gui_opt = getattr(self, 'opt_' + name, None) if gui_opt is None: continue self.set_value(gui_opt, val) if name in getattr(recs, 'disabled_options', []): gui_opt.setDisabled(True) def get_value(self, g): from calibre.gui2.convert.xpath_wizard import XPathEdit from calibre.gui2.convert.regex_builder import RegexEdit from calibre.gui2.widgets import EncodingComboBox ret = self.get_value_handler(g) if ret != 'this is a dummy return value, xcswx1avcx4x': return ret if hasattr(g, 'get_value_for_config'): return g.get_value_for_config if isinstance(g, (QSpinBox, QDoubleSpinBox)): return g.value() elif isinstance(g, (QLineEdit, QTextEdit, QPlainTextEdit)): func = getattr(g, 'toPlainText', getattr(g, 'text', None))() ans = str(func) if self.STRIP_TEXT_FIELDS: ans = ans.strip() if not ans: ans = None return ans elif isinstance(g, QFontComboBox): return str(QFontInfo(g.currentFont()).family()) elif isinstance(g, FontFamilyChooser): return g.font_family elif isinstance(g, EncodingComboBox): ans = str(g.currentText()).strip() try: codecs.lookup(ans) except: ans = '' if not ans: ans = None return ans elif isinstance(g, QComboBox): return str(g.currentText()) elif isinstance(g, QCheckBox): return bool(g.isChecked()) elif isinstance(g, XPathEdit): return g.xpath if g.xpath else None elif isinstance(g, RegexEdit): return g.regex if g.regex else None else: raise Exception('Can\'t get value from %s' % type(g)) def gui_obj_changed(self, gui_obj, *args): self.changed_signal.emit() def connect_gui_obj(self, g): f = partial(self.gui_obj_changed, g) try: self.connect_gui_obj_handler(g, f) return except NotImplementedError: pass from calibre.gui2.convert.xpath_wizard import XPathEdit from calibre.gui2.convert.regex_builder import RegexEdit if isinstance(g, (QSpinBox, QDoubleSpinBox)): g.valueChanged.connect(f) elif isinstance(g, (QLineEdit, QTextEdit, QPlainTextEdit)): g.textChanged.connect(f) elif isinstance(g, QComboBox): g.editTextChanged.connect(f) g.currentIndexChanged.connect(f) elif isinstance(g, QCheckBox): g.stateChanged.connect(f) elif isinstance(g, (XPathEdit, RegexEdit)): g.edit.editTextChanged.connect(f) g.edit.currentIndexChanged.connect(f) elif isinstance(g, FontFamilyChooser): g.family_changed.connect(f) else: raise Exception('Can\'t connect %s' % type(g)) def connect_gui_obj_handler(self, gui_obj, slot): raise NotImplementedError() def set_value(self, g, val): from calibre.gui2.convert.xpath_wizard import XPathEdit from calibre.gui2.convert.regex_builder import RegexEdit from calibre.gui2.widgets import EncodingComboBox if self.set_value_handler(g, val): return if hasattr(g, 'set_value_for_config'): g.set_value_for_config = val return if isinstance(g, (QSpinBox, QDoubleSpinBox)): g.setValue(val) elif isinstance(g, (QLineEdit, QTextEdit, QPlainTextEdit)): if not val: val = '' getattr(g, 'setPlainText', getattr(g, 'setText', None))(val) getattr(g, 'setCursorPosition', lambda x: x)(0) elif isinstance(g, QFontComboBox): g.setCurrentFont(QFont(val or '')) elif isinstance(g, FontFamilyChooser): g.font_family = val elif isinstance(g, EncodingComboBox): if val: g.setEditText(val) else: g.setCurrentIndex(0) elif isinstance(g, QComboBox) and val: idx = g.findText(val, Qt.MatchFlag.MatchFixedString) if idx < 0: g.addItem(val) idx = g.findText(val, Qt.MatchFlag.MatchFixedString) g.setCurrentIndex(idx) elif isinstance(g, QCheckBox): g.setCheckState(Qt.CheckState.Checked if bool(val) else Qt. CheckState.Unchecked) elif isinstance(g, (XPathEdit, RegexEdit)): g.edit.setText(val if val else '') else: raise Exception('Can\'t set value %s in %s' % (repr(val), str(g.objectName()))) self.post_set_value(g, val) def set_help(self, msg): if msg and getattr(msg, 'strip', lambda: True)(): try: self.set_help_signal.emit(msg) except: pass def setup_help(self, help_provider): for name in self._options: g = getattr(self, 'opt_' + name, None) if g is None: continue help = help_provider(name) if not help: continue if self.setup_help_handler(g, help): continue g._help = help self.setup_widget_help(g) def setup_widget_help(self, g): w = textwrap.TextWrapper(80) htext = '<div>%s</div>' % prepare_string_for_xml('\n'.join( w.wrap(g._help))) g.setToolTip(htext) g.setWhatsThis(htext) g.__class__.enterEvent = lambda obj, event: self.set_help( getattr(obj, '_help', obj.toolTip())) def set_value_handler(self, g, val): 'Return True iff you handle setting the value for g' return False def post_set_value(self, g, val): pass def get_value_handler(self, g): return 'this is a dummy return value, xcswx1avcx4x' def post_get_value(self, g): pass def setup_help_handler(self, g, help): return False def break_cycles(self): self.db = None def pre_commit_check(self): return True def commit(self, save_defaults=False): return self.commit_options(save_defaults) def config_title(self): return self.TITLE def config_icon(self): return self._icon
class MessageBox(QDialog): # {{{ ERROR = 0 WARNING = 1 INFO = 2 QUESTION = 3 resize_needed = pyqtSignal() def setup_ui(self): self.setObjectName("Dialog") self.resize(497, 235) self.gridLayout = l = QGridLayout(self) l.setObjectName("gridLayout") self.icon_widget = Icon(self) l.addWidget(self.icon_widget) self.msg = la = QLabel(self) la.setWordWrap(True), la.setMinimumWidth(400) la.setOpenExternalLinks(True) la.setObjectName("msg") l.addWidget(la, 0, 1, 1, 1) self.det_msg = dm = QTextBrowser(self) dm.setReadOnly(True) dm.setObjectName("det_msg") l.addWidget(dm, 1, 0, 1, 2) self.bb = bb = QDialogButtonBox(self) bb.setStandardButtons(QDialogButtonBox.StandardButton.Ok) bb.setObjectName("bb") bb.accepted.connect(self.accept) bb.rejected.connect(self.reject) l.addWidget(bb, 3, 0, 1, 2) self.toggle_checkbox = tc = QCheckBox(self) tc.setObjectName("toggle_checkbox") l.addWidget(tc, 2, 0, 1, 2) def __init__(self, type_, title, msg, det_msg='', q_icon=None, show_copy_button=True, parent=None, default_yes=True, yes_text=None, no_text=None, yes_icon=None, no_icon=None, add_abort_button=False, only_copy_details=False): QDialog.__init__(self, parent) self.only_copy_details = only_copy_details self.aborted = False if q_icon is None: icon = { self.ERROR: 'error', self.WARNING: 'warning', self.INFO: 'information', self.QUESTION: 'question', }[type_] icon = 'dialog_%s.png' % icon self.icon = QIcon(I(icon)) else: self.icon = q_icon if isinstance(q_icon, QIcon) else QIcon( I(q_icon)) self.setup_ui() self.setWindowTitle(title) self.setWindowIcon(self.icon) self.icon_widget.set_icon(self.icon) self.msg.setText(msg) if det_msg and Qt.mightBeRichText(det_msg): self.det_msg.setHtml(det_msg) else: self.det_msg.setPlainText(det_msg) self.det_msg.setVisible(False) self.toggle_checkbox.setVisible(False) if show_copy_button: self.ctc_button = self.bb.addButton( _('&Copy to clipboard'), QDialogButtonBox.ButtonRole.ActionRole) self.ctc_button.clicked.connect(self.copy_to_clipboard) self.show_det_msg = _('Show &details') self.hide_det_msg = _('Hide &details') self.det_msg_toggle = self.bb.addButton( self.show_det_msg, QDialogButtonBox.ButtonRole.ActionRole) self.det_msg_toggle.clicked.connect(self.toggle_det_msg) self.det_msg_toggle.setToolTip( _('Show detailed information about this error')) self.copy_action = QAction(self) self.addAction(self.copy_action) self.copy_action.setShortcuts(QKeySequence.StandardKey.Copy) self.copy_action.triggered.connect(self.copy_to_clipboard) self.is_question = type_ == self.QUESTION if self.is_question: self.bb.setStandardButtons(QDialogButtonBox.StandardButton.Yes | QDialogButtonBox.StandardButton.No) self.bb.button(QDialogButtonBox.StandardButton.Yes if default_yes else QDialogButtonBox.StandardButton.No).setDefault( True) self.default_yes = default_yes if yes_text is not None: self.bb.button( QDialogButtonBox.StandardButton.Yes).setText(yes_text) if no_text is not None: self.bb.button( QDialogButtonBox.StandardButton.No).setText(no_text) if yes_icon is not None: self.bb.button(QDialogButtonBox.StandardButton.Yes).setIcon( yes_icon if isinstance(yes_icon, QIcon ) else QIcon(I(yes_icon))) if no_icon is not None: self.bb.button(QDialogButtonBox.StandardButton.No).setIcon( no_icon if isinstance(no_icon, QIcon) else QIcon(I(no_icon) )) else: self.bb.button(QDialogButtonBox.StandardButton.Ok).setDefault(True) if add_abort_button: self.bb.addButton( QDialogButtonBox.StandardButton.Abort).clicked.connect( self.on_abort) if not det_msg: self.det_msg_toggle.setVisible(False) self.resize_needed.connect(self.do_resize, type=Qt.ConnectionType.QueuedConnection) self.do_resize() def on_abort(self): self.aborted = True def sizeHint(self): ans = QDialog.sizeHint(self) ans.setWidth( max(min(ans.width(), 500), self.bb.sizeHint().width() + 100)) ans.setHeight(min(ans.height(), 500)) return ans def toggle_det_msg(self, *args): vis = self.det_msg.isVisible() self.det_msg.setVisible(not vis) self.det_msg_toggle.setText( self.show_det_msg if vis else self.hide_det_msg) self.resize_needed.emit() def do_resize(self): self.resize(self.sizeHint()) def copy_to_clipboard(self, *args): text = self.det_msg.toPlainText() if not self.only_copy_details: text = f'calibre, version {__version__}\n{self.windowTitle()}: {self.msg.text()}\n\n{text}' QApplication.clipboard().setText(text) if hasattr(self, 'ctc_button'): self.ctc_button.setText(_('Copied')) def showEvent(self, ev): ret = QDialog.showEvent(self, ev) if self.is_question: try: self.bb.button(QDialogButtonBox.StandardButton.Yes if self. default_yes else QDialogButtonBox. StandardButton.No).setFocus( Qt.FocusReason.OtherFocusReason) except: pass # Buttons were changed else: self.bb.button(QDialogButtonBox.StandardButton.Ok).setFocus( Qt.FocusReason.OtherFocusReason) return ret def set_details(self, msg): if not msg: msg = '' if Qt.mightBeRichText(msg): self.det_msg.setHtml(msg) else: self.det_msg.setPlainText(msg) self.det_msg_toggle.setText(self.show_det_msg) self.det_msg_toggle.setVisible(bool(msg)) self.det_msg.setVisible(False) self.resize_needed.emit()
class TOCView(QWidget): # {{{ add_new_item = pyqtSignal(object, object) def __init__(self, parent, prefs): QWidget.__init__(self, parent) self.toc_title = None self.prefs = prefs l = self.l = QGridLayout() self.setLayout(l) self.tocw = t = TreeWidget(self) self.tocw.edit_item.connect(self.edit_item) l.addWidget(t, 0, 0, 7, 3) self.up_button = b = QToolButton(self) b.setIcon(QIcon(I('arrow-up.png'))) b.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) l.addWidget(b, 0, 3) b.setToolTip(_('Move current entry up [Ctrl+Up]')) b.clicked.connect(self.move_up) self.left_button = b = QToolButton(self) b.setIcon(QIcon(I('back.png'))) b.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) l.addWidget(b, 2, 3) b.setToolTip(_('Unindent the current entry [Ctrl+Left]')) b.clicked.connect(self.tocw.move_left) self.del_button = b = QToolButton(self) b.setIcon(QIcon(I('trash.png'))) b.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) l.addWidget(b, 3, 3) b.setToolTip(_('Remove all selected entries')) b.clicked.connect(self.del_items) self.right_button = b = QToolButton(self) b.setIcon(QIcon(I('forward.png'))) b.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) l.addWidget(b, 4, 3) b.setToolTip(_('Indent the current entry [Ctrl+Right]')) b.clicked.connect(self.tocw.move_right) self.down_button = b = QToolButton(self) b.setIcon(QIcon(I('arrow-down.png'))) b.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) l.addWidget(b, 6, 3) b.setToolTip(_('Move current entry down [Ctrl+Down]')) b.clicked.connect(self.move_down) self.expand_all_button = b = QPushButton(_('&Expand all')) col = 7 l.addWidget(b, col, 0) b.clicked.connect(self.tocw.expandAll) self.collapse_all_button = b = QPushButton(_('&Collapse all')) b.clicked.connect(self.tocw.collapseAll) l.addWidget(b, col, 1) self.default_msg = _('Double click on an entry to change the text') self.hl = hl = QLabel(self.default_msg) hl.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) l.addWidget(hl, col, 2, 1, -1) self.item_view = i = ItemView(self, self.prefs) self.item_view.delete_item.connect(self.delete_current_item) i.add_new_item.connect(self.add_new_item) i.create_from_xpath.connect(self.create_from_xpath) i.create_from_links.connect(self.create_from_links) i.create_from_files.connect(self.create_from_files) i.flatten_item.connect(self.flatten_item) i.flatten_toc.connect(self.flatten_toc) i.go_to_root.connect(self.go_to_root) l.addWidget(i, 0, 4, col, 1) l.setColumnStretch(2, 10) def edit_item(self): self.item_view.edit_item() def event(self, e): if e.type() == QEvent.Type.StatusTip: txt = unicode_type(e.tip()) or self.default_msg self.hl.setText(txt) return super(TOCView, self).event(e) def item_title(self, item): return unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '') def del_items(self): self.tocw.del_items() def delete_current_item(self): item = self.tocw.currentItem() if item is not None: self.tocw.push_history() p = item.parent() or self.root p.removeChild(item) def iter_items(self, parent=None): for item in self.tocw.iter_items(parent=parent): yield item def flatten_toc(self): self.tocw.push_history() found = True while found: found = False for item in self.iter_items(): if item.childCount() > 0: self._flatten_item(item) found = True break def flatten_item(self): self.tocw.push_history() self._flatten_item(self.tocw.currentItem()) def _flatten_item(self, item): if item is not None: p = item.parent() or self.root idx = p.indexOfChild(item) children = [item.child(i) for i in range(item.childCount())] for child in reversed(children): item.removeChild(child) p.insertChild(idx + 1, child) def go_to_root(self): self.tocw.setCurrentItem(None) def highlight_item(self, item): self.tocw.highlight_item(item) def move_up(self): self.tocw.move_up() def move_down(self): self.tocw.move_down() def data_changed(self, top_left, bottom_right): for r in range(top_left.row(), bottom_right.row() + 1): idx = self.tocw.model().index(r, 0, top_left.parent()) new_title = unicode_type( idx.data(Qt.ItemDataRole.DisplayRole) or '').strip() toc = idx.data(Qt.ItemDataRole.UserRole) if toc is not None: toc.title = new_title or _('(Untitled)') item = self.tocw.itemFromIndex(idx) self.tocw.update_status_tip(item) self.item_view.data_changed(item) def create_item(self, parent, child, idx=-1): if idx == -1: c = QTreeWidgetItem(parent) else: c = QTreeWidgetItem() parent.insertChild(idx, c) self.populate_item(c, child) return c def populate_item(self, c, child): c.setData(0, Qt.ItemDataRole.DisplayRole, child.title or _('(Untitled)')) c.setData(0, Qt.ItemDataRole.UserRole, child) c.setFlags(NODE_FLAGS) c.setData(0, Qt.ItemDataRole.DecorationRole, self.icon_map[child.dest_exists]) if child.dest_exists is False: c.setData( 0, Qt.ItemDataRole.ToolTipRole, _('The location this entry point to does not exist:\n%s') % child.dest_error) else: c.setData(0, Qt.ItemDataRole.ToolTipRole, None) self.tocw.update_status_tip(c) def __call__(self, ebook): self.ebook = ebook if not isinstance(ebook, AZW3Container): self.item_view.hide_azw3_warning() self.toc = get_toc(self.ebook) self.toc_lang, self.toc_uid = self.toc.lang, self.toc.uid self.toc_title = self.toc.toc_title self.blank = QIcon(I('blank.png')) self.ok = QIcon(I('ok.png')) self.err = QIcon(I('dot_red.png')) self.icon_map = {None: self.blank, True: self.ok, False: self.err} def process_item(toc_node, parent): for child in toc_node: c = self.create_item(parent, child) process_item(child, c) root = self.root = self.tocw.invisibleRootItem() root.setData(0, Qt.ItemDataRole.UserRole, self.toc) process_item(self.toc, root) self.tocw.model().dataChanged.connect(self.data_changed) self.tocw.currentItemChanged.connect(self.current_item_changed) self.tocw.setCurrentItem(None) def current_item_changed(self, current, previous): self.item_view(current) def update_item(self, item, where, name, frag, title): if isinstance(frag, tuple): frag = add_id(self.ebook, name, *frag) child = TOC(title, name, frag) child.dest_exists = True self.tocw.push_history() if item is None: # New entry at root level c = self.create_item(self.root, child) self.tocw.setCurrentItem( c, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect) self.tocw.scrollToItem(c) else: if where is None: # Editing existing entry self.populate_item(item, child) else: if where == 'inside': parent = item idx = -1 else: parent = item.parent() or self.root idx = parent.indexOfChild(item) if where == 'after': idx += 1 c = self.create_item(parent, child, idx=idx) self.tocw.setCurrentItem( c, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect) self.tocw.scrollToItem(c) def create_toc(self): root = TOC() def process_node(parent, toc_parent): for i in range(parent.childCount()): item = parent.child(i) title = unicode_type( item.data(0, Qt.ItemDataRole.DisplayRole) or '').strip() toc = item.data(0, Qt.ItemDataRole.UserRole) dest, frag = toc.dest, toc.frag toc = toc_parent.add(title, dest, frag) process_node(item, toc) process_node(self.tocw.invisibleRootItem(), root) return root def insert_toc_fragment(self, toc): def process_node(root, tocparent, added): for child in tocparent: item = self.create_item(root, child) added.append(item) process_node(item, child, added) self.tocw.push_history() nodes = [] process_node(self.root, toc, nodes) self.highlight_item(nodes[0]) def create_from_xpath(self, xpaths, remove_duplicates=True): toc = from_xpaths(self.ebook, xpaths) if len(toc) == 0: return error_dialog( self, _('No items found'), _('No items were found that could be added to the Table of Contents.' ), show=True) if remove_duplicates: toc.remove_duplicates() self.insert_toc_fragment(toc) def create_from_links(self): toc = from_links(self.ebook) if len(toc) == 0: return error_dialog( self, _('No items found'), _('No links were found that could be added to the Table of Contents.' ), show=True) self.insert_toc_fragment(toc) def create_from_files(self): toc = from_files(self.ebook) if len(toc) == 0: return error_dialog( self, _('No items found'), _('No files were found that could be added to the Table of Contents.' ), show=True) self.insert_toc_fragment(toc) def undo(self): self.tocw.pop_history()
class TreeWidget(QTreeWidget): # {{{ edit_item = pyqtSignal() history_state_changed = pyqtSignal() def __init__(self, parent): QTreeWidget.__init__(self, parent) self.history = [] self.setHeaderLabel(_('Table of Contents')) self.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) self.setDragEnabled(True) self.setSelectionMode( QAbstractItemView.SelectionMode.ExtendedSelection) self.viewport().setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.setAutoScroll(True) self.setAutoScrollMargin(ICON_SIZE * 2) self.setDefaultDropAction(Qt.DropAction.MoveAction) self.setAutoExpandDelay(1000) self.setAnimated(True) self.setMouseTracking(True) self.in_drop_event = False self.root = self.invisibleRootItem() self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) def push_history(self): self.history.append(self.serialize_tree()) self.history_state_changed.emit() def pop_history(self): if self.history: self.unserialize_tree(self.history.pop()) self.history_state_changed.emit() def commitData(self, editor): self.push_history() return QTreeWidget.commitData(self, editor) def iter_items(self, parent=None): if parent is None: parent = self.invisibleRootItem() for i in range(parent.childCount()): child = parent.child(i) yield child for gc in self.iter_items(parent=child): yield gc def update_status_tip(self, item): c = item.data(0, Qt.ItemDataRole.UserRole) if c is not None: frag = c.frag or '' if frag: frag = '#' + frag item.setStatusTip( 0, _('<b>Title</b>: {0} <b>Dest</b>: {1}{2}').format( c.title, c.dest, frag)) def serialize_tree(self): def serialize_node(node): return { 'title': node.data(0, Qt.ItemDataRole.DisplayRole), 'toc_node': node.data(0, Qt.ItemDataRole.UserRole), 'icon': node.data(0, Qt.ItemDataRole.DecorationRole), 'tooltip': node.data(0, Qt.ItemDataRole.ToolTipRole), 'is_selected': node.isSelected(), 'is_expanded': node.isExpanded(), 'children': list( map(serialize_node, (node.child(i) for i in range(node.childCount())))), } node = self.invisibleRootItem() return { 'children': list( map(serialize_node, (node.child(i) for i in range(node.childCount())))) } def unserialize_tree(self, serialized): def unserialize_node(dict_node, parent): n = QTreeWidgetItem(parent) n.setData(0, Qt.ItemDataRole.DisplayRole, dict_node['title']) n.setData(0, Qt.ItemDataRole.UserRole, dict_node['toc_node']) n.setFlags(NODE_FLAGS) n.setData(0, Qt.ItemDataRole.DecorationRole, dict_node['icon']) n.setData(0, Qt.ItemDataRole.ToolTipRole, dict_node['tooltip']) self.update_status_tip(n) n.setExpanded(dict_node['is_expanded']) n.setSelected(dict_node['is_selected']) for c in dict_node['children']: unserialize_node(c, n) i = self.invisibleRootItem() i.takeChildren() for child in serialized['children']: unserialize_node(child, i) def dropEvent(self, event): self.in_drop_event = True self.push_history() try: super(TreeWidget, self).dropEvent(event) finally: self.in_drop_event = False def selectedIndexes(self): ans = super(TreeWidget, self).selectedIndexes() if self.in_drop_event: # For order to be be preserved when moving by drag and drop, we # have to ensure that selectedIndexes returns an ordered list of # indexes. sort_map = { self.indexFromItem(item): i for i, item in enumerate(self.iter_items()) } ans = sorted(ans, key=lambda x: sort_map.get(x, -1)) return ans def highlight_item(self, item): self.setCurrentItem(item, 0, QItemSelectionModel.SelectionFlag.ClearAndSelect) self.scrollToItem(item) def check_multi_selection(self): if len(self.selectedItems()) > 1: info_dialog( self, _('Multiple items selected'), _('You are trying to move multiple items at once, this is not supported. Instead use' ' Drag and Drop to move multiple items'), show=True) return False return True def move_left(self): if not self.check_multi_selection(): return self.push_history() item = self.currentItem() if item is not None: parent = item.parent() if parent is not None: is_expanded = item.isExpanded() or item.childCount() == 0 gp = parent.parent() or self.invisibleRootItem() idx = gp.indexOfChild(parent) for gc in [ parent.child(i) for i in range( parent.indexOfChild(item) + 1, parent.childCount()) ]: parent.removeChild(gc) item.addChild(gc) parent.removeChild(item) gp.insertChild(idx + 1, item) if is_expanded: self.expandItem(item) self.highlight_item(item) def move_right(self): if not self.check_multi_selection(): return self.push_history() item = self.currentItem() if item is not None: parent = item.parent() or self.invisibleRootItem() idx = parent.indexOfChild(item) if idx > 0: is_expanded = item.isExpanded() np = parent.child(idx - 1) parent.removeChild(item) np.addChild(item) if is_expanded: self.expandItem(item) self.highlight_item(item) def move_down(self): if not self.check_multi_selection(): return self.push_history() item = self.currentItem() if item is None: if self.root.childCount() == 0: return item = self.root.child(0) self.highlight_item(item) return parent = item.parent() or self.root idx = parent.indexOfChild(item) if idx == parent.childCount() - 1: # At end of parent, need to become sibling of parent if parent is self.root: return gp = parent.parent() or self.root parent.removeChild(item) gp.insertChild(gp.indexOfChild(parent) + 1, item) else: sibling = parent.child(idx + 1) parent.removeChild(item) sibling.insertChild(0, item) self.highlight_item(item) def move_up(self): if not self.check_multi_selection(): return self.push_history() item = self.currentItem() if item is None: if self.root.childCount() == 0: return item = self.root.child(self.root.childCount() - 1) self.highlight_item(item) return parent = item.parent() or self.root idx = parent.indexOfChild(item) if idx == 0: # At end of parent, need to become sibling of parent if parent is self.root: return gp = parent.parent() or self.root parent.removeChild(item) gp.insertChild(gp.indexOfChild(parent), item) else: sibling = parent.child(idx - 1) parent.removeChild(item) sibling.addChild(item) self.highlight_item(item) def del_items(self): self.push_history() for item in self.selectedItems(): p = item.parent() or self.root p.removeChild(item) def title_case(self): self.push_history() from calibre.utils.titlecase import titlecase for item in self.selectedItems(): t = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '') item.setData(0, Qt.ItemDataRole.DisplayRole, titlecase(t)) def upper_case(self): self.push_history() for item in self.selectedItems(): t = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '') item.setData(0, Qt.ItemDataRole.DisplayRole, icu_upper(t)) def lower_case(self): self.push_history() for item in self.selectedItems(): t = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '') item.setData(0, Qt.ItemDataRole.DisplayRole, icu_lower(t)) def swap_case(self): self.push_history() from calibre.utils.icu import swapcase for item in self.selectedItems(): t = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '') item.setData(0, Qt.ItemDataRole.DisplayRole, swapcase(t)) def capitalize(self): self.push_history() from calibre.utils.icu import capitalize for item in self.selectedItems(): t = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '') item.setData(0, Qt.ItemDataRole.DisplayRole, capitalize(t)) def bulk_rename(self): from calibre.gui2.tweak_book.file_list import get_bulk_rename_settings sort_map = {id(item): i for i, item in enumerate(self.iter_items())} items = sorted(self.selectedItems(), key=lambda x: sort_map.get(id(x), -1)) settings = get_bulk_rename_settings( self, len(items), prefix=_('Chapter '), msg=_( 'All selected items will be renamed to the form prefix-number' ), sanitize=lambda x: x, leading_zeros=False) fmt, num = settings['prefix'], settings['start'] if fmt is not None and num is not None: self.push_history() for i, item in enumerate(items): item.setData(0, Qt.ItemDataRole.DisplayRole, fmt % (num + i)) def keyPressEvent(self, ev): if ev.key() == Qt.Key.Key_Left and ev.modifiers() & Qt.Modifier.CTRL: self.move_left() ev.accept() elif ev.key( ) == Qt.Key.Key_Right and ev.modifiers() & Qt.Modifier.CTRL: self.move_right() ev.accept() elif ev.key() == Qt.Key.Key_Up and (ev.modifiers() & Qt.Modifier.CTRL or ev.modifiers() & Qt.Modifier.ALT): self.move_up() ev.accept() elif ev.key() == Qt.Key.Key_Down and (ev.modifiers() & Qt.Modifier.CTRL or ev.modifiers() & Qt.Modifier.ALT): self.move_down() ev.accept() elif ev.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): self.del_items() ev.accept() else: return super(TreeWidget, self).keyPressEvent(ev) def show_context_menu(self, point): item = self.currentItem() def key(k): sc = unicode_type( QKeySequence(k | Qt.Modifier.CTRL).toString( QKeySequence.SequenceFormat.NativeText)) return ' [%s]' % sc if item is not None: m = QMenu(self) m.addAction(QIcon(I('edit_input.png')), _('Change the location this entry points to'), self.edit_item) m.addAction(QIcon(I('modified.png')), _('Bulk rename all selected items'), self.bulk_rename) m.addAction(QIcon(I('trash.png')), _('Remove all selected items'), self.del_items) m.addSeparator() ci = unicode_type(item.data(0, Qt.ItemDataRole.DisplayRole) or '') p = item.parent() or self.invisibleRootItem() idx = p.indexOfChild(item) if idx > 0: m.addAction(QIcon(I('arrow-up.png')), (_('Move "%s" up') % ci) + key(Qt.Key.Key_Up), self.move_up) if idx + 1 < p.childCount(): m.addAction(QIcon(I('arrow-down.png')), (_('Move "%s" down') % ci) + key(Qt.Key.Key_Down), self.move_down) if item.parent() is not None: m.addAction(QIcon(I('back.png')), (_('Unindent "%s"') % ci) + key(Qt.Key.Key_Left), self.move_left) if idx > 0: m.addAction(QIcon(I('forward.png')), (_('Indent "%s"') % ci) + key(Qt.Key.Key_Right), self.move_right) m.addSeparator() case_menu = QMenu(_('Change case'), m) case_menu.addAction(_('Upper case'), self.upper_case) case_menu.addAction(_('Lower case'), self.lower_case) case_menu.addAction(_('Swap case'), self.swap_case) case_menu.addAction(_('Title case'), self.title_case) case_menu.addAction(_('Capitalize'), self.capitalize) m.addMenu(case_menu) m.exec_(QCursor.pos())
class Rules(QWidget): RuleItemClass = RuleItem RuleEditDialogClass = RuleEditDialog changed = pyqtSignal() ACTION_KEY = 'action' MSG = _('You can specify rules to filter/transform tags here. Click the "Add rule" button' ' below to get started. The rules will be processed in order for every tag until either a' ' "remove" or a "keep" rule matches.') def __init__(self, parent=None): QWidget.__init__(self, parent) self.l = l = QVBoxLayout(self) self.msg_label = la = QLabel( '<p>' + self.MSG + '<p>' + _( 'You can <b>change an existing rule</b> by double clicking it') ) la.setWordWrap(True) l.addWidget(la) self.h = h = QHBoxLayout() l.addLayout(h) self.add_button = b = QPushButton(QIcon(I('plus.png')), _('&Add rule'), self) b.clicked.connect(self.add_rule) h.addWidget(b) self.remove_button = b = QPushButton(QIcon(I('minus.png')), _('&Remove rule(s)'), self) b.clicked.connect(self.remove_rules) h.addWidget(b) self.h3 = h = QHBoxLayout() l.addLayout(h) self.rule_list = r = QListWidget(self) self.delegate = Delegate(self) r.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) r.setItemDelegate(self.delegate) r.doubleClicked.connect(self.edit_rule) h.addWidget(r) r.setDragEnabled(True) r.viewport().setAcceptDrops(True) r.setDropIndicatorShown(True) r.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) r.setDefaultDropAction(Qt.DropAction.MoveAction) self.l2 = l = QVBoxLayout() h.addLayout(l) self.up_button = b = QToolButton(self) b.setIcon(QIcon(I('arrow-up.png'))), b.setToolTip(_('Move current rule up')) b.clicked.connect(self.move_up) l.addWidget(b) self.down_button = b = QToolButton(self) b.setIcon(QIcon(I('arrow-down.png'))), b.setToolTip(_('Move current rule down')) b.clicked.connect(self.move_down) l.addStretch(10), l.addWidget(b) def sizeHint(self): return QSize(800, 600) def add_rule(self): d = self.RuleEditDialogClass(self) if d.exec() == QDialog.DialogCode.Accepted: i = self.RuleItemClass(d.edit_widget.rule, self.rule_list) self.rule_list.scrollToItem(i) self.changed.emit() def edit_rule(self): i = self.rule_list.currentItem() if i is not None: d = self.RuleEditDialogClass(self) d.edit_widget.rule = i.data(Qt.ItemDataRole.UserRole) if d.exec() == QDialog.DialogCode.Accepted: rule = d.edit_widget.rule i.setData(DATA_ROLE, rule) i.setData(RENDER_ROLE, self.RuleItemClass.text_from_rule(rule, self.rule_list)) self.changed.emit() def remove_rules(self): changed = False for item in self.rule_list.selectedItems(): self.rule_list.takeItem(self.rule_list.row(item)) changed = True if changed: self.changed.emit() def move_selected(self, delta=-1): current_item = self.rule_list.currentItem() items = self.rule_list.selectedItems() if current_item is None or not items or not len(items): return row_map = {id(item): self.rule_list.row(item) for item in items} items.sort(key=lambda item: row_map[id(item)]) num = self.rule_list.count() for item in items: row = row_map[id(item)] nrow = (row + delta + num) % num self.rule_list.takeItem(row) self.rule_list.insertItem(nrow, item) sm = self.rule_list.selectionModel() for item in items: sm.select(self.rule_list.indexFromItem(item), QItemSelectionModel.SelectionFlag.Select) sm.setCurrentIndex(self.rule_list.indexFromItem(current_item), QItemSelectionModel.SelectionFlag.Current) self.changed.emit() def move_up(self): self.move_selected() def move_down(self): self.move_selected(1) @property def rules(self): ans = [] for r in range(self.rule_list.count()): ans.append(self.rule_list.item(r).data(DATA_ROLE)) return ans @rules.setter def rules(self, rules): self.rule_list.clear() for rule in rules: if self.ACTION_KEY in rule and 'match_type' in rule and 'query' in rule: self.RuleItemClass(rule, self.rule_list)
class ImageView(QWidget, ImageDropMixin): BORDER_WIDTH = 1 cover_changed = pyqtSignal(object) def __init__(self, parent=None, show_size_pref_name=None, default_show_size=False): QWidget.__init__(self, parent) self.show_size_pref_name = ( 'show_size_on_cover_' + show_size_pref_name) if show_size_pref_name else None self._pixmap = QPixmap() self.setMinimumSize(QSize(150, 200)) ImageDropMixin.__init__(self) self.draw_border = True self.show_size = False if self.show_size_pref_name: self.show_size = gprefs.get(self.show_size_pref_name, default_show_size) def setPixmap(self, pixmap): if not isinstance(pixmap, QPixmap): raise TypeError('Must use a QPixmap') self._pixmap = pixmap self.updateGeometry() self.update() def build_context_menu(self): m = ImageDropMixin.build_context_menu(self) if self.show_size_pref_name: text = _('Hide size in corner') if self.show_size else _( 'Show size in corner') m.addAction(text, self.toggle_show_size) return m def toggle_show_size(self): self.show_size ^= True if self.show_size_pref_name: gprefs[self.show_size_pref_name] = self.show_size self.update() def pixmap(self): return self._pixmap def sizeHint(self): if self._pixmap.isNull(): return self.minimumSize() return self._pixmap.size() def paintEvent(self, event): QWidget.paintEvent(self, event) pmap = self._pixmap if pmap.isNull(): return w, h = pmap.width(), pmap.height() ow, oh = w, h cw, ch = self.rect().width(), self.rect().height() scaled, nw, nh = fit_image(w, h, cw, ch) if scaled: pmap = pmap.scaled(int(nw * pmap.devicePixelRatio()), int(nh * pmap.devicePixelRatio()), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) w, h = int(pmap.width() / pmap.devicePixelRatio()), int( pmap.height() / pmap.devicePixelRatio()) x = int(abs(cw - w) / 2) y = int(abs(ch - h) / 2) target = QRect(x, y, w, h) p = QPainter(self) p.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform) p.drawPixmap(target, pmap) if self.draw_border: pen = QPen() pen.setWidth(self.BORDER_WIDTH) p.setPen(pen) p.drawRect(target) if self.show_size: draw_size(p, target, ow, oh) p.end()
class Editor(QMainWindow): has_line_numbers = True modification_state_changed = pyqtSignal(object) undo_redo_state_changed = pyqtSignal(object, object) copy_available_state_changed = pyqtSignal(object) data_changed = pyqtSignal(object) cursor_position_changed = pyqtSignal() word_ignored = pyqtSignal(object, object) link_clicked = pyqtSignal(object) class_clicked = pyqtSignal(object) rename_class = pyqtSignal(object) smart_highlighting_updated = pyqtSignal() def __init__(self, syntax, parent=None): QMainWindow.__init__(self, parent) if parent is None: self.setWindowFlags(Qt.WindowType.Widget) self.is_synced_to_container = False self.syntax = syntax self.editor = TextEdit(self) self.editor.setContextMenuPolicy( Qt.ContextMenuPolicy.CustomContextMenu) self.editor.customContextMenuRequested.connect(self.show_context_menu) self.setCentralWidget(self.editor) self.create_toolbars() self.undo_available = False self.redo_available = False self.copy_available = self.cut_available = False self.editor.modificationChanged.connect( self._modification_state_changed) self.editor.undoAvailable.connect(self._undo_available) self.editor.redoAvailable.connect(self._redo_available) self.editor.textChanged.connect(self._data_changed) self.editor.copyAvailable.connect(self._copy_available) self.editor.cursorPositionChanged.connect( self._cursor_position_changed) self.editor.link_clicked.connect(self.link_clicked) self.editor.class_clicked.connect(self.class_clicked) self.editor.smart_highlighting_updated.connect( self.smart_highlighting_updated) @property def current_line(self): return self.editor.textCursor().blockNumber() @current_line.setter def current_line(self, val): self.editor.go_to_line(val) @property def current_editing_state(self): c = self.editor.textCursor() return {'cursor': (c.anchor(), c.position())} @current_editing_state.setter def current_editing_state(self, val): anchor, position = val.get('cursor', (None, None)) if anchor is not None and position is not None: c = self.editor.textCursor() c.setPosition(anchor), c.setPosition( position, QTextCursor.MoveMode.KeepAnchor) self.editor.setTextCursor(c) def current_tag(self, for_position_sync=True): return self.editor.current_tag(for_position_sync=for_position_sync) @property def highlighter(self): return self.editor.highlighter @property def number_of_lines(self): return self.editor.blockCount() @property def data(self): ans = self.get_raw_data() ans, changed = replace_encoding_declarations(ans, enc='utf-8', limit=4 * 1024) if changed: self.data = ans return ans.encode('utf-8') @data.setter def data(self, val): self.editor.load_text(val, syntax=self.syntax, doc_name=editor_name(self)) def init_from_template(self, template): self.editor.load_text(template, syntax=self.syntax, process_template=True, doc_name=editor_name(self)) def change_document_name(self, newname): self.editor.change_document_name(newname) self.editor.completion_doc_name = newname def get_raw_data(self): # The EPUB spec requires NFC normalization, see section 1.3.6 of # http://www.idpf.org/epub/20/spec/OPS_2.0.1_draft.htm return unicodedata.normalize( 'NFC', str(self.editor.toPlainText()).rstrip('\0')) def replace_data(self, raw, only_if_different=True): if isinstance(raw, bytes): raw = raw.decode('utf-8') current = self.get_raw_data() if only_if_different else False if current != raw: self.editor.replace_text(raw) def apply_settings(self, prefs=None, dictionaries_changed=False): self.editor.apply_settings(prefs=None, dictionaries_changed=dictionaries_changed) def set_focus(self): self.editor.setFocus(Qt.FocusReason.OtherFocusReason) def action_triggered(self, action): action, args = action[0], action[1:] func = getattr(self.editor, action) func(*args) def insert_image(self, href, fullpage=False, preserve_aspect_ratio=False, width=-1, height=-1): self.editor.insert_image(href, fullpage=fullpage, preserve_aspect_ratio=preserve_aspect_ratio, width=width, height=height) def insert_hyperlink(self, href, text, template=None): self.editor.insert_hyperlink(href, text, template=template) def _build_insert_tag_button_menu(self): m = self.insert_tag_menu m.clear() names = tprefs['insert_tag_mru'] for name in names: m.addAction(name, partial(self.insert_tag, name)) m.addSeparator() m.addAction(_('Add a tag to this menu'), self.add_insert_tag) if names: m = m.addMenu(_('Remove from this menu')) for name in names: m.addAction(name, partial(self.remove_insert_tag, name)) def insert_tag(self, name): self.editor.insert_tag(name) mru = tprefs['insert_tag_mru'] try: mru.remove(name) except ValueError: pass mru.insert(0, name) tprefs['insert_tag_mru'] = mru self._build_insert_tag_button_menu() def add_insert_tag(self): name, ok = QInputDialog.getText(self, _('Name of tag to add'), _('Enter the name of the tag')) if ok: mru = tprefs['insert_tag_mru'] mru.insert(0, name) tprefs['insert_tag_mru'] = mru self._build_insert_tag_button_menu() def remove_insert_tag(self, name): mru = tprefs['insert_tag_mru'] try: mru.remove(name) except ValueError: pass tprefs['insert_tag_mru'] = mru self._build_insert_tag_button_menu() def set_request_completion(self, callback=None, doc_name=None): self.editor.request_completion = callback self.editor.completion_doc_name = doc_name def handle_completion_result(self, result): return self.editor.handle_completion_result(result) def undo(self): self.editor.undo() def redo(self): self.editor.redo() @property def selected_text(self): return self.editor.selected_text def get_smart_selection(self, update=True): return self.editor.smarts.get_smart_selection(self.editor, update=update) # Search and replace {{{ def mark_selected_text(self): self.editor.mark_selected_text() def find(self, *args, **kwargs): return self.editor.find(*args, **kwargs) def find_text(self, *args, **kwargs): return self.editor.find_text(*args, **kwargs) def find_spell_word(self, *args, **kwargs): return self.editor.find_spell_word(*args, **kwargs) def replace(self, *args, **kwargs): return self.editor.replace(*args, **kwargs) def all_in_marked(self, *args, **kwargs): return self.editor.all_in_marked(*args, **kwargs) def go_to_anchor(self, *args, **kwargs): return self.editor.go_to_anchor(*args, **kwargs) # }}} @property def has_marked_text(self): return self.editor.current_search_mark is not None @property def is_modified(self): return self.editor.is_modified @is_modified.setter def is_modified(self, val): self.editor.is_modified = val def create_toolbars(self): self.action_bar = b = self.addToolBar(_('Edit actions tool bar')) b.setObjectName('action_bar') # Needed for saveState self.tools_bar = b = self.addToolBar(_('Editor tools')) b.setObjectName('tools_bar') self.bars = [self.action_bar, self.tools_bar] if self.syntax == 'html': self.format_bar = b = self.addToolBar(_('Format text')) b.setObjectName('html_format_bar') self.bars.append(self.format_bar) self.insert_tag_menu = QMenu(self) self.populate_toolbars() for x in self.bars: x.setFloatable(False) x.topLevelChanged.connect(self.toolbar_floated) x.setIconSize( QSize(tprefs['toolbar_icon_size'], tprefs['toolbar_icon_size'])) def toolbar_floated(self, floating): if not floating: self.save_state() for ed in itervalues(editors): if ed is not self: ed.restore_state() def save_state(self): for bar in self.bars: if bar.isFloating(): return tprefs['%s-editor-state' % self.syntax] = bytearray(self.saveState()) def restore_state(self): state = tprefs.get('%s-editor-state' % self.syntax, None) if state is not None: self.restoreState(state) for bar in self.bars: bar.setVisible(len(bar.actions()) > 0) def populate_toolbars(self): self.action_bar.clear(), self.tools_bar.clear() def add_action(name, bar): if name is None: bar.addSeparator() return try: ac = actions[name] except KeyError: if DEBUG: prints('Unknown editor tool: %r' % name) return bar.addAction(ac) if name == 'insert-tag': w = bar.widgetForAction(ac) if hasattr(w, 'setPopupMode'): # For some unknown reason this button is occasionally a # QPushButton instead of a QToolButton w.setPopupMode( QToolButton.ToolButtonPopupMode.MenuButtonPopup) w.setMenu(self.insert_tag_menu) w.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) w.customContextMenuRequested.connect(w.showMenu) self._build_insert_tag_button_menu() elif name == 'change-paragraph': m = ac.m = QMenu() ac.setMenu(m) ch = bar.widgetForAction(ac) if hasattr(ch, 'setPopupMode'): # For some unknown reason this button is occasionally a # QPushButton instead of a QToolButton ch.setPopupMode( QToolButton.ToolButtonPopupMode.InstantPopup) for name in tuple('h%d' % d for d in range(1, 7)) + ('p', ): m.addAction(actions['rename-block-tag-%s' % name]) for name in tprefs.get('editor_common_toolbar', ()): add_action(name, self.action_bar) for name in tprefs.get('editor_%s_toolbar' % self.syntax, ()): add_action(name, self.tools_bar) if self.syntax == 'html': self.format_bar.clear() for name in tprefs['editor_format_toolbar']: add_action(name, self.format_bar) self.restore_state() def break_cycles(self): for x in ('modification_state_changed', 'word_ignored', 'link_clicked', 'class_clicked', 'smart_highlighting_updated'): try: getattr(self, x).disconnect() except TypeError: pass # in case this signal was never connected self.undo_redo_state_changed.disconnect() self.copy_available_state_changed.disconnect() self.cursor_position_changed.disconnect() self.data_changed.disconnect() self.editor.undoAvailable.disconnect() self.editor.redoAvailable.disconnect() self.editor.modificationChanged.disconnect() self.editor.textChanged.disconnect() self.editor.copyAvailable.disconnect() self.editor.cursorPositionChanged.disconnect() self.editor.link_clicked.disconnect() self.editor.class_clicked.disconnect() self.editor.smart_highlighting_updated.disconnect() self.editor.setPlainText('') self.editor.smarts = None self.editor.request_completion = None def _modification_state_changed(self): self.is_synced_to_container = self.is_modified self.modification_state_changed.emit(self.is_modified) def _data_changed(self): self.is_synced_to_container = False self.data_changed.emit(self) def _undo_available(self, available): self.undo_available = available self.undo_redo_state_changed.emit(self.undo_available, self.redo_available) def _redo_available(self, available): self.redo_available = available self.undo_redo_state_changed.emit(self.undo_available, self.redo_available) def _copy_available(self, available): self.copy_available = self.cut_available = available self.copy_available_state_changed.emit(available) def _cursor_position_changed(self, *args): self.cursor_position_changed.emit() @property def cursor_position(self): c = self.editor.textCursor() char = '' col = c.positionInBlock() if not c.atStart(): c.clearSelection() c.movePosition(QTextCursor.MoveOperation.PreviousCharacter, QTextCursor.MoveMode.KeepAnchor) char = str(c.selectedText()).rstrip('\0') return (c.blockNumber() + 1, col, char) def cut(self): self.editor.cut() def copy(self): self.editor.copy() def go_to_line(self, line, col=None): self.editor.go_to_line(line, col=col) def paste(self): if not self.editor.canPaste(): return error_dialog( self, _('No text'), _('There is no suitable text in the clipboard to paste.'), show=True) self.editor.paste() def contextMenuEvent(self, ev): ev.ignore() def fix_html(self): if self.syntax == 'html': from calibre.ebooks.oeb.polish.pretty import fix_html self.editor.replace_text( fix_html(current_container(), str(self.editor.toPlainText())).decode('utf-8')) return True return False def pretty_print(self, name): from calibre.ebooks.oeb.polish.pretty import (pretty_css, pretty_html, pretty_xml) if self.syntax in {'css', 'html', 'xml'}: func = { 'css': pretty_css, 'xml': pretty_xml }.get(self.syntax, pretty_html) original_text = str(self.editor.toPlainText()) prettied_text = func(current_container(), name, original_text).decode('utf-8') if original_text != prettied_text: self.editor.replace_text(prettied_text) return True return False def show_context_menu(self, pos): m = QMenu(self) a = m.addAction c = self.editor.cursorForPosition(pos) origc = QTextCursor(c) current_cursor = self.editor.textCursor() r = origr = self.editor.syntax_range_for_cursor(c) if ( r is None or not r.format.property(SPELL_PROPERTY) ) and c.positionInBlock() > 0 and not current_cursor.hasSelection(): c.setPosition(c.position() - 1) r = self.editor.syntax_range_for_cursor(c) if r is not None and r.format.property(SPELL_PROPERTY): word = self.editor.text_for_range(c.block(), r) locale = self.editor.spellcheck_locale_for_cursor(c) orig_pos = c.position() c.setPosition(orig_pos - utf16_length(word)) found = False self.editor.setTextCursor(c) if self.editor.find_spell_word([word], locale.langcode, center_on_cursor=False): found = True fc = self.editor.textCursor() if fc.position() < c.position(): self.editor.find_spell_word([word], locale.langcode, center_on_cursor=False) spell_cursor = self.editor.textCursor() if current_cursor.hasSelection(): # Restore the current cursor so that any selection is preserved # for the change case actions self.editor.setTextCursor(current_cursor) if found: suggestions = dictionaries.suggestions(word, locale)[:7] if suggestions: for suggestion in suggestions: ac = m.addAction( suggestion, partial(self.editor.simple_replace, suggestion, cursor=spell_cursor)) f = ac.font() f.setBold(True), ac.setFont(f) m.addSeparator() m.addAction(actions['spell-next']) m.addAction(_('Ignore this word'), partial(self._nuke_word, None, word, locale)) dics = dictionaries.active_user_dictionaries if len(dics) > 0: if len(dics) == 1: m.addAction( _('Add this word to the dictionary: {0}').format( dics[0].name), partial(self._nuke_word, dics[0].name, word, locale)) else: ac = m.addAction(_('Add this word to the dictionary')) dmenu = QMenu(m) ac.setMenu(dmenu) for dic in dics: dmenu.addAction( dic.name, partial(self._nuke_word, dic.name, word, locale)) m.addSeparator() if origr is not None and origr.format.property(LINK_PROPERTY): href = self.editor.text_for_range(origc.block(), origr) m.addAction( _('Open %s') % href, partial(self.link_clicked.emit, href)) if origr is not None and origr.format.property( CLASS_ATTRIBUTE_PROPERTY): cls = self.editor.class_for_position(pos) if cls: class_name = cls['class'] m.addAction( _('Rename the class {}').format(class_name), partial(self.rename_class.emit, class_name)) if origr is not None and (origr.format.property(TAG_NAME_PROPERTY) or origr.format.property(CSS_PROPERTY)): word = self.editor.text_for_range(origc.block(), origr) item_type = 'tag_name' if origr.format.property( TAG_NAME_PROPERTY) else 'css_property' url = help_url(word, item_type, self.editor.highlighter.doc_name, extra_data=current_container().opf_version) if url is not None: m.addAction( _('Show help for: %s') % word, partial(open_url, url)) for x in ('undo', 'redo'): ac = actions['editor-%s' % x] if ac.isEnabled(): a(ac) m.addSeparator() for x in ('cut', 'copy', 'paste'): ac = actions['editor-' + x] if ac.isEnabled(): a(ac) m.addSeparator() m.addAction(_('&Select all'), self.editor.select_all) if self.selected_text or self.has_marked_text: update_mark_text_action(self) m.addAction(actions['mark-selected-text']) if self.syntax != 'css' and actions['editor-cut'].isEnabled(): cm = QMenu(_('Change &case'), m) for ac in 'upper lower swap title capitalize'.split(): cm.addAction(actions['transform-case-' + ac]) m.addMenu(cm) if self.syntax == 'html': m.addAction(actions['multisplit']) m.exec(self.editor.viewport().mapToGlobal(pos)) def goto_sourceline(self, *args, **kwargs): return self.editor.goto_sourceline(*args, **kwargs) def goto_css_rule(self, *args, **kwargs): return self.editor.goto_css_rule(*args, **kwargs) def get_tag_contents(self, *args, **kwargs): return self.editor.get_tag_contents(*args, **kwargs) def _nuke_word(self, dic, word, locale): if dic is None: dictionaries.ignore_word(word, locale) else: dictionaries.add_to_user_dictionary(dic, word, locale) self.word_ignored.emit(word, locale)
class FilenamePattern(QWidget, Ui_Form): # {{{ changed_signal = pyqtSignal() def __init__(self, parent): QWidget.__init__(self, parent) self.setupUi(self) try: self.help_label.setText( self.help_label.text() % localize_user_manual_link( 'https://manual.calibre-ebook.com/regexp.html')) except TypeError: pass # link already localized self.test_button.clicked.connect(self.do_test) self.re.lineEdit().returnPressed[()].connect(self.do_test) self.filename.returnPressed[()].connect(self.do_test) connect_lambda(self.re.lineEdit().textChanged, self, lambda self, x: self.changed_signal.emit()) def initialize(self, defaults=False): # Get all items in the combobox. If we are resetting # to defaults we don't want to lose what the user # has added. val_hist = [str(self.re.lineEdit().text())] + [ str(self.re.itemText(i)) for i in range(self.re.count()) ] self.re.clear() if defaults: val = prefs.defaults['filename_pattern'] else: val = prefs['filename_pattern'] self.re.lineEdit().setText(val) val_hist += gprefs.get('filename_pattern_history', [ '(?P<title>.+)', r'(?P<author>[^_-]+) -?\s*(?P<series>[^_0-9-]*)(?P<series_index>[0-9]*)\s*-\s*(?P<title>[^_].+) ?' ]) if val in val_hist: del val_hist[val_hist.index(val)] val_hist.insert(0, val) for v in val_hist: # Ensure we don't have duplicate items. if v and self.re.findText(v) == -1: self.re.addItem(v) self.re.setCurrentIndex(0) def do_test(self): from calibre.ebooks.metadata import authors_to_string from calibre.ebooks.metadata.meta import metadata_from_filename fname = str(self.filename.text()) ext = os.path.splitext(fname)[1][1:].lower() if ext not in BOOK_EXTENSIONS: return warning_dialog( self, _('Test file name invalid'), _('The file name <b>%s</b> does not appear to end with a' ' file extension. It must end with a file ' ' extension like .epub or .mobi') % fname, show=True) try: pat = self.pattern() except Exception as err: error_dialog(self, _('Invalid regular expression'), _('Invalid regular expression: %s') % err).exec() return mi = metadata_from_filename(fname, pat) if mi.title: self.title.setText(mi.title) else: self.title.setText(_('No match')) if mi.authors: self.authors.setText(authors_to_string(mi.authors)) else: self.authors.setText(_('No match')) if mi.series: self.series.setText(mi.series) else: self.series.setText(_('No match')) if mi.series_index is not None: self.series_index.setText(str(mi.series_index)) else: self.series_index.setText(_('No match')) if mi.publisher: self.publisher.setText(mi.publisher) else: self.publisher.setText(_('No match')) if mi.pubdate: self.pubdate.setText(strftime('%Y-%m-%d', mi.pubdate)) else: self.pubdate.setText(_('No match')) self.isbn.setText(_('No match') if mi.isbn is None else str(mi.isbn)) self.comments.setText(mi.comments if mi.comments else _('No match')) def pattern(self): pat = str(self.re.lineEdit().text()) return re.compile(pat) def commit(self): pat = self.pattern().pattern prefs['filename_pattern'] = pat history = [] history_pats = [str(self.re.lineEdit().text())] + [ str(self.re.itemText(i)) for i in range(self.re.count()) ] for p in history_pats[:24]: # Ensure we don't have duplicate items. if p and p not in history: history.append(p) gprefs['filename_pattern_history'] = history return pat
class HighlightsPanel(QWidget): jump_to_cfi = pyqtSignal(object) request_highlight_action = pyqtSignal(object, object) web_action = pyqtSignal(object, object) toggle_requested = pyqtSignal() notes_edited_signal = pyqtSignal(object, object) def __init__(self, parent=None): QWidget.__init__(self, parent) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.l = l = QVBoxLayout(self) l.setContentsMargins(0, 0, 0, 0) self.search_input = si = SearchInput(self, 'highlights-search') si.do_search.connect(self.search_requested) l.addWidget(si) la = QLabel(_('Double click to jump to an entry')) la.setWordWrap(True) l.addWidget(la) self.highlights = h = Highlights(self) l.addWidget(h) h.jump_to_highlight.connect(self.jump_to_highlight) h.delete_requested.connect(self.remove_highlight) h.edit_requested.connect(self.edit_highlight) h.edit_notes_requested.connect(self.edit_notes) h.current_highlight_changed.connect(self.current_highlight_changed) self.load = h.load self.refresh = h.refresh self.h = h = QHBoxLayout() def button(icon, text, tt, target): b = QPushButton(QIcon(I(icon)), text, self) b.setToolTip(tt) b.setFocusPolicy(Qt.FocusPolicy.NoFocus) b.clicked.connect(target) return b self.edit_button = button('edit_input.png', _('Modify'), _('Modify the selected highlight'), self.edit_highlight) self.remove_button = button('trash.png', _('Delete'), _('Delete the selected highlights'), self.remove_highlight) self.export_button = button('save.png', _('Export'), _('Export all highlights'), self.export) h.addWidget(self.edit_button), h.addWidget( self.remove_button), h.addWidget(self.export_button) self.notes_display = nd = NotesDisplay(self) nd.notes_edited.connect(self.notes_edited) l.addWidget(nd) nd.setVisible(False) l.addLayout(h) def notes_edited(self, text): h = self.highlights.current_highlight if h is not None: h['notes'] = text self.web_action.emit('set-notes-in-highlight', h) self.notes_edited_signal.emit(h['uuid'], text) def set_tooltips(self, rmap): a = rmap.get('create_annotation') if a: def as_text(idx): return index_to_key_sequence(idx).toString( QKeySequence.SequenceFormat.NativeText) tt = self.add_button.toolTip().partition('[')[0].strip() keys = sorted(filter(None, map(as_text, a))) if keys: self.add_button.setToolTip('{} [{}]'.format( tt, ', '.join(keys))) def search_requested(self, query): if not self.highlights.find_query(query): error_dialog(self, _('No matches'), _('No highlights match the search: {}').format( query.text), show=True) def focus(self): self.highlights.setFocus(Qt.FocusReason.OtherFocusReason) def jump_to_highlight(self, highlight): self.request_highlight_action.emit(highlight['uuid'], 'goto') def current_highlight_changed(self, highlight): nd = self.notes_display if highlight is None or not highlight.get('notes'): nd.show_notes() else: nd.show_notes(highlight['notes']) def no_selected_highlight(self): error_dialog(self, _('No selected highlight'), _('No highlight is currently selected'), show=True) def edit_highlight(self): h = self.highlights.current_highlight if h is None: return self.no_selected_highlight() self.request_highlight_action.emit(h['uuid'], 'edit') def edit_notes(self): self.notes_display.edit_notes() def remove_highlight(self): highlights = tuple(self.highlights.selected_highlights) if not highlights: return self.no_selected_highlight() if confirm(ngettext( 'Are you sure you want to delete this highlight permanently?', 'Are you sure you want to delete all {} highlights permanently?', len(highlights)).format(len(highlights)), 'delete-highlight-from-viewer', parent=self, config_set=vprefs): for h in highlights: self.request_highlight_action.emit(h['uuid'], 'delete') def export(self): hl = list(self.highlights.all_highlights) if not hl: return error_dialog(self, _('No highlights'), _('This book has no highlights to export'), show=True) Export(hl, self).exec_() def selected_text_changed(self, text, annot_id): if annot_id: self.highlights.find_annot_id(annot_id) def keyPressEvent(self, ev): sc = get_shortcut_for(self, ev) if sc == 'toggle_highlights' or ev.key() == Qt.Key.Key_Escape: self.toggle_requested.emit() return super().keyPressEvent(ev)
class LocationManager(QObject): # {{{ locations_changed = pyqtSignal() unmount_device = pyqtSignal() location_selected = pyqtSignal(object) configure_device = pyqtSignal() update_device_metadata = pyqtSignal() def __init__(self, parent=None): QObject.__init__(self, parent) self.free = [-1, -1, -1] self.count = 0 self.location_actions = QActionGroup(self) self.location_actions.setExclusive(True) self.current_location = 'library' self._mem = [] self.tooltips = {} self.all_actions = [] def ac(name, text, icon, tooltip): icon = QIcon(I(icon)) ac = self.location_actions.addAction(icon, text) setattr(self, 'location_' + name, ac) ac.setAutoRepeat(False) ac.setCheckable(True) receiver = partial(self._location_selected, name) ac.triggered.connect(receiver) self.tooltips[name] = tooltip m = QMenu(parent) self._mem.append(m) a = m.addAction(icon, tooltip) a.triggered.connect(receiver) if name != 'library': self._mem.append(a) a = m.addAction(QIcon(I('eject.png')), _('Eject this device')) a.triggered.connect(self._eject_requested) self._mem.append(a) a = m.addAction(QIcon(I('config.png')), _('Configure this device')) a.triggered.connect(self._configure_requested) self._mem.append(a) a = m.addAction(QIcon(I('sync.png')), _('Update cached metadata on device')) a.triggered.connect( lambda x: self.update_device_metadata.emit()) self._mem.append(a) else: ac.setToolTip(tooltip) ac.setMenu(m) ac.calibre_name = name self.all_actions.append(ac) return ac self.library_action = ac('library', _('Library'), 'lt.png', _('Show books in calibre library')) ac('main', _('Device'), 'reader.png', _('Show books in the main memory of the device')) ac('carda', _('Card A'), 'sd.png', _('Show books in storage card A')) ac('cardb', _('Card B'), 'sd.png', _('Show books in storage card B')) def set_switch_actions(self, quick_actions, rename_actions, delete_actions, switch_actions, choose_action): self.switch_menu = self.library_action.menu() if self.switch_menu: self.switch_menu.addSeparator() else: self.switch_menu = QMenu() self.switch_menu.addAction(choose_action) self.cs_menus = [] for t, acs in [(_('Quick switch'), quick_actions), (_('Rename library'), rename_actions), (_('Delete library'), delete_actions)]: if acs: self.cs_menus.append(QMenu(t)) for ac in acs: self.cs_menus[-1].addAction(ac) self.switch_menu.addMenu(self.cs_menus[-1]) self.switch_menu.addSeparator() for ac in switch_actions: self.switch_menu.addAction(ac) if self.switch_menu != self.library_action.menu(): self.library_action.setMenu(self.switch_menu) def _location_selected(self, location, *args): if location != self.current_location and hasattr( self, 'location_' + location): self.current_location = location self.location_selected.emit(location) getattr(self, 'location_' + location).setChecked(True) def _eject_requested(self, *args): self.unmount_device.emit() def _configure_requested(self): self.configure_device.emit() def update_devices(self, cp=(None, None), fs=[-1, -1, -1], icon=None): if icon is None: icon = I('reader.png') self.location_main.setIcon(QIcon(icon)) had_device = self.has_device if cp is None: cp = (None, None) if isinstance(cp, (bytes, unicode_type)): cp = (cp, None) if len(fs) < 3: fs = list(fs) + [0] self.free[0] = fs[0] self.free[1] = fs[1] self.free[2] = fs[2] cpa, cpb = cp self.free[1] = fs[1] if fs[1] is not None and cpa is not None else -1 self.free[2] = fs[2] if fs[2] is not None and cpb is not None else -1 self.update_tooltips() if self.has_device != had_device: self.location_library.setChecked(True) self.locations_changed.emit() if not self.has_device: self.location_library.trigger() def update_tooltips(self): for i, loc in enumerate(('main', 'carda', 'cardb')): t = self.tooltips[loc] if self.free[i] > -1: t += '\n\n%s ' % human_readable(self.free[i]) + _('available') ac = getattr(self, 'location_' + loc) ac.setToolTip(t) ac.setWhatsThis(t) ac.setStatusTip(t) @property def has_device(self): return max(self.free) > -1 @property def available_actions(self): ans = [self.location_library] for i, loc in enumerate(('main', 'carda', 'cardb')): if self.free[i] > -1: ans.append(getattr(self, 'location_' + loc)) return ans
class IdentifyWidget(QWidget): # {{{ rejected = pyqtSignal() results_found = pyqtSignal() book_selected = pyqtSignal(object, object) def __init__(self, log, parent=None): QWidget.__init__(self, parent) self.log = log self.abort = Event() self.caches = {} self.l = l = QVBoxLayout(self) names = [ '<b>' + p.name + '</b>' for p in metadata_plugins(['identify']) if p.is_configured() ] self.top = QLabel('<p>' + _('calibre is downloading metadata from: ') + ', '.join(names)) self.top.setWordWrap(True) l.addWidget(self.top) self.splitter = s = QSplitter(self) s.setChildrenCollapsible(False) l.addWidget(s, 100) self.results_view = ResultsView(self) self.results_view.book_selected.connect(self.emit_book_selected) self.get_result = self.results_view.get_result s.addWidget(self.results_view) self.comments_view = Comments(self) s.addWidget(self.comments_view) s.setStretchFactor(0, 2) s.setStretchFactor(1, 1) self.results_view.show_details_signal.connect( self.comments_view.show_data) self.query = QLabel('download starting...') self.query.setWordWrap(True) l.addWidget(self.query) self.comments_view.show_wait() state = gprefs.get('metadata-download-identify-widget-splitter-state') if state is not None: s.restoreState(state) def save_state(self): gprefs['metadata-download-identify-widget-splitter-state'] = bytearray( self.splitter.saveState()) def emit_book_selected(self, book): self.book_selected.emit(book, self.caches) def start(self, title=None, authors=None, identifiers={}): self.log.clear() self.log('Starting download') parts, simple_desc = [], '' if title: parts.append('title:' + title) simple_desc += _('Title: %s ') % title if authors: parts.append('authors:' + authors_to_string(authors)) simple_desc += _('Authors: %s ') % authors_to_string(authors) if identifiers: x = ', '.join('%s:%s' % (k, v) for k, v in iteritems(identifiers)) parts.append(x) if 'isbn' in identifiers: simple_desc += 'ISBN: %s' % identifiers['isbn'] self.query.setText(simple_desc) self.log(str(self.query.text())) self.worker = IdentifyWorker(self.log, self.abort, title, authors, identifiers, self.caches) self.worker.start() QTimer.singleShot(50, self.update) def update(self): if self.worker.is_alive(): QTimer.singleShot(50, self.update) else: self.process_results() def process_results(self): if self.worker.error is not None: error_dialog(self, _('Download failed'), _('Failed to download metadata. Click ' 'Show Details to see details'), show=True, det_msg=self.worker.error) self.rejected.emit() return if not self.worker.results: log = ''.join(self.log.plain_text) error_dialog( self, _('No matches found'), '<p>' + _('Failed to find any books that ' 'match your search. Try making the search <b>less ' 'specific</b>. For example, use only the author\'s ' 'last name and a single distinctive word from ' 'the title.<p>To see the full log, click "Show details".'), show=True, det_msg=log) self.rejected.emit() return self.results_view.show_results(self.worker.results) self.results_found.emit() def cancel(self): self.abort.set()
class Page(QWebEnginePage): # {{{ elem_clicked = pyqtSignal(object, object, object, object, object) frag_shown = pyqtSignal(object) def __init__(self, prefs): self.log = default_log self.current_frag = None self.com_id = str(uuid4()) QWebEnginePage.__init__(self) secure_webengine(self.settings(), for_viewer=True) self.titleChanged.connect(self.title_changed) self.loadFinished.connect(self.show_frag) s = QWebEngineScript() s.setName('toc.js') s.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) s.setRunsOnSubFrames(True) s.setWorldId(QWebEngineScript.ScriptWorldId.ApplicationWorld) js = P('toc.js', allow_user_override=False, data=True).decode('utf-8').replace('COM_ID', self.com_id, 1) if 'preview_background' in prefs.defaults and 'preview_foreground' in prefs.defaults: from calibre.gui2.tweak_book.preview import get_editor_settings settings = get_editor_settings(prefs) else: if is_dark_theme(): settings = { 'is_dark_theme': True, 'bg': dark_color.name(), 'fg': dark_text_color.name(), 'link': dark_link_color.name(), } else: settings = {} js = js.replace('SETTINGS', json.dumps(settings), 1) dark_mode_css = P('dark_mode.css', data=True, allow_user_override=False).decode('utf-8') js = js.replace('CSS', json.dumps(dark_mode_css), 1) s.setSourceCode(js) self.scripts().insert(s) def javaScriptConsoleMessage(self, level, msg, lineno, msgid): self.log('JS:', str(msg)) def javaScriptAlert(self, origin, msg): self.log(str(msg)) def title_changed(self, title): parts = title.split('-', 1) if len(parts) == 2 and parts[0] == self.com_id: self.runJavaScript('JSON.stringify(window.calibre_toc_data)', QWebEngineScript.ScriptWorldId.ApplicationWorld, self.onclick) def onclick(self, data): try: tag, elem_id, loc, totals, frac = json.loads(data) except Exception: return elem_id = elem_id or None self.elem_clicked.emit(tag, frac, elem_id, loc, totals) def show_frag(self, ok): if ok and self.current_frag: self.runJavaScript(''' document.location = '#non-existent-anchor'; document.location = '#' + {}; '''.format(json.dumps(self.current_frag))) self.current_frag = None self.runJavaScript('window.pageYOffset/document.body.scrollHeight', QWebEngineScript.ScriptWorldId.ApplicationWorld, self.frag_shown.emit)
class CoversWidget(QWidget): # {{{ chosen = pyqtSignal() finished = pyqtSignal() def __init__(self, log, current_cover, parent=None): QWidget.__init__(self, parent) self.log = log self.abort = Event() self.l = l = QGridLayout() self.setLayout(l) self.msg = QLabel() self.msg.setWordWrap(True) l.addWidget(self.msg, 0, 0) self.covers_view = CoversView(current_cover, self) self.covers_view.chosen.connect(self.chosen) l.addWidget(self.covers_view, 1, 0) self.continue_processing = True def reset_covers(self): self.covers_view.reset_covers() def start(self, book, current_cover, title, authors, caches): self.continue_processing = True self.abort.clear() self.book, self.current_cover = book, current_cover self.title, self.authors = title, authors self.log('Starting cover download for:', book.title) self.log('Query:', title, authors, self.book.identifiers) self.msg.setText( '<p>' + _('Downloading covers for <b>%s</b>, please wait...') % book.title) self.covers_view.start() self.worker = CoverWorker(self.log, self.abort, self.title, self.authors, book.identifiers, caches) self.worker.start() QTimer.singleShot(50, self.check) self.covers_view.setFocus(Qt.FocusReason.OtherFocusReason) def check(self): if self.worker.is_alive() and not self.abort.is_set(): QTimer.singleShot(50, self.check) try: self.process_result(self.worker.rq.get_nowait()) except Empty: pass else: self.process_results() def process_results(self): while self.continue_processing: try: self.process_result(self.worker.rq.get_nowait()) except Empty: break if self.continue_processing: self.covers_view.clear_failed() if self.worker.error and self.worker.error.strip(): error_dialog(self, _('Download failed'), _('Failed to download any covers, click' ' "Show details" for details.'), det_msg=self.worker.error, show=True) num = self.covers_view.model().rowCount() if num < 2: txt = _( 'Could not find any covers for <b>%s</b>') % self.book.title else: if num == 2: txt = _('Found a cover for {title}').format(title=self.title) else: txt = _( 'Found <b>{num}</b> covers for {title}. When the download completes,' ' the covers will be sorted by size.').format( title=self.title, num=num - 1) self.msg.setText(txt) self.msg.setWordWrap(True) self.covers_view.stop() self.finished.emit() def process_result(self, result): if not self.continue_processing: return plugin_name, width, height, fmt, data = result self.covers_view.model().update_result(plugin_name, width, height, data) def cleanup(self): self.covers_view.delegate.stop_animation() self.continue_processing = False def cancel(self): self.cleanup() self.abort.set() def cover_pixmap(self): idx = None for i in self.covers_view.selectionModel().selectedIndexes(): if i.isValid(): idx = i break if idx is None: idx = self.covers_view.currentIndex() return self.covers_view.model().cover_pixmap(idx)
class TextEdit(PlainTextEdit): link_clicked = pyqtSignal(object) class_clicked = pyqtSignal(object) smart_highlighting_updated = pyqtSignal() def __init__(self, parent=None, expected_geometry=(100, 50)): PlainTextEdit.__init__(self, parent) self.snippet_manager = SnippetManager(self) self.completion_popup = CompletionPopup(self) self.request_completion = self.completion_doc_name = None self.clear_completion_cache_timer = t = QTimer(self) t.setInterval(5000), t.timeout.connect( self.clear_completion_cache), t.setSingleShot(True) self.textChanged.connect(t.start) self.last_completion_request = -1 self.gutter_width = 0 self.tw = 2 self.expected_geometry = expected_geometry self.saved_matches = {} self.syntax = None self.smarts = NullSmarts(self) self.current_cursor_line = None self.current_search_mark = None self.smarts_highlight_timer = t = QTimer() t.setInterval(750), t.setSingleShot(True), t.timeout.connect( self.update_extra_selections) self.highlighter = SyntaxHighlighter() self.line_number_area = LineNumbers(self) self.apply_settings() self.setMouseTracking(True) self.cursorPositionChanged.connect(self.highlight_cursor_line) self.blockCountChanged[int].connect(self.update_line_number_area_width) self.updateRequest.connect(self.update_line_number_area) def get_droppable_files(self, md): def is_mt_ok(mt): return self.syntax == 'html' and (mt in OEB_DOCS or mt in OEB_STYLES or mt.startswith('image/')) if md.hasFormat(CONTAINER_DND_MIMETYPE): for line in as_unicode(bytes( md.data(CONTAINER_DND_MIMETYPE))).splitlines(): mt = current_container().mime_map.get( line, 'application/octet-stream') if is_mt_ok(mt): yield line, mt, True return for qurl in md.urls(): if qurl.isLocalFile() and os.access(qurl.toLocalFile(), os.R_OK): path = qurl.toLocalFile() mt = guess_type(path) if is_mt_ok(mt): yield path, mt, False def canInsertFromMimeData(self, md): if md.hasText() or (md.hasHtml() and self.syntax == 'html') or md.hasImage(): return True elif tuple(self.get_droppable_files(md)): return True return False def insertFromMimeData(self, md): files = tuple(self.get_droppable_files(md)) base = self.highlighter.doc_name or None def get_name(name): folder = get_recommended_folders(current_container(), (name, ))[name] or '' if folder: folder += '/' return folder + name def get_href(name): return current_container().name_to_href(name, base) def insert_text(text): c = self.textCursor() c.insertText(text) self.setTextCursor(c) self.ensureCursorVisible() def add_file(name, data, mt=None): from calibre.gui2.tweak_book.boss import get_boss name = current_container().add_file(name, data, media_type=mt, modify_name_if_needed=True) get_boss().refresh_file_list() return name if files: for path, mt, is_name in files: if is_name: name = path else: name = get_name(os.path.basename(path)) with lopen(path, 'rb') as f: name = add_file(name, f.read(), mt) href = get_href(name) if mt.startswith('image/'): self.insert_image(href) elif mt in OEB_STYLES: insert_text( '<link href="{}" rel="stylesheet" type="text/css"/>'. format(href)) elif mt in OEB_DOCS: self.insert_hyperlink(href, name) self.ensureCursorVisible() return if md.hasImage(): img = md.imageData() if img is not None and not img.isNull(): data = image_to_data(img, fmt='PNG') name = add_file(get_name('dropped_image.png'), data) self.insert_image(get_href(name)) self.ensureCursorVisible() return if md.hasText(): return insert_text(md.text()) if md.hasHtml(): insert_text(md.html()) return @property def is_modified(self): ''' True if the document has been modified since it was loaded or since the last time is_modified was set to False. ''' return self.document().isModified() @is_modified.setter def is_modified(self, val): self.document().setModified(bool(val)) def sizeHint(self): return self.size_hint def apply_settings(self, prefs=None, dictionaries_changed=False): # {{{ prefs = prefs or tprefs self.setAcceptDrops(prefs.get('editor_accepts_drops', True)) self.setLineWrapMode( QPlainTextEdit.LineWrapMode.WidgetWidth if prefs['editor_line_wrap'] else QPlainTextEdit.LineWrapMode.NoWrap) theme = get_theme(prefs['editor_theme']) self.apply_theme(theme) w = self.fontMetrics() self.space_width = w.width(' ') self.tw = self.smarts.override_tab_stop_width if self.smarts.override_tab_stop_width is not None else prefs[ 'editor_tab_stop_width'] self.setTabStopWidth(self.tw * self.space_width) if dictionaries_changed: self.highlighter.rehighlight() def apply_theme(self, theme): self.theme = theme pal = self.palette() pal.setColor(QPalette.ColorRole.Base, theme_color(theme, 'Normal', 'bg')) pal.setColor(QPalette.ColorRole.AlternateBase, theme_color(theme, 'CursorLine', 'bg')) pal.setColor(QPalette.ColorRole.Text, theme_color(theme, 'Normal', 'fg')) pal.setColor(QPalette.ColorRole.Highlight, theme_color(theme, 'Visual', 'bg')) pal.setColor(QPalette.ColorRole.HighlightedText, theme_color(theme, 'Visual', 'fg')) self.setPalette(pal) self.tooltip_palette = pal = QPalette() pal.setColor(QPalette.ColorRole.ToolTipBase, theme_color(theme, 'Tooltip', 'bg')) pal.setColor(QPalette.ColorRole.ToolTipText, theme_color(theme, 'Tooltip', 'fg')) self.line_number_palette = pal = QPalette() pal.setColor(QPalette.ColorRole.Base, theme_color(theme, 'LineNr', 'bg')) pal.setColor(QPalette.ColorRole.Text, theme_color(theme, 'LineNr', 'fg')) pal.setColor(QPalette.ColorRole.BrightText, theme_color(theme, 'LineNrC', 'fg')) self.match_paren_format = theme_format(theme, 'MatchParen') font = self.font() ff = tprefs['editor_font_family'] if ff is None: ff = default_font_family() font.setFamily(ff) font.setPointSize(tprefs['editor_font_size']) self.tooltip_font = QFont(font) self.tooltip_font.setPointSize(font.pointSize() - 1) self.setFont(font) self.highlighter.apply_theme(theme) w = self.fontMetrics() self.number_width = max( map(lambda x: w.width(unicode_type(x)), range(10))) self.size_hint = QSize( self.expected_geometry[0] * w.averageCharWidth(), self.expected_geometry[1] * w.height()) self.highlight_color = theme_color(theme, 'HighlightRegion', 'bg') self.highlight_cursor_line() self.completion_popup.clear_caches(), self.completion_popup.update() # }}} def load_text(self, text, syntax='html', process_template=False, doc_name=None): self.syntax = syntax self.highlighter = get_highlighter(syntax)() self.highlighter.apply_theme(self.theme) self.highlighter.set_document(self.document(), doc_name=doc_name) sclass = get_smarts(syntax) if sclass is not None: self.smarts = sclass(self) if self.smarts.override_tab_stop_width is not None: self.tw = self.smarts.override_tab_stop_width self.setTabStopWidth(self.tw * self.space_width) if isinstance(text, bytes): text = text.decode('utf-8', 'replace') self.setPlainText(unicodedata.normalize('NFC', unicode_type(text))) if process_template and QPlainTextEdit.find(self, '%CURSOR%'): c = self.textCursor() c.insertText('') def change_document_name(self, newname): self.highlighter.doc_name = newname self.highlighter.rehighlight( ) # Ensure links are checked w.r.t. the new name correctly def replace_text(self, text): c = self.textCursor() pos = c.position() c.beginEditBlock() c.clearSelection() c.select(QTextCursor.SelectionType.Document) c.insertText(unicodedata.normalize('NFC', text)) c.endEditBlock() c.setPosition(min(pos, len(text))) self.setTextCursor(c) self.ensureCursorVisible() def simple_replace(self, text, cursor=None): c = cursor or self.textCursor() c.insertText(unicodedata.normalize('NFC', text)) self.setTextCursor(c) def go_to_line(self, lnum, col=None): lnum = max(1, min(self.blockCount(), lnum)) c = self.textCursor() c.clearSelection() c.movePosition(QTextCursor.MoveOperation.Start) c.movePosition(QTextCursor.MoveOperation.NextBlock, n=lnum - 1) c.movePosition(QTextCursor.MoveOperation.StartOfLine) c.movePosition(QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor) text = unicode_type(c.selectedText()).rstrip('\0') if col is None: c.movePosition(QTextCursor.MoveOperation.StartOfLine) lt = text.lstrip() if text and lt and lt != text: c.movePosition(QTextCursor.MoveOperation.NextWord) else: c.setPosition(c.block().position() + col) if c.blockNumber() + 1 > lnum: # We have moved past the end of the line c.setPosition(c.block().position()) c.movePosition(QTextCursor.MoveOperation.EndOfBlock) self.setTextCursor(c) self.ensureCursorVisible() def update_extra_selections(self, instant=True): sel = [] if self.current_cursor_line is not None: sel.append(self.current_cursor_line) if self.current_search_mark is not None: sel.append(self.current_search_mark) if instant and not self.highlighter.has_requests and self.smarts is not None: sel.extend(self.smarts.get_extra_selections(self)) self.smart_highlighting_updated.emit() else: self.smarts_highlight_timer.start() self.setExtraSelections(sel) # Search and replace {{{ def mark_selected_text(self): sel = QTextEdit.ExtraSelection() sel.format.setBackground(self.highlight_color) sel.cursor = self.textCursor() if sel.cursor.hasSelection(): self.current_search_mark = sel c = self.textCursor() c.clearSelection() self.setTextCursor(c) else: self.current_search_mark = None self.update_extra_selections() def find_in_marked(self, pat, wrap=False, save_match=None): if self.current_search_mark is None: return False csm = self.current_search_mark.cursor reverse = pat.flags & regex.REVERSE c = self.textCursor() c.clearSelection() m_start = min(csm.position(), csm.anchor()) m_end = max(csm.position(), csm.anchor()) if c.position() < m_start: c.setPosition(m_start) if c.position() > m_end: c.setPosition(m_end) pos = m_start if reverse else m_end if wrap: pos = m_end if reverse else m_start c.setPosition(pos, QTextCursor.MoveMode.KeepAnchor) raw = unicode_type(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') m = pat.search(raw) if m is None: return False start, end = m.span() if start == end: return False if wrap: if reverse: textpos = c.anchor() start, end = textpos + end, textpos + start else: start, end = m_start + start, m_start + end else: if reverse: start, end = m_start + end, m_start + start else: start, end = c.anchor() + start, c.anchor() + end c.clearSelection() c.setPosition(start) c.setPosition(end, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) # Center search result on screen self.centerCursor() if save_match is not None: self.saved_matches[save_match] = (pat, m) return True def all_in_marked(self, pat, template=None): if self.current_search_mark is None: return 0 c = self.current_search_mark.cursor raw = unicode_type(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') if template is None: count = len(pat.findall(raw)) else: from calibre.gui2.tweak_book.function_replace import Function repl_is_func = isinstance(template, Function) if repl_is_func: template.init_env() raw, count = pat.subn(template, raw) if repl_is_func: from calibre.gui2.tweak_book.search import show_function_debug_output if getattr(template.func, 'append_final_output_to_marked', False): retval = template.end() if retval: raw += unicode_type(retval) else: template.end() show_function_debug_output(template) if count > 0: start_pos = min(c.anchor(), c.position()) c.insertText(raw) end_pos = max(c.anchor(), c.position()) c.setPosition(start_pos), c.setPosition( end_pos, QTextCursor.MoveMode.KeepAnchor) self.update_extra_selections() return count def smart_comment(self): from calibre.gui2.tweak_book.editor.comments import smart_comment smart_comment(self, self.syntax) def sort_css(self): from calibre.gui2.dialogs.confirm_delete import confirm if confirm(_( 'Sorting CSS rules can in rare cases change the effective styles applied to the book.' ' Are you sure you want to proceed?'), 'edit-book-confirm-sort-css', parent=self, config_set=tprefs): c = self.textCursor() c.beginEditBlock() c.movePosition(QTextCursor.MoveOperation.Start), c.movePosition( QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) text = unicode_type(c.selectedText()).replace( PARAGRAPH_SEPARATOR, '\n').rstrip('\0') from calibre.ebooks.oeb.polish.css import sort_sheet text = css_text(sort_sheet(current_container(), text)) c.insertText(text) c.movePosition(QTextCursor.MoveOperation.Start) c.endEditBlock() self.setTextCursor(c) def find(self, pat, wrap=False, marked=False, complete=False, save_match=None): if marked: return self.find_in_marked(pat, wrap=wrap, save_match=save_match) reverse = pat.flags & regex.REVERSE c = self.textCursor() c.clearSelection() if complete: # Search the entire text c.movePosition(QTextCursor.MoveOperation. End if reverse else QTextCursor.MoveOperation.Start) pos = QTextCursor.MoveOperation.Start if reverse else QTextCursor.MoveOperation.End if wrap and not complete: pos = QTextCursor.MoveOperation.End if reverse else QTextCursor.MoveOperation.Start c.movePosition(pos, QTextCursor.MoveMode.KeepAnchor) raw = unicode_type(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') m = pat.search(raw) if m is None: return False start, end = m.span() if start == end: return False if wrap and not complete: if reverse: textpos = c.anchor() start, end = textpos + end, textpos + start else: if reverse: # Put the cursor at the start of the match start, end = end, start else: textpos = c.anchor() start, end = textpos + start, textpos + end c.clearSelection() c.setPosition(start) c.setPosition(end, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) # Center search result on screen self.centerCursor() if save_match is not None: self.saved_matches[save_match] = (pat, m) return True def find_text(self, pat, wrap=False, complete=False): reverse = pat.flags & regex.REVERSE c = self.textCursor() c.clearSelection() if complete: # Search the entire text c.movePosition(QTextCursor.MoveOperation. End if reverse else QTextCursor.MoveOperation.Start) pos = QTextCursor.MoveOperation.Start if reverse else QTextCursor.MoveOperation.End if wrap and not complete: pos = QTextCursor.MoveOperation.End if reverse else QTextCursor.MoveOperation.Start c.movePosition(pos, QTextCursor.MoveMode.KeepAnchor) if hasattr(self.smarts, 'find_text'): self.highlighter.join() found, start, end = self.smarts.find_text(pat, c, reverse) if not found: return False else: raw = unicode_type(c.selectedText()).replace( PARAGRAPH_SEPARATOR, '\n').rstrip('\0') m = pat.search(raw) if m is None: return False start, end = m.span() if start == end: return False if reverse: start, end = end, start c.clearSelection() c.setPosition(start) c.setPosition(end, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) # Center search result on screen self.centerCursor() return True def find_spell_word(self, original_words, lang, from_cursor=True, center_on_cursor=True): c = self.textCursor() c.setPosition(c.position()) if not from_cursor: c.movePosition(QTextCursor.MoveOperation.Start) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) def find_first_word(haystack): match_pos, match_word = -1, None for w in original_words: idx = index_of(w, haystack, lang=lang) if idx > -1 and (match_pos == -1 or match_pos > idx): match_pos, match_word = idx, w return match_pos, match_word while True: text = unicode_type(c.selectedText()).rstrip('\0') idx, word = find_first_word(text) if idx == -1: return False c.setPosition(c.anchor() + idx) c.setPosition(c.position() + string_length(word), QTextCursor.MoveMode.KeepAnchor) if self.smarts.verify_for_spellcheck(c, self.highlighter): self.highlighter.join() # Ensure highlighting is finished locale = self.spellcheck_locale_for_cursor(c) if not lang or not locale or (locale and lang == locale.langcode): self.setTextCursor(c) if center_on_cursor: self.centerCursor() return True c.setPosition(c.position()) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) return False def find_next_spell_error(self, from_cursor=True): c = self.textCursor() if not from_cursor: c.movePosition(QTextCursor.MoveOperation.Start) block = c.block() while block.isValid(): for r in block.layout().additionalFormats(): if r.format.property(SPELL_PROPERTY): if not from_cursor or block.position( ) + r.start + r.length > c.position(): c.setPosition(block.position() + r.start) c.setPosition(c.position() + r.length, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) return True block = block.next() return False def replace(self, pat, template, saved_match='gui'): c = self.textCursor() raw = unicode_type(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') m = pat.fullmatch(raw) if m is None: # This can happen if either the user changed the selected text or # the search expression uses lookahead/lookbehind operators. See if # the saved match matches the currently selected text and # use it, if so. if saved_match is not None and saved_match in self.saved_matches: saved_pat, saved = self.saved_matches.pop(saved_match) if saved_pat == pat and saved.group() == raw: m = saved if m is None: return False if callable(template): text = template(m) else: text = m.expand(template) c.insertText(text) return True def go_to_anchor(self, anchor): if anchor is TOP: c = self.textCursor() c.movePosition(QTextCursor.MoveOperation.Start) self.setTextCursor(c) return True base = r'''%%s\s*=\s*['"]{0,1}%s''' % regex.escape(anchor) raw = unicode_type(self.toPlainText()) m = regex.search(base % 'id', raw) if m is None: m = regex.search(base % 'name', raw) if m is not None: c = self.textCursor() c.setPosition(m.start()) self.setTextCursor(c) return True return False # }}} # Line numbers and cursor line {{{ def highlight_cursor_line(self): sel = QTextEdit.ExtraSelection() sel.format.setBackground(self.palette().alternateBase()) sel.format.setProperty(QTextFormat.Property.FullWidthSelection, True) sel.cursor = self.textCursor() sel.cursor.clearSelection() self.current_cursor_line = sel self.update_extra_selections(instant=False) # Update the cursor line's line number in the line number area try: self.line_number_area.update(0, self.last_current_lnum[0], self.line_number_area.width(), self.last_current_lnum[1]) except AttributeError: pass block = self.textCursor().block() top = int( self.blockBoundingGeometry(block).translated( self.contentOffset()).top()) height = int(self.blockBoundingRect(block).height()) self.line_number_area.update(0, top, self.line_number_area.width(), height) def update_line_number_area_width(self, block_count=0): self.gutter_width = self.line_number_area_width() self.setViewportMargins(self.gutter_width, 0, 0, 0) def line_number_area_width(self): digits = 1 limit = max(1, self.blockCount()) while limit >= 10: limit /= 10 digits += 1 return 8 + self.number_width * digits def update_line_number_area(self, rect, dy): if dy: self.line_number_area.scroll(0, dy) else: self.line_number_area.update(0, rect.y(), self.line_number_area.width(), rect.height()) if rect.contains(self.viewport().rect()): self.update_line_number_area_width() def resizeEvent(self, ev): QPlainTextEdit.resizeEvent(self, ev) cr = self.contentsRect() self.line_number_area.setGeometry( QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())) def paint_line_numbers(self, ev): painter = QPainter(self.line_number_area) painter.fillRect( ev.rect(), self.line_number_palette.color(QPalette.ColorRole.Base)) block = self.firstVisibleBlock() num = block.blockNumber() top = int( self.blockBoundingGeometry(block).translated( self.contentOffset()).top()) bottom = top + int(self.blockBoundingRect(block).height()) current = self.textCursor().block().blockNumber() painter.setPen(self.line_number_palette.color(QPalette.ColorRole.Text)) while block.isValid() and top <= ev.rect().bottom(): if block.isVisible() and bottom >= ev.rect().top(): if current == num: painter.save() painter.setPen( self.line_number_palette.color( QPalette.ColorRole.BrightText)) f = QFont(self.font()) f.setBold(True) painter.setFont(f) self.last_current_lnum = (top, bottom - top) painter.drawText(0, top, self.line_number_area.width() - 5, self.fontMetrics().height(), Qt.AlignmentFlag.AlignRight, unicode_type(num + 1)) if current == num: painter.restore() block = block.next() top = bottom bottom = top + int(self.blockBoundingRect(block).height()) num += 1 # }}} def override_shortcut(self, ev): # Let the global cut/copy/paste/undo/redo shortcuts work, this avoids the nbsp # problem as well, since they use the overridden createMimeDataFromSelection() method # instead of the one from Qt (which makes copy() work), and allows proper customization # of the shortcuts if ev in (QKeySequence.StandardKey.Copy, QKeySequence.StandardKey.Cut, QKeySequence.StandardKey.Paste, QKeySequence.StandardKey.Undo, QKeySequence.StandardKey.Redo): ev.ignore() return True # This is used to convert typed hex codes into unicode # characters if ev.key() == Qt.Key.Key_X and ev.modifiers( ) == Qt.KeyboardModifier.AltModifier: ev.accept() return True return PlainTextEdit.override_shortcut(self, ev) def text_for_range(self, block, r): c = self.textCursor() c.setPosition(block.position() + r.start) c.setPosition(c.position() + r.length, QTextCursor.MoveMode.KeepAnchor) return self.selected_text_from_cursor(c) def spellcheck_locale_for_cursor(self, c): with store_locale: formats = self.highlighter.parse_single_block(c.block())[0] pos = c.positionInBlock() for r in formats: if r.start <= pos <= r.start + r.length and r.format.property( SPELL_PROPERTY): return r.format.property(SPELL_LOCALE_PROPERTY) def recheck_word(self, word, locale): c = self.textCursor() c.movePosition(QTextCursor.MoveOperation.Start) block = c.block() while block.isValid(): for r in block.layout().additionalFormats(): if r.format.property(SPELL_PROPERTY) and self.text_for_range( block, r) == word: self.highlighter.reformat_block(block) break block = block.next() # Tooltips {{{ def syntax_range_for_cursor(self, cursor): if cursor.isNull(): return pos = cursor.positionInBlock() for r in cursor.block().layout().additionalFormats(): if r.start <= pos <= r.start + r.length and r.format.property( SYNTAX_PROPERTY): return r def show_tooltip(self, ev): c = self.cursorForPosition(ev.pos()) fmt_range = self.syntax_range_for_cursor(c) fmt = getattr(fmt_range, 'format', None) if fmt is not None: tt = unicode_type(fmt.toolTip()) if tt: QToolTip.setFont(self.tooltip_font) QToolTip.setPalette(self.tooltip_palette) QToolTip.showText(ev.globalPos(), textwrap.fill(tt)) return QToolTip.hideText() ev.ignore() # }}} def link_for_position(self, pos): c = self.cursorForPosition(pos) r = self.syntax_range_for_cursor(c) if r is not None and r.format.property(LINK_PROPERTY): return self.text_for_range(c.block(), r) def select_class_name_at_cursor(self, cursor): valid = re.compile(r'[\w_0-9\-]+', flags=re.UNICODE) def keep_going(): q = cursor.selectedText() m = valid.match(q) return m is not None and m.group() == q def run_loop(forward=True): cursor.setPosition(pos) n, p = QTextCursor.MoveOperation.NextCharacter, QTextCursor.MoveOperation.PreviousCharacter if not forward: n, p = p, n while True: if not cursor.movePosition(n, QTextCursor.MoveMode.KeepAnchor): break if not keep_going(): cursor.movePosition(p, QTextCursor.MoveMode.KeepAnchor) break ans = cursor.position() cursor.setPosition(pos) return ans pos = cursor.position() forwards_limit = run_loop() backwards_limit = run_loop(forward=False) cursor.setPosition(backwards_limit) cursor.setPosition(forwards_limit, QTextCursor.MoveMode.KeepAnchor) return self.selected_text_from_cursor(cursor) def class_for_position(self, pos): c = self.cursorForPosition(pos) r = self.syntax_range_for_cursor(c) if r is not None and r.format.property(CLASS_ATTRIBUTE_PROPERTY): class_name = self.select_class_name_at_cursor(c) if class_name: tags = self.current_tag(for_position_sync=False, cursor=c) return {'class': class_name, 'sourceline_address': tags} def mousePressEvent(self, ev): if self.completion_popup.isVisible( ) and not self.completion_popup.rect().contains(ev.pos()): # For some reason using eventFilter for this does not work, so we # implement it here self.completion_popup.abort() if ev.modifiers() & Qt.Modifier.CTRL: url = self.link_for_position(ev.pos()) if url is not None: ev.accept() self.link_clicked.emit(url) return class_data = self.class_for_position(ev.pos()) if class_data is not None: ev.accept() self.class_clicked.emit(class_data) return return PlainTextEdit.mousePressEvent(self, ev) def get_range_inside_tag(self): c = self.textCursor() left = min(c.anchor(), c.position()) right = max(c.anchor(), c.position()) # For speed we use QPlainTextEdit's toPlainText as we dont care about # spaces in this context raw = unicode_type(QPlainTextEdit.toPlainText(self)) # Make sure the left edge is not within a <> gtpos = raw.find('>', left) ltpos = raw.find('<', left) if gtpos < ltpos: left = gtpos + 1 if gtpos > -1 else left right = max(left, right) if right != left: gtpos = raw.find('>', right) ltpos = raw.find('<', right) if ltpos > gtpos: ltpos = raw.rfind('<', left, right + 1) right = max(ltpos, left) return left, right def format_text(self, formatting): if self.syntax != 'html': return if formatting.startswith('justify_'): return self.smarts.set_text_alignment( self, formatting.partition('_')[-1]) color = 'currentColor' if formatting in {'color', 'background-color'}: color = QColorDialog.getColor( QColor(Qt.GlobalColor.black if formatting == 'color' else Qt.GlobalColor.white), self, _('Choose color'), QColorDialog.ColorDialogOption.ShowAlphaChannel) if not color.isValid(): return r, g, b, a = color.getRgb() if a == 255: color = 'rgb(%d, %d, %d)' % (r, g, b) else: color = 'rgba(%d, %d, %d, %.2g)' % (r, g, b, a / 255) prefix, suffix = { 'bold': ('<b>', '</b>'), 'italic': ('<i>', '</i>'), 'underline': ('<u>', '</u>'), 'strikethrough': ('<strike>', '</strike>'), 'superscript': ('<sup>', '</sup>'), 'subscript': ('<sub>', '</sub>'), 'color': ('<span style="color: %s">' % color, '</span>'), 'background-color': ('<span style="background-color: %s">' % color, '</span>'), }[formatting] left, right = self.get_range_inside_tag() c = self.textCursor() c.setPosition(left) c.setPosition(right, QTextCursor.MoveMode.KeepAnchor) prev_text = unicode_type(c.selectedText()).rstrip('\0') c.insertText(prefix + prev_text + suffix) if prev_text: right = c.position() c.setPosition(left) c.setPosition(right, QTextCursor.MoveMode.KeepAnchor) else: c.setPosition(c.position() - len(suffix)) self.setTextCursor(c) def insert_image(self, href, fullpage=False, preserve_aspect_ratio=False, width=-1, height=-1): if width <= 0: width = 1200 if height <= 0: height = 1600 c = self.textCursor() template, alt = 'url(%s)', '' left = min(c.position(), c.anchor()) if self.syntax == 'html': left, right = self.get_range_inside_tag() c.setPosition(left) c.setPosition(right, QTextCursor.MoveMode.KeepAnchor) href = prepare_string_for_xml(href, True) if fullpage: template = '''\ <div style="page-break-before:always; page-break-after:always; page-break-inside:avoid">\ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" \ version="1.1" width="100%%" height="100%%" viewBox="0 0 {w} {h}" preserveAspectRatio="{a}">\ <image width="{w}" height="{h}" xlink:href="%s"/>\ </svg></div>'''.format(w=width, h=height, a='xMidYMid meet' if preserve_aspect_ratio else 'none') else: alt = _('Image') template = '<img alt="{0}" src="%s" />'.format(alt) text = template % href c.insertText(text) if self.syntax == 'html' and not fullpage: c.setPosition(left + 10) c.setPosition(c.position() + len(alt), QTextCursor.MoveMode.KeepAnchor) else: c.setPosition(left) c.setPosition(left + len(text), QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) def insert_hyperlink(self, target, text, template=None): if hasattr(self.smarts, 'insert_hyperlink'): self.smarts.insert_hyperlink(self, target, text, template=template) def insert_tag(self, tag): if hasattr(self.smarts, 'insert_tag'): self.smarts.insert_tag(self, tag) def remove_tag(self): if hasattr(self.smarts, 'remove_tag'): self.smarts.remove_tag(self) def split_tag(self): if hasattr(self.smarts, 'split_tag'): self.smarts.split_tag(self) def keyPressEvent(self, ev): if ev.key() == Qt.Key.Key_X and ev.modifiers( ) == Qt.KeyboardModifier.AltModifier: if self.replace_possible_unicode_sequence(): ev.accept() return if ev.key() == Qt.Key.Key_Insert: self.setOverwriteMode(self.overwriteMode() ^ True) ev.accept() return if self.snippet_manager.handle_key_press(ev): self.completion_popup.hide() return if self.smarts.handle_key_press(ev, self): self.handle_keypress_completion(ev) return QPlainTextEdit.keyPressEvent(self, ev) self.handle_keypress_completion(ev) def handle_keypress_completion(self, ev): if self.request_completion is None: return code = ev.key() if code in (0, Qt.Key.Key_unknown, Qt.Key.Key_Shift, Qt.Key.Key_Control, Qt.Key.Key_Alt, Qt.Key.Key_Meta, Qt.Key.Key_AltGr, Qt.Key.Key_CapsLock, Qt.Key.Key_NumLock, Qt.Key.Key_ScrollLock, Qt.Key.Key_Up, Qt.Key.Key_Down): # We ignore up/down arrow so as to not break scrolling through the # text with the arrow keys return result = self.smarts.get_completion_data(self, ev) if result is None: self.last_completion_request += 1 else: self.last_completion_request = self.request_completion(*result) self.completion_popup.mark_completion( self, None if result is None else result[-1]) def handle_completion_result(self, result): if result.request_id[0] >= self.last_completion_request: self.completion_popup.handle_result(result) def clear_completion_cache(self): if self.request_completion is not None and self.completion_doc_name: self.request_completion(None, 'file:' + self.completion_doc_name) def replace_possible_unicode_sequence(self): c = self.textCursor() has_selection = c.hasSelection() if has_selection: text = unicode_type(c.selectedText()).rstrip('\0') else: c.setPosition(c.position() - min(c.positionInBlock(), 6), QTextCursor.MoveMode.KeepAnchor) text = unicode_type(c.selectedText()).rstrip('\0') m = re.search(r'[a-fA-F0-9]{2,6}$', text) if m is None: return False text = m.group() try: num = int(text, 16) except ValueError: return False if num > 0x10ffff or num < 1: return False end_pos = max(c.anchor(), c.position()) c.setPosition(end_pos - len(text)), c.setPosition( end_pos, QTextCursor.MoveMode.KeepAnchor) c.insertText(safe_chr(num)) return True def select_all(self): c = self.textCursor() c.clearSelection() c.setPosition(0) c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(c) def rename_block_tag(self, new_name): if hasattr(self.smarts, 'rename_block_tag'): self.smarts.rename_block_tag(self, new_name) def current_tag(self, for_position_sync=True, cursor=None): use_matched_tag = False if cursor is None: use_matched_tag = True cursor = self.textCursor() return self.smarts.cursor_position_with_sourceline( cursor, for_position_sync=for_position_sync, use_matched_tag=use_matched_tag) def goto_sourceline(self, sourceline, tags, attribute=None): return self.smarts.goto_sourceline(self, sourceline, tags, attribute=attribute) def get_tag_contents(self): c = self.smarts.get_inner_HTML(self) if c is not None: return self.selected_text_from_cursor(c) def goto_css_rule(self, rule_address, sourceline_address=None): from calibre.gui2.tweak_book.editor.smarts.css import find_rule block = None if self.syntax == 'css': raw = unicode_type(self.toPlainText()) line, col = find_rule(raw, rule_address) if line is not None: block = self.document().findBlockByNumber(line - 1) elif sourceline_address is not None: sourceline, tags = sourceline_address if self.goto_sourceline(sourceline, tags): c = self.textCursor() c.setPosition(c.position() + 1) self.setTextCursor(c) raw = self.get_tag_contents() line, col = find_rule(raw, rule_address) if line is not None: block = self.document().findBlockByNumber(c.blockNumber() + line - 1) if block is not None and block.isValid(): c = self.textCursor() c.setPosition(block.position() + col) self.setTextCursor(c) def change_case(self, action, cursor=None): cursor = cursor or self.textCursor() text = self.selected_text_from_cursor(cursor) text = { 'lower': lower, 'upper': upper, 'capitalize': capitalize, 'title': titlecase, 'swap': swapcase }[action](text) cursor.insertText(text) self.setTextCursor(cursor)
class ChooseTheme(Dialog): cover_downloaded = pyqtSignal(object, object) themes_downloaded = pyqtSignal() def __init__(self, parent=None): try: self.current_theme = json.loads(I('icon-theme.json', data=True))['title'] except Exception: self.current_theme = None Dialog.__init__(self, _('Choose an icon theme'), 'choose-icon-theme-dialog', parent) self.finished.connect(self.on_finish) self.dialog_closed = False self.themes_downloaded.connect(self.show_themes, type=Qt.ConnectionType.QueuedConnection) self.cover_downloaded.connect(self.set_cover, type=Qt.ConnectionType.QueuedConnection) self.keep_downloading = True self.commit_changes = None self.new_theme_title = None def on_finish(self): self.dialog_closed = True def sizeHint(self): h = self.screen().availableSize().height() return QSize(900, h - 75) def setup_ui(self): self.vl = vl = QVBoxLayout(self) self.stack = l = QStackedLayout() self.pi = pi = ProgressIndicator(self, 256) vl.addLayout(l), vl.addWidget(self.bb) self.restore_defs_button = b = self.bb.addButton( _('Restore &default icons'), QDialogButtonBox.ButtonRole.ActionRole) b.clicked.connect(self.restore_defaults) b.setIcon(QIcon(I('view-refresh.png'))) self.c = c = QWidget(self) self.c.v = v = QVBoxLayout(self.c) v.addStretch(), v.addWidget(pi, 0, Qt.AlignmentFlag.AlignCenter) self.wait_msg = m = QLabel(self) v.addWidget(m, 0, Qt.AlignmentFlag.AlignCenter), v.addStretch() f = m.font() f.setBold(True), f.setPointSize(28), m.setFont(f) self.start_spinner() l.addWidget(c) self.w = w = QWidget(self) l.addWidget(w) w.l = l = QGridLayout(w) def add_row(x, y=None): if isinstance(x, str): x = QLabel(x) row = l.rowCount() if y is None: if isinstance(x, QLabel): x.setWordWrap(True) l.addWidget(x, row, 0, 1, 2) else: if isinstance(x, QLabel): x.setBuddy(y) l.addWidget(x, row, 0), l.addWidget(y, row, 1) add_row( _('Choose an icon theme below. You will need to restart' ' calibre to see the new icons.')) add_row( _('Current icon theme:') + '\xa0<b>' + (self.current_theme or 'None')) self.sort_by = sb = QComboBox(self) add_row(_('&Sort by:'), sb) sb.addItems([ _('Number of icons'), _('Popularity'), _('Name'), ]) sb.setEditable(False), sb.setCurrentIndex( gprefs.get('choose_icon_theme_sort_by', 1)) sb.currentIndexChanged[int].connect(self.re_sort) sb.currentIndexChanged[int].connect( lambda: gprefs.set('choose_icon_theme_sort_by', sb.currentIndex())) self.theme_list = tl = QListWidget(self) tl.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.delegate = Delegate(tl) tl.setItemDelegate(self.delegate) tl.itemDoubleClicked.connect(self.accept) tl.itemPressed.connect(self.item_clicked) add_row(tl) t = Thread(name='GetIconThemes', target=self.get_themes) t.daemon = True t.start() def item_clicked(self, item): if QApplication.mouseButtons() & Qt.MouseButton.RightButton: theme = item.data(Qt.ItemDataRole.UserRole) or {} url = theme.get('url') if url: safe_open_url(url) else: error_dialog(self, _('No homepage'), _('The {} theme has no homepage').format( theme.get('name', _('Unknown'))), show=True) def start_spinner(self, msg=None): self.pi.startAnimation() self.stack.setCurrentIndex(0) self.wait_msg.setText(msg or _('Downloading, please wait...')) def end_spinner(self): self.pi.stopAnimation() self.stack.setCurrentIndex(1) @property def sort_on(self): return { 0: 'number', 1: 'usage', 2: 'title' }[self.sort_by.currentIndex()] def re_sort(self): self.themes.sort(key=lambda x: sort_key(x.get('title', ''))) field = self.sort_on if field == 'number': self.themes.sort(key=lambda x: x.get('number', 0), reverse=True) elif field == 'usage': self.themes.sort(key=lambda x: self.usage.get(x.get('name'), 0), reverse=True) self.theme_list.clear() for theme in self.themes: i = QListWidgetItem( theme.get('title', '') + ' {} {}'.format( theme.get('number'), self.usage.get(theme.get('name'))), self.theme_list) i.setData(Qt.ItemDataRole.UserRole, theme) if 'cover-pixmap' in theme: i.setData(Qt.ItemDataRole.DecorationRole, theme['cover-pixmap']) def get_themes(self): self.usage = {} def get_usage(): try: self.usage = json.loads( bz2.decompress( get_https_resource_securely(BASE_URL + '/usage.json.bz2'))) except Exception: import traceback traceback.print_exc() t = Thread(name='IconThemeUsage', target=get_usage) t.daemon = True t.start() try: self.themes = json.loads( bz2.decompress( get_https_resource_securely(BASE_URL + '/themes.json.bz2'))) except Exception: import traceback self.themes = traceback.format_exc() t.join() if not sip.isdeleted(self): self.themes_downloaded.emit() def show_themes(self): self.end_spinner() if not isinstance(self.themes, list): error_dialog( self, _('Failed to download list of themes'), _('Failed to download list of themes, click "Show details" for more information' ), det_msg=self.themes, show=True) self.reject() return for theme in self.themes: theme['usage'] = self.usage.get(theme['name'], 0) self.re_sort() get_covers(self.themes, self) def __iter__(self): for i in range(self.theme_list.count()): yield self.theme_list.item(i) def item_from_name(self, name): for item in self: if item.data(Qt.ItemDataRole.UserRole)['name'] == name: return item def set_cover(self, theme, cdata): theme['cover-pixmap'] = p = QPixmap() try: dpr = self.devicePixelRatioF() except AttributeError: dpr = self.devicePixelRatio() if isinstance(cdata, bytes): p.loadFromData(cdata) p.setDevicePixelRatio(dpr) item = self.item_from_name(theme['name']) if item is not None: item.setData(Qt.ItemDataRole.DecorationRole, p) def restore_defaults(self): if self.current_theme is not None: if not question_dialog( self, _('Are you sure?'), _('Are you sure you want to remove the <b>%s</b> icon theme' ' and return to the stock icons?') % self.current_theme): return self.commit_changes = remove_icon_theme Dialog.accept(self) def accept(self): if self.theme_list.currentRow() < 0: return error_dialog(self, _('No theme selected'), _('You must first select an icon theme'), show=True) theme = self.theme_list.currentItem().data(Qt.ItemDataRole.UserRole) url = BASE_URL + theme['icons-url'] size = theme['compressed-size'] theme = {k: theme.get(k, '') for k in 'name title version'.split()} self.keep_downloading = True d = DownloadProgress(self, size) d.canceled_signal.connect( lambda: setattr(self, 'keep_downloading', False)) self.downloaded_theme = None def download(): self.downloaded_theme = buf = BytesIO() try: response = get_https_resource_securely(url, get_response=True) while self.keep_downloading: raw = response.read(1024) if not raw: break buf.write(raw) d.downloaded(buf.tell()) d.queue_accept() except Exception: import traceback self.downloaded_theme = traceback.format_exc() d.queue_reject() t = Thread(name='DownloadIconTheme', target=download) t.daemon = True t.start() ret = d.exec() if self.downloaded_theme and not isinstance(self.downloaded_theme, BytesIO): return error_dialog( self, _('Download failed'), _('Failed to download icon theme, click "Show details" for more information.' ), show=True, det_msg=self.downloaded_theme) if ret == QDialog.DialogCode.Rejected or not self.keep_downloading or d.canceled or self.downloaded_theme is None: return dt = self.downloaded_theme def commit_changes(): import lzma dt.seek(0) f = BytesIO(lzma.decompress(dt.getvalue())) f.seek(0) remove_icon_theme() install_icon_theme(theme, f) self.commit_changes = commit_changes self.new_theme_title = theme['title'] return Dialog.accept(self)
class AddCover(Dialog): import_requested = pyqtSignal(object, object) def __init__(self, container, parent=None): self.container = container Dialog.__init__(self, _('Add a cover'), 'add-cover-wizard', parent) @property def image_names(self): img_types = {guess_type('a.' + x) for x in ('png', 'jpeg', 'gif')} for name, mt in iteritems(self.container.mime_map): if mt.lower() in img_types: yield name def setup_ui(self): self.l = l = QVBoxLayout(self) self.setLayout(l) self.gb = gb = QGroupBox(_('&Images in book'), self) self.v = v = QVBoxLayout(gb) gb.setLayout(v), gb.setFlat(True) self.names, self.names_filter = create_filterable_names_list( sorted(self.image_names, key=sort_key), filter_text=_('Filter the list of images'), parent=self) self.names.doubleClicked.connect( self.double_clicked, type=Qt.ConnectionType.QueuedConnection) self.cover_view = CoverView(self) l.addWidget(self.names_filter) v.addWidget(self.names) self.splitter = s = QSplitter(self) l.addWidget(s) s.addWidget(gb) s.addWidget(self.cover_view) self.h = h = QHBoxLayout() self.preserve = p = QCheckBox(_('Preserve aspect ratio')) p.setToolTip( textwrap.fill( _('If enabled the cover image you select will be embedded' ' into the book in such a way that when viewed, its aspect' ' ratio (ratio of width to height) will be preserved.' ' This will mean blank spaces around the image if the screen' ' the book is being viewed on has an aspect ratio different' ' to the image.'))) p.setChecked(tprefs['add_cover_preserve_aspect_ratio']) p.setVisible(self.container.book_type != 'azw3') def on_state_change(s): tprefs.set('add_cover_preserve_aspect_ratio', s == Qt.CheckState.Checked) p.stateChanged.connect(on_state_change) self.info_label = il = QLabel('\xa0') h.addWidget(p), h.addStretch(1), h.addWidget(il) l.addLayout(h) l.addWidget(self.bb) b = self.bb.addButton(_('Import &image'), QDialogButtonBox.ButtonRole.ActionRole) b.clicked.connect(self.import_image) b.setIcon(QIcon(I('document_open.png'))) self.names.setFocus(Qt.FocusReason.OtherFocusReason) self.names.selectionModel().currentChanged.connect( self.current_image_changed) cname = get_raster_cover_name(self.container) if cname: row = self.names.model().find_name(cname) if row > -1: self.names.setCurrentIndex(self.names.model().index(row)) def double_clicked(self): self.accept() @property def file_name(self): return self.names.model().name_for_index(self.names.currentIndex()) def current_image_changed(self): self.info_label.setText('') name = self.file_name if name is not None: data = self.container.raw_data(name, decode=False) self.cover_view.set_pixmap(data) self.info_label.setText('{0}x{1}px | {2}'.format( self.cover_view.pixmap.width(), self.cover_view.pixmap.height(), human_readable(len(data)))) def import_image(self): ans = choose_images(self, 'add-cover-choose-image', _('Choose a cover image'), formats=('jpg', 'jpeg', 'png', 'gif')) if ans: from calibre.gui2.tweak_book.file_list import NewFileDialog d = NewFileDialog(self) d.do_import_file(ans[0], hide_button=True) if d.exec_() == QDialog.DialogCode.Accepted: self.import_requested.emit(d.file_name, d.file_data) self.container = current_container() self.names_filter.clear() self.names.model().set_names( sorted(self.image_names, key=sort_key)) i = self.names.model().find_name(d.file_name) self.names.setCurrentIndex(self.names.model().index(i)) self.current_image_changed() @classmethod def test(cls): import sys from calibre.ebooks.oeb.polish.container import get_container c = get_container(sys.argv[-1], tweak_mode=True) d = cls(c) if d.exec_() == QDialog.DialogCode.Accepted: pass
class CoverView(QWidget): # {{{ cover_changed = pyqtSignal(object, object) cover_removed = pyqtSignal(object) open_cover_with = pyqtSignal(object, object) search_internet = pyqtSignal(object) def __init__(self, vertical, parent=None): QWidget.__init__(self, parent) self._current_pixmap_size = QSize(120, 120) self.vertical = vertical self.animation = QPropertyAnimation(self, b'current_pixmap_size', self) self.animation.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutExpo)) self.animation.setDuration(1000) self.animation.setStartValue(QSize(0, 0)) self.animation.valueChanged.connect(self.value_changed) self.setSizePolicy( QSizePolicy.Policy.Expanding if vertical else QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) self.default_pixmap = QPixmap(I('default_cover.png')) self.pixmap = self.default_pixmap self.pwidth = self.pheight = None self.data = {} self.do_layout() def value_changed(self, val): self.update() def setCurrentPixmapSize(self, val): self._current_pixmap_size = val def do_layout(self): if self.rect().width() == 0 or self.rect().height() == 0: return pixmap = self.pixmap pwidth, pheight = pixmap.width(), pixmap.height() try: self.pwidth, self.pheight = fit_image(pwidth, pheight, self.rect().width(), self.rect().height())[1:] except: self.pwidth, self.pheight = self.rect().width()-1, \ self.rect().height()-1 self.current_pixmap_size = QSize(self.pwidth, self.pheight) self.animation.setEndValue(self.current_pixmap_size) def show_data(self, data): self.animation.stop() same_item = getattr(data, 'id', True) == self.data.get('id', False) self.data = {'id':data.get('id', None)} if data.cover_data[1]: self.pixmap = QPixmap.fromImage(data.cover_data[1]) if self.pixmap.isNull() or self.pixmap.width() < 5 or \ self.pixmap.height() < 5: self.pixmap = self.default_pixmap else: self.pixmap = self.default_pixmap self.do_layout() self.update() if (not same_item and not config['disable_animations'] and self.isVisible()): self.animation.start() def paintEvent(self, event): canvas_size = self.rect() width = self.current_pixmap_size.width() extrax = canvas_size.width() - width if extrax < 0: extrax = 0 x = int(extrax//2) height = self.current_pixmap_size.height() extray = canvas_size.height() - height if extray < 0: extray = 0 y = int(extray//2) target = QRect(x, y, width, height) p = QPainter(self) p.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform) try: dpr = self.devicePixelRatioF() except AttributeError: dpr = self.devicePixelRatio() spmap = self.pixmap.scaled(target.size() * dpr, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) spmap.setDevicePixelRatio(dpr) p.drawPixmap(target, spmap) if gprefs['bd_overlay_cover_size']: sztgt = target.adjusted(0, 0, 0, -4) f = p.font() f.setBold(True) p.setFont(f) sz = '\u00a0%d x %d\u00a0'%(self.pixmap.width(), self.pixmap.height()) flags = Qt.AlignmentFlag.AlignBottom|Qt.AlignmentFlag.AlignRight|Qt.TextFlag.TextSingleLine szrect = p.boundingRect(sztgt, flags, sz) p.fillRect(szrect.adjusted(0, 0, 0, 4), QColor(0, 0, 0, 200)) p.setPen(QPen(QColor(255,255,255))) p.drawText(sztgt, flags, sz) p.end() current_pixmap_size = pyqtProperty('QSize', fget=lambda self: self._current_pixmap_size, fset=setCurrentPixmapSize ) def contextMenuEvent(self, ev): cm = QMenu(self) paste = cm.addAction(_('Paste cover')) copy = cm.addAction(_('Copy cover')) save = cm.addAction(_('Save cover to disk')) remove = cm.addAction(_('Remove cover')) gc = cm.addAction(_('Generate cover from metadata')) cm.addSeparator() if not QApplication.instance().clipboard().mimeData().hasImage(): paste.setEnabled(False) copy.triggered.connect(self.copy_to_clipboard) paste.triggered.connect(self.paste_from_clipboard) remove.triggered.connect(self.remove_cover) gc.triggered.connect(self.generate_cover) save.triggered.connect(self.save_cover) create_open_cover_with_menu(self, cm) cm.si = m = create_search_internet_menu(self.search_internet.emit) cm.addMenu(m) cm.exec_(ev.globalPos()) def open_with(self, entry): id_ = self.data.get('id', None) if id_ is not None: self.open_cover_with.emit(id_, entry) def choose_open_with(self): from calibre.gui2.open_with import choose_program entry = choose_program('cover_image', self) if entry is not None: self.open_with(entry) def copy_to_clipboard(self): QApplication.instance().clipboard().setPixmap(self.pixmap) def paste_from_clipboard(self, pmap=None): if not isinstance(pmap, QPixmap): cb = QApplication.instance().clipboard() pmap = cb.pixmap() if pmap.isNull() and cb.supportsSelection(): pmap = cb.pixmap(QClipboard.Mode.Selection) if not pmap.isNull(): self.update_cover(pmap) def save_cover(self): from calibre.gui2.ui import get_gui book_id = self.data.get('id') db = get_gui().current_db.new_api path = choose_save_file( self, 'save-cover-from-book-details', _('Choose cover save location'), filters=[(_('JPEG images'), ['jpg', 'jpeg'])], all_files=False, initial_filename='{}.jpeg'.format(sanitize_file_name(db.field_for('title', book_id, default_value='cover'))) ) if path: db.copy_cover_to(book_id, path) def update_cover(self, pmap=None, cdata=None): if pmap is None: pmap = QPixmap() pmap.loadFromData(cdata) if pmap.isNull(): return if pmap.hasAlphaChannel(): pmap = QPixmap.fromImage(blend_image(image_from_x(pmap))) self.pixmap = pmap self.do_layout() self.update() self.update_tooltip(getattr(self.parent(), 'current_path', '')) if not config['disable_animations']: self.animation.start() id_ = self.data.get('id', None) if id_ is not None: self.cover_changed.emit(id_, cdata or pixmap_to_data(pmap)) def generate_cover(self, *args): book_id = self.data.get('id') if book_id is not None: from calibre.ebooks.covers import generate_cover from calibre.gui2.ui import get_gui mi = get_gui().current_db.new_api.get_metadata(book_id) cdata = generate_cover(mi) self.update_cover(cdata=cdata) def remove_cover(self): if not confirm_delete( _('Are you sure you want to delete the cover permanently?'), 'book-details-confirm-cover-remove', parent=self): return id_ = self.data.get('id', None) self.pixmap = self.default_pixmap self.do_layout() self.update() if id_ is not None: self.cover_removed.emit(id_) def update_tooltip(self, current_path): try: sz = self.pixmap.size() except: sz = QSize(0, 0) self.setToolTip( '<p>'+_('Double click to open the Book details window') + '<br><br>' + _('Path') + ': ' + current_path + '<br><br>' + _('Cover size: %(width)d x %(height)d pixels')%dict( width=sz.width(), height=sz.height()) )
class Results(QWidget): MARGIN = 4 item_selected = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent=parent) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.results = () self.current_result = -1 self.max_result = -1 self.mouse_hover_result = -1 self.setMouseTracking(True) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.text_option = to = QTextOption() to.setWrapMode(QTextOption.WrapMode.NoWrap) self.divider = QStaticText('\xa0→ \xa0') self.divider.setTextFormat(Qt.TextFormat.PlainText) def item_from_y(self, y): if not self.results: return delta = self.results[0][0].size().height() + self.MARGIN maxy = self.height() pos = 0 for i, r in enumerate(self.results): bottom = pos + delta if pos <= y < bottom: return i break pos = bottom if pos > min(y, maxy): break return -1 def mouseMoveEvent(self, ev): y = ev.pos().y() prev = self.mouse_hover_result self.mouse_hover_result = self.item_from_y(y) if prev != self.mouse_hover_result: self.update() def mousePressEvent(self, ev): if ev.button() == 1: i = self.item_from_y(ev.pos().y()) if i != -1: ev.accept() self.current_result = i self.update() self.item_selected.emit() return return QWidget.mousePressEvent(self, ev) def change_current(self, delta=1): if not self.results: return nc = self.current_result + delta if 0 <= nc <= self.max_result: self.current_result = nc self.update() def __call__(self, results): if results: self.current_result = 0 prefixes = [ QStaticText('<b>%s</b>' % os.path.basename(x)) for x in results ] [(p.setTextFormat(Qt.TextFormat.RichText), p.setTextOption(self.text_option)) for p in prefixes] self.maxwidth = max([x.size().width() for x in prefixes]) self.results = tuple( (prefix, self.make_text(text, positions), text) for prefix, (text, positions) in zip(prefixes, iteritems(results))) else: self.results = () self.current_result = -1 self.max_result = min(10, len(self.results) - 1) self.mouse_hover_result = -1 self.update() def make_text(self, text, positions): text = QStaticText( make_highlighted_text(emphasis_style(), text, positions)) text.setTextOption(self.text_option) text.setTextFormat(Qt.TextFormat.RichText) return text def paintEvent(self, ev): offset = QPoint(0, 0) p = QPainter(self) p.setClipRect(ev.rect()) bottom = self.rect().bottom() if self.results: for i, (prefix, full, text) in enumerate(self.results): size = prefix.size() if offset.y() + size.height() > bottom: break self.max_result = i offset.setX(0) if i in (self.current_result, self.mouse_hover_result): p.save() if i != self.current_result: p.setPen(Qt.PenStyle.DotLine) p.drawLine(offset, QPoint(self.width(), offset.y())) p.restore() offset.setY(offset.y() + self.MARGIN // 2) p.drawStaticText(offset, prefix) offset.setX(self.maxwidth + 5) p.drawStaticText(offset, self.divider) offset.setX(offset.x() + self.divider.size().width()) p.drawStaticText(offset, full) offset.setY(offset.y() + size.height() + self.MARGIN // 2) if i in (self.current_result, self.mouse_hover_result): offset.setX(0) p.save() if i != self.current_result: p.setPen(Qt.PenStyle.DotLine) p.drawLine(offset, QPoint(self.width(), offset.y())) p.restore() else: p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, _('No results found')) p.end() @property def selected_result(self): try: return self.results[self.current_result][-1] except IndexError: pass
class BookDetails(QWidget): # {{{ show_book_info = pyqtSignal() open_containing_folder = pyqtSignal(int) view_specific_format = pyqtSignal(int, object) search_requested = pyqtSignal(object) remove_specific_format = pyqtSignal(int, object) remove_metadata_item = pyqtSignal(int, object, object) save_specific_format = pyqtSignal(int, object) restore_specific_format = pyqtSignal(int, object) set_cover_from_format = pyqtSignal(int, object) compare_specific_format = pyqtSignal(int, object) copy_link = pyqtSignal(object) remote_file_dropped = pyqtSignal(object, object) files_dropped = pyqtSignal(object, object) cover_changed = pyqtSignal(object, object) open_cover_with = pyqtSignal(object, object) cover_removed = pyqtSignal(object) view_device_book = pyqtSignal(object) manage_category = pyqtSignal(object, object) edit_identifiers = pyqtSignal() open_fmt_with = pyqtSignal(int, object, object) edit_book = pyqtSignal(int, object) find_in_tag_browser = pyqtSignal(object, object) # Drag 'n drop {{{ def dragEnterEvent(self, event): md = event.mimeData() if dnd_has_extension(md, image_extensions() + BOOK_EXTENSIONS, allow_all_extensions=True, allow_remote=True) or \ dnd_has_image(md): event.acceptProposedAction() def dropEvent(self, event): event.setDropAction(Qt.DropAction.CopyAction) md = event.mimeData() image_exts = set(image_extensions()) - set(tweaks['cover_drop_exclude']) x, y = dnd_get_image(md, image_exts) if x is not None: # We have an image, set cover event.accept() if y is None: # Local image self.cover_view.paste_from_clipboard(x) self.update_layout() else: self.remote_file_dropped.emit(x, y) # We do not support setting cover *and* adding formats for # a remote drop, anyway, so return return # Now look for ebook files urls, filenames = dnd_get_files(md, BOOK_EXTENSIONS, allow_all_extensions=True, filter_exts=image_exts) if not urls: # Nothing found return if not filenames: # Local files self.files_dropped.emit(event, urls) else: # Remote files, use the first file self.remote_file_dropped.emit(urls[0], filenames[0]) event.accept() def dragMoveEvent(self, event): event.acceptProposedAction() # }}} def __init__(self, vertical, parent=None): QWidget.__init__(self, parent) self.last_data = {} self.setAcceptDrops(True) self._layout = DetailsLayout(vertical, self) self.setLayout(self._layout) self.current_path = '' self.cover_view = CoverView(vertical, self) self.cover_view.search_internet.connect(self.search_internet) self.cover_view.cover_changed.connect(self.cover_changed.emit) self.cover_view.open_cover_with.connect(self.open_cover_with.emit) self.cover_view.cover_removed.connect(self.cover_removed.emit) self._layout.addWidget(self.cover_view) self.book_info = BookInfo(vertical, self) self.book_info.show_book_info = self.show_book_info self.book_info.search_internet = self.search_internet self.book_info.search_requested = self.search_requested.emit self._layout.addWidget(self.book_info) self.book_info.link_clicked.connect(self.handle_click) self.book_info.remove_format.connect(self.remove_specific_format) self.book_info.remove_item.connect(self.remove_metadata_item) self.book_info.open_fmt_with.connect(self.open_fmt_with) self.book_info.edit_book.connect(self.edit_book) self.book_info.save_format.connect(self.save_specific_format) self.book_info.restore_format.connect(self.restore_specific_format) self.book_info.set_cover_format.connect(self.set_cover_from_format) self.book_info.compare_format.connect(self.compare_specific_format) self.book_info.copy_link.connect(self.copy_link) self.book_info.manage_category.connect(self.manage_category) self.book_info.find_in_tag_browser.connect(self.find_in_tag_browser) self.book_info.edit_identifiers.connect(self.edit_identifiers) self.setCursor(Qt.CursorShape.PointingHandCursor) def search_internet(self, data): if self.last_data: if data.author is None: url = url_for_book_search(data.where, title=self.last_data['title'], author=self.last_data['authors'][0]) else: url = url_for_author_search(data.where, author=data.author) safe_open_url(url) def handle_click(self, link): typ, val = link.partition(':')[::2] def search_term(field, val): self.search_requested.emit('{}:"={}"'.format(field, val.replace('"', '\\"'))) def browse(url): try: safe_open_url(QUrl(url, QUrl.ParsingMode.TolerantMode)) except Exception: import traceback traceback.print_exc() if typ == 'action': data = json_loads(from_hex_bytes(val)) dt = data['type'] if dt == 'search': search_term(data['term'], data['value']) elif dt == 'author': url = data['url'] if url == 'calibre': search_term('authors', data['name']) else: browse(url) elif dt == 'format': book_id, fmt = data['book_id'], data['fmt'] self.view_specific_format.emit(int(book_id), fmt) elif dt == 'identifier': if data['url']: browse(data['url']) elif dt == 'path': self.open_containing_folder.emit(int(data['loc'])) elif dt == 'devpath': self.view_device_book.emit(data['loc']) else: browse(link) def mouseDoubleClickEvent(self, ev): ev.accept() self.show_book_info.emit() def show_data(self, data): try: self.last_data = {'title':data.title, 'authors':data.authors} except Exception: self.last_data = {} self.book_info.show_data(data) self.cover_view.show_data(data) self.current_path = getattr(data, 'path', '') self.update_layout() def update_layout(self): self.cover_view.setVisible(gprefs['bd_show_cover']) self._layout.do_layout(self.rect()) self.cover_view.update_tooltip(self.current_path) def reset_info(self): self.show_data(Metadata(_('Unknown')))
class TOCEditor(QDialog): # {{{ explode_done = pyqtSignal(object) writing_done = pyqtSignal(object) def __init__(self, pathtobook, title=None, parent=None, prefs=None, write_result_to=None): QDialog.__init__(self, parent) self.write_result_to = write_result_to self.prefs = prefs or te_prefs self.pathtobook = pathtobook self.working = True t = title or os.path.basename(pathtobook) self.book_title = t self.setWindowTitle(_('Edit the ToC in %s') % t) self.setWindowIcon(QIcon(I('highlight_only_on.png'))) l = self.l = QVBoxLayout() self.setLayout(l) self.stacks = s = QStackedWidget(self) l.addWidget(s) self.loading_widget = lw = QWidget(self) s.addWidget(lw) ll = self.ll = QVBoxLayout() lw.setLayout(ll) self.pi = pi = ProgressIndicator() pi.setDisplaySize(QSize(200, 200)) pi.startAnimation() ll.addWidget(pi, alignment=Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignCenter) la = self.wait_label = QLabel(_('Loading %s, please wait...') % t) la.setWordWrap(True) f = la.font() f.setPointSize(20), la.setFont(f) ll.addWidget(la, alignment=Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) self.toc_view = TOCView(self, self.prefs) self.toc_view.add_new_item.connect(self.add_new_item) self.toc_view.tocw.history_state_changed.connect( self.update_history_buttons) s.addWidget(self.toc_view) self.item_edit = ItemEdit(self) s.addWidget(self.item_edit) bb = self.bb = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) l.addWidget(bb) bb.accepted.connect(self.accept) bb.rejected.connect(self.reject) self.undo_button = b = bb.addButton( _('&Undo'), QDialogButtonBox.ButtonRole.ActionRole) b.setToolTip(_('Undo the last action, if any')) b.setIcon(QIcon(I('edit-undo.png'))) b.clicked.connect(self.toc_view.undo) self.explode_done.connect(self.read_toc, type=Qt.ConnectionType.QueuedConnection) self.writing_done.connect(self.really_accept, type=Qt.ConnectionType.QueuedConnection) r = QApplication.desktop().availableGeometry(self) self.resize(r.width() - 100, r.height() - 100) geom = self.prefs.get('toc_editor_window_geom', None) if geom is not None: QApplication.instance().safe_restore_geometry(self, bytes(geom)) self.stacks.currentChanged.connect(self.update_history_buttons) self.update_history_buttons() def update_history_buttons(self): self.undo_button.setVisible(self.stacks.currentIndex() == 1) self.undo_button.setEnabled(bool(self.toc_view.tocw.history)) def add_new_item(self, item, where): self.item_edit(item, where) self.stacks.setCurrentIndex(2) def accept(self): if self.stacks.currentIndex() == 2: self.toc_view.update_item(*self.item_edit.result) self.prefs['toc_edit_splitter_state'] = bytearray( self.item_edit.splitter.saveState()) self.stacks.setCurrentIndex(1) elif self.stacks.currentIndex() == 1: self.working = False Thread(target=self.write_toc).start() self.pi.startAnimation() self.wait_label.setText( _('Writing %s, please wait...') % self.book_title) self.stacks.setCurrentIndex(0) self.bb.setEnabled(False) def really_accept(self, tb): self.prefs['toc_editor_window_geom'] = bytearray(self.saveGeometry()) if tb: error_dialog(self, _('Failed to write book'), _('Could not write %s. Click "Show details" for' ' more information.') % self.book_title, det_msg=tb, show=True) super(TOCEditor, self).reject() return self.write_result(0) super(TOCEditor, self).accept() def reject(self): if not self.bb.isEnabled(): return if self.stacks.currentIndex() == 2: self.prefs['toc_edit_splitter_state'] = bytearray( self.item_edit.splitter.saveState()) self.stacks.setCurrentIndex(1) else: self.working = False self.prefs['toc_editor_window_geom'] = bytearray( self.saveGeometry()) self.write_result(1) super(TOCEditor, self).reject() def write_result(self, res): if self.write_result_to: with tempfile.NamedTemporaryFile(dir=os.path.dirname( self.write_result_to), delete=False) as f: src = f.name f.write(str(res).encode('utf-8')) f.flush() atomic_rename(src, self.write_result_to) def start(self): t = Thread(target=self.explode) t.daemon = True self.log = GUILog() t.start() def explode(self): tb = None try: self.ebook = get_container(self.pathtobook, log=self.log) except: import traceback tb = traceback.format_exc() if self.working: self.working = False self.explode_done.emit(tb) def read_toc(self, tb): if tb: error_dialog(self, _('Failed to load book'), _('Could not load %s. Click "Show details" for' ' more information.') % self.book_title, det_msg=tb, show=True) self.reject() return self.pi.stopAnimation() self.toc_view(self.ebook) self.item_edit.load(self.ebook) self.stacks.setCurrentIndex(1) def write_toc(self): tb = None try: toc = self.toc_view.create_toc() toc.toc_title = getattr(self.toc_view, 'toc_title', None) commit_toc(self.ebook, toc, lang=self.toc_view.toc_lang, uid=self.toc_view.toc_uid) self.ebook.commit() except: import traceback tb = traceback.format_exc() self.writing_done.emit(tb)