def __init__(self, QWidget_parent=None): QWidget.__init__(self, QWidget_parent) # параметры self._Margin = 50 self._isWatcher = False self._isChangeIndex = False self._is1Day = False self._isMore1Day = False self._date = QDate() self._curves = {} self._legend = "" self._plotterScale = PlotterScale(self) # параметры, которые сохраняются self._colorExt = QColor(Qt.white) self._colorInside = QColor(Qt.white) self._colorCurve = QColor(Qt.red) self._fontGrid = QFont('Arial', 8, QFont.Normal) self._fontTitle = QFont('Arial', 20, QFont.Normal) self._fontLegend = QFont('Arial', 8, QFont.Normal) self._fontTitleY = QFont('Arial', 16, QFont.Normal) self._pixmapSaveWidth = 800 self._pixmapSaveHeight = 600 self._toolbar = QToolBar(self) self._contexMenu = QMenu(self) saveAction = self._contexMenu.addAction(self.tr('Сохранить...')) saveAction.triggered.connect(self.onSavePlotter) self._timer = QTimer(self) self._timer.setTimerType(Qt.VeryCoarseTimer) self._timer.timeout.connect(self.tickTimer) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.timerTick = pyqtSignal(name='timerTick') self.noWatching = pyqtSignal(bool, name='noWatching') self.variablesPlotter = pyqtSignal(QDate, int, name='variablesPlotter')
class Scene(QObject): object_spawned = pyqtSignal(SceneObject, name='object_spawned') object_despawned = pyqtSignal(SceneObject, name='object_despawned') def __init__(self, width, height): super(Scene, self).__init__() self.rootNode = SceneNode() self.backgroundPixmap = None self.backgroundBrush = QBrush(QColor(0, 0, 0)) self.width = width self.height = height self.sceneAabb = QRectF(0.0, 0.0, width, height) return def get_width(self): return self.width def get_height(self): return self.height def get_root_node(self): return self.rootNode def get_background_brush(self): return self.backgroundBrush def set_background_brush(self, brush): self.backgroundBrush = brush def get_background_pixmap(self): return self.backgroundPixmap def set_background_pixmap(self, pixmap): self.backgroundPixmap = pixmap def draw(self, painter): painter.setClipRect(self.sceneAabb) self.update_node(self.rootNode) if self.backgroundPixmap is not None: painter.drawPixmap(self.sceneAabb, self.backgroundPixmap, self.backgroundPixmap.rect()) else: painter.fillRect(self.sceneAabb, self.backgroundBrush) self.draw_node(painter, self.rootNode) return def draw_node(self, painter, node): for childNode in node.children: if QRectF.intersects(self.sceneAabb, childNode.aabb): self.draw_node(painter, childNode) node.update_transform() if node.attachedObject is not None: node.attachedObject.draw(painter) return def update_node(self, node): for childNode in node.children: self.update_node(childNode) node.update_transform() return def spawn(self, scene_object, parent_object=None): node = SceneNode() if parent_object is None: self.rootNode.attach_child(node) else: parent_object.get_scene_node().attach_child(node) scene_object.spawn(node) self.object_spawned.emit(scene_object) return def despawn(self, scene_object): node = scene_object.get_scene_node() if node is not None: for child in node.children: if child.attachedObject is not None: self.despawn(child.attachedObject) scene_object.despawn() node.change_parent(None) self.object_despawned.emit(scene_object) return def reset_scene(self): self.reset_node(self.rootNode) return def reset_node(self, node): for childNode in reversed(node.children): self.reset_node(childNode) if node.attachedObject is not None: self.despawn(node.attachedObject) return
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.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: 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 Plugins(QObject): class Plugin: # enum Type Invalid = 0 InternalPlugin = 1 SharedLibraryPlugin = 2 PythonPlugin = 3 QmlPlugin = 4 def __init__(self): self.type = self.Invalid self.pluginId = '' self.pluginSpec = PluginSpec() self.instance = None # PluginInterface # InternalPlugin self.internalInstance = None # SharedLibraryPlugin self.libraryPath = '' self.pluginLoader = None # QPluginLoader # Other self.data = None def isLoaded(self): return not not self.instance def __eq__(self, other): return (self.type == other.type and self.pluginId == other.pluginId) def __init__(self, parent=None): super().__init__(parent) # protected: self._loadedPlugins = [] # QList<PluginInterface> # private: self._availablePlugins = [] # QList<Plugin> self._allowedPlugins = [] # QStringList self._pluginsLoaded = False self._speedDial = SpeedDial(self) # SpeedDial self._internalPlugins = [] # QList<PluginInterface> self._pythonPlugin = None # QLibrary self.loadSettings() from mc.app.MainApplication import MainApplication if not MainApplication.isTestModeEnabled(): return self._loadPythonSupport() def getAvailablePlugins(self): ''' @return: QList<Plugin> ''' pass def loadPlugin(self, plugin): ''' @param: plugin Plugin @return: bool ''' pass def unloadPlugin(self, plugin): ''' @param: plugin Plugin ''' pass def removePlugin(self, plugin): ''' @param: plugin Plugin ''' pass def shutdown(self): pass # SpeedDial def speedDial(self): ''' @return: SpeedDial ''' return self._speedDial @classmethod def createSpec(cls, metaData): ''' @param: metaData DesktopFile @return: PluginSpec ''' pass # public Q_SLOTS def loadSettings(self): pass def loadPlugins(self): pass # Q_SIGNALS pluginUnloaded = pyqtSignal(PluginInterface) # plugin refreshedLoadedPlugins = pyqtSignal() # private: def _loadPythonSupport(self): pass def _loadPlugin(self, id_): pass def _loadInternalPlugin(self, name): pass def _loadSharedLibraryPlugin(self, name): pass def _loadPythonPlugin(self, name): pass def _initPlugin(self, state, plugin): ''' @param: state PluginInterface::InitState @param: plugin Plugin @return: bool ''' pass def _initInternalPlugin(self, plugin): ''' @param: plugin Plugin ''' pass def _initSharedLibraryPlugin(self, plugin): ''' @param: plugin Plugin ''' pass def _initPythonPlugin(self, plugin): ''' @param: plugin Plugin ''' pass def _registerAvailablePlugin(self, plugin): ''' @param: plugin Plugin ''' pass def _refreshLoadedPlugins(self): pass def _loadAvailablePlugins(self): pass
class Preferences(QMainWindow): run_wizard_requested = pyqtSignal() def __init__(self, gui, initial_plugin=None, close_after_initial=False): QMainWindow.__init__(self, gui) self.gui = gui self.must_restart = False self.committed = False self.close_after_initial = close_after_initial self.resize(930, 720) nh, nw = min_available_height() - 25, available_width() - 10 if nh < 0: nh = 800 if nw < 0: nw = 600 nh = min(self.height(), nh) nw = min(self.width(), nw) self.resize(nw, nh) self.esc_action = QAction(self) self.addAction(self.esc_action) self.esc_action.setShortcut(QKeySequence(Qt.Key_Escape)) self.esc_action.triggered.connect(self.esc) geom = gprefs.get('preferences_window_geometry', None) if geom is not None: self.restoreGeometry(geom) # Center if islinux: self.move(gui.rect().center() - self.rect().center()) self.setWindowModality(Qt.WindowModal) self.setWindowTitle(__appname__ + ' - ' + _('Preferences')) self.setWindowIcon(QIcon(I('config.png'))) self.status_bar = StatusBar(self) self.setStatusBar(self.status_bar) self.stack = QStackedWidget(self) self.cw = QWidget(self) self.cw.setLayout(QVBoxLayout()) self.cw.layout().addWidget(self.stack) self.bb = QDialogButtonBox(QDialogButtonBox.Close) self.wizard_button = self.bb.addButton(_('Run welcome wizard'), self.bb.ActionRole) self.wizard_button.setIcon(QIcon(I('wizard.png'))) self.wizard_button.clicked.connect(self.run_wizard, type=Qt.QueuedConnection) self.cw.layout().addWidget(self.bb) self.bb.button(self.bb.Close).setDefault(True) self.bb.rejected.connect(self.close, type=Qt.QueuedConnection) self.setCentralWidget(self.cw) self.browser = Browser(self) self.browser.show_plugin.connect(self.show_plugin) self.stack.addWidget(self.browser) self.scroll_area = QScrollArea(self) self.stack.addWidget(self.scroll_area) self.scroll_area.setWidgetResizable(True) self.setContextMenuPolicy(Qt.NoContextMenu) self.bar = QToolBar(self) self.addToolBar(self.bar) self.bar.setVisible(False) self.bar.setIconSize(QSize(ICON_SIZE, ICON_SIZE)) self.bar.setMovable(False) self.bar.setFloatable(False) self.bar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.apply_action = self.bar.addAction(QIcon(I('ok.png')), _('&Apply'), self.commit) self.cancel_action = self.bar.addAction(QIcon(I('window-close.png')), _('&Cancel'), self.cancel) self.bar_title = BarTitle(self.bar) self.bar.addWidget(self.bar_title) self.restore_action = self.bar.addAction(QIcon(I('clear_left.png')), _('Restore &defaults'), self.restore_defaults) for ac, tt in [('apply', _('Save changes')), ('cancel', _('Cancel and return to overview'))]: ac = getattr(self, ac + '_action') ac.setToolTip(tt) ac.setWhatsThis(tt) ac.setStatusTip(tt) for ch in self.bar.children(): if isinstance(ch, QToolButton): ch.setCursor(Qt.PointingHandCursor) ch.setAutoRaise(True) self.stack.setCurrentIndex(0) if initial_plugin is not None: category, name = initial_plugin plugin = get_plugin(category, name) if plugin is not None: self.show_plugin(plugin) def run_wizard(self): self.close() self.run_wizard_requested.emit() def set_tooltips_for_labels(self): def process_child(child): for g in child.children(): if isinstance(g, QLabel): buddy = g.buddy() if buddy is not None and hasattr(buddy, 'toolTip'): htext = unicode(buddy.toolTip()).strip() etext = unicode(g.toolTip()).strip() if htext and not etext: g.setToolTip(htext) g.setWhatsThis(htext) else: process_child(g) process_child(self.showing_widget) def show_plugin(self, plugin): self.showing_widget = plugin.create_widget(self.scroll_area) self.showing_widget.genesis(self.gui) self.showing_widget.initialize() self.set_tooltips_for_labels() self.scroll_area.setWidget(self.showing_widget) self.stack.setCurrentIndex(1) self.showing_widget.show() self.setWindowTitle(__appname__ + ' - ' + _('Preferences') + ' - ' + plugin.gui_name) self.apply_action.setEnabled(False) self.showing_widget.changed_signal.connect( lambda: self.apply_action.setEnabled(True)) self.showing_widget.restart_now.connect(self.restart_now) self.restore_action.setEnabled( self.showing_widget.supports_restoring_to_defaults) tt = self.showing_widget.restore_defaults_desc if not self.restore_action.isEnabled(): tt = _('Restoring to defaults not supported for') + ' ' + \ plugin.gui_name self.restore_action.setToolTip(textwrap.fill(tt)) self.restore_action.setWhatsThis(textwrap.fill(tt)) self.restore_action.setStatusTip(tt) self.bar_title.show_plugin(plugin) self.setWindowIcon(QIcon(plugin.icon)) self.bar.setVisible(True) self.bb.setVisible(False) def hide_plugin(self): self.showing_widget = QWidget(self.scroll_area) self.scroll_area.setWidget(self.showing_widget) self.setWindowTitle(__appname__ + ' - ' + _('Preferences')) self.bar.setVisible(False) self.stack.setCurrentIndex(0) self.setWindowIcon(QIcon(I('config.png'))) self.bb.setVisible(True) def esc(self, *args): if self.stack.currentIndex() == 1: self.cancel() elif self.stack.currentIndex() == 0: self.close() def restart_now(self): try: self.showing_widget.commit() except AbortCommit: return self.hide_plugin() self.close() self.gui.quit(restart=True) def commit(self, *args): try: must_restart = self.showing_widget.commit() except AbortCommit: return rc = self.showing_widget.restart_critical self.committed = True do_restart = False if must_restart: self.must_restart = True msg = _('Some of the changes you made require a restart.' ' Please restart calibre as soon as possible.') if rc: msg = _('The changes you have made require calibre be ' 'restarted immediately. You will not be allowed to ' 'set any more preferences, until you restart.') do_restart = show_restart_warning(msg, parent=self) self.showing_widget.refresh_gui(self.gui) self.hide_plugin() if self.close_after_initial or (must_restart and rc) or do_restart: self.close() if do_restart: self.gui.quit(restart=True) def cancel(self, *args): if self.close_after_initial: self.close() else: self.hide_plugin() def restore_defaults(self, *args): self.showing_widget.restore_defaults() def closeEvent(self, *args): gprefs.set('preferences_window_geometry', bytearray(self.saveGeometry())) if self.committed: self.gui.must_restart_before_config = self.must_restart self.gui.tags_view.recount() self.gui.create_device_menu() self.gui.set_device_menu_items_state( bool(self.gui.device_connected)) self.gui.bars_manager.apply_settings() self.gui.bars_manager.update_bars() self.gui.build_context_menus() return QMainWindow.closeEvent(self, *args)
class BookInfo(QWebView): 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_author = pyqtSignal(object) open_fmt_with = pyqtSignal(int, object, object) def __init__(self, vertical, parent=None): QWebView.__init__(self, parent) s = self.settings() s.setAttribute(s.JavascriptEnabled, False) self.vertical = vertical self.page().setLinkDelegationPolicy(self.page().DelegateAllLinks) self.linkClicked.connect(self.link_activated) self._link_clicked = False self.setAttribute(Qt.WA_OpaquePaintEvent, False) palette = self.palette() self.setAcceptDrops(False) palette.setBrush(QPalette.Base, Qt.transparent) self.page().setPalette(palette) for x, icon in [ ('remove_format', 'trash.png'), ('save_format', 'save.png'), ('restore_format', 'edit-undo.png'), ('copy_link', 'edit-copy.png'), ('manage_author', 'user_profile.png'), ('compare_format', 'diff.png'), ('set_cover_format', 'default_cover.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.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.NoFocus) def remove_item_triggered(self): field, value, book_id = self.remove_item_action.data if field: 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 manage_author_triggerred(self): self.manage_author.emit(self.manage_author_action.current_fmt) def link_activated(self, link): self._link_clicked = True if str(link.scheme()) in ('http', 'https'): return open_url(link) link = str(link.toString(NO_URL_FORMATTING)) self.link_clicked.emit(link) def turnoff_scrollbar(self, *args): self.page().mainFrame().setScrollBarPolicy(Qt.Horizontal, Qt.ScrollBarAlwaysOff) def show_data(self, mi): html = render_html(mi, css(), self.vertical, self.parent()) self.setHtml(html) def mouseDoubleClickEvent(self, ev): swidth = self.page().mainFrame().scrollBarGeometry(Qt.Vertical).width() sheight = self.page().mainFrame().scrollBarGeometry( Qt.Horizontal).height() if self.width() - ev.x() < swidth or \ self.height() - ev.y() < sheight: # Filter out double clicks on the scroll bar ev.accept() else: ev.ignore() def contextMenuEvent(self, ev): details_context_menu_event(self, ev, self) 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)
class Results(QWidget): EMPH = "color:magenta; font-weight:bold" MARGIN = 4 item_selected = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent=parent) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.results = () self.current_result = -1 self.max_result = -1 self.mouse_hover_result = -1 self.setMouseTracking(True) self.setFocusPolicy(Qt.NoFocus) self.text_option = to = QTextOption() to.setWrapMode(to.NoWrap) self.divider = QStaticText('\xa0→ \xa0') self.divider.setTextFormat(Qt.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.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 izip(prefixes, results.iteritems())) 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(self.EMPH, text, positions)) text.setTextOption(self.text_option) text.setTextFormat(Qt.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.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.DotLine) p.drawLine(offset, QPoint(self.width(), offset.y())) p.restore() else: p.drawText(self.rect(), Qt.AlignCenter, _('No results found')) p.end() @property def selected_result(self): try: return self.results[self.current_result][-1] except IndexError: pass
class Central(QStackedWidget): # {{{ ' The central widget, hosts the editors ' current_editor_changed = pyqtSignal() close_requested = pyqtSignal(object) def __init__(self, parent=None): QStackedWidget.__init__(self, parent) self.welcome = w = QLabel( '<p>' + _('Double click a file in the left panel to start editing' ' it.')) self.addWidget(w) w.setWordWrap(True) w.setAlignment(Qt.AlignTop | Qt.AlignHCenter) self.container = c = QWidget(self) self.addWidget(c) l = c.l = QVBoxLayout(c) c.setLayout(l) l.setContentsMargins(0, 0, 0, 0) self.editor_tabs = t = QTabWidget(c) l.addWidget(t) t.setDocumentMode(True) t.setTabsClosable(True) t.setMovable(True) pal = self.palette() if pal.color(pal.WindowText).lightness() > 128: i = QImage(I('modified.png')) i.invertPixels() self.modified_icon = QIcon(QPixmap.fromImage(i)) else: self.modified_icon = QIcon(I('modified.png')) self.editor_tabs.currentChanged.connect(self.current_editor_changed) self.editor_tabs.tabCloseRequested.connect(self._close_requested) self.search_panel = SearchPanel(self) l.addWidget(self.search_panel) self.restore_state() self.editor_tabs.tabBar().installEventFilter(self) def _close_requested(self, index): editor = self.editor_tabs.widget(index) self.close_requested.emit(editor) def add_editor(self, name, editor): fname = name.rpartition('/')[2] index = self.editor_tabs.addTab(editor, fname) self.editor_tabs.setTabToolTip(index, _('Full path:') + ' ' + name) editor.modification_state_changed.connect(self.editor_modified) @property def tab_order(self): ans = [] rmap = {v: k for k, v in iteritems(editors)} for i in range(self.editor_tabs.count()): name = rmap.get(self.editor_tabs.widget(i)) if name is not None: ans.append(name) return ans def rename_editor(self, editor, name): for i in range(self.editor_tabs.count()): if self.editor_tabs.widget(i) is editor: fname = name.rpartition('/')[2] self.editor_tabs.setTabText(i, fname) self.editor_tabs.setTabToolTip(i, _('Full path:') + ' ' + name) def show_editor(self, editor): self.setCurrentIndex(1) self.editor_tabs.setCurrentWidget(editor) def close_editor(self, editor): for i in range(self.editor_tabs.count()): if self.editor_tabs.widget(i) is editor: self.editor_tabs.removeTab(i) if self.editor_tabs.count() == 0: self.setCurrentIndex(0) return True return False def editor_modified(self, *args): tb = self.editor_tabs.tabBar() for i in range(self.editor_tabs.count()): editor = self.editor_tabs.widget(i) modified = getattr(editor, 'is_modified', False) tb.setTabIcon(i, self.modified_icon if modified else QIcon()) def close_current_editor(self): ed = self.current_editor if ed is not None: self.close_requested.emit(ed) def close_all_but_current_editor(self): self.close_all_but(self.current_editor) def close_all_but(self, ed): close = [] if ed is not None: for i in range(self.editor_tabs.count()): q = self.editor_tabs.widget(i) if q is not None and q is not ed: close.append(q) for q in close: self.close_requested.emit(q) @property def current_editor(self): return self.editor_tabs.currentWidget() def save_state(self): tprefs.set('search-panel-visible', self.search_panel.isVisible()) self.search_panel.save_state() for ed in itervalues(editors): ed.save_state() if self.current_editor is not None: self.current_editor.save_state( ) # Ensure the current editor saves it state last def restore_state(self): self.search_panel.setVisible(tprefs.get('search-panel-visible', False)) self.search_panel.restore_state() def show_find(self): self.search_panel.show_panel() def pre_fill_search(self, text): self.search_panel.pre_fill(text) def eventFilter(self, obj, event): base = super(Central, self) if obj is not self.editor_tabs.tabBar() or event.type( ) != QEvent.MouseButtonPress or event.button() not in (Qt.RightButton, Qt.MidButton): return base.eventFilter(obj, event) index = self.editor_tabs.tabBar().tabAt(event.pos()) if index < 0: return base.eventFilter(obj, event) if event.button() == Qt.MidButton: self._close_requested(index) ed = self.editor_tabs.widget(index) if ed is not None: menu = QMenu(self) menu.addAction(actions['close-current-tab'].icon(), _('Close tab'), partial(self.close_requested.emit, ed)) menu.addSeparator() menu.addAction(actions['close-all-but-current-tab'].icon(), _('Close other tabs'), partial(self.close_all_but, ed)) menu.exec_(self.editor_tabs.tabBar().mapToGlobal(event.pos())) return True
class Client(QObject): '''Handles socket.''' # Emitted Signals comm_update = pyqtSignal([str], name='communication_update') chat_update = pyqtSignal([dict], name='chat_update') client_connected = pyqtSignal(name='client_connected') handshake_succeded = pyqtSignal(name='handshake_succeeded') handshake_failed = pyqtSignal(name='handshake_failed') msg_ready = pyqtSignal([dict], name='msg_ready') comm_error = pyqtSignal(name='comm_error') # Signals listened to # data_ready = pyqtSignal([str, dict], name='data_ready') def __init__(self, parent): QObject.__init__(self, parent) self._parent = parent self._km = parent.key_manager self._codec = parent.codec self._VERSION = parent.VERSION self._qsocket = QTcpSocket(self) self._bytestream = None self._other_party = None self._this_party = None self._ip_address = None self._port = None self._setup() def _setup(self): # Ready read self._qsocket.readyRead.connect(self._on_ready_read) # On connection self._qsocket.connected.connect(self._on_connection) # On disconnection self._qsocket.disconnected.connect(self._on_disconnection) # Prime the json generator self._msg_buffer = self._add_to_buffer() self._msg_buffer.send(None) # Connect self.msg_ready self.msg_ready.connect(self._dispatch_msg) # COnnect connection to _client_handshake_1 to start it off. self._qsocket.connected.connect(self._client_handshake_1) # Connect error to emit socket update. self._qsocket.error.connect(self._on_error) def _on_connection(self): name = str(self._qsocket.peerName()) address = self._qsocket.peerAddress().toString() self.comm_update.emit('Connected to ' + name + ' (' + address + ')' + '.') def _on_disconnection(self): self.comm_update.emit('Connection terminated.') self.comm_error.emit() def _on_error(self): self.comm_update.emit(self._qsocket.errorString() + '.') self.comm_error.emit() def _on_ready_read(self): data = self._qsocket.read(4096) self._msg_buffer.send(data) # Re-emit readyRead, quasi-recursive. if self._qsocket.bytesAvailable(): self._qsocket.readyRead.emit() def _add_to_buffer(self): '''Generator created in _setup. Issues data via signal.''' # Set up constants. opener = '{' #123 closer = '}' #125 single_quote = '\'' # 39 double_quote = '\"' # 34 self._bytestream = io.BytesIO() inside_double_quote = False inside_single_quote = False opener_count = 0 closer_count = 0 # Repeat this loop until end of time. while True: # Given via generator.send method. raw_data = yield data = raw_data.decode('utf-8') for character in data: if character == single_quote: # If True, False. If False, True. inside_single_quote = not inside_single_quote if character == double_quote: inside_double_quote = not inside_double_quote if inside_single_quote or inside_double_quote: pass else: if character == opener: opener_count += 1 if character == closer: closer_count += 1 if opener_count == closer_count: # Write final character to bytestream self._bytestream.write(character.encode()) # Complete JSON message. json_msg = self._bytestream.getvalue().decode('utf-8') msg = json.loads(json_msg) self.msg_ready.emit(msg) # Reset because it's a new message now. self._bytestream = io.BytesIO() inside_double_quote = False inside_single_quote = False opener_count = 0 closer_count = 0 # Back to "while True" loop continue # Write to bytestream self._bytestream.write(character.encode()) def connect_(self, identity, ip_address, port, numeric=True): self.comm_update.emit('Connecting.') self._this_party = identity # If connected, disconnect. try: if self._qsocket.state == 3: self._qsocket.disconnectFromHost() except AttributeError: pass # Connect to host if numeric: self._qsocket.connectToHost(QHostAddress(ip_address), int(port)) else: self._qsocket.connectToHost(ip_address, int(port)) def _dispatch_msg(self, msg_dict): '''Route message to correct function.''' msg_type = msg_dict['metadata']['type'] routing_dict = { 'text': self._recv_text, 'file': None, 'server_handshake_1': self._client_handshake_2, 'server_handshake_2': self._client_handshake_3 } function_to_use = routing_dict[msg_type] function_to_use(msg_dict) def _client_handshake_1(self): # Client sends client handshake 1 with list of keys (first msg). key_list = [key.name for key in self._km.get_all_keys()] data_dict = {'key_list': key_list} client_msg_1 = self._create_json_msg('client_handshake_1', data_dict) self._qsocket.write(client_msg_1.encode()) def _client_handshake_2(self, msg_dict): self._other_party = msg_dict['metadata']['sender'] shared_keys = msg_dict['data']['shared_keys'] client_shared_key_data = [ self._km.get_key_data(key) for key in shared_keys ] data_dict = {'client_shared_key_data': client_shared_key_data} client_msg_2 = self._create_json_msg('client_handshake_2', data_dict) self._qsocket.write(client_msg_2.encode()) def _client_handshake_3(self, msg_dict): '''No message.''' # Server sends server handshake 2. harmonized_data = msg_dict['data']['harmonized_data'] if not harmonized_data: self.comm_update.emit('No shared keys.') self._qsocket.disconnectFromHost() return False for item in harmonized_data: if item['sender'] == self._this_party: self._km.add_to_outbound(item['name']) if item['sender'] == self._other_party: self._km.add_to_inbound(item['name']) self.handshake_succeded.emit() self.comm_update.emit('Client-side handshake complete.') def _recv_text(self, msg): ciphertext = msg['data']['ciphertext'] cipherhash = msg['data']['cipherhash'] plaintext = self._codec.decode(ciphertext, cipherhash) msg['plaintext'] = plaintext msg['source'] = self._other_party self.chat_update.emit(msg) def send_text(self, text_data): data_bytes = text_data.encode('utf-8') ciphertext, cipherhash = self._codec.encode(data_bytes) data_dict = { 'ciphertext': base64.b64encode(ciphertext).decode(), 'cipherhash': base64.b64encode(cipherhash).decode() } msg = self._create_json_msg('text', data_dict) self._qsocket.write(msg.encode()) self.chat_update.emit({ 'source': self._this_party, 'content': text_data }) def _send_file(self, file_object): pass def _recv_file(self, output_path): pass def _create_json_msg(self, msg_type, data_dict): '''Create msg based on template (must be json serialize-able).''' base = { 'metadata': { 'brickton_version': self._VERSION, 'sender': self._this_party, 'receiver': self._other_party, 'timestamp': '{0:.7f}'.format(time.time()), 'encoding': 'base64', 'type': msg_type }, 'data': {} } # Types to check against. types = [ 'text', 'file', 'server_handshake_1', 'server_handshake_2', 'client_handshake_1', 'client_handshake_2' ] # Type error checking. if not msg_type in types: raise BricktonError('Message type not valid.') # Keys by type. keys_by_type = { 'text': ['ciphertext', 'cipherhash'], 'file': ['ciphertext', 'cipherhash', 'ciphername'], 'server_handshake_1': ['shared_keys'], 'server_handshake_2': ['harmonized_data'], 'client_handshake_1': ['key_list'], 'client_handshake_2': ['client_shared_key_data'] } # Keys by type error checking. for key in data_dict.keys(): if not key in keys_by_type[msg_type]: raise BricktonError('Improper data for message type supplied.') # Populate data dict. for key, value in data_dict.items(): base['data'][key] = value # Create and return msg as json. msg_string = json.dumps(base) return msg_string def stop(self): self._qsocket.close() self.terminate()
class Main( MainWindow, MainWindowMixin, DeviceMixin, EmailMixin, # {{{ TagBrowserMixin, CoverFlowMixin, LibraryViewMixin, SearchBoxMixin, SavedSearchBoxMixin, SearchRestrictionMixin, LayoutMixin, UpdateMixin, EbookDownloadMixin): 'The main GUI' proceed_requested = pyqtSignal(object, object) book_converted = pyqtSignal(object, object) shutting_down = False def __init__(self, opts, parent=None, gui_debug=None): global _gui MainWindow.__init__(self, opts, parent=parent, disable_automatic_gc=True) self.setWindowIcon(QApplication.instance().windowIcon()) self.jobs_pointer = Pointer(self) self.proceed_requested.connect(self.do_proceed, type=Qt.QueuedConnection) self.proceed_question = ProceedQuestion(self) self.job_error_dialog = JobError(self) self.keyboard = Manager(self) _gui = self self.opts = opts self.device_connected = None self.gui_debug = gui_debug self.iactions = OrderedDict() # Actions for action in interface_actions(): if opts.ignore_plugins and action.plugin_path is not None: continue try: ac = self.init_iaction(action) except: # Ignore errors in loading user supplied plugins import traceback traceback.print_exc() if action.plugin_path is None: raise continue ac.plugin_path = action.plugin_path ac.interface_action_base_plugin = action self.add_iaction(ac) self.load_store_plugins() def init_iaction(self, action): ac = action.load_actual_plugin(self) ac.plugin_path = action.plugin_path ac.interface_action_base_plugin = action action.actual_iaction_plugin_loaded = True return ac def add_iaction(self, ac): acmap = self.iactions if ac.name in acmap: if ac.priority >= acmap[ac.name].priority: acmap[ac.name] = ac else: acmap[ac.name] = ac def load_store_plugins(self): from calibre.gui2.store.loader import Stores self.istores = Stores() for store in available_store_plugins(): if self.opts.ignore_plugins and store.plugin_path is not None: continue try: st = self.init_istore(store) self.add_istore(st) except: # Ignore errors in loading user supplied plugins import traceback traceback.print_exc() if store.plugin_path is None: raise continue self.istores.builtins_loaded() def init_istore(self, store): st = store.load_actual_plugin(self) st.plugin_path = store.plugin_path st.base_plugin = store store.actual_istore_plugin_loaded = True return st def add_istore(self, st): stmap = self.istores if st.name in stmap: if st.priority >= stmap[st.name].priority: stmap[st.name] = st else: stmap[st.name] = st def initialize(self, library_path, db, listener, actions, show_gui=True): opts = self.opts self.preferences_action, self.quit_action = actions self.library_path = library_path self.library_broker = GuiLibraryBroker(db) self.content_server = None self.server_change_notification_timer = t = QTimer(self) self.server_changes = Queue() t.setInterval(1000), t.timeout.connect( self.handle_changes_from_server_debounced), t.setSingleShot(True) self._spare_pool = None self.must_restart_before_config = False self.listener = Listener(listener) self.check_messages_timer = QTimer() self.check_messages_timer.timeout.connect( self.another_instance_wants_to_talk) self.check_messages_timer.start(1000) for ac in self.iactions.values(): try: ac.do_genesis() except Exception: # Ignore errors in third party plugins import traceback traceback.print_exc() if getattr(ac, 'plugin_path', None) is None: raise self.donate_action = QAction(QIcon(I('donate.png')), _('&Donate to support calibre'), self) for st in self.istores.values(): st.do_genesis() MainWindowMixin.init_main_window_mixin(self, db) # Jobs Button {{{ self.job_manager = JobManager() self.jobs_dialog = JobsDialog(self, self.job_manager) self.jobs_button = JobsButton(horizontal=True, parent=self) self.jobs_button.initialize(self.jobs_dialog, self.job_manager) # }}} LayoutMixin.init_layout_mixin(self) DeviceMixin.init_device_mixin(self) self.progress_indicator = ProgressIndicator(self) self.progress_indicator.pos = (0, 20) self.verbose = opts.verbose self.get_metadata = GetMetadata() self.upload_memory = {} self.metadata_dialogs = [] self.default_thumbnail = None self.tb_wrapper = textwrap.TextWrapper(width=40) self.viewers = collections.deque() self.system_tray_icon = None if config['systray_icon']: self.system_tray_icon = factory( app_id='com.calibre-ebook.gui').create_system_tray_icon( parent=self, title='calibre') if self.system_tray_icon is not None: self.system_tray_icon.setIcon( QIcon(I('lt.png', allow_user_override=False))) if not (iswindows or isosx): self.system_tray_icon.setIcon( QIcon.fromTheme('calibre-gui', self.system_tray_icon.icon())) self.system_tray_icon.setToolTip(self.jobs_button.tray_tooltip()) self.system_tray_icon.setVisible(True) self.jobs_button.tray_tooltip_updated.connect( self.system_tray_icon.setToolTip) elif config['systray_icon']: prints( 'Failed to create system tray icon, your desktop environment probably does not support the StatusNotifier spec' ) self.system_tray_menu = QMenu(self) self.toggle_to_tray_action = self.system_tray_menu.addAction( QIcon(I('page.png')), '') self.toggle_to_tray_action.triggered.connect( self.system_tray_icon_activated) self.system_tray_menu.addAction(self.donate_action) self.eject_action = self.system_tray_menu.addAction( QIcon(I('eject.png')), _('&Eject connected device')) self.eject_action.setEnabled(False) self.addAction(self.quit_action) self.system_tray_menu.addAction(self.quit_action) self.keyboard.register_shortcut('quit calibre', _('Quit calibre'), default_keys=('Ctrl+Q', ), action=self.quit_action) if self.system_tray_icon is not None: self.system_tray_icon.setContextMenu(self.system_tray_menu) self.system_tray_icon.activated.connect( self.system_tray_icon_activated) self.quit_action.triggered[bool].connect(self.quit) self.donate_action.triggered[bool].connect(self.donate) self.minimize_action = QAction(_('Minimize the calibre window'), self) self.addAction(self.minimize_action) self.keyboard.register_shortcut('minimize calibre', self.minimize_action.text(), default_keys=(), action=self.minimize_action) self.minimize_action.triggered.connect(self.showMinimized) self.esc_action = QAction(self) self.addAction(self.esc_action) self.keyboard.register_shortcut('clear current search', _('Clear the current search'), default_keys=('Esc', ), action=self.esc_action) self.esc_action.triggered.connect(self.esc) self.shift_esc_action = QAction(self) self.addAction(self.shift_esc_action) self.keyboard.register_shortcut('focus book list', _('Focus the book list'), default_keys=('Shift+Esc', ), action=self.shift_esc_action) self.shift_esc_action.triggered.connect(self.shift_esc) self.ctrl_esc_action = QAction(self) self.addAction(self.ctrl_esc_action) self.keyboard.register_shortcut('clear virtual library', _('Clear the virtual library'), default_keys=('Ctrl+Esc', ), action=self.ctrl_esc_action) self.ctrl_esc_action.triggered.connect(self.ctrl_esc) self.alt_esc_action = QAction(self) self.addAction(self.alt_esc_action) self.keyboard.register_shortcut('clear additional restriction', _('Clear the additional restriction'), default_keys=('Alt+Esc', ), action=self.alt_esc_action) self.alt_esc_action.triggered.connect( self.clear_additional_restriction) # ###################### Start spare job server ######################## QTimer.singleShot(1000, self.create_spare_pool) # ###################### Location Manager ######################## self.location_manager.location_selected.connect(self.location_selected) self.location_manager.unmount_device.connect( self.device_manager.umount_device) self.location_manager.configure_device.connect( self.configure_connected_device) self.location_manager.update_device_metadata.connect( self.update_metadata_on_device) self.eject_action.triggered.connect(self.device_manager.umount_device) # ################### Update notification ################### UpdateMixin.init_update_mixin(self, opts) # ###################### Search boxes ######################## SearchRestrictionMixin.init_search_restriction_mixin(self) SavedSearchBoxMixin.init_saved_seach_box_mixin(self) # ###################### Library view ######################## LibraryViewMixin.init_library_view_mixin(self, db) SearchBoxMixin.init_search_box_mixin(self) # Requires current_db self.library_view.model().count_changed_signal.connect( self.iactions['Choose Library'].count_changed) if not gprefs.get('quick_start_guide_added', False): try: add_quick_start_guide(self.library_view) except: import traceback traceback.print_exc() for view in ('library', 'memory', 'card_a', 'card_b'): v = getattr(self, '%s_view' % view) v.selectionModel().selectionChanged.connect(self.update_status_bar) v.model().count_changed_signal.connect(self.update_status_bar) self.library_view.model().count_changed() self.bars_manager.database_changed(self.library_view.model().db) self.library_view.model().database_changed.connect( self.bars_manager.database_changed, type=Qt.QueuedConnection) # ########################## Tags Browser ############################## TagBrowserMixin.init_tag_browser_mixin(self, db) self.library_view.model().database_changed.connect( self.populate_tb_manage_menu, type=Qt.QueuedConnection) # ######################## Search Restriction ########################## if db.prefs['virtual_lib_on_startup']: self.apply_virtual_library(db.prefs['virtual_lib_on_startup']) self.rebuild_vl_tabs() # ########################## Cover Flow ################################ CoverFlowMixin.init_cover_flow_mixin(self) self._calculated_available_height = min(max_available_height() - 15, self.height()) self.resize(self.width(), self._calculated_available_height) self.build_context_menus() for ac in self.iactions.values(): try: ac.gui_layout_complete() except: import traceback traceback.print_exc() if ac.plugin_path is None: raise if config['autolaunch_server']: self.start_content_server() self.read_settings() self.finalize_layout() self.bars_manager.start_animation() self.set_window_title() for ac in self.iactions.values(): try: ac.initialization_complete() except: import traceback traceback.print_exc() if ac.plugin_path is None: raise self.set_current_library_information(current_library_name(), db.library_id, db.field_metadata) register_keyboard_shortcuts() self.keyboard.finalize() if show_gui: # Note this has to come after restoreGeometry() because of # https://bugreports.qt.io/browse/QTBUG-56831 self.show() if self.system_tray_icon is not None and self.system_tray_icon.isVisible( ) and opts.start_in_tray: self.hide_windows() self.auto_adder = AutoAdder(gprefs['auto_add_path'], self) self.save_layout_state() # Collect cycles now gc.collect() QApplication.instance().shutdown_signal_received.connect(self.quit) if show_gui and self.gui_debug is not None: QTimer.singleShot(10, self.show_gui_debug_msg) self.iactions['Connect Share'].check_smartdevice_menus() QTimer.singleShot(1, self.start_smartdevice) QTimer.singleShot(100, self.update_toggle_to_tray_action) def show_gui_debug_msg(self): info_dialog(self, _('Debug mode'), '<p>' + _('You have started calibre in debug mode. After you ' 'quit calibre, the debug log will be available in ' 'the file: %s<p>The ' 'log will be displayed automatically.') % self.gui_debug, show=True) def esc(self, *args): self.clear_button.click() def shift_esc(self): self.current_view().setFocus(Qt.OtherFocusReason) def ctrl_esc(self): self.apply_virtual_library() self.current_view().setFocus(Qt.OtherFocusReason) def start_smartdevice(self): message = None if self.device_manager.get_option('smartdevice', 'autostart'): try: message = self.device_manager.start_plugin('smartdevice') except: message = 'start smartdevice unknown exception' prints(message) import traceback traceback.print_exc() if message: if not self.device_manager.is_running('Wireless Devices'): error_dialog( self, _('Problem starting the wireless device'), _('The wireless device driver had problems starting. ' 'It said "%s"') % message, show=True) self.iactions['Connect Share'].set_smartdevice_action_state() def start_content_server(self, check_started=True): from calibre.srv.embedded import Server self.content_server = Server( self.library_broker, Dispatcher(self.handle_changes_from_server)) self.content_server.state_callback = Dispatcher( self.iactions['Connect Share'].content_server_state_changed) if check_started: self.content_server.start_failure_callback = \ Dispatcher(self.content_server_start_failed) self.content_server.start() def handle_changes_from_server(self, library_path, change_event): if DEBUG: prints('Received server change event: {} for {}'.format( change_event, library_path)) if self.library_broker.is_gui_library(library_path): self.server_changes.put((library_path, change_event)) self.server_change_notification_timer.start() def handle_changes_from_server_debounced(self): if self.shutting_down: return changes = [] while True: try: library_path, change_event = self.server_changes.get_nowait() except Empty: break if self.library_broker.is_gui_library(library_path): changes.append(change_event) if changes: handle_changes(changes, self) def content_server_start_failed(self, msg): error_dialog(self, _('Failed to start Content server'), _('Could not start the Content server. Error:\n\n%s') % msg, show=True) def resizeEvent(self, ev): MainWindow.resizeEvent(self, ev) self.search.setMaximumWidth(self.width() - 150) def create_spare_pool(self, *args): if self._spare_pool is None: num = min(detect_ncpus(), int(config['worker_limit'] / 2.0)) self._spare_pool = Pool(max_workers=num, name='GUIPool') def spare_pool(self): ans, self._spare_pool = self._spare_pool, None QTimer.singleShot(1000, self.create_spare_pool) return ans def do_proceed(self, func, payload): if callable(func): func(payload) def no_op(self, *args): pass def system_tray_icon_activated(self, r=False): if r in (QSystemTrayIcon.Trigger, QSystemTrayIcon.MiddleClick, False): if self.isVisible(): if self.isMinimized(): self.showNormal() else: self.hide_windows() else: self.show_windows() if self.isMinimized(): self.showNormal() @property def is_minimized_to_tray(self): return getattr(self, '__systray_minimized', False) def ask_a_yes_no_question(self, title, msg, det_msg='', show_copy_button=False, ans_when_user_unavailable=True, skip_dialog_name=None, skipped_value=True): if self.is_minimized_to_tray: return ans_when_user_unavailable return question_dialog(self, title, msg, det_msg=det_msg, show_copy_button=show_copy_button, skip_dialog_name=skip_dialog_name, skip_dialog_skipped_value=skipped_value) def update_toggle_to_tray_action(self, *args): if hasattr(self, 'toggle_to_tray_action'): self.toggle_to_tray_action.setText( _('Hide main window') if self.isVisible( ) else _('Show main window')) def hide_windows(self): for window in QApplication.topLevelWidgets(): if isinstance(window, (MainWindow, QDialog)) and \ window.isVisible(): window.hide() setattr(window, '__systray_minimized', True) self.update_toggle_to_tray_action() def show_windows(self, *args): for window in QApplication.topLevelWidgets(): if getattr(window, '__systray_minimized', False): window.show() setattr(window, '__systray_minimized', False) self.update_toggle_to_tray_action() def test_server(self, *args): if self.content_server is not None and \ self.content_server.exception is not None: error_dialog(self, _('Failed to start Content server'), unicode(self.content_server.exception)).exec_() @property def current_db(self): return self.library_view.model().db def refresh_all(self): m = self.library_view.model() m.db.data.refresh(clear_caches=False, do_search=False) self.saved_searches_changed(recount=False) m.resort() m.research() self.tags_view.recount() def another_instance_wants_to_talk(self): try: msg = self.listener.queue.get_nowait() except Empty: return if msg.startswith('launched:'): import json try: argv = json.loads(msg[len('launched:'):]) except ValueError: prints('Failed to decode message from other instance: %r' % msg) if DEBUG: error_dialog( self, 'Invalid message', 'Received an invalid message from other calibre instance.' ' Do you have multiple versions of calibre installed?', det_msg='Invalid msg: %r' % msg, show=True) argv = () if isinstance(argv, (list, tuple)) and len(argv) > 1: files = [ os.path.abspath(p) for p in argv[1:] if not os.path.isdir(p) and os.access(p, os.R_OK) ] if files: self.iactions['Add Books'].add_filesystem_book(files) self.setWindowState(self.windowState() & ~Qt.WindowMinimized | Qt.WindowActive) self.show_windows() self.raise_() self.activateWindow() elif msg.startswith('refreshdb:'): m = self.library_view.model() m.db.new_api.reload_from_db() self.refresh_all() elif msg.startswith('shutdown:'): self.quit(confirm_quit=False) elif msg.startswith('bookedited:'): parts = msg.split(':')[1:] try: book_id, fmt, library_id = parts[:3] book_id = int(book_id) m = self.library_view.model() db = m.db.new_api if m.db.library_id == library_id and db.has_id(book_id): db.format_metadata(book_id, fmt, allow_cache=False, update_db=True) db.update_last_modified((book_id, )) m.refresh_ids((book_id, )) except Exception: import traceback traceback.print_exc() else: print msg def current_view(self): '''Convenience method that returns the currently visible view ''' idx = self.stack.currentIndex() if idx == 0: return self.library_view if idx == 1: return self.memory_view if idx == 2: return self.card_a_view if idx == 3: return self.card_b_view def booklists(self): return self.memory_view.model().db, self.card_a_view.model( ).db, self.card_b_view.model().db def library_moved(self, newloc, copy_structure=False, allow_rebuild=False): if newloc is None: return with self.library_broker: default_prefs = None try: olddb = self.library_view.model().db if copy_structure: default_prefs = olddb.prefs except: olddb = None if copy_structure and olddb is not None and default_prefs is not None: default_prefs[ 'field_metadata'] = olddb.new_api.field_metadata.all_metadata( ) db = self.library_broker.prepare_for_gui_library_change(newloc) if db is None: try: db = LibraryDatabase(newloc, default_prefs=default_prefs) except apsw.Error: if not allow_rebuild: raise import traceback repair = question_dialog( self, _('Corrupted database'), _('The library database at %s appears to be corrupted. Do ' 'you want calibre to try and rebuild it automatically? ' 'The rebuild may not be completely successful.') % force_unicode(newloc, filesystem_encoding), det_msg=traceback.format_exc()) if repair: from calibre.gui2.dialogs.restore_library import repair_library_at if repair_library_at(newloc, parent=self): db = LibraryDatabase(newloc, default_prefs=default_prefs) else: return else: return self.library_path = newloc prefs['library_path'] = self.library_path self.book_on_device(None, reset=True) db.set_book_on_device_func(self.book_on_device) self.library_view.set_database(db) self.tags_view.set_database(db, self.alter_tb) self.library_view.model().set_book_on_device_func( self.book_on_device) self.status_bar.clear_message() self.search.clear() self.saved_search.clear() self.book_details.reset_info() # self.library_view.model().count_changed() db = self.library_view.model().db self.iactions['Choose Library'].count_changed(db.count()) self.set_window_title() self.apply_named_search_restriction( '') # reset restriction to null self.saved_searches_changed( recount=False) # reload the search restrictions combo box if db.prefs['virtual_lib_on_startup']: self.apply_virtual_library(db.prefs['virtual_lib_on_startup']) self.rebuild_vl_tabs() for action in self.iactions.values(): action.library_changed(db) self.library_broker.gui_library_changed(db, olddb) if self.device_connected: self.set_books_in_library(self.booklists(), reset=True) self.refresh_ondevice() self.memory_view.reset() self.card_a_view.reset() self.card_b_view.reset() self.set_current_library_information(current_library_name(), db.library_id, db.field_metadata) self.library_view.set_current_row(0) # Run a garbage collection now so that it does not freeze the # interface later gc.collect() def set_window_title(self): db = self.current_db restrictions = [ x for x in (db.data.get_base_restriction_name(), db.data.get_search_restriction_name()) if x ] restrictions = ' :: '.join(restrictions) font = QFont() if restrictions: restrictions = ' :: ' + restrictions font.setBold(True) font.setItalic(True) self.virtual_library.setFont(font) title = u'{0} - || {1}{2} ||'.format( __appname__, self.iactions['Choose Library'].library_name(), restrictions) self.setWindowTitle(title) def location_selected(self, location): ''' Called when a location icon is clicked (e.g. Library) ''' page = 0 if location == 'library' else 1 if location == 'main' else 2 if location == 'carda' else 3 self.stack.setCurrentIndex(page) self.book_details.reset_info() for x in ('tb', 'cb'): splitter = getattr(self, x + '_splitter') splitter.button.setEnabled(location == 'library') for action in self.iactions.values(): action.location_selected(location) if location == 'library': self.virtual_library_menu.setEnabled(True) self.highlight_only_button.setEnabled(True) self.vl_tabs.setEnabled(True) else: self.virtual_library_menu.setEnabled(False) self.highlight_only_button.setEnabled(False) self.vl_tabs.setEnabled(False) # Reset the view in case something changed while it was invisible self.current_view().reset() self.set_number_of_books_shown() self.update_status_bar() def job_exception(self, job, dialog_title=_('Conversion error'), retry_func=None): if not hasattr(self, '_modeless_dialogs'): self._modeless_dialogs = [] minz = self.is_minimized_to_tray if self.isVisible(): for x in list(self._modeless_dialogs): if not x.isVisible(): self._modeless_dialogs.remove(x) try: if 'calibre.ebooks.DRMError' in job.details: if not minz: from calibre.gui2.dialogs.drm_error import DRMErrorMessage d = DRMErrorMessage( self, _('Cannot convert') + ' ' + job.description.split(':')[-1].partition('(')[-1][:-1]) d.setModal(False) d.show() self._modeless_dialogs.append(d) return if 'calibre.ebooks.oeb.transforms.split.SplitError' in job.details: title = job.description.split(':')[-1].partition('(')[-1][:-1] msg = _('<p><b>Failed to convert: %s') % title msg += '<p>' + _(''' Many older e-book reader devices are incapable of displaying EPUB files that have internal components over a certain size. Therefore, when converting to EPUB, calibre automatically tries to split up the EPUB into smaller sized pieces. For some files that are large undifferentiated blocks of text, this splitting fails. <p>You can <b>work around the problem</b> by either increasing the maximum split size under <i>EPUB output</i> in the conversion dialog, or by turning on Heuristic Processing, also in the conversion dialog. Note that if you make the maximum split size too large, your e-book reader may have trouble with the EPUB. ''') if not minz: d = error_dialog(self, _('Conversion Failed'), msg, det_msg=job.details) d.setModal(False) d.show() self._modeless_dialogs.append(d) return if 'calibre.ebooks.mobi.reader.mobi6.KFXError:' in job.details: if not minz: title = job.description.split(':')[-1].partition( '(')[-1][:-1] msg = _('<p><b>Failed to convert: %s') % title idx = job.details.index( 'calibre.ebooks.mobi.reader.mobi6.KFXError:') msg += '<p>' + re.sub( r'(https:\S+)', r'<a href="\1">{}</a>'.format( _('here')), job.details[idx:].partition(':')[2].strip()) d = error_dialog(self, _('Conversion failed'), msg, det_msg=job.details) d.setModal(False) d.show() self._modeless_dialogs.append(d) return if 'calibre.web.feeds.input.RecipeDisabled' in job.details: if not minz: msg = job.details msg = msg[msg. find('calibre.web.feeds.input.RecipeDisabled:'):] msg = msg.partition(':')[-1] d = error_dialog(self, _('Recipe Disabled'), '<p>%s</p>' % msg) d.setModal(False) d.show() self._modeless_dialogs.append(d) return if 'calibre.ebooks.conversion.ConversionUserFeedBack:' in job.details: if not minz: import json payload = job.details.rpartition( 'calibre.ebooks.conversion.ConversionUserFeedBack:' )[-1] payload = json.loads('{' + payload.partition('{')[-1]) d = { 'info': info_dialog, 'warn': warning_dialog, 'error': error_dialog }.get(payload['level'], error_dialog) d = d(self, payload['title'], '<p>%s</p>' % payload['msg'], det_msg=payload['det_msg']) d.setModal(False) d.show() self._modeless_dialogs.append(d) return except: pass if job.killed: return try: prints(job.details, file=sys.stderr) except: pass if not minz: self.job_error_dialog.show_error(dialog_title, _('<b>Failed</b>') + ': ' + unicode(job.description), det_msg=job.details, retry_func=retry_func) def read_settings(self): geometry = config['main_window_geometry'] if geometry is not None: self.restoreGeometry(geometry) self.read_layout_settings() def write_settings(self): with gprefs: # Only write to gprefs once config.set('main_window_geometry', self.saveGeometry()) dynamic.set('sort_history', self.library_view.model().sort_history) self.save_layout_state() def quit(self, checked=True, restart=False, debug_on_restart=False, confirm_quit=True): if self.shutting_down: return if confirm_quit and not self.confirm_quit(): return try: self.shutdown() except: pass self.restart_after_quit = restart self.debug_on_restart = debug_on_restart QApplication.instance().quit() def donate(self, *args): open_url(QUrl('https://calibre-ebook.com/donate')) def confirm_quit(self): if self.job_manager.has_jobs(): msg = _('There are active jobs. Are you sure you want to quit?') if self.job_manager.has_device_jobs(): msg = '<p>'+__appname__ + \ _(''' is communicating with the device!<br> Quitting may cause corruption on the device.<br> Are you sure you want to quit?''')+'</p>' if not question_dialog(self, _('Active jobs'), msg): return False if self.proceed_question.questions: msg = _( 'There are library updates waiting. Are you sure you want to quit?' ) if not question_dialog(self, _('Library updates waiting'), msg): return False from calibre.db.delete_service import has_jobs if has_jobs(): msg = _('Some deleted books are still being moved to the Recycle ' 'Bin, if you quit now, they will be left behind. Are you ' 'sure you want to quit?') if not question_dialog(self, _('Active jobs'), msg): return False return True def shutdown(self, write_settings=True): self.shutting_down = True self.show_shutdown_message() self.server_change_notification_timer.stop() from calibre.customize.ui import has_library_closed_plugins if has_library_closed_plugins(): self.show_shutdown_message( _('Running database shutdown plugins. This could take a few seconds...' )) self.grid_view.shutdown() db = None try: db = self.library_view.model().db cf = db.clean except: pass else: cf() # Save the current field_metadata for applications like calibre2opds # Goes here, because if cf is valid, db is valid. db.new_api.set_pref('field_metadata', db.field_metadata.all_metadata()) db.commit_dirty_cache() db.prefs.write_serialized(prefs['library_path']) for action in self.iactions.values(): if not action.shutting_down(): return if write_settings: self.write_settings() self.check_messages_timer.stop() if hasattr(self, 'update_checker'): self.update_checker.shutdown() self.listener.close() self.job_manager.server.close() self.job_manager.threaded_server.close() self.device_manager.keep_going = False self.auto_adder.stop() # Do not report any errors that happen after the shutdown # We cannot restore the original excepthook as that causes PyQt to # call abort() on unhandled exceptions import traceback def eh(t, v, tb): try: traceback.print_exception(t, v, tb, file=sys.stderr) except: pass sys.excepthook = eh mb = self.library_view.model().metadata_backup if mb is not None: mb.stop() self.library_view.model().close() try: try: if self.content_server is not None: # If the Content server has any sockets being closed then # this can take quite a long time (minutes). Tell the user that it is # happening. self.show_shutdown_message( _('Shutting down the Content server. This could take a while...' )) s = self.content_server self.content_server = None s.exit() except: pass except KeyboardInterrupt: pass self.hide_windows() if self._spare_pool is not None: self._spare_pool.shutdown() from calibre.db.delete_service import shutdown shutdown() time.sleep(2) self.istores.join() return True def run_wizard(self, *args): if self.confirm_quit(): self.run_wizard_b4_shutdown = True self.restart_after_quit = True try: self.shutdown(write_settings=False) except: pass QApplication.instance().quit() def closeEvent(self, e): if self.shutting_down: return self.write_settings() if self.system_tray_icon is not None and self.system_tray_icon.isVisible( ): if not dynamic['systray_msg'] and not isosx: info_dialog( self, 'calibre', 'calibre ' + _('will keep running in the system tray. To close it, ' 'choose <b>Quit</b> in the context menu of the ' 'system tray.'), show_copy_button=False).exec_() dynamic['systray_msg'] = True self.hide_windows() e.ignore() else: if self.confirm_quit(): try: self.shutdown(write_settings=False) except: import traceback traceback.print_exc() e.accept() else: e.ignore()
class ManageFonts(Dialog): container_changed = pyqtSignal() embed_all_fonts = pyqtSignal() subset_all_fonts = pyqtSignal() def __init__(self, parent=None): Dialog.__init__(self, _('Manage Fonts'), 'manage-fonts', parent=parent) def setup_ui(self): self.setAttribute(Qt.WA_DeleteOnClose, False) self.l = l = QVBoxLayout(self) self.setLayout(l) self.bb.clear() self.bb.addButton(self.bb.Close) self.splitter = s = QSplitter(self) l.addWidget(s), l.addWidget(self.bb) self.fonts_view = fv = QTableView(self) self.model = m = AllFonts(fv) fv.horizontalHeader().setStretchLastSection(True) fv.setModel(m) fv.setSortingEnabled(True) fv.setShowGrid(False) fv.setAlternatingRowColors(True) fv.setSelectionMode(fv.ExtendedSelection) fv.setSelectionBehavior(fv.SelectRows) fv.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder) self.container = c = QWidget() l = c.l = QVBoxLayout(c) c.setLayout(l) s.addWidget(fv), s.addWidget(c) self.cb = b = QPushButton(_('&Change selected fonts')) b.setIcon(QIcon(I('auto_author_sort.png'))) b.clicked.connect(self.change_fonts) l.addWidget(b) self.rb = b = QPushButton(_('&Remove selected fonts')) b.clicked.connect(self.remove_fonts) b.setIcon(QIcon(I('trash.png'))) l.addWidget(b) self.eb = b = QPushButton(_('&Embed all fonts')) b.setIcon(QIcon(I('embed-fonts.png'))) b.clicked.connect(self.embed_fonts) l.addWidget(b) self.sb = b = QPushButton(_('&Subset all fonts')) b.setIcon(QIcon(I('subset-fonts.png'))) b.clicked.connect(self.subset_fonts) l.addWidget(b) self.refresh_button = b = self.bb.addButton(_('&Refresh'), self.bb.ActionRole) b.setToolTip( _('Rescan the book for fonts in case you have made changes')) b.setIcon(QIcon(I('view-refresh.png'))) b.clicked.connect(self.refresh) self.la = la = QLabel('<p>' + _( ''' All the fonts declared in this book are shown to the left, along with whether they are embedded or not. You can remove or replace any selected font and also embed any declared fonts that are not already embedded.''' )) la.setWordWrap(True) l.addWidget(la) l.setAlignment(Qt.AlignTop | Qt.AlignHCenter) def sizeHint(self): return Dialog.sizeHint(self) + QSize(100, 50) def display(self): if not self.isVisible(): self.show() self.raise_() QTimer.singleShot(0, self.model.build) def get_selected_data(self): ans = self.model.data_for_indices( list(self.fonts_view.selectedIndexes())) if not ans: error_dialog( self, _('No fonts selected'), _('No fonts selected, you must first select some fonts in the left panel' ), show=True) return ans def change_fonts(self): fonts = self.get_selected_data() if not fonts: return d = ChangeFontFamily(', '.join(fonts), { f for f, embedded in self.model.font_data.iteritems() if embedded }, self) if d.exec_() != d.Accepted: return changed = False new_family = d.normalized_family for font in fonts: changed |= change_font(current_container(), font, new_family) if changed: self.model.build() self.container_changed.emit() def remove_fonts(self): fonts = self.get_selected_data() if not fonts: return changed = False for font in fonts: changed |= change_font(current_container(), font) if changed: self.model.build() self.container_changed.emit() def embed_fonts(self): self.embed_all_fonts.emit() def subset_fonts(self): self.subset_all_fonts.emit() def refresh(self): self.model.build()
class WebView(RestartingWebEngineView): cfi_changed = pyqtSignal(object) reload_book = pyqtSignal() toggle_toc = pyqtSignal() toggle_bookmarks = pyqtSignal() toggle_inspector = pyqtSignal() toggle_lookup = pyqtSignal() update_current_toc_nodes = pyqtSignal(object, object) toggle_full_screen = pyqtSignal() ask_for_open = pyqtSignal(object) selection_changed = pyqtSignal(object) view_image = pyqtSignal(object) def __init__(self, parent=None): self._host_widget = None self.callback_id_counter = count() self.callback_map = {} self.current_cfi = None RestartingWebEngineView.__init__(self, parent) self.dead_renderer_error_shown = False self.render_process_failed.connect(self.render_process_died) w = QApplication.instance().desktop().availableGeometry(self).width() self._size_hint = QSize(int(w / 3), int(w / 2)) self._page = WebPage(self) self.bridge.bridge_ready.connect(self.on_bridge_ready) self.bridge.set_session_data.connect(self.set_session_data) self.bridge.reload_book.connect(self.reload_book) self.bridge.toggle_toc.connect(self.toggle_toc) self.bridge.toggle_bookmarks.connect(self.toggle_bookmarks) self.bridge.toggle_inspector.connect(self.toggle_inspector) self.bridge.toggle_lookup.connect(self.toggle_lookup) self.bridge.update_current_toc_nodes.connect( self.update_current_toc_nodes) self.bridge.toggle_full_screen.connect(self.toggle_full_screen) self.bridge.ask_for_open.connect(self.ask_for_open) self.bridge.selection_changed.connect(self.selection_changed) self.bridge.view_image.connect(self.view_image) self.bridge.report_cfi.connect(self.call_callback) self.pending_bridge_ready_actions = {} self.setPage(self._page) self.setAcceptDrops(False) self.setUrl(QUrl('{}://{}/'.format(FAKE_PROTOCOL, FAKE_HOST))) self.urlChanged.connect(self.url_changed) if parent is not None: self.inspector = Inspector( parent.inspector_dock.toggleViewAction(), self) parent.inspector_dock.setWidget(self.inspector) def url_changed(self, url): if url.hasFragment(): frag = url.fragment(url.FullyDecoded) if frag and frag.startswith('bookpos='): cfi = frag[len('bookpos='):] if cfi: self.current_cfi = cfi self.cfi_changed.emit(cfi) @property def host_widget(self): ans = self._host_widget if ans is not None and not sip.isdeleted(ans): return ans def change_zoom_by(self, steps=1): # TODO: Add UI for this ss = vprefs['session_data'].get('zoom_step_size') or 20 amt = (ss / 100) * steps self._page.setZoomFactor(self._page.zoomFactor() + amt) def render_process_died(self): if self.dead_renderer_error_shown: return self.dead_renderer_error_shown = True error_dialog(self, _('Render process crashed'), _('The Qt WebEngine Render process has crashed.' ' You should try restarting the viewer.'), show=True) def event(self, event): if event.type() == event.ChildPolished: child = event.child() if 'HostView' in child.metaObject().className(): self._host_widget = child self._host_widget.setFocus(Qt.OtherFocusReason) return QWebEngineView.event(self, event) def sizeHint(self): return self._size_hint def refresh(self): self.pageAction(QWebEnginePage.ReloadAndBypassCache).trigger() @property def bridge(self): return self._page.bridge def on_bridge_ready(self): f = QApplication.instance().font() self.bridge.create_view(vprefs['session_data'], QFontDatabase().families(), field_metadata.all_metadata(), f.family(), f.pointSize()) for func, args in iteritems(self.pending_bridge_ready_actions): getattr(self.bridge, func)(*args) def start_book_load(self, initial_cfi=None, initial_toc_node=None): key = (set_book_path.path, ) self.execute_when_ready('start_book_load', key, initial_cfi, initial_toc_node, set_book_path.pathtoebook) def execute_when_ready(self, action, *args): if self.bridge.ready: getattr(self.bridge, action)(*args) else: self.pending_bridge_ready_actions[action] = args def show_preparing_message(self): msg = _('Preparing book for first read, please wait') + '…' self.execute_when_ready('show_preparing_message', msg) def goto_toc_node(self, node_id): self.execute_when_ready('goto_toc_node', node_id) def goto_cfi(self, cfi): self.execute_when_ready('goto_cfi', cfi) def notify_full_screen_state_change(self, in_fullscreen_mode): self.execute_when_ready('full_screen_state_changed', in_fullscreen_mode) def set_session_data(self, key, val): if key == '*' and val is None: vprefs['session_data'] = {} apply_font_settings(self._page) elif key != '*': sd = vprefs['session_data'] sd[key] = val vprefs['session_data'] = sd if key in ('standalone_font_settings', 'base_font_size'): apply_font_settings(self._page) def do_callback(self, func_name, callback): cid = next(self.callback_id_counter) self.callback_map[cid] = callback self.execute_when_ready('get_current_cfi', cid) def call_callback(self, request_id, data): callback = self.callback_map.pop(request_id, None) if callback is not None: callback(data) def get_current_cfi(self, callback): self.do_callback('get_current_cfi', callback) def show_home_page(self): self.execute_when_ready('show_home_page')
class Preview(QWidget): sync_requested = pyqtSignal(object, object) split_requested = pyqtSignal(object, object, object) split_start_requested = pyqtSignal() link_clicked = pyqtSignal(object, object) refresh_starting = pyqtSignal() refreshed = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent) self.l = l = QVBoxLayout() self.setLayout(l) l.setContentsMargins(0, 0, 0, 0) self.view = WebView(self) self.view.page().sync_requested.connect(self.request_sync) self.view.page().split_requested.connect(self.request_split) self.view.page().loadFinished.connect(self.load_finished) self.inspector = self.view.inspector self.inspector.setPage(self.view.page()) l.addWidget(self.view) self.bar = QToolBar(self) l.addWidget(self.bar) ac = actions['auto-reload-preview'] ac.setCheckable(True) ac.setChecked(True) ac.toggled.connect(self.auto_reload_toggled) self.auto_reload_toggled(ac.isChecked()) self.bar.addAction(ac) ac = actions['sync-preview-to-editor'] ac.setCheckable(True) ac.setChecked(True) ac.toggled.connect(self.sync_toggled) self.sync_toggled(ac.isChecked()) self.bar.addAction(ac) self.bar.addSeparator() ac = actions['split-in-preview'] ac.setCheckable(True) ac.setChecked(False) ac.toggled.connect(self.split_toggled) self.split_toggled(ac.isChecked()) self.bar.addAction(ac) ac = actions['reload-preview'] ac.triggered.connect(self.refresh) self.bar.addAction(ac) actions['preview-dock'].toggled.connect(self.visibility_changed) self.current_name = None self.last_sync_request = None self.refresh_timer = QTimer(self) self.refresh_timer.timeout.connect(self.refresh) parse_worker.start() self.current_sync_request = None self.search = HistoryLineEdit2(self) self.search.initialize('tweak_book_preview_search') self.search.setPlaceholderText(_('Search in preview')) self.search.returnPressed.connect(partial(self.find, 'next')) self.bar.addSeparator() self.bar.addWidget(self.search) for d in ('next', 'prev'): ac = actions['find-%s-preview' % d] ac.triggered.connect(partial(self.find, d)) self.bar.addAction(ac) def find(self, direction): text = unicode(self.search.text()) self.view.findText( text, QWebPage.FindWrapsAroundDocument | (QWebPage.FindBackward if direction == 'prev' else QWebPage.FindFlags(0))) def request_sync(self, tagname, href, lnum): if self.current_name: c = current_container() if tagname == 'a' and href: if href and href.startswith('#'): name = self.current_name else: name = c.href_to_name(href, self.current_name) if href else None if name == self.current_name: return self.view.page().go_to_anchor( urlparse(href).fragment, lnum) if name and c.exists(name) and c.mime_map[name] in OEB_DOCS: return self.link_clicked.emit( name, urlparse(href).fragment or TOP) self.sync_requested.emit(self.current_name, lnum) def request_split(self, loc, totals): if self.current_name: self.split_requested.emit(self.current_name, loc, totals) def sync_to_editor(self, name, sourceline_address): self.current_sync_request = (name, sourceline_address) QTimer.singleShot(100, self._sync_to_editor) def _sync_to_editor(self): if not actions['sync-preview-to-editor'].isChecked(): return try: if self.refresh_timer.isActive( ) or self.current_sync_request[0] != self.current_name: return QTimer.singleShot(100, self._sync_to_editor) except TypeError: return # Happens if current_sync_request is None sourceline_address = self.current_sync_request[1] self.current_sync_request = None self.view.page().go_to_sourceline_address(sourceline_address) def report_worker_launch_error(self): if parse_worker.launch_error is not None: tb, parse_worker.launch_error = parse_worker.launch_error, None error_dialog( self, _('Failed to launch worker'), _('Failed to launch the worker process used for rendering the preview' ), det_msg=tb, show=True) def show(self, name): if name != self.current_name: self.refresh_timer.stop() self.current_name = name self.report_worker_launch_error() parse_worker.add_request(name) self.view.setUrl( QUrl.fromLocalFile(current_container().name_to_abspath(name))) return True def refresh(self): if self.current_name: self.refresh_timer.stop() # This will check if the current html has changed in its editor, # and re-parse it if so self.report_worker_launch_error() parse_worker.add_request(self.current_name) # Tell webkit to reload all html and associated resources current_url = QUrl.fromLocalFile( current_container().name_to_abspath(self.current_name)) self.refresh_starting.emit() if current_url != self.view.url(): # The container was changed self.view.setUrl(current_url) else: self.view.refresh() self.refreshed.emit() def clear(self): self.view.clear() self.current_name = None @property def current_root(self): return self.view.page().current_root @property def is_visible(self): return actions['preview-dock'].isChecked() @property def live_css_is_visible(self): try: return actions['live-css-dock'].isChecked() except KeyError: return False def start_refresh_timer(self): if self.live_css_is_visible or ( self.is_visible and actions['auto-reload-preview'].isChecked()): self.refresh_timer.start(tprefs['preview_refresh_time'] * 1000) def stop_refresh_timer(self): self.refresh_timer.stop() def auto_reload_toggled(self, checked): if self.live_css_is_visible and not actions[ 'auto-reload-preview'].isChecked(): actions['auto-reload-preview'].setChecked(True) error_dialog( self, _('Cannot disable'), _('Auto reloading of the preview panel cannot be disabled while the' ' Live CSS panel is open.'), show=True) actions['auto-reload-preview'].setToolTip( _('Auto reload preview when text changes in editor' ) if not checked else _('Disable auto reload of preview')) def sync_toggled(self, checked): actions['sync-preview-to-editor'].setToolTip( _('Disable syncing of preview position to editor position' ) if checked else _( 'Enable syncing of preview position to editor position')) def visibility_changed(self, is_visible): if is_visible: self.refresh() def split_toggled(self, checked): actions['split-in-preview'].setToolTip( textwrap.fill( _('Abort file split') if checked else _('Split this file at a specified location.\n\nAfter clicking this button, click' ' inside the preview panel above at the location you want the file to be split.' ))) if checked: self.split_start_requested.emit() else: self.view.page().split_mode(False) def do_start_split(self): self.view.page().split_mode(True) def stop_split(self): actions['split-in-preview'].setChecked(False) def load_finished(self, ok): if actions['split-in-preview'].isChecked(): if ok: self.do_start_split() else: self.stop_split() def apply_settings(self): s = self.view.page().settings() s.setFontSize(s.DefaultFontSize, tprefs['preview_base_font_size']) s.setFontSize(s.DefaultFixedFontSize, tprefs['preview_mono_font_size']) s.setFontSize(s.MinimumLogicalFontSize, tprefs['preview_minimum_font_size']) s.setFontSize(s.MinimumFontSize, tprefs['preview_minimum_font_size']) sf, ssf, mf = tprefs['preview_serif_family'], tprefs[ 'preview_sans_family'], tprefs['preview_mono_family'] s.setFontFamily(s.StandardFont, { 'serif': sf, 'sans': ssf, 'mono': mf, None: sf }[tprefs['preview_standard_font_family']]) s.setFontFamily(s.SerifFont, sf) s.setFontFamily(s.SansSerifFont, ssf) s.setFontFamily(s.FixedFont, mf)
class WebPage(QWebPage): sync_requested = pyqtSignal(object, object, object) split_requested = pyqtSignal(object, object) def __init__(self, parent): QWebPage.__init__(self, parent) settings = self.settings() apply_settings(settings, config().parse()) settings.setMaximumPagesInCache(0) settings.setAttribute(settings.JavaEnabled, False) settings.setAttribute(settings.PluginsEnabled, False) settings.setAttribute(settings.PrivateBrowsingEnabled, True) settings.setAttribute(settings.JavascriptCanOpenWindows, False) settings.setAttribute(settings.JavascriptCanAccessClipboard, False) settings.setAttribute(settings.LinksIncludedInFocusChain, False) settings.setAttribute(settings.DeveloperExtrasEnabled, True) settings.setDefaultTextEncoding('utf-8') data = 'data:text/css;charset=utf-8;base64,' css = '[data-in-split-mode="1"] [data-is-block="1"]:hover { cursor: pointer !important; border-top: solid 5px green !important }' data += b64encode(css.encode('utf-8')) settings.setUserStyleSheetUrl(QUrl(data)) self.setNetworkAccessManager(NetworkAccessManager(self)) self.setLinkDelegationPolicy(self.DelegateAllLinks) self.mainFrame().javaScriptWindowObjectCleared.connect( self.init_javascript) self.init_javascript() @dynamic_property def current_root(self): def fget(self): return self.networkAccessManager().current_root def fset(self, val): self.networkAccessManager().current_root = val return property(fget=fget, fset=fset) def javaScriptConsoleMessage(self, msg, lineno, source_id): prints('preview js:%s:%s:' % (unicode(source_id), lineno), unicode(msg)) def init_javascript(self): if not hasattr(self, 'js'): from calibre.utils.resources import compiled_coffeescript self.js = compiled_coffeescript('ebooks.oeb.display.utils', dynamic=False) self.js += P('csscolorparser.js', data=True, allow_user_override=False) self.js += compiled_coffeescript('ebooks.oeb.polish.preview', dynamic=False) self._line_numbers = None mf = self.mainFrame() mf.addToJavaScriptWindowObject("py_bridge", self) mf.evaluateJavaScript(self.js) @pyqtSlot(str, str, str) def request_sync(self, tag_name, href, sourceline_address): try: self.sync_requested.emit(unicode(tag_name), unicode(href), json.loads(unicode(sourceline_address))) except (TypeError, ValueError, OverflowError, AttributeError): pass def go_to_anchor(self, anchor, lnum): self.mainFrame().evaluateJavaScript( 'window.calibre_preview_integration.go_to_anchor(%s, %s)' % (json.dumps(anchor), json.dumps(str(lnum)))) @pyqtSlot(str, str) def request_split(self, loc, totals): actions['split-in-preview'].setChecked(False) loc, totals = json.loads(unicode(loc)), json.loads(unicode(totals)) if not loc or not totals: return error_dialog(self.view(), _('Invalid location'), _('Cannot split on the body tag'), show=True) self.split_requested.emit(loc, totals) @property def line_numbers(self): if self._line_numbers is None: def atoi(x): try: ans = int(x) except (TypeError, ValueError): ans = None return ans val = self.mainFrame().evaluateJavaScript( 'window.calibre_preview_integration.line_numbers()') self._line_numbers = sorted( uniq(filter(lambda x: x is not None, map(atoi, val)))) return self._line_numbers def go_to_line(self, lnum): try: lnum = find_le(self.line_numbers, lnum) except IndexError: return self.mainFrame().evaluateJavaScript( 'window.calibre_preview_integration.go_to_line(%d)' % lnum) def go_to_sourceline_address(self, sourceline_address): lnum, tags = sourceline_address if lnum is None: return tags = [x.lower() for x in tags] self.mainFrame().evaluateJavaScript( 'window.calibre_preview_integration.go_to_sourceline_address(%d, %s)' % (lnum, json.dumps(tags))) def split_mode(self, enabled): self.mainFrame().evaluateJavaScript( 'window.calibre_preview_integration.split_mode(%s)' % ('true' if enabled else 'false'))
class FileList(QTreeWidget): delete_requested = pyqtSignal(object, object) reorder_spine = pyqtSignal(object) rename_requested = pyqtSignal(object, object) bulk_rename_requested = pyqtSignal(object) edit_file = pyqtSignal(object, object, object) merge_requested = pyqtSignal(object, object, object) mark_requested = pyqtSignal(object, object) export_requested = pyqtSignal(object, object) replace_requested = pyqtSignal(object, object, object, object) link_stylesheets_requested = pyqtSignal(object, object, object) initiate_file_copy = pyqtSignal(object) initiate_file_paste = pyqtSignal() def __init__(self, parent=None): QTreeWidget.__init__(self, parent) self.categories = {} self.ordered_selected_indexes = False pi = plugins['progress_indicator'][0] if hasattr(pi, 'set_no_activate_on_click'): pi.set_no_activate_on_click(self) self.current_edited_name = None self.delegate = ItemDelegate(self) self.delegate.rename_requested.connect(self.rename_requested) self.setTextElideMode(Qt.ElideMiddle) self.setItemDelegate(self.delegate) self.setIconSize(QSize(16, 16)) self.header().close() self.setDragEnabled(True) self.setEditTriggers(self.EditKeyPressed) self.setSelectionMode(self.ExtendedSelection) self.viewport().setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(self.InternalMove) self.setAutoScroll(True) self.setAutoScrollMargin(TOP_ICON_SIZE * 2) self.setDefaultDropAction(Qt.MoveAction) self.setAutoExpandDelay(1000) self.setAnimated(True) self.setMouseTracking(True) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) self.root = self.invisibleRootItem() self.emblem_cache = {} self.rendered_emblem_cache = {} self.top_level_pixmap_cache = { name: QIcon(I(icon)).pixmap(TOP_ICON_SIZE, TOP_ICON_SIZE) for name, icon in iteritems({ 'text': 'keyboard-prefs.png', 'styles': 'lookfeel.png', 'fonts': 'font.png', 'misc': 'mimetypes/dir.png', 'images': 'view-image.png', }) } self.itemActivated.connect(self.item_double_clicked) def mimeTypes(self): ans = QTreeWidget.mimeTypes(self) ans.append(CONTAINER_DND_MIMETYPE) return ans def mimeData(self, indices): ans = QTreeWidget.mimeData(self, indices) names = (idx.data(0, NAME_ROLE) for idx in indices if idx.data(0, MIME_ROLE)) ans.setData(CONTAINER_DND_MIMETYPE, '\n'.join(filter(None, names)).encode('utf-8')) return ans @property def current_name(self): ci = self.currentItem() if ci is not None: return unicode_type(ci.data(0, NAME_ROLE) or '') return '' def get_state(self): s = {'pos': self.verticalScrollBar().value()} s['expanded'] = { c for c, item in iteritems(self.categories) if item.isExpanded() } s['selected'] = { unicode_type(i.data(0, NAME_ROLE) or '') for i in self.selectedItems() } return s def set_state(self, state): for category, item in iteritems(self.categories): item.setExpanded(category in state['expanded']) self.verticalScrollBar().setValue(state['pos']) for parent in itervalues(self.categories): for c in (parent.child(i) for i in range(parent.childCount())): name = unicode_type(c.data(0, NAME_ROLE) or '') if name in state['selected']: c.setSelected(True) def item_from_name(self, name): for parent in itervalues(self.categories): for c in (parent.child(i) for i in range(parent.childCount())): q = unicode_type(c.data(0, NAME_ROLE) or '') if q == name: return c def select_name(self, name, set_as_current_index=False): for parent in itervalues(self.categories): for c in (parent.child(i) for i in range(parent.childCount())): q = unicode_type(c.data(0, NAME_ROLE) or '') c.setSelected(q == name) if q == name: self.scrollToItem(c) if set_as_current_index: self.setCurrentItem(c) def select_names(self, names, current_name=None): for parent in itervalues(self.categories): for c in (parent.child(i) for i in range(parent.childCount())): q = unicode_type(c.data(0, NAME_ROLE) or '') c.setSelected(q in names) if q == current_name: self.scrollToItem(c) s = self.selectionModel() s.setCurrentIndex(self.indexFromItem(c), s.NoUpdate) def mark_name_as_current(self, name): current = self.item_from_name(name) if current is not None: if self.current_edited_name is not None: ci = self.item_from_name(self.current_edited_name) if ci is not None: ci.setData(0, Qt.FontRole, None) self.current_edited_name = name self.mark_item_as_current(current) def mark_item_as_current(self, item): font = QFont(self.font()) font.setItalic(True) font.setBold(True) item.setData(0, Qt.FontRole, font) def clear_currently_edited_name(self): if self.current_edited_name: ci = self.item_from_name(self.current_edited_name) if ci is not None: ci.setData(0, Qt.FontRole, None) self.current_edited_name = None def build(self, container, preserve_state=True): if container is None: return if preserve_state: state = self.get_state() self.clear() self.root = self.invisibleRootItem() self.root.setFlags(Qt.ItemIsDragEnabled) self.categories = {} for category, text, __ in CATEGORIES: self.categories[category] = i = QTreeWidgetItem(self.root, 0) i.setText(0, text) i.setData(0, Qt.DecorationRole, self.top_level_pixmap_cache[category]) f = i.font(0) f.setBold(True) i.setFont(0, f) i.setData(0, NAME_ROLE, category) flags = Qt.ItemIsEnabled if category == 'text': flags |= Qt.ItemIsDropEnabled i.setFlags(flags) processed, seen = {}, {} cover_page_name = get_cover_page_name(container) cover_image_name = get_raster_cover_name(container) manifested_names = set() for names in itervalues(container.manifest_type_map): manifested_names |= set(names) def get_category(name, mt): category = 'misc' if mt.startswith('image/'): category = 'images' elif mt in OEB_FONTS: category = 'fonts' elif mt in OEB_STYLES: category = 'styles' elif mt in OEB_DOCS: category = 'text' ext = name.rpartition('.')[-1].lower() if ext in {'ttf', 'otf', 'woff'}: # Probably wrong mimetype in the OPF category = 'fonts' return category def set_display_name(name, item): if tprefs['file_list_shows_full_pathname']: text = name else: if name in processed: # We have an exact duplicate (can happen if there are # duplicates in the spine) item.setText(0, processed[name].text(0)) item.setText(1, processed[name].text(1)) return parts = name.split('/') text = parts.pop() while text in seen and parts: text = parts.pop() + '/' + text seen[text] = item item.setText(0, text) item.setText(1, as_hex_unicode(numeric_sort_key(text))) def render_emblems(item, emblems): emblems = tuple(emblems) if not emblems: return icon = self.rendered_emblem_cache.get(emblems, None) if icon is None: pixmaps = [] for emblem in emblems: pm = self.emblem_cache.get(emblem, None) if pm is None: pm = self.emblem_cache[emblem] = QIcon( I(emblem)).pixmap(self.iconSize()) pixmaps.append(pm) num = len(pixmaps) w, h = pixmaps[0].width(), pixmaps[0].height() if num == 1: icon = self.rendered_emblem_cache[emblems] = QIcon( pixmaps[0]) else: canvas = QPixmap((num * w) + ((num - 1) * 2), h) canvas.setDevicePixelRatio(pixmaps[0].devicePixelRatio()) canvas.fill(Qt.transparent) painter = QPainter(canvas) for i, pm in enumerate(pixmaps): painter.drawPixmap( int(i * (w + 2) / canvas.devicePixelRatio()), 0, pm) painter.end() icon = self.rendered_emblem_cache[emblems] = canvas item.setData(0, Qt.DecorationRole, icon) cannot_be_renamed = container.names_that_must_not_be_changed ncx_mime = guess_type('a.ncx') nav_items = frozenset(container.manifest_items_with_property('nav')) def create_item(name, linear=None): imt = container.mime_map.get(name, guess_type(name)) icat = get_category(name, imt) category = 'text' if linear is not None else ({ 'text': 'misc' }.get(icat, icat)) item = QTreeWidgetItem( self.categories['text' if linear is not None else category], 1) flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable if category == 'text': flags |= Qt.ItemIsDragEnabled if name not in cannot_be_renamed: flags |= Qt.ItemIsEditable item.setFlags(flags) item.setStatusTip(0, _('Full path: ') + name) item.setData(0, NAME_ROLE, name) item.setData(0, CATEGORY_ROLE, category) item.setData(0, LINEAR_ROLE, bool(linear)) item.setData(0, MIME_ROLE, imt) set_display_name(name, item) tooltips = [] emblems = [] if name in {cover_page_name, cover_image_name}: emblems.append('default_cover.png') tooltips.append( _('This file is the cover %s for this book') % (_('image') if name == cover_image_name else _('page'))) if name in container.opf_name: emblems.append('metadata.png') tooltips.append( _('This file contains all the metadata and book structure information' )) if imt == ncx_mime or name in nav_items: emblems.append('toc.png') tooltips.append( _('This file contains the metadata table of contents')) if name not in manifested_names and not container.ok_to_be_unmanifested( name): emblems.append('dialog_question.png') tooltips.append( _('This file is not listed in the book manifest')) if linear is False: emblems.append('arrow-down.png') tooltips.append( _('This file is marked as non-linear in the spine\nDrag it to the top to make it linear' )) if linear is None and icat == 'text': # Text item outside spine emblems.append('dialog_warning.png') tooltips.append( _('This file is a text file that is not referenced in the spine' )) if category == 'text' and name in processed: # Duplicate entry in spine emblems.append('dialog_error.png') tooltips.append( _('This file occurs more than once in the spine')) render_emblems(item, emblems) if tooltips: item.setData(0, Qt.ToolTipRole, '\n'.join(tooltips)) return item for name, linear in container.spine_names: processed[name] = create_item(name, linear=linear) for name in container.name_path_map: if name in processed: continue processed[name] = create_item(name) for name, c in iteritems(self.categories): c.setExpanded(True) if name != 'text': c.sortChildren(1, Qt.AscendingOrder) if preserve_state: self.set_state(state) if self.current_edited_name: item = self.item_from_name(self.current_edited_name) if item is not None: self.mark_item_as_current(item) def show_context_menu(self, point): item = self.itemAt(point) if item is None or item in tuple(itervalues(self.categories)): return m = QMenu(self) sel = self.selectedItems() num = len(sel) container = current_container() ci = self.currentItem() if ci is not None: cn = unicode_type(ci.data(0, NAME_ROLE) or '') mt = unicode_type(ci.data(0, MIME_ROLE) or '') cat = unicode_type(ci.data(0, CATEGORY_ROLE) or '') n = elided_text(cn.rpartition('/')[-1]) m.addAction(QIcon(I('save.png')), _('Export %s') % n, partial(self.export, cn)) if cn not in container.names_that_must_not_be_changed and cn not in container.names_that_must_not_be_removed and mt not in OEB_FONTS: m.addAction( _('Replace %s with file...') % n, partial(self.replace, cn)) if num > 1: m.addAction(QIcon(I('save.png')), _('Export all %d selected files') % num, self.export_selected) m.addSeparator() m.addAction(QIcon(I('modified.png')), _('&Rename %s') % n, self.edit_current_item) if is_raster_image(mt): m.addAction(QIcon(I('default_cover.png')), _('Mark %s as cover image') % n, partial(self.mark_as_cover, cn)) elif current_container( ).SUPPORTS_TITLEPAGES and mt in OEB_DOCS and cat == 'text': m.addAction(QIcon(I('default_cover.png')), _('Mark %s as cover page') % n, partial(self.mark_as_titlepage, cn)) m.addSeparator() if num > 0: m.addSeparator() if num > 1: m.addAction(QIcon(I('modified.png')), _('&Bulk rename the selected files'), self.request_bulk_rename) m.addAction(QIcon(I('modified.png')), _('Change the file extension for the selected files'), self.request_change_ext) m.addAction( QIcon(I('trash.png')), ngettext('&Delete the selected file', '&Delete the {} selected files', num).format(num), self.request_delete) m.addAction( QIcon(I('edit-copy.png')), ngettext( '&Copy the selected file to another editor instance', '&Copy the {} selected files to another editor instance', num).format(num), self.copy_selected_files) m.addSeparator() md = QApplication.instance().clipboard().mimeData() if md.hasUrls() and md.hasFormat(FILE_COPY_MIME): m.addAction(_('Paste files from other editor instance'), self.paste_from_other_instance) selected_map = defaultdict(list) for item in sel: selected_map[unicode_type( item.data(0, CATEGORY_ROLE) or '')].append(unicode_type(item.data(0, NAME_ROLE) or '')) for items in itervalues(selected_map): items.sort(key=self.index_of_name) if selected_map['text']: m.addAction(QIcon(I('format-text-color.png')), _('Link &stylesheets...'), partial(self.link_stylesheets, selected_map['text'])) if len(selected_map['text']) > 1: m.addAction( QIcon(I('merge.png')), _('&Merge selected text files'), partial(self.start_merge, 'text', selected_map['text'])) if len(selected_map['styles']) > 1: m.addAction( QIcon(I('merge.png')), _('&Merge selected style files'), partial(self.start_merge, 'styles', selected_map['styles'])) if len(list(m.actions())) > 0: m.popup(self.mapToGlobal(point)) def index_of_name(self, name): for category, parent in iteritems(self.categories): for i in range(parent.childCount()): item = parent.child(i) if unicode_type(item.data(0, NAME_ROLE) or '') == name: return (category, i) return (None, -1) def start_merge(self, category, names): d = MergeDialog(names, self) if d.exec_() == d.Accepted and d.ans: self.merge_requested.emit(category, names, d.ans) def edit_current_item(self): if not current_container().SUPPORTS_FILENAMES: error_dialog( self, _('Cannot rename'), _('%s books do not support file renaming as they do not use file names' ' internally. The filenames you see are automatically generated from the' ' internal structures of the original file.') % current_container().book_type.upper(), show=True) return if self.currentItem() is not None: self.editItem(self.currentItem()) def mark_as_cover(self, name): self.mark_requested.emit(name, 'cover') def mark_as_titlepage(self, name): first = unicode_type( self.categories['text'].child(0).data(0, NAME_ROLE) or '') == name move_to_start = False if not first: move_to_start = question_dialog( self, _('Not first item'), _('%s is not the first text item. You should only mark the' ' first text item as cover. Do you want to make it the' ' first item?') % elided_text(name)) self.mark_requested.emit(name, 'titlepage:%r' % move_to_start) def keyPressEvent(self, ev): if ev.key() in (Qt.Key_Delete, Qt.Key_Backspace): ev.accept() self.request_delete() else: return QTreeWidget.keyPressEvent(self, ev) def request_rename_common(self): if not current_container().SUPPORTS_FILENAMES: error_dialog( self, _('Cannot rename'), _('%s books do not support file renaming as they do not use file names' ' internally. The filenames you see are automatically generated from the' ' internal structures of the original file.') % current_container().book_type.upper(), show=True) return names = { unicode_type(item.data(0, NAME_ROLE) or '') for item in self.selectedItems() } bad = names & current_container().names_that_must_not_be_changed if bad: error_dialog(self, _('Cannot rename'), _('The file(s) %s cannot be renamed.') % ('<b>%s</b>' % ', '.join(bad)), show=True) return names = sorted(names, key=self.index_of_name) return names def request_bulk_rename(self): names = self.request_rename_common() if names is not None: categories = Counter( unicode_type(item.data(0, CATEGORY_ROLE) or '') for item in self.selectedItems()) settings = get_bulk_rename_settings( self, len(names), category=categories.most_common(1)[0][0], allow_spine_order=True) fmt, num = settings['prefix'], settings['start'] if fmt is not None: def change_name(name, num): parts = name.split('/') base, ext = parts[-1].rpartition('.')[0::2] parts[-1] = (fmt % num) + '.' + ext return '/'.join(parts) if settings['spine_order']: order_map = get_spine_order_for_all_files( current_container()) select_map = {n: i for i, n in enumerate(names)} def key(n): return order_map.get(n, (sys.maxsize, select_map[n])) name_map = { n: change_name(n, num + i) for i, n in enumerate(sorted(names, key=key)) } else: name_map = { n: change_name(n, num + i) for i, n in enumerate(names) } self.bulk_rename_requested.emit(name_map) def request_change_ext(self): names = self.request_rename_common() if names is not None: text, ok = QInputDialog.getText(self, _('Rename files'), _('New file extension:')) if ok and text: ext = text.lstrip('.') def change_name(name): base = posixpath.splitext(name)[0] return base + '.' + ext name_map = {n: change_name(n) for n in names} self.bulk_rename_requested.emit(name_map) @property def selected_names(self): ans = { unicode_type(item.data(0, NAME_ROLE) or '') for item in self.selectedItems() } ans.discard('') return ans def copy_selected_files(self): self.initiate_file_copy.emit(self.selected_names) def paste_from_other_instance(self): self.initiate_file_paste.emit() def request_delete(self): names = self.selected_names bad = names & current_container().names_that_must_not_be_removed if bad: return error_dialog(self, _('Cannot delete'), _('The file(s) %s cannot be deleted.') % ('<b>%s</b>' % ', '.join(bad)), show=True) text = self.categories['text'] children = (text.child(i) for i in range(text.childCount())) spine_removals = [(unicode_type(item.data(0, NAME_ROLE) or ''), item.isSelected()) for item in children] other_removals = { unicode_type(item.data(0, NAME_ROLE) or '') for item in self.selectedItems() if unicode_type(item.data(0, CATEGORY_ROLE) or '') != 'text' } self.delete_requested.emit(spine_removals, other_removals) def delete_done(self, spine_removals, other_removals): removals = [] for i, (name, remove) in enumerate(spine_removals): if remove: removals.append(self.categories['text'].child(i)) for category, parent in iteritems(self.categories): if category != 'text': for i in range(parent.childCount()): child = parent.child(i) if unicode_type(child.data(0, NAME_ROLE) or '') in other_removals: removals.append(child) # The sorting by index is necessary otherwise Qt crashes with recursive # repaint detected message for c in sorted(removals, key=lambda x: x.parent().indexOfChild(x), reverse=True): sip.delete(c) # A bug in the raster paint engine on linux causes a crash if the scrollbar # is at the bottom and the delete happens to cause the scrollbar to # update b = self.verticalScrollBar() if b.value() == b.maximum(): b.setValue(b.minimum()) QTimer.singleShot(0, lambda: b.setValue(b.maximum())) def __enter__(self): self.ordered_selected_indexes = True def __exit__(self, *args): self.ordered_selected_indexes = False def selectedIndexes(self): ans = QTreeWidget.selectedIndexes(self) if self.ordered_selected_indexes: ans = list(sorted(ans, key=lambda idx: idx.row())) return ans def dropEvent(self, event): with self: text = self.categories['text'] pre_drop_order = { text.child(i): i for i in range(text.childCount()) } super(FileList, self).dropEvent(event) current_order = { text.child(i): i for i in range(text.childCount()) } if current_order != pre_drop_order: order = [] for child in (text.child(i) for i in range(text.childCount())): name = unicode_type(child.data(0, NAME_ROLE) or '') linear = bool(child.data(0, LINEAR_ROLE)) order.append([name, linear]) # Ensure that all non-linear items are at the end, any non-linear # items not at the end will be made linear for i, (name, linear) in tuple(enumerate(order)): if not linear and i < len(order) - 1 and order[i + 1][1]: order[i][1] = True self.reorder_spine.emit(order) def item_double_clicked(self, item, column): category = unicode_type(item.data(0, CATEGORY_ROLE) or '') if category: self._request_edit(item) def _request_edit(self, item): category = unicode_type(item.data(0, CATEGORY_ROLE) or '') mime = unicode_type(item.data(0, MIME_ROLE) or '') name = unicode_type(item.data(0, NAME_ROLE) or '') syntax = {'text': 'html', 'styles': 'css'}.get(category, None) self.edit_file.emit(name, syntax, mime) def request_edit(self, name): item = self.item_from_name(name) if item is not None: self._request_edit(item) else: error_dialog(self, _('Cannot edit'), _('No item with the name: %s was found') % name, show=True) def edit_next_file(self, currently_editing=None, backwards=False): category = self.categories['text'] seen_current = False items = (category.child(i) for i in range(category.childCount())) if backwards: items = reversed(tuple(items)) for item in items: name = unicode_type(item.data(0, NAME_ROLE) or '') if seen_current: self._request_edit(item) return True if currently_editing == name: seen_current = True return False @property def all_files(self): return (category.child(i) for category in itervalues(self.categories) for i in range(category.childCount())) @property def searchable_names(self): ans = { 'text': OrderedDict(), 'styles': OrderedDict(), 'selected': OrderedDict(), 'open': OrderedDict() } for item in self.all_files: category = unicode_type(item.data(0, CATEGORY_ROLE) or '') mime = unicode_type(item.data(0, MIME_ROLE) or '') name = unicode_type(item.data(0, NAME_ROLE) or '') ok = category in {'text', 'styles'} if ok: ans[category][name] = syntax_from_mime(name, mime) if not ok: if category == 'misc': ok = mime in { guess_type('a.' + x) for x in ('opf', 'ncx', 'txt', 'xml') } elif category == 'images': ok = mime == guess_type('a.svg') if ok: cats = [] if item.isSelected(): cats.append('selected') if name in editors: cats.append('open') for cat in cats: ans[cat][name] = syntax_from_mime(name, mime) return ans def export(self, name): path = choose_save_file(self, 'tweak_book_export_file', _('Choose location'), filters=[(_('Files'), [name.rpartition('.')[-1].lower()])], all_files=False, initial_filename=name.split('/')[-1]) if path: self.export_requested.emit(name, path) def export_selected(self): names = self.selected_names if not names: return path = choose_dir(self, 'tweak_book_export_selected', _('Choose location')) if path: self.export_requested.emit(names, path) def replace(self, name): c = current_container() mt = c.mime_map[name] oext = name.rpartition('.')[-1].lower() filters = [oext] fname = _('Files') if mt in OEB_DOCS: fname = _('HTML files') filters = 'html htm xhtm xhtml shtml'.split() elif is_raster_image(mt): fname = _('Images') filters = 'jpeg jpg gif png'.split() path = choose_files(self, 'tweak_book_import_file', _('Choose file'), filters=[(fname, filters)], select_only_single_file=True) if not path: return path = path[0] ext = path.rpartition('.')[-1].lower() force_mt = None if mt in OEB_DOCS: force_mt = c.guess_type('a.html') nname = os.path.basename(path) nname, ext = nname.rpartition('.')[0::2] nname = nname + '.' + ext.lower() self.replace_requested.emit(name, path, nname, force_mt) def link_stylesheets(self, names): s = self.categories['styles'] sheets = [ unicode_type(s.child(i).data(0, NAME_ROLE) or '') for i in range(s.childCount()) ] if not sheets: return error_dialog( self, _('No stylesheets'), _('This book currently has no stylesheets. You must first create a stylesheet' ' before linking it.'), show=True) d = QDialog(self) d.l = l = QVBoxLayout(d) d.setLayout(l) d.setWindowTitle(_('Choose stylesheets')) d.la = la = QLabel( _('Choose the stylesheets to link. Drag and drop to re-arrange')) la.setWordWrap(True) l.addWidget(la) d.s = s = QListWidget(d) l.addWidget(s) s.setDragEnabled(True) s.setDropIndicatorShown(True) s.setDragDropMode(self.InternalMove) s.setAutoScroll(True) s.setDefaultDropAction(Qt.MoveAction) for name in sheets: i = QListWidgetItem(name, s) flags = Qt.ItemIsEnabled | Qt.ItemIsUserCheckable | Qt.ItemIsDragEnabled | Qt.ItemIsSelectable i.setFlags(flags) i.setCheckState(Qt.Checked) d.r = r = QCheckBox(_('Remove existing links to stylesheets')) r.setChecked(tprefs['remove_existing_links_when_linking_sheets']) l.addWidget(r) d.bb = bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) bb.accepted.connect(d.accept), bb.rejected.connect(d.reject) l.addWidget(bb) if d.exec_() == d.Accepted: tprefs['remove_existing_links_when_linking_sheets'] = r.isChecked() sheets = [ unicode_type(s.item(il).text()) for il in range(s.count()) if s.item(il).checkState() == Qt.Checked ] if sheets: self.link_stylesheets_requested.emit(names, sheets, r.isChecked())
class ChooseLibraryAction(InterfaceAction): name = 'Choose Library' action_spec = (_('Choose Library'), 'lt.png', _('Choose calibre library to work with'), None) dont_add_to = frozenset(['context-menu-device']) action_add_menu = True action_menu_clone_qaction = _('Switch/create library...') restore_view_state = pyqtSignal(object) def genesis(self): self.count_changed(0) self.action_choose = self.menuless_qaction self.action_exim = ac = QAction(_('Export/Import all calibre data'), self.gui) ac.triggered.connect(self.exim_data) self.stats = LibraryUsageStats() self.popup_type = (QToolButton.InstantPopup if len(self.stats.stats) > 1 else QToolButton.MenuButtonPopup) if len(self.stats.stats) > 1: self.action_choose.triggered.connect(self.choose_library) else: self.qaction.triggered.connect(self.choose_library) self.choose_menu = self.qaction.menu() ac = self.create_action(spec=(_('Pick a random book'), 'random.png', None, None), attr='action_pick_random') ac.triggered.connect(self.pick_random) if not os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None): self.choose_menu.addAction(self.action_choose) self.quick_menu = QMenu(_('Quick switch')) self.quick_menu_action = self.choose_menu.addMenu(self.quick_menu) self.rename_menu = QMenu(_('Rename library')) self.rename_menu_action = self.choose_menu.addMenu(self.rename_menu) self.choose_menu.addAction(ac) self.delete_menu = QMenu(_('Remove library')) self.delete_menu_action = self.choose_menu.addMenu(self.delete_menu) self.choose_menu.addAction(self.action_exim) else: self.choose_menu.addAction(ac) self.rename_separator = self.choose_menu.addSeparator() self.switch_actions = [] for i in range(5): ac = self.create_action(spec=('', None, None, None), attr='switch_action%d'%i) self.switch_actions.append(ac) ac.setVisible(False) ac.triggered.connect(partial(self.qs_requested, i), type=Qt.QueuedConnection) self.choose_menu.addAction(ac) self.rename_separator = self.choose_menu.addSeparator() self.maintenance_menu = QMenu(_('Library maintenance')) ac = self.create_action(spec=(_('Library metadata backup status'), 'lt.png', None, None), attr='action_backup_status') ac.triggered.connect(self.backup_status, type=Qt.QueuedConnection) self.maintenance_menu.addAction(ac) ac = self.create_action(spec=(_('Check library'), 'lt.png', None, None), attr='action_check_library') ac.triggered.connect(self.check_library, type=Qt.QueuedConnection) self.maintenance_menu.addAction(ac) ac = self.create_action(spec=(_('Restore database'), 'lt.png', None, None), attr='action_restore_database') ac.triggered.connect(self.restore_database, type=Qt.QueuedConnection) self.maintenance_menu.addAction(ac) self.choose_menu.addMenu(self.maintenance_menu) self.view_state_map = {} self.restore_view_state.connect(self._restore_view_state, type=Qt.QueuedConnection) @property def preserve_state_on_switch(self): ans = getattr(self, '_preserve_state_on_switch', None) if ans is None: self._preserve_state_on_switch = ans = \ self.gui.library_view.preserve_state(require_selected_ids=False) return ans def pick_random(self, *args): self.gui.iactions['Pick Random Book'].pick_random() def exim_data(self): if isportable: return error_dialog(self.gui, _('Cannot export/import'), _( 'You are running calibre portable, all calibre data is already in the' ' calibre portable folder. Export/import is unavailable.'), show=True) if self.gui.job_manager.has_jobs(): return error_dialog(self.gui, _('Cannot export/import'), _('Cannot export/import data while there are running jobs.'), show=True) from calibre.gui2.dialogs.exim import EximDialog d = EximDialog(parent=self.gui) if d.exec_() == d.Accepted: if d.restart_needed: self.gui.iactions['Restart'].restart() def library_name(self): db = self.gui.library_view.model().db path = db.library_path if isbytestring(path): path = path.decode(filesystem_encoding) path = path.replace(os.sep, '/') return self.stats.pretty(path) def update_tooltip(self, count): tooltip = self.action_spec[2] + '\n\n' + ngettext('{0} [{1} book]', '{0} [{1} books]', count).format( getattr(self, 'last_lname', ''), count) a = self.qaction a.setToolTip(tooltip) a.setStatusTip(tooltip) a.setWhatsThis(tooltip) def library_changed(self, db): lname = self.stats.library_used(db) self.last_lname = lname if len(lname) > 16: lname = lname[:16] + u'…' a = self.qaction a.setText(lname.replace('&', '&&&')) # I have no idea why this requires a triple ampersand self.update_tooltip(db.count()) self.build_menus() state = self.view_state_map.get(self.stats.canonicalize_path( db.library_path), None) if state is not None: self.restore_view_state.emit(state) def _restore_view_state(self, state): self.preserve_state_on_switch.state = state def initialization_complete(self): self.library_changed(self.gui.library_view.model().db) def build_menus(self): if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None): return db = self.gui.library_view.model().db locations = list(self.stats.locations(db)) for ac in self.switch_actions: ac.setVisible(False) self.quick_menu.clear() self.rename_menu.clear() self.delete_menu.clear() quick_actions, rename_actions, delete_actions = [], [], [] for name, loc in locations: name = name.replace('&', '&&') ac = self.quick_menu.addAction(name, Dispatcher(partial(self.switch_requested, loc))) ac.setStatusTip(_('Switch to: %s') % loc) quick_actions.append(ac) ac = self.rename_menu.addAction(name, Dispatcher(partial(self.rename_requested, name, loc))) rename_actions.append(ac) ac.setStatusTip(_('Rename: %s') % loc) ac = self.delete_menu.addAction(name, Dispatcher(partial(self.delete_requested, name, loc))) delete_actions.append(ac) ac.setStatusTip(_('Remove: %s') % loc) qs_actions = [] locations_by_frequency = locations if len(locations) >= tweaks['many_libraries']: locations_by_frequency = list(self.stats.locations(db, limit=sys.maxsize)) for i, x in enumerate(locations_by_frequency[:len(self.switch_actions)]): name, loc = x name = name.replace('&', '&&') ac = self.switch_actions[i] ac.setText(name) ac.setStatusTip(_('Switch to: %s') % loc) ac.setVisible(True) qs_actions.append(ac) self.qs_locations = [i[1] for i in locations_by_frequency] self.quick_menu_action.setVisible(bool(locations)) self.rename_menu_action.setVisible(bool(locations)) self.delete_menu_action.setVisible(bool(locations)) self.gui.location_manager.set_switch_actions(quick_actions, rename_actions, delete_actions, qs_actions, self.action_choose) # Allow the cloned actions in the OS X global menubar to update for a in (self.qaction, self.menuless_qaction): a.changed.emit() def location_selected(self, loc): enabled = loc == 'library' self.qaction.setEnabled(enabled) def rename_requested(self, name, location): LibraryDatabase = db_class() loc = location.replace('/', os.sep) base = os.path.dirname(loc) old_name = name.replace('&&', '&') newname, ok = QInputDialog.getText(self.gui, _('Rename') + ' ' + old_name, '<p>'+_('Choose a new name for the library <b>%s</b>. ')%name + '<p>'+_('Note that the actual library folder will be renamed.'), text=old_name) newname = sanitize_file_name_unicode(unicode(newname)) if not ok or not newname or newname == old_name: return newloc = os.path.join(base, newname) if os.path.exists(newloc): return error_dialog(self.gui, _('Already exists'), _('The folder %s already exists. Delete it first.') % newloc, show=True) if (iswindows and len(newloc) > LibraryDatabase.WINDOWS_LIBRARY_PATH_LIMIT): return error_dialog(self.gui, _('Too long'), _('Path to library too long. Must be less than' ' %d characters.')%LibraryDatabase.WINDOWS_LIBRARY_PATH_LIMIT, show=True) if not os.path.exists(loc): error_dialog(self.gui, _('Not found'), _('Cannot rename as no library was found at %s. ' 'Try switching to this library first, then switch back ' 'and retry the renaming.')%loc, show=True) return try: os.rename(loc, newloc) except: import traceback det_msg = 'Location: %r New Location: %r\n%s'%(loc, newloc, traceback.format_exc()) error_dialog(self.gui, _('Rename failed'), _('Failed to rename the library at %s. ' 'The most common cause for this is if one of the files' ' in the library is open in another program.') % loc, det_msg=det_msg, show=True) return self.stats.rename(location, newloc) self.build_menus() self.gui.iactions['Copy To Library'].build_menus() def delete_requested(self, name, location): loc = location.replace('/', os.sep) if not question_dialog( self.gui, _('Library removed'), _( 'The library %s has been removed from calibre. ' 'The files remain on your computer, if you want ' 'to delete them, you will have to do so manually.') % ('<code>%s</code>' % loc), override_icon='dialog_information.png', yes_text=_('&OK'), no_text=_('&Undo'), yes_icon='ok.png', no_icon='edit-undo.png'): return self.stats.remove(location) self.build_menus() self.gui.iactions['Copy To Library'].build_menus() if os.path.exists(loc): open_local_file(loc) def backup_status(self, location): self.__backup_status_dialog = d = BackupStatus(self.gui) d.show() def mark_dirty(self): db = self.gui.library_view.model().db db.dirtied(list(db.data.iterallids())) info_dialog(self.gui, _('Backup metadata'), _('Metadata will be backed up while calibre is running, at the ' 'rate of approximately 1 book every three seconds.'), show=True) def restore_database(self): LibraryDatabase = db_class() m = self.gui.library_view.model() db = m.db if (iswindows and len(db.library_path) > LibraryDatabase.WINDOWS_LIBRARY_PATH_LIMIT): return error_dialog(self.gui, _('Too long'), _('Path to library too long. Must be less than' ' %d characters. Move your library to a location with' ' a shorter path using Windows Explorer, then point' ' calibre to the new location and try again.')% LibraryDatabase.WINDOWS_LIBRARY_PATH_LIMIT, show=True) from calibre.gui2.dialogs.restore_library import restore_database m = self.gui.library_view.model() m.stop_metadata_backup() db = m.db db.prefs.disable_setting = True if restore_database(db, self.gui): self.gui.library_moved(db.library_path, call_close=False) def check_library(self): from calibre.gui2.dialogs.check_library import CheckLibraryDialog, DBCheck self.gui.library_view.save_state() m = self.gui.library_view.model() m.stop_metadata_backup() db = m.db db.prefs.disable_setting = True library_path = db.library_path d = DBCheck(self.gui, db) d.start() try: m.close() except: pass d.break_cycles() self.gui.library_moved(library_path, call_close=False) if d.rejected: return if d.error is None: if not question_dialog(self.gui, _('Success'), _('Found no errors in your calibre library database.' ' Do you want calibre to check if the files in your ' ' library match the information in the database?')): return else: return error_dialog(self.gui, _('Failed'), _('Database integrity check failed, click Show details' ' for details.'), show=True, det_msg=d.error[1]) self.gui.status_bar.show_message( _('Starting library scan, this may take a while')) try: QCoreApplication.processEvents() d = CheckLibraryDialog(self.gui, m.db) if not d.do_exec(): info_dialog(self.gui, _('No problems found'), _('The files in your library match the information ' 'in the database.'), show=True) finally: self.gui.status_bar.clear_message() def look_for_portable_lib(self, db, location): base = get_portable_base() if base is None: return False, None loc = location.replace('/', os.sep) candidate = os.path.join(base, os.path.basename(loc)) if db.exists_at(candidate): newloc = candidate.replace(os.sep, '/') self.stats.rename(location, newloc) return True, newloc return False, None def switch_requested(self, location): if not self.change_library_allowed(): return db = self.gui.library_view.model().db current_lib = self.stats.canonicalize_path(db.library_path) self.view_state_map[current_lib] = self.preserve_state_on_switch.state loc = location.replace('/', os.sep) exists = db.exists_at(loc) if not exists: exists, new_location = self.look_for_portable_lib(db, location) if exists: location = new_location loc = location.replace('/', os.sep) if not exists: d = MovedDialog(self.stats, location, self.gui) ret = d.exec_() self.build_menus() self.gui.iactions['Copy To Library'].build_menus() if ret == d.Accepted: loc = d.newloc.replace('/', os.sep) else: return # from calibre.utils.mem import memory # import weakref # from PyQt5.Qt import QTimer # self.dbref = weakref.ref(self.gui.library_view.model().db) # self.before_mem = memory() self.gui.library_moved(loc, allow_rebuild=True) # QTimer.singleShot(5000, self.debug_leak) def debug_leak(self): import gc from calibre.utils.mem import memory ref = self.dbref for i in xrange(3): gc.collect() if ref() is not None: print 'DB object alive:', ref() for r in gc.get_referrers(ref())[:10]: print r print print 'before:', self.before_mem print 'after:', memory() print self.dbref = self.before_mem = None def qs_requested(self, idx, *args): self.switch_requested(self.qs_locations[idx]) def count_changed(self, new_count): self.update_tooltip(new_count) def choose_library(self, *args): if not self.change_library_allowed(): return from calibre.gui2.dialogs.choose_library import ChooseLibrary self.gui.library_view.save_state() db = self.gui.library_view.model().db location = self.stats.canonicalize_path(db.library_path) self.pre_choose_dialog_location = location c = ChooseLibrary(db, self.choose_library_callback, self.gui) c.exec_() def choose_library_callback(self, newloc, copy_structure=False, library_renamed=False): self.gui.library_moved(newloc, copy_structure=copy_structure, allow_rebuild=True) if library_renamed: self.stats.rename(self.pre_choose_dialog_location, prefs['library_path']) self.build_menus() self.gui.iactions['Copy To Library'].build_menus() def change_library_allowed(self): if os.environ.get('CALIBRE_OVERRIDE_DATABASE_PATH', None): warning_dialog(self.gui, _('Not allowed'), _('You cannot change libraries while using the environment' ' variable CALIBRE_OVERRIDE_DATABASE_PATH.'), show=True) return False if self.gui.job_manager.has_jobs(): warning_dialog(self.gui, _('Not allowed'), _('You cannot change libraries while jobs' ' are running.'), show=True) return False if self.gui.proceed_question.questions: warning_dialog(self.gui, _('Not allowed'), _('You cannot change libraries until all' ' updates are accepted or rejected.'), show=True) return False return True
class Editor(QWidget): # {{{ toolbar_prefs_name = None data_changed = pyqtSignal() def __init__(self, parent=None, one_line_toolbar=False, toolbar_prefs_name=None): QWidget.__init__(self, parent) self.toolbar_prefs_name = toolbar_prefs_name or self.toolbar_prefs_name self.toolbar1 = QToolBar(self) self.toolbar2 = QToolBar(self) self.toolbar3 = QToolBar(self) for i in range(1, 4): t = getattr(self, 'toolbar%d' % i) t.setIconSize(QSize(18, 18)) self.editor = EditorWidget(self) self.editor.data_changed.connect(self.data_changed) self.set_base_url = self.editor.set_base_url self.set_html = self.editor.set_html self.tabs = QTabWidget(self) self.tabs.setTabPosition(self.tabs.South) self.wyswyg = QWidget(self.tabs) self.code_edit = QPlainTextEdit(self.tabs) self.source_dirty = False self.wyswyg_dirty = True self._layout = QVBoxLayout(self) self.wyswyg.layout = l = QVBoxLayout(self.wyswyg) self.setLayout(self._layout) l.setContentsMargins(0, 0, 0, 0) if one_line_toolbar: tb = QHBoxLayout() l.addLayout(tb) else: tb = l tb.addWidget(self.toolbar1) tb.addWidget(self.toolbar2) tb.addWidget(self.toolbar3) l.addWidget(self.editor) self._layout.addWidget(self.tabs) self.tabs.addTab(self.wyswyg, _('N&ormal view')) self.tabs.addTab(self.code_edit, _('&HTML source')) self.tabs.currentChanged[int].connect(self.change_tab) self.highlighter = Highlighter(self.code_edit.document()) self.layout().setContentsMargins(0, 0, 0, 0) if self.toolbar_prefs_name is not None: hidden = gprefs.get(self.toolbar_prefs_name) if hidden: self.hide_toolbars() # toolbar1 {{{ self.toolbar1.addAction(self.editor.action_undo) self.toolbar1.addAction(self.editor.action_redo) self.toolbar1.addAction(self.editor.action_select_all) self.toolbar1.addAction(self.editor.action_remove_format) self.toolbar1.addAction(self.editor.action_clear) self.toolbar1.addSeparator() for x in ('copy', 'cut', 'paste'): ac = getattr(self.editor, 'action_' + x) self.toolbar1.addAction(ac) self.toolbar1.addSeparator() self.toolbar1.addAction(self.editor.action_background) # }}} # toolbar2 {{{ for x in ('', 'un'): ac = getattr(self.editor, 'action_%sordered_list' % x) self.toolbar2.addAction(ac) self.toolbar2.addSeparator() for x in ('superscript', 'subscript', 'indent', 'outdent'): self.toolbar2.addAction(getattr(self.editor, 'action_' + x)) if x in ('subscript', 'outdent'): self.toolbar2.addSeparator() self.toolbar2.addAction(self.editor.action_block_style) w = self.toolbar2.widgetForAction(self.editor.action_block_style) if hasattr(w, 'setPopupMode'): w.setPopupMode(w.InstantPopup) self.toolbar2.addAction(self.editor.action_insert_link) self.toolbar2.addAction(self.editor.action_insert_hr) # }}} # toolbar3 {{{ for x in ('bold', 'italic', 'underline', 'strikethrough'): ac = getattr(self.editor, 'action_' + x) self.toolbar3.addAction(ac) self.toolbar3.addSeparator() for x in ('left', 'center', 'right', 'justified'): ac = getattr(self.editor, 'action_align_' + x) self.toolbar3.addAction(ac) self.toolbar3.addSeparator() self.toolbar3.addAction(self.editor.action_color) # }}} self.code_edit.textChanged.connect(self.code_dirtied) self.editor.page().contentsChanged.connect(self.wyswyg_dirtied) def set_minimum_height_for_editor(self, val): self.editor.setMinimumHeight(val) @property def html(self): self.tabs.setCurrentIndex(0) return self.editor.html @html.setter def html(self, v): self.editor.html = v def change_tab(self, index): # print 'reloading:', (index and self.wyswyg_dirty) or (not index and # self.source_dirty) if index == 1: # changing to code view if self.wyswyg_dirty: self.code_edit.setPlainText(self.editor.html) self.wyswyg_dirty = False elif index == 0: # changing to wyswyg if self.source_dirty: self.editor.html = unicode_type(self.code_edit.toPlainText()) self.source_dirty = False @property def tab(self): return 'code' if self.tabs.currentWidget( ) is self.code_edit else 'wyswyg' @tab.setter def tab(self, val): self.tabs.setCurrentWidget(self.code_edit if val == 'code' else self.wyswyg) def wyswyg_dirtied(self, *args): self.wyswyg_dirty = True def code_dirtied(self, *args): self.source_dirty = True def hide_toolbars(self): self.toolbar1.setVisible(False) self.toolbar2.setVisible(False) self.toolbar3.setVisible(False) def show_toolbars(self): self.toolbar1.setVisible(True) self.toolbar2.setVisible(True) self.toolbar3.setVisible(True) def toggle_toolbars(self): visible = self.toolbars_visible getattr(self, ('hide' if visible else 'show') + '_toolbars')() if self.toolbar_prefs_name is not None: gprefs.set(self.toolbar_prefs_name, visible) @property def toolbars_visible(self): return self.toolbar1.isVisible() or self.toolbar2.isVisible( ) or self.toolbar3.isVisible() @toolbars_visible.setter def toolbars_visible(self, val): getattr(self, ('show' if val else 'hide') + '_toolbars')() def set_readonly(self, what): self.editor.set_readonly(what) def hide_tabs(self): self.tabs.tabBar().setVisible(False)
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 self.container.mime_map.iteritems(): 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.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') p.stateChanged.connect(lambda s: tprefs.set( 'add_cover_preserve_aspect_ratio', s == Qt.Checked)) 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'), self.bb.ActionRole) b.clicked.connect(self.import_image) b.setIcon(QIcon(I('document_open.png'))) self.names.setFocus(Qt.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_() == d.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_() == d.Accepted: pass
class EditorWidget(QWebView, LineEditECM): # {{{ data_changed = pyqtSignal() def __init__(self, parent=None): QWebView.__init__(self, parent) self.base_url = None self._parent = weakref.ref(parent) self.readonly = False self.comments_pat = re.compile(r'<!--.*?-->', re.DOTALL) extra_shortcuts = { 'ToggleBold': 'Bold', 'ToggleItalic': 'Italic', 'ToggleUnderline': 'Underline', } for wac, name, icon, text, checkable in [ ('ToggleBold', 'bold', 'format-text-bold', _('Bold'), True), ('ToggleItalic', 'italic', 'format-text-italic', _('Italic'), True), ('ToggleUnderline', 'underline', 'format-text-underline', _('Underline'), True), ('ToggleStrikethrough', 'strikethrough', 'format-text-strikethrough', _('Strikethrough'), True), ('ToggleSuperscript', 'superscript', 'format-text-superscript', _('Superscript'), True), ('ToggleSubscript', 'subscript', 'format-text-subscript', _('Subscript'), True), ('InsertOrderedList', 'ordered_list', 'format-list-ordered', _('Ordered list'), True), ('InsertUnorderedList', 'unordered_list', 'format-list-unordered', _('Unordered list'), True), ('AlignLeft', 'align_left', 'format-justify-left', _('Align left'), False), ('AlignCenter', 'align_center', 'format-justify-center', _('Align center'), False), ('AlignRight', 'align_right', 'format-justify-right', _('Align right'), False), ('AlignJustified', 'align_justified', 'format-justify-fill', _('Align justified'), False), ('Undo', 'undo', 'edit-undo', _('Undo'), False), ('Redo', 'redo', 'edit-redo', _('Redo'), False), ('RemoveFormat', 'remove_format', 'edit-clear', _('Remove formatting'), False), ('Copy', 'copy', 'edit-copy', _('Copy'), False), ('Paste', 'paste', 'edit-paste', _('Paste'), False), ('Cut', 'cut', 'edit-cut', _('Cut'), False), ('Indent', 'indent', 'format-indent-more', _('Increase indentation'), False), ('Outdent', 'outdent', 'format-indent-less', _('Decrease indentation'), False), ('SelectAll', 'select_all', 'edit-select-all', _('Select all'), False), ]: ac = PageAction(wac, icon, text, checkable, self) setattr(self, 'action_' + name, ac) ss = extra_shortcuts.get(wac, None) if ss: ac.setShortcut(QKeySequence(getattr(QKeySequence, ss))) if wac == 'RemoveFormat': ac.triggered.connect(self.remove_format_cleanup, type=Qt.QueuedConnection) self.action_color = QAction(QIcon(I('format-text-color.png')), _('Foreground color'), self) self.action_color.triggered.connect(self.foreground_color) self.action_background = QAction(QIcon(I('format-fill-color.png')), _('Background color'), self) self.action_background.triggered.connect(self.background_color) self.action_block_style = QAction(QIcon(I('format-text-heading.png')), _('Style text block'), self) self.action_block_style.setToolTip(_('Style the selected text block')) self.block_style_menu = QMenu(self) self.action_block_style.setMenu(self.block_style_menu) self.block_style_actions = [] for text, name in [ (_('Normal'), 'p'), (_('Heading') + ' 1', 'h1'), (_('Heading') + ' 2', 'h2'), (_('Heading') + ' 3', 'h3'), (_('Heading') + ' 4', 'h4'), (_('Heading') + ' 5', 'h5'), (_('Heading') + ' 6', 'h6'), (_('Pre-formatted'), 'pre'), (_('Blockquote'), 'blockquote'), (_('Address'), 'address'), ]: ac = BlockStyleAction(text, name, self) self.block_style_menu.addAction(ac) self.block_style_actions.append(ac) self.action_insert_link = QAction(QIcon(I('insert-link.png')), _('Insert link or image'), self) self.action_insert_hr = QAction(QIcon(I('format-text-hr.png')), _('Insert separator'), self) self.action_insert_link.triggered.connect(self.insert_link) self.action_insert_hr.triggered.connect(self.insert_hr) self.pageAction(QWebPage.ToggleBold).changed.connect( self.update_link_action) self.action_insert_link.setEnabled(False) self.action_insert_hr.setEnabled(False) self.action_clear = QAction(QIcon(I('trash.png')), _('Clear'), self) self.action_clear.triggered.connect(self.clear_text) self.page().setLinkDelegationPolicy(QWebPage.DelegateAllLinks) self.page().linkClicked.connect(self.link_clicked) secure_web_page(self.page().settings()) self.setHtml('') self.set_readonly(False) self.page().contentsChanged.connect(self.data_changed) def update_link_action(self): wac = self.pageAction(QWebPage.ToggleBold).isEnabled() self.action_insert_link.setEnabled(wac) self.action_insert_hr.setEnabled(wac) def set_readonly(self, what): self.readonly = what self.page().setContentEditable(not self.readonly) def clear_text(self, *args): us = self.page().undoStack() us.beginMacro('clear all text') self.action_select_all.trigger() self.action_remove_format.trigger() self.exec_command('delete') us.endMacro() self.set_font_style() self.setFocus(Qt.OtherFocusReason) def link_clicked(self, url): open_url(url) def foreground_color(self): col = QColorDialog.getColor(Qt.black, self, _('Choose foreground color'), QColorDialog.ShowAlphaChannel) if col.isValid(): self.exec_command('foreColor', unicode_type(col.name())) def background_color(self): col = QColorDialog.getColor(Qt.white, self, _('Choose background color'), QColorDialog.ShowAlphaChannel) if col.isValid(): self.exec_command('hiliteColor', unicode_type(col.name())) def insert_hr(self, *args): self.exec_command('insertHTML', '<hr>') def insert_link(self, *args): link, name, is_image = self.ask_link() if not link: return url = self.parse_link(link) if url.isValid(): url = unicode_type(url.toString(NO_URL_FORMATTING)) self.setFocus(Qt.OtherFocusReason) if is_image: self.exec_command( 'insertHTML', '<img src="%s" alt="%s"></img>' % (prepare_string_for_xml(url, True), prepare_string_for_xml(name or _('Image'), True))) elif name: self.exec_command( 'insertHTML', '<a href="%s">%s</a>' % (prepare_string_for_xml( url, True), prepare_string_for_xml(name))) else: self.exec_command('createLink', url) else: error_dialog(self, _('Invalid URL'), _('The url %r is invalid') % link, show=True) def ask_link(self): d = QDialog(self) d.setWindowTitle(_('Create link')) l = QFormLayout() l.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) d.setLayout(l) d.url = QLineEdit(d) d.name = QLineEdit(d) d.treat_as_image = QCheckBox(d) d.setMinimumWidth(600) d.bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) d.br = b = QPushButton(_('&Browse')) b.setIcon(QIcon(I('document_open.png'))) def cf(): files = choose_files(d, 'select link file', _('Choose file'), select_only_single_file=True) if files: path = files[0] d.url.setText(path) if path and os.path.exists(path): with lopen(path, 'rb') as f: q = what(f) is_image = q in {'jpeg', 'png', 'gif'} d.treat_as_image.setChecked(is_image) b.clicked.connect(cf) d.la = la = QLabel( _('Enter a URL. If you check the "Treat the URL as an image" box ' 'then the URL will be added as an image reference instead of as ' 'a link. You can also choose to create a link to a file on ' 'your computer. ' 'Note that if you create a link to a file on your computer, it ' 'will stop working if the file is moved.')) la.setWordWrap(True) la.setStyleSheet('QLabel { margin-bottom: 1.5ex }') l.setWidget(0, l.SpanningRole, la) l.addRow(_('Enter &URL:'), d.url) l.addRow(_('Treat the URL as an &image'), d.treat_as_image) l.addRow(_('Enter &name (optional):'), d.name) l.addRow(_('Choose a file on your computer:'), d.br) l.addRow(d.bb) d.bb.accepted.connect(d.accept) d.bb.rejected.connect(d.reject) d.resize(d.sizeHint()) link, name, is_image = None, None, False if d.exec_() == d.Accepted: link, name = unicode_type(d.url.text()).strip(), unicode_type( d.name.text()).strip() is_image = d.treat_as_image.isChecked() return link, name, is_image def parse_link(self, link): link = link.strip() if link and os.path.exists(link): return QUrl.fromLocalFile(link) has_schema = re.match(r'^[a-zA-Z]+:', link) if has_schema is not None: url = QUrl(link, QUrl.TolerantMode) if url.isValid(): return url if os.path.exists(link): return QUrl.fromLocalFile(link) if has_schema is None: first, _, rest = link.partition('.') prefix = 'http' if first == 'ftp': prefix = 'ftp' url = QUrl(prefix + '://' + link, QUrl.TolerantMode) if url.isValid(): return url return QUrl(link, QUrl.TolerantMode) def sizeHint(self): return QSize(150, 150) def exec_command(self, cmd, arg=None): frame = self.page().mainFrame() if arg is not None: js = 'document.execCommand("%s", false, %s);' % ( cmd, json.dumps(unicode_type(arg))) else: js = 'document.execCommand("%s", false, null);' % cmd frame.evaluateJavaScript(js) def remove_format_cleanup(self): self.html = self.html @property def html(self): ans = '' try: if not self.page().mainFrame().documentElement().findFirst( 'meta[name="calibre-dont-sanitize"]').isNull(): # Bypass cleanup if special meta tag exists return unicode_type(self.page().mainFrame().toHtml()) check = unicode_type(self.page().mainFrame().toPlainText()).strip() raw = unicode_type(self.page().mainFrame().toHtml()) raw = xml_to_unicode(raw, strip_encoding_pats=True, resolve_entities=True)[0] raw = self.comments_pat.sub('', raw) if not check and '<img' not in raw.lower(): return ans try: root = html.fromstring(raw) except Exception: root = parse(raw, maybe_xhtml=False, sanitize_names=True) elems = [] for body in root.xpath('//body'): if body.text: elems.append(body.text) elems += [ html.tostring(x, encoding='unicode') for x in body if x.tag not in ('script', 'style') ] if len(elems) > 1: ans = '<div>%s</div>' % (''.join(elems)) else: ans = ''.join(elems) if not ans.startswith('<'): ans = '<p>%s</p>' % ans ans = xml_replace_entities(ans) except: import traceback traceback.print_exc() return ans @html.setter def html(self, val): if self.base_url is None: self.setHtml(val) else: self.setHtml(val, self.base_url) self.set_font_style() def set_base_url(self, qurl): self.base_url = qurl self.setHtml('', self.base_url) def set_html(self, val, allow_undo=True): if not allow_undo or self.readonly: self.html = val return mf = self.page().mainFrame() mf.evaluateJavaScript('document.execCommand("selectAll", false, null)') mf.evaluateJavaScript('document.execCommand("insertHTML", false, %s)' % json.dumps(unicode_type(val))) self.set_font_style() def set_font_style(self): fi = QFontInfo(QApplication.font(self)) f = fi.pixelSize() + 1 + int( tweaks['change_book_details_font_size_by']) fam = unicode_type(fi.family()).strip().replace('"', '') if not fam: fam = 'sans-serif' style = 'font-size: %fpx; font-family:"%s",sans-serif;' % (f, fam) # toList() is needed because PyQt on Debian is old/broken for body in self.page().mainFrame().documentElement().findAll( 'body').toList(): body.setAttribute('style', style) self.page().setContentEditable(not self.readonly) def event(self, ev): if ev.type() in (ev.KeyPress, ev.KeyRelease, ev.ShortcutOverride) and hasattr( ev, 'key') and ev.key() in (Qt.Key_Tab, Qt.Key_Escape, Qt.Key_Backtab): if (ev.key() == Qt.Key_Tab and ev.modifiers() & Qt.ControlModifier and ev.type() == ev.KeyPress): self.exec_command('insertHTML', '<span style="white-space:pre">\t</span>') ev.accept() return True ev.ignore() return False return QWebView.event(self, ev) def text(self): return self.page().selectedText() def setText(self, text): self.exec_command('insertText', text) def contextMenuEvent(self, ev): menu = self.page().createStandardContextMenu() paste = self.pageAction(QWebPage.Paste) for action in menu.actions(): if action == paste: menu.insertAction(action, self.pageAction(QWebPage.PasteAndMatchStyle)) st = self.text() if st and st.strip(): self.create_change_case_menu(menu) parent = self._parent() if hasattr(parent, 'toolbars_visible'): vis = parent.toolbars_visible menu.addAction( _('%s toolbars') % (_('Hide') if vis else _('Show')), parent.toggle_toolbars) menu.exec_(ev.globalPos())
class EbookViewer(MainWindow): msg_from_anotherinstance = pyqtSignal(object) book_preparation_started = pyqtSignal() book_prepared = pyqtSignal(object, object) MAIN_WINDOW_STATE_VERSION = 1 def __init__(self, open_at=None, continue_reading=None, force_reload=False): MainWindow.__init__(self, None) self.shutting_down = self.close_forced = False self.force_reload = force_reload connect_lambda(self.book_preparation_started, self, lambda self: self.loading_overlay( _('Preparing book for first read, please wait')), type=Qt.QueuedConnection) self.maximized_at_last_fullscreen = False self.save_pos_timer = t = QTimer(self) t.setSingleShot(True), t.setInterval(3000), t.setTimerType( Qt.VeryCoarseTimer) connect_lambda(t.timeout, self, lambda self: self.save_annotations(in_book_file=False)) self.pending_open_at = open_at self.base_window_title = _('E-book viewer') self.setWindowTitle(self.base_window_title) self.in_full_screen_mode = None self.image_popup = ImagePopup(self) self.actions_toolbar = at = ActionsToolBar(self) at.open_book_at_path.connect(self.ask_for_open) self.addToolBar(Qt.LeftToolBarArea, at) try: os.makedirs(annotations_dir) except EnvironmentError: pass self.current_book_data = {} self.book_prepared.connect(self.load_finished, type=Qt.QueuedConnection) self.dock_defs = dock_defs() def create_dock(title, name, area, areas=Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea): ans = QDockWidget(title, self) ans.setObjectName(name) self.addDockWidget(area, ans) ans.setVisible(False) ans.visibilityChanged.connect(self.dock_visibility_changed) return ans for dock_def in itervalues(self.dock_defs): setattr( self, '{}_dock'.format(dock_def.name.partition('-')[0]), create_dock(dock_def.title, dock_def.name, dock_def.initial_area, dock_def.allowed_areas)) self.toc_container = w = QWidget(self) w.l = QVBoxLayout(w) self.toc = TOCView(w) self.toc.clicked[QModelIndex].connect(self.toc_clicked) self.toc.searched.connect(self.toc_searched) self.toc_search = TOCSearch(self.toc, parent=w) w.l.addWidget(self.toc), w.l.addWidget( self.toc_search), w.l.setContentsMargins(0, 0, 0, 0) self.toc_dock.setWidget(w) self.search_widget = w = SearchPanel(self) w.search_requested.connect(self.start_search) self.search_dock.setWidget(w) self.search_dock.visibilityChanged.connect( self.search_widget.visibility_changed) self.lookup_widget = w = Lookup(self) self.lookup_dock.visibilityChanged.connect( self.lookup_widget.visibility_changed) self.lookup_dock.setWidget(w) self.bookmarks_widget = w = BookmarkManager(self) connect_lambda( w.create_requested, self, lambda self: self.web_view. get_current_cfi(self.bookmarks_widget.create_new_bookmark)) w.edited.connect(self.bookmarks_edited) w.activated.connect(self.bookmark_activated) w.toggle_requested.connect(self.toggle_bookmarks) self.bookmarks_dock.setWidget(w) self.highlights_widget = w = HighlightsPanel(self) self.highlights_dock.setWidget(w) self.web_view = WebView(self) self.web_view.cfi_changed.connect(self.cfi_changed) self.web_view.reload_book.connect(self.reload_book) self.web_view.toggle_toc.connect(self.toggle_toc) self.web_view.show_search.connect(self.show_search) self.web_view.find_next.connect(self.search_widget.find_next_requested) self.search_widget.show_search_result.connect( self.web_view.show_search_result) self.web_view.search_result_not_found.connect( self.search_widget.search_result_not_found) self.web_view.toggle_bookmarks.connect(self.toggle_bookmarks) self.web_view.toggle_highlights.connect(self.toggle_highlights) self.web_view.new_bookmark.connect( self.bookmarks_widget.create_requested) self.web_view.toggle_inspector.connect(self.toggle_inspector) self.web_view.toggle_lookup.connect(self.toggle_lookup) self.web_view.quit.connect(self.quit) self.web_view.update_current_toc_nodes.connect( self.toc.update_current_toc_nodes) self.web_view.toggle_full_screen.connect(self.toggle_full_screen) self.web_view.ask_for_open.connect(self.ask_for_open, type=Qt.QueuedConnection) self.web_view.selection_changed.connect( self.lookup_widget.selected_text_changed, type=Qt.QueuedConnection) self.web_view.view_image.connect(self.view_image, type=Qt.QueuedConnection) self.web_view.copy_image.connect(self.copy_image, type=Qt.QueuedConnection) self.web_view.show_loading_message.connect(self.show_loading_message) self.web_view.show_error.connect(self.show_error) self.web_view.print_book.connect(self.print_book, type=Qt.QueuedConnection) self.web_view.reset_interface.connect(self.reset_interface, type=Qt.QueuedConnection) self.web_view.quit.connect(self.quit, type=Qt.QueuedConnection) self.web_view.shortcuts_changed.connect(self.shortcuts_changed) self.web_view.scrollbar_context_menu.connect( self.scrollbar_context_menu) self.web_view.close_prep_finished.connect(self.close_prep_finished) self.web_view.highlights_changed.connect(self.highlights_changed) self.actions_toolbar.initialize(self.web_view, self.search_dock.toggleViewAction()) self.setCentralWidget(self.web_view) self.loading_overlay = LoadingOverlay(self) self.restore_state() self.actions_toolbar.update_visibility() self.dock_visibility_changed() if continue_reading: self.continue_reading() def shortcuts_changed(self, smap): rmap = defaultdict(list) for k, v in iteritems(smap): rmap[v].append(k) self.actions_toolbar.set_tooltips(rmap) def resizeEvent(self, ev): self.loading_overlay.resize(self.size()) return MainWindow.resizeEvent(self, ev) def scrollbar_context_menu(self, x, y, frac): m = QMenu(self) amap = {} def a(text, name): m.addAction(text) amap[text] = name a(_('Scroll here'), 'here') m.addSeparator() a(_('Start of book'), 'start_of_book') a(_('End of book'), 'end_of_book') m.addSeparator() a(_('Previous section'), 'previous_section') a(_('Next section'), 'next_section') m.addSeparator() a(_('Start of current file'), 'start_of_file') a(_('End of current file'), 'end_of_file') m.addSeparator() a(_('Hide this scrollbar'), 'toggle_scrollbar') q = m.exec_(QCursor.pos()) if not q: return q = amap[q.text()] if q == 'here': self.web_view.goto_frac(frac) else: self.web_view.trigger_shortcut(q) # IPC {{{ def handle_commandline_arg(self, arg): if arg: if os.path.isfile(arg) and os.access(arg, os.R_OK): self.load_ebook(arg) else: prints('Cannot read from:', arg, file=sys.stderr) def another_instance_wants_to_talk(self, msg): try: path, open_at = msg except Exception: return self.load_ebook(path, open_at=open_at) self.raise_() # }}} # Fullscreen {{{ def set_full_screen(self, on): if on: self.maximized_at_last_fullscreen = self.isMaximized() if not self.actions_toolbar.visible_in_fullscreen: self.actions_toolbar.setVisible(False) self.showFullScreen() else: self.actions_toolbar.update_visibility() if self.maximized_at_last_fullscreen: self.showMaximized() else: self.showNormal() def changeEvent(self, ev): if ev.type() == QEvent.WindowStateChange: in_full_screen_mode = self.isFullScreen() if self.in_full_screen_mode is None or self.in_full_screen_mode != in_full_screen_mode: self.in_full_screen_mode = in_full_screen_mode self.web_view.notify_full_screen_state_change( self.in_full_screen_mode) return MainWindow.changeEvent(self, ev) def toggle_full_screen(self): self.set_full_screen(not self.isFullScreen()) # }}} # Docks (ToC, Bookmarks, Lookup, etc.) {{{ def toggle_inspector(self): visible = self.inspector_dock.toggleViewAction().isChecked() self.inspector_dock.setVisible(not visible) def toggle_toc(self): self.toc_dock.setVisible(not self.toc_dock.isVisible()) def show_search(self): self.search_dock.setVisible(True) self.search_dock.activateWindow() self.search_dock.raise_() self.search_widget.focus_input() def start_search(self, search_query): name = self.web_view.current_content_file if name: self.search_widget.start_search(search_query, name) self.web_view.setFocus(Qt.OtherFocusReason) def toggle_bookmarks(self): is_visible = self.bookmarks_dock.isVisible() self.bookmarks_dock.setVisible(not is_visible) if is_visible: self.web_view.setFocus(Qt.OtherFocusReason) else: self.bookmarks_widget.bookmarks_list.setFocus(Qt.OtherFocusReason) def toggle_highlights(self): is_visible = self.highlights_dock.isVisible() self.highlights_dock.setVisible(not is_visible) if is_visible: self.web_view.setFocus(Qt.OtherFocusReason) else: self.highlights_widget.focus() def toggle_lookup(self): self.lookup_dock.setVisible(not self.lookup_dock.isVisible()) def toc_clicked(self, index): item = self.toc_model.itemFromIndex(index) self.web_view.goto_toc_node(item.node_id) def toc_searched(self, index): item = self.toc_model.itemFromIndex(index) self.web_view.goto_toc_node(item.node_id) def bookmarks_edited(self, bookmarks): self.current_book_data['annotations_map']['bookmark'] = bookmarks # annotations will be saved in book file on exit self.save_annotations(in_book_file=False) def bookmark_activated(self, cfi): self.web_view.goto_cfi(cfi) def view_image(self, name): path = get_path_for_name(name) if path: pmap = QPixmap() if pmap.load(path): self.image_popup.current_img = pmap self.image_popup.current_url = QUrl.fromLocalFile(path) self.image_popup() else: error_dialog(self, _('Invalid image'), _("Failed to load the image {}").format(name), show=True) else: error_dialog(self, _('Image not found'), _("Failed to find the image {}").format(name), show=True) def copy_image(self, name): path = get_path_for_name(name) if not path: return error_dialog(self, _('Image not found'), _("Failed to find the image {}").format(name), show=True) try: img = image_from_path(path) except Exception: return error_dialog(self, _('Invalid image'), _("Failed to load the image {}").format(name), show=True) url = QUrl.fromLocalFile(path) md = QMimeData() md.setImageData(img) md.setUrls([url]) QApplication.instance().clipboard().setMimeData(md) def dock_visibility_changed(self): vmap = { dock.objectName().partition('-')[0]: dock.toggleViewAction().isChecked() for dock in self.dock_widgets } self.actions_toolbar.update_dock_actions(vmap) # }}} # Load book {{{ def show_loading_message(self, msg): if msg: self.loading_overlay(msg) else: self.loading_overlay.hide() def show_error(self, title, msg, details): self.loading_overlay.hide() error_dialog(self, title, msg, det_msg=details or None, show=True) def print_book(self): from .printing import print_book print_book(set_book_path.pathtoebook, book_title=self.current_book_data['metadata']['title'], parent=self) @property def dock_widgets(self): return self.findChildren(QDockWidget, options=Qt.FindDirectChildrenOnly) def reset_interface(self): for dock in self.dock_widgets: dock.setFloating(False) area = self.dock_defs[dock.objectName().partition('-') [0]].initial_area self.removeDockWidget(dock) self.addDockWidget(area, dock) dock.setVisible(False) for toolbar in self.findChildren(QToolBar): toolbar.setVisible(False) self.removeToolBar(toolbar) self.addToolBar(Qt.LeftToolBarArea, toolbar) def ask_for_open(self, path=None): if path is None: files = choose_files(self, 'ebook viewer open dialog', _('Choose e-book'), [(_('E-books'), available_input_formats())], all_files=False, select_only_single_file=True) if not files: return path = files[0] self.load_ebook(path) def continue_reading(self): rl = vprefs['session_data'].get('standalone_recently_opened') if rl: entry = rl[0] self.load_ebook(entry['pathtoebook']) def load_ebook(self, pathtoebook, open_at=None, reload_book=False): self.web_view.show_home_page_on_ready = False if open_at: self.pending_open_at = open_at self.setWindowTitle( _('Loading book') + '… — {}'.format(self.base_window_title)) self.loading_overlay(_('Loading book, please wait')) self.save_annotations() self.current_book_data = {} self.search_widget.clear_searches() t = Thread(name='LoadBook', target=self._load_ebook_worker, args=(pathtoebook, open_at, reload_book or self.force_reload)) t.daemon = True t.start() def reload_book(self): if self.current_book_data: self.load_ebook(self.current_book_data['pathtoebook'], reload_book=True) def _load_ebook_worker(self, pathtoebook, open_at, reload_book): if DEBUG: start_time = monotonic() try: ans = prepare_book(pathtoebook, force=reload_book, prepare_notify=self.prepare_notify) except WorkerError as e: self.book_prepared.emit(False, { 'exception': e, 'tb': e.orig_tb, 'pathtoebook': pathtoebook }) except Exception as e: import traceback self.book_prepared.emit( False, { 'exception': e, 'tb': traceback.format_exc(), 'pathtoebook': pathtoebook }) else: if DEBUG: print('Book prepared in {:.2f} seconds'.format(monotonic() - start_time)) self.book_prepared.emit( True, { 'base': ans, 'pathtoebook': pathtoebook, 'open_at': open_at, 'reloaded': reload_book }) def prepare_notify(self): self.book_preparation_started.emit() def load_finished(self, ok, data): if self.shutting_down: return open_at, self.pending_open_at = self.pending_open_at, None self.web_view.clear_caches() if not ok: self.setWindowTitle(self.base_window_title) tb = data['tb'].strip() tb = re.split( r'^calibre\.gui2\.viewer\.convert_book\.ConversionFailure:\s*', tb, maxsplit=1, flags=re.M)[-1] last_line = tuple(tb.strip().splitlines())[-1] if last_line.startswith('calibre.ebooks.DRMError'): DRMErrorMessage(self).exec_() else: error_dialog( self, _('Loading book failed'), _('Failed to open the book at {0}. Click "Show details" for more info.' ).format(data['pathtoebook']), det_msg=tb, show=True) self.loading_overlay.hide() self.web_view.show_home_page() return try: set_book_path(data['base'], data['pathtoebook']) except Exception: if data['reloaded']: raise self.load_ebook(data['pathtoebook'], open_at=data['open_at'], reload_book=True) return self.current_book_data = data self.current_book_data['annotations_map'] = defaultdict(list) self.current_book_data['annotations_path_key'] = path_key( data['pathtoebook']) + '.json' self.load_book_data() self.update_window_title() initial_cfi = self.initial_cfi_for_current_book() initial_position = { 'type': 'cfi', 'data': initial_cfi } if initial_cfi else None if open_at: if open_at.startswith('toc:'): initial_toc_node = self.toc_model.node_id_for_text( open_at[len('toc:'):]) initial_position = {'type': 'toc', 'data': initial_toc_node} elif open_at.startswith('toc-href:'): initial_toc_node = self.toc_model.node_id_for_href( open_at[len('toc-href:'):], exact=True) initial_position = {'type': 'toc', 'data': initial_toc_node} elif open_at.startswith('toc-href-contains:'): initial_toc_node = self.toc_model.node_id_for_href( open_at[len('toc-href-contains:'):], exact=False) initial_position = {'type': 'toc', 'data': initial_toc_node} elif open_at.startswith('epubcfi(/'): initial_position = {'type': 'cfi', 'data': open_at} elif open_at.startswith('ref:'): initial_position = { 'type': 'ref', 'data': open_at[len('ref:'):] } elif is_float(open_at): initial_position = {'type': 'bookpos', 'data': float(open_at)} highlights = self.current_book_data['annotations_map']['highlight'] self.highlights_widget.load(highlights) self.web_view.start_book_load(initial_position=initial_position, highlights=list( map(serialize_annotation, highlights))) def load_book_data(self): self.load_book_annotations() path = os.path.join(self.current_book_data['base'], 'calibre-book-manifest.json') with open(path, 'rb') as f: raw = f.read() self.current_book_data['manifest'] = manifest = json.loads(raw) toc = manifest.get('toc') self.toc_model = TOC(toc) self.toc.setModel(self.toc_model) self.bookmarks_widget.set_bookmarks( self.current_book_data['annotations_map']['bookmark']) self.current_book_data['metadata'] = set_book_path.parsed_metadata self.current_book_data['manifest'] = set_book_path.parsed_manifest def load_book_annotations(self): amap = self.current_book_data['annotations_map'] path = os.path.join(self.current_book_data['base'], 'calibre-book-annotations.json') if os.path.exists(path): with open(path, 'rb') as f: raw = f.read() merge_annotations(json_loads(raw), amap) path = os.path.join(annotations_dir, self.current_book_data['annotations_path_key']) if os.path.exists(path): with open(path, 'rb') as f: raw = f.read() merge_annotations(parse_annotations(raw), amap) def update_window_title(self): try: title = self.current_book_data['metadata']['title'] except Exception: title = _('Unknown') book_format = self.current_book_data['manifest']['book_format'] title = '{} [{}] — {}'.format(title, book_format, self.base_window_title) self.setWindowTitle(title) # }}} # CFI management {{{ def initial_cfi_for_current_book(self): lrp = self.current_book_data['annotations_map']['last-read'] if lrp and get_session_pref('remember_last_read', default=True): lrp = lrp[0] if lrp['pos_type'] == 'epubcfi': return lrp['pos'] def cfi_changed(self, cfi): if not self.current_book_data: return self.current_book_data['annotations_map']['last-read'] = [{ 'pos': cfi, 'pos_type': 'epubcfi', 'timestamp': utcnow() }] self.save_pos_timer.start() # }}} # State serialization {{{ def save_annotations(self, in_book_file=True): if not self.current_book_data: return amap = self.current_book_data['annotations_map'] annots = as_bytes(serialize_annotations(amap)) with open( os.path.join(annotations_dir, self.current_book_data['annotations_path_key']), 'wb') as f: f.write(annots) if in_book_file and self.current_book_data.get( 'pathtoebook', '').lower().endswith('.epub') and get_session_pref( 'save_annotations_in_ebook', default=True): path = self.current_book_data['pathtoebook'] if os.access(path, os.W_OK): before_stat = os.stat(path) save_annots_to_epub(path, annots) update_book(path, before_stat, {'calibre-book-annotations.json': annots}) def highlights_changed(self, highlights): if not self.current_book_data: return for h in highlights: h['timestamp'] = parse_iso8601(h['timestamp'], assume_utc=True) amap = self.current_book_data['annotations_map'] amap['highlight'] = highlights self.save_annotations() def save_state(self): with vprefs: vprefs['main_window_state'] = bytearray( self.saveState(self.MAIN_WINDOW_STATE_VERSION)) vprefs['main_window_geometry'] = bytearray(self.saveGeometry()) def restore_state(self): state = vprefs['main_window_state'] geom = vprefs['main_window_geometry'] if geom and get_session_pref('remember_window_geometry', default=False): QApplication.instance().safe_restore_geometry(self, geom) else: QApplication.instance().ensure_window_on_screen(self) if state: self.restoreState(state, self.MAIN_WINDOW_STATE_VERSION) self.inspector_dock.setVisible(False) def quit(self): self.close() def force_close(self): if not self.close_forced: self.close_forced = True self.quit() def close_prep_finished(self, cfi): if cfi: self.cfi_changed(cfi) self.force_close() def closeEvent(self, ev): if self.current_book_data and self.web_view.view_is_ready and not self.close_forced: ev.ignore() if not self.shutting_down: self.shutting_down = True QTimer.singleShot(2000, self.force_close) self.web_view.prepare_for_close() return self.shutting_down = True self.search_widget.shutdown() try: self.save_annotations() self.save_state() except Exception: import traceback traceback.print_exc() clean_running_workers() return MainWindow.closeEvent(self, ev)
class RecipeList(QWidget): # {{{ edit_recipe = pyqtSignal(object, object) def __init__(self, parent, model): QWidget.__init__(self, parent) self.l = l = QHBoxLayout(self) self.view = v = QListView(self) v.doubleClicked.connect(self.item_activated) v.setModel(CustomRecipeModel(model)) l.addWidget(v) self.stacks = s = QStackedWidget(self) l.addWidget(s, stretch=10, alignment=Qt.AlignmentFlag.AlignTop) self.first_msg = la = QLabel( _('Create a new news source by clicking one of the buttons below')) la.setWordWrap(True) s.addWidget(la) self.w = w = QWidget(self) w.l = l = QVBoxLayout(w) l.setContentsMargins(0, 0, 0, 0) s.addWidget(w) self.title = la = QLabel(w) la.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop) l.addWidget(la) l.setSpacing(20) self.edit_button = b = QPushButton(QIcon(I('modified.png')), _('&Edit this recipe'), w) b.setSizePolicy( QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) b.clicked.connect(self.edit_requested) l.addWidget(b) self.remove_button = b = QPushButton(QIcon(I('list_remove.png')), _('&Remove this recipe'), w) b.clicked.connect(self.remove) b.setSizePolicy( QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) l.addWidget(b) self.export_button = b = QPushButton(QIcon(I('save.png')), _('S&ave recipe as file'), w) b.setSizePolicy( QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) b.clicked.connect(self.save_recipe) l.addWidget(b) self.download_button = b = QPushButton( QIcon(I('download-metadata.png')), _('&Download this recipe'), w) b.clicked.connect(self.download) b.setSizePolicy( QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)) l.addWidget(b) self.select_row() v.selectionModel().currentRowChanged.connect(self.recipe_selected) def select_row(self, row=0): v = self.view if v.model().rowCount() > 0: idx = v.model().index(row) if idx.isValid(): v.selectionModel().select(idx, v.selectionModel().ClearAndSelect) v.setCurrentIndex(idx) self.recipe_selected(idx) def add(self, title, src): row = self.model.add(title, src) self.select_row(row) def update(self, row, title, src): self.model.update(row, title, src) self.select_row(row) @property def model(self): return self.view.model() def recipe_selected(self, cur, prev=None): if cur.isValid(): self.stacks.setCurrentIndex(1) self.title.setText('<h2 style="text-align:center">%s</h2>' % self.model.title(cur)) else: self.stacks.setCurrentIndex(0) def edit_requested(self): idx = self.view.currentIndex() if idx.isValid(): src = self.model.script(idx) if src is not None: self.edit_recipe.emit(idx.row(), src) def save_recipe(self): idx = self.view.currentIndex() if idx.isValid(): src = self.model.script(idx) if src is not None: path = choose_save_file(self, 'save-custom-recipe', _('Save recipe'), filters=[(_('Recipes'), ['recipe'])], all_files=False, initial_filename='{}.recipe'.format( self.model.title(idx))) if path: with open(path, 'wb') as f: f.write(as_bytes(src)) def item_activated(self, idx): if idx.isValid(): src = self.model.script(idx) if src is not None: self.edit_recipe.emit(idx.row(), src) def remove(self): idx = self.view.currentIndex() if idx.isValid(): self.model.remove((idx.row(), )) self.select_row() if self.model.rowCount() == 0: self.stacks.setCurrentIndex(0) def download(self): idx = self.view.currentIndex() if idx.isValid(): urn = self.model.urn(idx) title = self.model.title(idx) from calibre.gui2.ui import get_gui gui = get_gui() gui.iactions['Fetch News'].download_custom_recipe(title, urn) def has_title(self, title): return self.model.has_title(title) def add_many(self, script_map): self.model.add_many(script_map) self.select_row() def replace_many_by_title(self, script_map): self.model.replace_many_by_title(script_map) self.select_row()
class ActionsToolBar(ToolBar): action_triggered = pyqtSignal(object) open_book_at_path = pyqtSignal(object) def __init__(self, parent=None): ToolBar.__init__(self, parent) self.setObjectName('actions_toolbar') self.customContextMenuRequested.connect(self.show_context_menu) def show_context_menu(self, pos): m = QMenu(self) a = m.addAction(_('Customize this toolbar')) a.triggered.connect(self.customize) a = m.addAction(_('Hide this toolbar')) a.triggered.connect(self.hide_toolbar) m.exec_(pos) def hide_toolbar(self): self.web_view.trigger_shortcut('toggle_toolbar') def initialize(self, web_view, toggle_search_action): self.web_view = web_view shortcut_action = self.create_shortcut_action aa = all_actions() self.action_triggered.connect(web_view.trigger_shortcut) page = web_view.page() web_view.paged_mode_changed.connect(self.update_mode_action) web_view.reference_mode_changed.connect( self.update_reference_mode_action) web_view.standalone_misc_settings_changed.connect( self.update_visibility) web_view.autoscroll_state_changed.connect( self.update_autoscroll_action) web_view.customize_toolbar.connect(self.customize, type=Qt.QueuedConnection) web_view.view_created.connect(self.on_view_created) self.back_action = page.action(QWebEnginePage.Back) self.back_action.setIcon(aa.back.icon) self.back_action.setText(aa.back.text) self.forward_action = page.action(QWebEnginePage.Forward) self.forward_action.setIcon(aa.forward.icon) self.forward_action.setText(aa.forward.text) self.open_action = a = QAction(aa.open.icon, aa.open.text, self) self.open_menu = m = QMenu(self) a.setMenu(m) m.aboutToShow.connect(self.populate_open_menu) connect_lambda(a.triggered, self, lambda self: self.open_book_at_path.emit(None)) self.copy_action = shortcut_action('copy') self.increase_font_size_action = shortcut_action('increase_font_size') self.decrease_font_size_action = shortcut_action('decrease_font_size') self.fullscreen_action = shortcut_action('fullscreen') self.next_action = shortcut_action('next') self.previous_action = shortcut_action('previous') self.next_section_action = shortcut_action('next_section') self.previous_section_action = shortcut_action('previous_section') self.search_action = a = toggle_search_action a.setText(aa.search.text), a.setIcon(aa.search.icon) self.toc_action = a = shortcut_action('toc') a.setCheckable(True) self.bookmarks_action = a = shortcut_action('bookmarks') a.setCheckable(True) self.reference_action = a = shortcut_action('reference') a.setCheckable(True) self.toggle_highlights_action = self.highlights_action = a = shortcut_action( 'toggle_highlights') a.setCheckable(True) self.lookup_action = a = shortcut_action('lookup') a.setCheckable(True) self.inspector_action = a = shortcut_action('inspector') a.setCheckable(True) self.autoscroll_action = a = shortcut_action('autoscroll') a.setCheckable(True) self.update_autoscroll_action(False) self.chrome_action = shortcut_action('chrome') self.mode_action = a = shortcut_action('mode') a.setCheckable(True) self.print_action = shortcut_action('print') self.preferences_action = shortcut_action('preferences') self.metadata_action = shortcut_action('metadata') self.update_mode_action() self.color_scheme_action = a = QAction(aa.color_scheme.icon, aa.color_scheme.text, self) self.color_scheme_menu = m = QMenu(self) a.setMenu(m) m.aboutToShow.connect(self.populate_color_scheme_menu) self.add_actions() def add_actions(self): self.clear() actions = current_actions() for x in actions: if x is None: self.addSeparator() else: try: self.addAction(getattr(self, '{}_action'.format(x))) except AttributeError: pass w = self.widgetForAction(self.color_scheme_action) if w: w.setPopupMode(w.InstantPopup) def update_mode_action(self): mode = get_session_pref('read_mode', default='paged', group=None) a = self.mode_action if mode == 'paged': a.setChecked(False) a.setToolTip( _('Switch to flow mode -- where the text is not broken into pages' )) else: a.setChecked(True) a.setToolTip( _('Switch to paged mode -- where the text is broken into pages' )) def update_autoscroll_action(self, active): self.autoscroll_action.setChecked(active) self.autoscroll_action.setToolTip( _('Turn off auto-scrolling' ) if active else _('Turn on auto-scrolling')) def update_reference_mode_action(self, enabled): self.reference_action.setChecked(enabled) def update_dock_actions(self, visibility_map): for k in ('toc', 'bookmarks', 'lookup', 'inspector', 'highlights'): ac = getattr(self, '{}_action'.format(k)) ac.setChecked(visibility_map[k]) def set_tooltips(self, rmap): for sc, a in iteritems(self.shortcut_actions): if a.isCheckable(): continue x = rmap.get(sc) if x is not None: def as_text(idx): return index_to_key_sequence(idx).toString( QKeySequence.NativeText) keys = sorted(filter(None, map(as_text, x))) if keys: a.setToolTip('{} [{}]'.format(a.text(), ', '.join(keys))) def populate_open_menu(self): m = self.open_menu m.clear() recent = get_session_pref('standalone_recently_opened', group=None, default=()) if recent: for entry in recent: try: path = os.path.abspath(entry['pathtoebook']) except Exception: continue if hasattr(set_book_path, 'pathtoebook') and path == os.path.abspath( set_book_path.pathtoebook): continue m.addAction('{}\t {}'.format( elided_text(entry['title'], pos='right', width=250), elided_text(os.path.basename(path), width=250))).triggered.connect( partial(self.open_book_at_path.emit, path)) def on_view_created(self, data): self.default_color_schemes = data['default_color_schemes'] def populate_color_scheme_menu(self): m = self.color_scheme_menu m.clear() ccs = get_session_pref('current_color_scheme', group=None) or '' ucs = get_session_pref('user_color_schemes', group=None) or {} def add_action(key, defns): a = m.addAction(defns[key]['name']) a.setCheckable(True) a.setObjectName('color-switch-action:{}'.format(key)) a.triggered.connect(self.color_switch_triggerred) if key == ccs: a.setChecked(True) for key in sorted(ucs, key=lambda x: primary_sort_key(ucs[x]['name'])): add_action(key, ucs) m.addSeparator() for key in sorted(self.default_color_schemes, key=lambda x: primary_sort_key( self.default_color_schemes[x]['name'])): add_action(key, self.default_color_schemes) def color_switch_triggerred(self): key = self.sender().objectName().partition(':')[-1] self.action_triggered.emit('switch_color_scheme:' + key) def update_visibility(self): self.setVisible( bool(get_session_pref('show_actions_toolbar', default=False))) @property def visible_in_fullscreen(self): return bool( get_session_pref('show_actions_toolbar_in_fullscreen', default=False)) def customize(self): d = ConfigureToolBar(parent=self.parent()) if d.exec_() == d.Accepted: self.add_actions()
class Signal(QObject): update_found = pyqtSignal(object, object)
class TextEdit(PlainTextEdit): link_clicked = pyqtSignal(object) smart_highlighting_updated = pyqtSignal() def __init__(self, parent=None, expected_geometry=(100, 50)): PlainTextEdit.__init__(self, parent) self.gutter_width = 0 self.expected_geometry = expected_geometry self.saved_matches = {} 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) self.syntax = None @dynamic_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. ''' def fget(self): return self.document().isModified() def fset(self, val): self.document().setModified(bool(val)) return property(fget=fget, fset=fset) def sizeHint(self): return self.size_hint def apply_settings(self, prefs=None, dictionaries_changed=False): # {{{ prefs = prefs or tprefs self.setLineWrapMode( QPlainTextEdit.WidgetWidth if prefs['editor_line_wrap'] else QPlainTextEdit.NoWrap) theme = get_theme(prefs['editor_theme']) self.apply_theme(theme) w = self.fontMetrics() self.space_width = w.width(' ') self.setTabStopWidth(prefs['editor_tab_stop_width'] * self.space_width) if dictionaries_changed: self.highlighter.rehighlight() def apply_theme(self, theme): self.theme = theme pal = self.palette() pal.setColor(pal.Base, theme_color(theme, 'Normal', 'bg')) pal.setColor(pal.AlternateBase, theme_color(theme, 'CursorLine', 'bg')) pal.setColor(pal.Text, theme_color(theme, 'Normal', 'fg')) pal.setColor(pal.Highlight, theme_color(theme, 'Visual', 'bg')) pal.setColor(pal.HighlightedText, theme_color(theme, 'Visual', 'fg')) self.setPalette(pal) self.tooltip_palette = pal = QPalette() pal.setColor(pal.ToolTipBase, theme_color(theme, 'Tooltip', 'bg')) pal.setColor(pal.ToolTipText, theme_color(theme, 'Tooltip', 'fg')) self.line_number_palette = pal = QPalette() pal.setColor(pal.Base, theme_color(theme, 'LineNr', 'bg')) pal.setColor(pal.Text, theme_color(theme, 'LineNr', 'fg')) pal.setColor(pal.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(str(x)), xrange(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() # }}} 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 = { 'html': HTMLSmarts, 'xml': HTMLSmarts, 'css': CSSSmarts }.get(syntax, None) if sclass is not None: self.smarts = sclass(self) self.setPlainText(unicodedata.normalize('NFC', 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(c.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): c = 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(c.Start) c.movePosition(c.NextBlock, n=lnum - 1) c.movePosition(c.StartOfLine) c.movePosition(c.EndOfLine, c.KeepAnchor) text = unicode(c.selectedText()).rstrip('\0') if col is None: c.movePosition(c.StartOfLine) lt = text.lstrip() if text and lt and lt != text: c.movePosition(c.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(c.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: 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, c.KeepAnchor) raw = unicode(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, c.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(c.selectedText()).replace(PARAGRAPH_SEPARATOR, '\n').rstrip('\0') if template is None: count = len(pat.findall(raw)) else: raw, count = pat.subn(template, raw) 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, c.KeepAnchor) self.update_extra_selections() return count 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(c.End if reverse else c.Start) pos = c.Start if reverse else c.End if wrap and not complete: pos = c.End if reverse else c.Start c.movePosition(pos, c.KeepAnchor) raw = unicode(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, c.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_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(c.Start) c.movePosition(c.End, c.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(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), c.KeepAnchor) if self.smarts.verify_for_spellcheck(c, self.highlighter): self.setTextCursor(c) if center_on_cursor: self.centerCursor() return True c.setPosition(c.position()) c.movePosition(c.End, c.KeepAnchor) return False def find_next_spell_error(self, from_cursor=True): c = self.textCursor() if not from_cursor: c.movePosition(c.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, c.KeepAnchor) self.setTextCursor(c) return True block = block.next() return False def replace(self, pat, template, saved_match='gui'): c = self.textCursor() raw = unicode(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 text = m.expand(template) c.insertText(text) return True def go_to_anchor(self, anchor): if anchor is TOP: c = self.textCursor() c.movePosition(c.Start) self.setTextCursor(c) return True base = r'''%%s\s*=\s*['"]{0,1}%s''' % regex.escape(anchor) raw = unicode(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.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.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.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.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.AlignRight, str(num + 1)) if current == num: painter.restore() block = block.next() top = bottom bottom = top + int(self.blockBoundingRect(block).height()) num += 1 # }}} def event(self, ev): if ev.type() == ev.ToolTip: self.show_tooltip(ev) return True if ev.type() == ev.ShortcutOverride: if ev in ( # Let the global cut/copy/paste/undo/redo shortcuts work,this avoids the nbsp # problem as well, since they use the overridden copy() method # instead of the one from Qt, and allows proper customization # of the shortcuts QKeySequence.Copy, QKeySequence.Cut, QKeySequence.Paste, QKeySequence.Undo, QKeySequence.Redo ) or ( # This is used to convert typed hex codes into unicode # characters ev.key() == Qt.Key_X and ev.modifiers() == Qt.AltModifier): ev.ignore() return False return QPlainTextEdit.event(self, ev) def text_for_range(self, block, r): c = self.textCursor() c.setPosition(block.position() + r.start) c.setPosition(c.position() + r.length, c.KeepAnchor) return unicode(c.selectedText()) 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(c.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 syntax_format_for_cursor(self, cursor): return getattr(self.syntax_range_for_cursor(cursor), 'format', None) def show_tooltip(self, ev): c = self.cursorForPosition(ev.pos()) fmt = self.syntax_format_for_cursor(c) if fmt is not None: tt = unicode(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 mousePressEvent(self, ev): if ev.modifiers() & Qt.CTRL: url = self.link_for_position(ev.pos()) if url is not None: ev.accept() self.link_clicked.emit(url) 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(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.black if formatting == 'color' else Qt.white), self, _('Choose color'), QColorDialog.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, c.KeepAnchor) prev_text = unicode(c.selectedText()).rstrip('\0') c.insertText(prefix + prev_text + suffix) if prev_text: right = c.position() c.setPosition(left) c.setPosition(right, c.KeepAnchor) else: c.setPosition(c.position() - len(suffix)) self.setTextCursor(c) def insert_image(self, href): 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, c.KeepAnchor) alt = _('Image') template = '<img alt="{0}" src="%s" />'.format(alt) href = prepare_string_for_xml(href, True) text = template % href c.insertText(text) if self.syntax == 'html': c.setPosition(left + 10) c.setPosition(c.position() + len(alt), c.KeepAnchor) else: c.setPosition(left) c.setPosition(left + len(text), c.KeepAnchor) self.setTextCursor(c) def insert_hyperlink(self, target, text): if hasattr(self.smarts, 'insert_hyperlink'): self.smarts.insert_hyperlink(self, target, text) def insert_tag(self, tag): if hasattr(self.smarts, 'insert_tag'): self.smarts.insert_tag(self, tag) def keyPressEvent(self, ev): if ev.key() == Qt.Key_X and ev.modifiers() == Qt.AltModifier: if self.replace_possible_unicode_sequence(): ev.accept() return if ev.key() == Qt.Key_Insert: self.setOverwriteMode(self.overwriteMode() ^ True) ev.accept() return if isosx and ev.modifiers() == Qt.ControlModifier and re.search( r'[a-zA-Z0-9]+', ev.text()) is not None: # For some reason Qt 5 translates Cmd+key into text on OS X # https://bugreports.qt-project.org/browse/QTBUG-36281 ev.setAccepted(False) return QPlainTextEdit.keyPressEvent(self, ev) if (ev.key() == Qt.Key_Semicolon or ';' in unicode(ev.text( ))) and tprefs['replace_entities_as_typed'] and self.syntax == 'html': self.replace_possible_entity() def replace_possible_unicode_sequence(self): c = self.textCursor() has_selection = c.hasSelection() if has_selection: text = unicode(c.selectedText()).rstrip('\0') else: c.setPosition(c.position() - min(c.positionInBlock(), 6), c.KeepAnchor) text = unicode(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, c.KeepAnchor) c.insertText(safe_chr(num)) return True def replace_possible_entity(self): c = self.textCursor() c.setPosition(c.position() - min(c.positionInBlock(), 10), c.KeepAnchor) text = unicode(c.selectedText()).rstrip('\0') m = entity_pat.search(text) if m is None: return ent = m.group() repl = xml_entity_to_unicode(m) if repl != ent: c.setPosition(c.position() + m.start(), c.KeepAnchor) c.insertText(repl) def select_all(self): c = self.textCursor() c.clearSelection() c.setPosition(0) c.movePosition(c.End, c.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): return self.smarts.cursor_position_with_sourceline( self.textCursor(), for_position_sync=for_position_sync) 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.smart.css import find_rule block = None if self.syntax == 'css': raw = unicode(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 CacheUpdateThread(Thread, QObject): total_changed = pyqtSignal(int) update_progress = pyqtSignal(int) update_details = pyqtSignal(type(u'')) def __init__(self, config, seralize_books_function, timeout): Thread.__init__(self) QObject.__init__(self) self.daemon = True self.config = config self.seralize_books = seralize_books_function self.timeout = timeout self._run = True def abort(self): self._run = False def run(self): url = 'https://www.mobileread.com/forums/ebooks.php?do=getlist&type=html' self.update_details.emit(_('Checking last download date.')) last_download = self.config.get('last_download', None) # Don't update the book list if our cache is less than one week old. if last_download and (time.time() - last_download) < 604800: return self.update_details.emit(_('Downloading book list from MobileRead.')) # Download the book list HTML file from MobileRead. br = browser() raw_data = None try: with closing(br.open(url, timeout=self.timeout)) as f: raw_data = f.read() except: return if not raw_data or not self._run: return self.update_details.emit(_('Processing books.')) # Turn books listed in the HTML file into SearchResults's. books = [] try: data = html.fromstring(raw_data) raw_books = data.xpath('//ul/li') self.total_changed.emit(len(raw_books)) for i, book_data in enumerate(raw_books): self.update_details.emit( _('%(num)s of %(tot)s books processed.') % dict( num=i, tot=len(raw_books))) book = SearchResult() book.detail_item = ''.join(book_data.xpath('.//a/@href')) book.formats = ''.join(book_data.xpath('.//i/text()')) book.formats = book.formats.strip() text = ''.join(book_data.xpath('.//a/text()')) if ':' in text: book.author, q, text = text.partition(':') book.author = book.author.strip() book.title = text.strip() books.append(book) if not self._run: books = [] break else: self.update_progress.emit(i) except: pass # Save the book list and it's create time. if books: self.config['book_list'] = self.seralize_books(books) self.config['last_download'] = time.time()
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.OutExpo)) self.animation.setDuration(1000) self.animation.setStartValue(QSize(0, 0)) self.animation.valueChanged.connect(self.value_changed) self.setSizePolicy( QSizePolicy.Expanding if vertical else QSizePolicy.Minimum, QSizePolicy.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.Antialiasing | QPainter.SmoothPixmapTransform) try: dpr = self.devicePixelRatioF() except AttributeError: dpr = self.devicePixelRatio() spmap = self.pixmap.scaled(target.size() * dpr, Qt.KeepAspectRatio, Qt.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.AlignBottom|Qt.AlignRight|Qt.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(cb.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): 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 Diff(Dialog): revert_requested = pyqtSignal() line_activated = pyqtSignal(object, object, object) def __init__(self, revert_button_msg=None, parent=None, show_open_in_editor=False, show_as_window=False): self.context = 3 self.beautify = False self.apply_diff_calls = [] self.show_open_in_editor = show_open_in_editor self.revert_button_msg = revert_button_msg Dialog.__init__(self, _('Differences between books'), 'diff-dialog', parent=parent) self.setWindowFlags(self.windowFlags() | Qt.WindowType.WindowMinMaxButtonsHint) if show_as_window: self.setWindowFlags(Qt.WindowType.Window) self.view.line_activated.connect(self.line_activated) def sizeHint(self): geom = QApplication.instance().desktop().availableGeometry(self) return QSize(int(0.9 * geom.width()), int(0.8 * geom.height())) def setup_ui(self): self.setWindowIcon(QIcon(I('diff.png'))) self.stacks = st = QStackedLayout(self) self.busy = BusyWidget(self) self.w = QWidget(self) st.addWidget(self.busy), st.addWidget(self.w) self.setLayout(st) self.l = l = QGridLayout() self.w.setLayout(l) self.view = v = DiffView(self, show_open_in_editor=self.show_open_in_editor) l.addWidget(v, l.rowCount(), 0, 1, -1) r = l.rowCount() self.bp = b = QToolButton(self) b.setIcon(QIcon(I('back.png'))) connect_lambda(b.clicked, self, lambda self: self.view.next_change(-1)) b.setToolTip(_('Go to previous change') + ' [p]') b.setText(_('&Previous change')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) l.addWidget(b, r, 0) self.bn = b = QToolButton(self) b.setIcon(QIcon(I('forward.png'))) connect_lambda(b.clicked, self, lambda self: self.view.next_change(1)) b.setToolTip(_('Go to next change') + ' [n]') b.setText(_('&Next change')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) l.addWidget(b, r, 1) self.search = s = HistoryLineEdit2(self) s.initialize('diff_search_history') l.addWidget(s, r, 2) s.setPlaceholderText(_('Search for text')) connect_lambda(s.returnPressed, self, lambda self: self.do_search(False)) self.sbn = b = QToolButton(self) b.setIcon(QIcon(I('arrow-down.png'))) connect_lambda(b.clicked, self, lambda self: self.do_search(False)) b.setToolTip(_('Find next match')) b.setText(_('Next &match')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) l.addWidget(b, r, 3) self.sbp = b = QToolButton(self) b.setIcon(QIcon(I('arrow-up.png'))) connect_lambda(b.clicked, self, lambda self: self.do_search(True)) b.setToolTip(_('Find previous match')) b.setText(_('P&revious match')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) l.addWidget(b, r, 4) self.lb = b = QRadioButton(_('Left panel'), self) b.setToolTip(_('Perform search in the left panel')) l.addWidget(b, r, 5) self.rb = b = QRadioButton(_('Right panel'), self) b.setToolTip(_('Perform search in the right panel')) l.addWidget(b, r, 6) b.setChecked(True) self.pb = b = QToolButton(self) b.setIcon(QIcon(I('config.png'))) b.setText(_('&Options')), b.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) b.setToolTip(_('Change how the differences are displayed')) b.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) m = QMenu(b) b.setMenu(m) cm = self.cm = QMenu(_('Lines of context around each change')) for i in (3, 5, 10, 50): cm.addAction(_('Show %d lines of context') % i, partial(self.change_context, i)) cm.addAction(_('Show all text'), partial(self.change_context, None)) self.beautify_action = m.addAction('', self.toggle_beautify) self.set_beautify_action_text() m.addMenu(cm) l.addWidget(b, r, 7) self.hl = QHBoxLayout() l.addLayout(self.hl, l.rowCount(), 0, 1, -1) self.names = QLabel('') self.hl.addWidget(self.names, r) self.bb.setStandardButtons(self.bb.Close) if self.revert_button_msg is not None: self.rvb = b = self.bb.addButton(self.revert_button_msg, self.bb.ActionRole) b.setIcon(QIcon(I('edit-undo.png'))), b.setAutoDefault(False) b.clicked.connect(self.revert_requested) b.clicked.connect(self.reject) self.bb.button(self.bb.Close).setDefault(True) self.hl.addWidget(self.bb, r) self.view.setFocus(Qt.FocusReason.OtherFocusReason) def break_cycles(self): self.view = None for x in ('revert_requested', 'line_activated'): try: getattr(self, x).disconnect() except: pass def do_search(self, reverse): text = unicode_type(self.search.text()) if not text.strip(): return v = self.view.view.left if self.lb.isChecked() else self.view.view.right v.search(text, reverse=reverse) def change_context(self, context): if context == self.context: return self.context = context self.refresh() def refresh(self): with self: self.view.clear() for args, kwargs in self.apply_diff_calls: kwargs['context'] = self.context kwargs['beautify'] = self.beautify self.view.add_diff(*args, **kwargs) self.view.finalize() def toggle_beautify(self): self.beautify = not self.beautify self.set_beautify_action_text() self.refresh() def set_beautify_action_text(self): self.beautify_action.setText( _('Beautify files before comparing them') if not self.beautify else _('Do not beautify files before comparing')) def __enter__(self): self.stacks.setCurrentIndex(0) self.busy.setVisible(True) self.busy.pi.startAnimation() QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) QApplication.processEvents(QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents | QEventLoop.ProcessEventsFlag.ExcludeSocketNotifiers) def __exit__(self, *args): self.busy.pi.stopAnimation() self.stacks.setCurrentIndex(1) QApplication.restoreOverrideCursor() def set_names(self, names): if isinstance(names, tuple): self.names.setText('%s <--> %s' % names) else: self.names.setText('') def ebook_diff(self, path1, path2, names=None): self.set_names(names) with self: identical = self.apply_diff(_('The books are identical'), *ebook_diff(path1, path2)) self.view.finalize() if identical: self.reject() def container_diff(self, left, right, identical_msg=None, names=None): self.set_names(names) with self: identical = self.apply_diff(identical_msg or _('No changes found'), *container_diff(left, right)) self.view.finalize() if identical: self.reject() def file_diff(self, left, right, identical_msg=None): with self: identical = self.apply_diff(identical_msg or _('The files are identical'), *file_diff(left, right)) self.view.finalize() if identical: self.reject() def string_diff(self, left, right, **kw): with self: identical = self.apply_diff(kw.pop('identical_msg', None) or _('No differences found'), *string_diff(left, right, **kw)) self.view.finalize() if identical: self.reject() def dir_diff(self, left, right, identical_msg=None): with self: identical = self.apply_diff(identical_msg or _('The directories are identical'), *dir_diff(left, right)) self.view.finalize() if identical: self.reject() def apply_diff(self, identical_msg, cache, syntax_map, changed_names, renamed_names, removed_names, added_names): self.view.clear() self.apply_diff_calls = calls = [] def add(args, kwargs): self.view.add_diff(*args, **kwargs) calls.append((args, kwargs)) if len(changed_names) + len(renamed_names) + len(removed_names) + len(added_names) < 1: self.busy.setVisible(False) info_dialog(self, _('No changes found'), identical_msg, show=True) self.busy.setVisible(True) return True kwargs = lambda name: {'context':self.context, 'beautify':self.beautify, 'syntax':syntax_map.get(name, None)} if isinstance(changed_names, dict): for name, other_name in sorted(iteritems(changed_names), key=lambda x:numeric_sort_key(x[0])): args = (name, other_name, cache.left(name), cache.right(other_name)) add(args, kwargs(name)) else: for name in sorted(changed_names, key=numeric_sort_key): args = (name, name, cache.left(name), cache.right(name)) add(args, kwargs(name)) for name in sorted(added_names, key=numeric_sort_key): args = (_('[%s was added]') % name, name, None, cache.right(name)) add(args, kwargs(name)) for name in sorted(removed_names, key=numeric_sort_key): args = (name, _('[%s was removed]') % name, cache.left(name), None) add(args, kwargs(name)) for name, new_name in sorted(iteritems(renamed_names), key=lambda x:numeric_sort_key(x[0])): args = (name, new_name, None, None) add(args, kwargs(name)) def keyPressEvent(self, ev): if not self.view.handle_key(ev): if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): return # The enter key is used by the search box, so prevent it closing the dialog if ev.key() == Qt.Key.Key_Slash: return self.search.setFocus(Qt.FocusReason.OtherFocusReason) if ev.matches(QKeySequence.StandardKey.Copy): text = self.view.view.left.selected_text + self.view.view.right.selected_text if text: QApplication.clipboard().setText(text) return if ev.matches(QKeySequence.StandardKey.FindNext): self.sbn.click() return if ev.matches(QKeySequence.StandardKey.FindPrevious): self.sbp.click() return return Dialog.keyPressEvent(self, ev)
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.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.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.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 ConfigWidgetBase(QWidget, ConfigWidgetInterface): ''' Base class that contains code to easily add standard config widgets like checkboxes, combo boxes, text fields and so on. See the :meth:`register` method. This class automatically handles change notification, resetting to default, translation between gui objects and config objects, etc. for registered settings. If your config widget inherits from this class but includes setting that are not registered, you should override the :class:`ConfigWidgetInterface` methods and call the base class methods inside the overrides. ''' changed_signal = pyqtSignal() restart_now = pyqtSignal() supports_restoring_to_defaults = True restart_critical = False def __init__(self, parent=None): QWidget.__init__(self, parent) if hasattr(self, 'setupUi'): self.setupUi(self) self.settings = {} def register(self, name, config_obj, gui_name=None, choices=None, restart_required=False, empty_string_is_None=True, setting=Setting): ''' Register a setting. :param name: The setting name :param config: The config object that reads/writes the setting :param gui_name: The name of the GUI object that presents an interface to change the setting. By default it is assumed to be ``'opt_' + name``. :param choices: If this setting is a multiple choice (combobox) based setting, the list of choices. The list is a list of two element tuples of the form: ``[(gui name, value), ...]`` :param setting: The class responsible for managing this setting. The default class handles almost all cases, so this param is rarely used. ''' setting = setting(name, config_obj, self, gui_name=gui_name, choices=choices, restart_required=restart_required, empty_string_is_None=empty_string_is_None) return self.register_setting(setting) def register_setting(self, setting): self.settings[setting.name] = setting return setting def initialize(self): for setting in self.settings.values(): setting.initialize() def commit(self, *args): restart_required = False for setting in self.settings.values(): rr = setting.commit() if rr: restart_required = True return restart_required def restore_defaults(self, *args): for setting in self.settings.values(): setting.restore_defaults()
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) smart_highlighting_updated = pyqtSignal() def __init__(self, syntax, parent=None): QMainWindow.__init__(self, parent) if parent is None: self.setWindowFlags(Qt.Widget) self.is_synced_to_container = False self.syntax = syntax self.editor = TextEdit(self) self.editor.setContextMenuPolicy(Qt.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.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, c.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 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', unicode_type(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.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)) if names: m.addSeparator() 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 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 occassionally a # QPushButton instead of a QToolButton w.setPopupMode(QToolButton.MenuButtonPopup) w.setMenu(self.insert_tag_menu) w.setContextMenuPolicy(Qt.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 occassionally a # QPushButton instead of a QToolButton ch.setPopupMode(QToolButton.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', '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.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(c.PreviousCharacter, c.KeepAnchor) char = unicode_type(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(), unicode_type(self.editor.toPlainText())).decode('utf-8')) return True return False def pretty_print(self, name): from calibre.ebooks.oeb.polish.pretty import pretty_html, pretty_css, pretty_xml if self.syntax in {'css', 'html', 'xml'}: func = {'css':pretty_css, 'xml':pretty_xml}.get(self.syntax, pretty_html) original_text = unicode_type(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(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 ItemDelegate(QStyledItemDelegate): # {{{ rename_requested = pyqtSignal(object, object) def setEditorData(self, editor, index): name = unicode_type(index.data(NAME_ROLE) or '') # We do this because Qt calls selectAll() unconditionally on the # editor, and we want only a part of the file name to be selected QTimer.singleShot(0, partial(self.set_editor_data, name, editor)) def set_editor_data(self, name, editor): if sip.isdeleted(editor): return editor.setText(name) ext_pos = name.rfind('.') slash_pos = name.rfind('/') if slash_pos == -1 and ext_pos > 0: editor.setSelection(0, ext_pos) elif ext_pos > -1 and slash_pos > -1 and ext_pos > slash_pos + 1: editor.setSelection(slash_pos + 1, ext_pos - slash_pos - 1) else: editor.selectAll() def setModelData(self, editor, model, index): newname = unicode_type(editor.text()) oldname = unicode_type(index.data(NAME_ROLE) or '') if newname != oldname: self.rename_requested.emit(oldname, newname) def sizeHint(self, option, index): ans = QStyledItemDelegate.sizeHint(self, option, index) top_level = not index.parent().isValid() ans += QSize(0, 20 if top_level else 10) return ans def paint(self, painter, option, index): top_level = not index.parent().isValid() hover = option.state & QStyle.State_MouseOver if hover: if top_level: suffix = '%s(%d)' % (NBSP, index.model().rowCount(index)) else: try: suffix = NBSP + human_readable( current_container().filesize( unicode_type(index.data(NAME_ROLE) or ''))) except EnvironmentError: suffix = NBSP + human_readable(0) br = painter.boundingRect(option.rect, Qt.AlignRight | Qt.AlignVCenter, suffix) if top_level and index.row() > 0: option.rect.adjust(0, 5, 0, 0) painter.drawLine(option.rect.topLeft(), option.rect.topRight()) option.rect.adjust(0, 1, 0, 0) if hover: option.rect.adjust(0, 0, -br.width(), 0) QStyledItemDelegate.paint(self, painter, option, index) if hover: option.rect.adjust(0, 0, br.width(), 0) painter.drawText(option.rect, Qt.AlignRight | Qt.AlignVCenter, suffix)