class Dispatcher(QObject): ''' Convenience class to use Qt signals with arbitrary python callables. By default, ensures that a function call always happens in the thread this Dispatcher was created in. Note that if you create the Dispatcher in a thread without an event loop of its own, the function call will happen in the GUI thread (I think). ''' dispatch_signal = pyqtSignal(object, object) def __init__(self, func, queued=True, parent=None): QObject.__init__(self, parent) self.func = func typ = Qt.QueuedConnection if not queued: typ = Qt.AutoConnection if queued is None else Qt.DirectConnection self.dispatch_signal.connect(self.dispatch, type=typ) def __call__(self, *args, **kwargs): self.dispatch_signal.emit(args, kwargs) def dispatch(self, args, kwargs): self.func(*args, **kwargs)
class UnitedStates(QObject): library_changed = pyqtSignal() def __init__(self): QObject.__init__(self) self.edit_stamp = datetime.datetime(2013, 1, 11, 0, 0, 0, 0) tmp_lsb = os.path.join(tempfile.gettempdir(), "lsb") if os.path.exists(tmp_lsb): shutil.rmtree(tmp_lsb, ignore_errors=True) self.portable_directory = tmp_lsb # self.portable_directory = "windows_logs" self.plugin_url = ('https://github.com/marcellmars/' 'letssharebooks/raw/master/calibreletssharebooks/' 'letssharebooks_calibre.zip') self.running_version = ".".join(map(str, lsb.version)) try: self.latest_version = urllib2.urlopen( 'https://raw.github.com/marcellmars/letssharebooks/master/' 'calibreletssharebooks/_version').read()[:-1].encode("utf-8") except: self.latest_version = "0.0.0" def library_changed_emit(self): self.library_changed.emit()
class TOCViewer(QWidget): navigate_requested = pyqtSignal(object, object) def __init__(self, parent=None): QWidget.__init__(self, parent) self.l = l = QGridLayout(self) self.setLayout(l) l.setContentsMargins(0, 0, 0, 0) self.is_visible = False self.view = QTreeWidget(self) self.delegate = Delegate(self.view) self.view.setItemDelegate(self.delegate) self.view.setHeaderHidden(True) self.view.setAnimated(True) self.view.setContextMenuPolicy(Qt.CustomContextMenu) self.view.customContextMenuRequested.connect(self.show_context_menu, type=Qt.QueuedConnection) self.view.itemActivated.connect(self.emit_navigate) self.view.itemPressed.connect(self.item_pressed) pi = plugins['progress_indicator'][0] if hasattr(pi, 'set_no_activate_on_click'): pi.set_no_activate_on_click(self.view) self.view.itemDoubleClicked.connect(self.emit_navigate) l.addWidget(self.view) self.refresh_action = QAction(QIcon(I('view-refresh.png')), _('&Refresh'), self) self.refresh_action.triggered.connect(self.build) def item_pressed(self, item): if QApplication.mouseButtons() & Qt.LeftButton: QTimer.singleShot(0, self.emit_navigate) def show_context_menu(self, pos): menu = QMenu(self) menu.addAction(actions['edit-toc']) menu.addAction(_('&Expand all'), self.view.expandAll) menu.addAction(_('&Collapse all'), self.view.collapseAll) menu.addAction(self.refresh_action) menu.exec_(self.view.mapToGlobal(pos)) def iteritems(self, parent=None): if parent is None: parent = self.invisibleRootItem() for i in xrange(parent.childCount()): child = parent.child(i) yield child for gc in self.iteritems(parent=child): yield gc def emit_navigate(self, *args): item = self.view.currentItem() if item is not None: dest = unicode(item.data(0, DEST_ROLE).toString()) frag = unicode(item.data(0, FRAG_ROLE).toString()) if not frag: frag = TOP self.navigate_requested.emit(dest, frag) def build(self): c = current_container() if c is None: return toc = get_toc(c, verify_destinations=False) def process_node(toc, parent): for child in toc: node = QTreeWidgetItem(parent) node.setText(0, child.title or '') node.setData(0, DEST_ROLE, child.dest or '') node.setData(0, FRAG_ROLE, child.frag or '') tt = _('File: {0}\nAnchor: {1}').format( child.dest or '', child.frag or _('Top of file')) node.setData(0, Qt.ToolTipRole, tt) process_node(child, node) self.view.clear() process_node(toc, self.view.invisibleRootItem()) def visibility_changed(self, visible): self.is_visible = visible if visible: self.build() def update_if_visible(self): if self.is_visible: self.build()
class GridView(QListView): update_item = pyqtSignal(object) files_dropped = pyqtSignal(object) def __init__(self, parent): QListView.__init__(self, parent) setup_dnd_interface(self) self.setUniformItemSizes(True) self.setWrapping(True) self.setFlow(self.LeftToRight) # We cannot set layout mode to batched, because that breaks # restore_vpos() # self.setLayoutMode(self.Batched) self.setResizeMode(self.Adjust) self.setSelectionMode(self.ExtendedSelection) self.setVerticalScrollMode(self.ScrollPerPixel) self.delegate = CoverDelegate(self) self.delegate.animation.valueChanged.connect( self.animation_value_changed) self.delegate.animation.finished.connect(self.animation_done) self.setItemDelegate(self.delegate) self.setSpacing(self.delegate.spacing) self.padding_left = 0 self.set_color() self.ignore_render_requests = Event() self.thumbnail_cache = ThumbnailCache( max_size=gprefs['cover_grid_disk_cache_size'], thumbnail_size=(self.delegate.cover_size.width(), self.delegate.cover_size.height())) self.render_thread = None self.update_item.connect(self.re_render, type=Qt.QueuedConnection) self.doubleClicked.connect(self.double_clicked) self.setCursor(Qt.PointingHandCursor) self.gui = parent self.context_menu = None self.update_timer = QTimer(self) self.update_timer.setInterval(200) self.update_timer.timeout.connect(self.update_viewport) self.update_timer.setSingleShot(True) @property def first_visible_row(self): geom = self.viewport().geometry() for y in xrange(geom.top(), (self.spacing() * 2) + geom.top(), 5): for x in xrange(geom.left(), (self.spacing() * 2) + geom.left(), 5): ans = self.indexAt(QPoint(x, y)).row() if ans > -1: return ans @property def last_visible_row(self): geom = self.viewport().geometry() for y in xrange(geom.bottom(), geom.bottom() - 2 * self.spacing(), -5): for x in xrange(geom.left(), (self.spacing() * 2) + geom.left(), 5): ans = self.indexAt(QPoint(x, y)).row() if ans > -1: item_width = self.delegate.item_size.width( ) + 2 * self.spacing() return ans + (geom.width() // item_width) def update_viewport(self): self.ignore_render_requests.clear() self.update_timer.stop() m = self.model() for r in xrange(self.first_visible_row or 0, self.last_visible_row or (m.count() - 1)): self.update(m.index(r, 0)) def double_clicked(self, index): d = self.delegate if d.animating is None and not config['disable_animations']: d.animating = index d.animation.start() if tweaks['doubleclick_on_library_view'] == 'open_viewer': self.gui.iactions['View'].view_triggered(index) elif tweaks['doubleclick_on_library_view'] in { 'edit_metadata', 'edit_cell' }: self.gui.iactions['Edit Metadata'].edit_metadata(False, False) def animation_value_changed(self, value): if self.delegate.animating is not None: self.update(self.delegate.animating) def animation_done(self): if self.delegate.animating is not None: idx = self.delegate.animating self.delegate.animating = None self.update(idx) def set_color(self): r, g, b = gprefs['cover_grid_color'] pal = QPalette() col = QColor(r, g, b) pal.setColor(pal.Base, col) tex = gprefs['cover_grid_texture'] if tex: from calibre.gui2.preferences.texture_chooser import texture_path path = texture_path(tex) if path: pm = QPixmap(path) if not pm.isNull(): val = pm.scaled(1, 1).toImage().pixel(0, 0) r, g, b = qRed(val), qGreen(val), qBlue(val) pal.setBrush(pal.Base, QBrush(pm)) dark = (r + g + b) / 3.0 < 128 pal.setColor(pal.Text, QColor(Qt.white if dark else Qt.black)) self.setPalette(pal) self.delegate.highlight_color = pal.color(pal.Text) def refresh_settings(self): size_changed = ( gprefs['cover_grid_width'] != self.delegate.original_width or gprefs['cover_grid_height'] != self.delegate.original_height) if (size_changed or gprefs['cover_grid_show_title'] != self.delegate.original_show_title): self.delegate.set_dimensions() self.setSpacing(self.delegate.spacing) if size_changed: self.delegate.cover_cache.clear() if gprefs['cover_grid_spacing'] != self.delegate.original_spacing: self.delegate.calculate_spacing() self.setSpacing(self.delegate.spacing) self.set_color() self.delegate.cover_cache.set_limit(gprefs['cover_grid_cache_size']) if size_changed: self.thumbnail_cache.set_thumbnail_size( self.delegate.cover_size.width(), self.delegate.cover_size.height()) cs = gprefs['cover_grid_disk_cache_size'] if (cs * (1024**2)) != self.thumbnail_cache.max_size: self.thumbnail_cache.set_size(cs) def shown(self): if self.render_thread is None: self.thumbnail_cache.set_database(self.gui.current_db) self.render_thread = Thread(target=self.render_covers) self.render_thread.daemon = True self.render_thread.start() def render_covers(self): q = self.delegate.render_queue while True: book_id = q.get() try: if book_id is None: return if self.ignore_render_requests.is_set(): continue try: self.render_cover(book_id) except: import traceback traceback.print_exc() finally: q.task_done() def render_cover(self, book_id): if self.ignore_render_requests.is_set(): return tcdata, timestamp = self.thumbnail_cache[book_id] use_cache = False if timestamp is None: # Not in cache has_cover, cdata, timestamp = self.model( ).db.new_api.cover_or_cache(book_id, 0) else: has_cover, cdata, timestamp = self.model( ).db.new_api.cover_or_cache(book_id, timestamp) if has_cover and cdata is None: # The cached cover is fresh cdata = tcdata use_cache = True if has_cover: p = QImage() p.loadFromData(cdata, CACHE_FORMAT if cdata is tcdata else 'JPEG') if p.isNull() and cdata is tcdata: # Invalid image in cache self.thumbnail_cache.invalidate((book_id, )) self.update_item.emit(book_id) return cdata = None if p.isNull() else p if not use_cache: # cache is stale if cdata is not None: width, height = p.width(), p.height() scaled, nwidth, nheight = fit_image( width, height, self.delegate.cover_size.width(), self.delegate.cover_size.height()) if scaled: if self.ignore_render_requests.is_set(): return p = p.scaled(nwidth, nheight, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) cdata = p # update cache if cdata is None: self.thumbnail_cache.invalidate((book_id, )) else: try: self.thumbnail_cache.insert(book_id, timestamp, image_to_data(cdata)) except EncodeError as err: self.thumbnail_cache.invalidate((book_id, )) prints(err) except Exception: import traceback traceback.print_exc() elif tcdata is not None: # Cover was removed, but it exists in cache, remove from cache self.thumbnail_cache.invalidate((book_id, )) self.delegate.cover_cache.set(book_id, cdata) self.update_item.emit(book_id) def re_render(self, book_id): self.delegate.cover_cache.clear_staging() m = self.model() try: index = m.db.row(book_id) except (IndexError, ValueError, KeyError): return self.update(m.index(index, 0)) def shutdown(self): self.ignore_render_requests.set() self.delegate.render_queue.put(None) self.thumbnail_cache.shutdown() def set_database(self, newdb, stage=0): if stage == 0: self.ignore_render_requests.set() try: for x in (self.delegate.cover_cache, self.thumbnail_cache): self.model().db.new_api.remove_cover_cache(x) except AttributeError: pass # db is None for x in (self.delegate.cover_cache, self.thumbnail_cache): newdb.new_api.add_cover_cache(x) try: # Use a timeout so that if, for some reason, the render thread # gets stuck, we dont deadlock, future covers wont get # rendered, but this is better than a deadlock join_with_timeout(self.delegate.render_queue) except RuntimeError: print('Cover rendering thread is stuck!') finally: self.ignore_render_requests.clear() else: self.delegate.cover_cache.clear() def select_rows(self, rows): sel = QItemSelection() sm = self.selectionModel() m = self.model() # Create a range based selector for each set of contiguous rows # as supplying selectors for each individual row causes very poor # performance if a large number of rows has to be selected. for k, g in itertools.groupby(enumerate(rows), lambda (i, x): i - x): group = list(map(operator.itemgetter(1), g)) sel.merge( QItemSelection(m.index(min(group), 0), m.index(max(group), 0)), sm.Select) sm.select(sel, sm.ClearAndSelect)
class CoverDelegate(QStyledItemDelegate): # {{{ needs_redraw = pyqtSignal() def __init__(self, parent): QStyledItemDelegate.__init__(self, parent) self.angle = 0 self.timer = QTimer(self) self.timer.timeout.connect(self.frame_changed) self.color = parent.palette().color(QPalette.WindowText) self.spinner_width = 64 def frame_changed(self, *args): self.angle = (self.angle + 30) % 360 self.needs_redraw.emit() def start_animation(self): self.angle = 0 self.timer.start(200) def stop_animation(self): self.timer.stop() def draw_spinner(self, painter, rect): width = rect.width() outer_radius = (width - 1) * 0.5 inner_radius = (width - 1) * 0.5 * 0.38 capsule_height = outer_radius - inner_radius capsule_width = int(capsule_height * (0.23 if width > 32 else 0.35)) capsule_radius = capsule_width // 2 painter.save() painter.setRenderHint(painter.Antialiasing) for i in xrange(12): color = QColor(self.color) color.setAlphaF(1.0 - (i / 12.0)) painter.setPen(Qt.NoPen) painter.setBrush(color) painter.save() painter.translate(rect.center()) painter.rotate(self.angle - i * 30.0) painter.drawRoundedRect(-capsule_width * 0.5, -(inner_radius + capsule_height), capsule_width, capsule_height, capsule_radius, capsule_radius) painter.restore() painter.restore() def paint(self, painter, option, index): QStyledItemDelegate.paint(self, painter, option, index) style = QApplication.style() waiting = self.timer.isActive() and index.data(Qt.UserRole).toBool() if waiting: rect = QRect(0, 0, self.spinner_width, self.spinner_width) rect.moveCenter(option.rect.center()) self.draw_spinner(painter, rect) else: # Ensure the cover is rendered over any selection rect style.drawItemPixmap(painter, option.rect, Qt.AlignTop | Qt.AlignHCenter, QPixmap(index.data(Qt.DecorationRole)))
class IdentifyWidget(QWidget): # {{{ rejected = pyqtSignal() results_found = pyqtSignal() book_selected = pyqtSignal(object, object) def __init__(self, log, parent=None): QWidget.__init__(self, parent) self.log = log self.abort = Event() self.caches = {} self.l = l = QGridLayout() self.setLayout(l) names = [ '<b>' + p.name + '</b>' for p in metadata_plugins(['identify']) if p.is_configured() ] self.top = QLabel('<p>' + _('calibre is downloading metadata from: ') + ', '.join(names)) self.top.setWordWrap(True) l.addWidget(self.top, 0, 0) self.results_view = ResultsView(self) self.results_view.book_selected.connect(self.emit_book_selected) self.get_result = self.results_view.get_result l.addWidget(self.results_view, 1, 0) self.comments_view = Comments(self) l.addWidget(self.comments_view, 1, 1) self.results_view.show_details_signal.connect( self.comments_view.show_data) self.query = QLabel('download starting...') self.query.setWordWrap(True) l.addWidget(self.query, 2, 0, 1, 2) self.comments_view.show_data('<h2>' + _('Please wait') + '<br><span id="dots">.</span></h2>' + ''' <script type="text/javascript"> window.onload=function(){ var dotspan = document.getElementById('dots'); window.setInterval(function(){ if(dotspan.textContent == '............'){ dotspan.textContent = '.'; } else{ dotspan.textContent += '.'; } }, 400); } </script> ''') def emit_book_selected(self, book): self.book_selected.emit(book, self.caches) def start(self, title=None, authors=None, identifiers={}): self.log.clear() self.log('Starting download') parts, simple_desc = [], '' if title: parts.append('title:' + title) simple_desc += _('Title: %s ') % title if authors: parts.append('authors:' + authors_to_string(authors)) simple_desc += _('Authors: %s ') % authors_to_string(authors) if identifiers: x = ', '.join('%s:%s' % (k, v) for k, v in identifiers.iteritems()) parts.append(x) if 'isbn' in identifiers: simple_desc += ' ISBN: %s' % identifiers['isbn'] self.query.setText(simple_desc) self.log(unicode(self.query.text())) self.worker = IdentifyWorker(self.log, self.abort, title, authors, identifiers, self.caches) self.worker.start() QTimer.singleShot(50, self.update) def update(self): if self.worker.is_alive(): QTimer.singleShot(50, self.update) else: self.process_results() def process_results(self): if self.worker.error is not None: error_dialog(self, _('Download failed'), _('Failed to download metadata. Click ' 'Show Details to see details'), show=True, det_msg=self.worker.error) self.rejected.emit() return if not self.worker.results: log = ''.join(self.log.plain_text) error_dialog( self, _('No matches found'), '<p>' + _('Failed to find any books that ' 'match your search. Try making the search <b>less ' 'specific</b>. For example, use only the author\'s ' 'last name and a single distinctive word from ' 'the title.<p>To see the full log, click Show Details.'), show=True, det_msg=log) self.rejected.emit() return self.results_view.show_results(self.worker.results) self.results_found.emit() def cancel(self): self.abort.set()
class 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.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) 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 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' + _('{0} [{1} books]').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) 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.qs_locations = [i[1] for i in locations] self.rename_menu.clear() self.delete_menu.clear() quick_actions, rename_actions, delete_actions = [], [], [] for name, loc in locations: ac = self.quick_menu.addAction( name, Dispatcher(partial(self.switch_requested, loc))) quick_actions.append(ac) ac = self.rename_menu.addAction( name, Dispatcher(partial(self.rename_requested, name, loc))) rename_actions.append(ac) ac = self.delete_menu.addAction( name, Dispatcher(partial(self.delete_requested, name, loc))) delete_actions.append(ac) qs_actions = [] for i, x in enumerate(locations[:len(self.switch_actions)]): name, loc = x ac = self.switch_actions[i] ac.setText(name) ac.setVisible(True) qs_actions.append(ac) 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) 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) newname, ok = QInputDialog.getText( self.gui, _('Rename') + ' ' + name, '<p>' + _('Choose a new name for the library <b>%s</b>. ') % name + '<p>' + _('Note that the actual library folder will be renamed.'), text=name) newname = sanitize_file_name_unicode(unicode(newname)) if not ok or not newname or newname == 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) self.stats.remove(location) self.build_menus() self.gui.iactions['Copy To Library'].build_menus() info_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.') % loc, show=True) 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, DBCheckNew self.gui.library_view.save_state() m = self.gui.library_view.model() m.stop_metadata_backup() db = m.db db.prefs.disable_setting = True if hasattr(db, 'new_api'): d = DBCheckNew(self.gui, db) else: d = DBCheck(self.gui, db) d.start() try: d.conn.close() except: pass d.break_cycles() self.gui.library_moved(db.library_path, call_close=not d.closed_orig_conn) 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 PyQt4.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_() self.choose_dialog_library_renamed = getattr(c, 'library_renamed', False) def choose_library_callback(self, newloc, copy_structure=False): self.gui.library_moved(newloc, copy_structure=copy_structure, allow_rebuild=True) if getattr(self, 'choose_dialog_library_renamed', False): 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 return True
class Completer(QListView): # {{{ item_selected = pyqtSignal(object) relayout_needed = pyqtSignal() def __init__(self, completer_widget, max_visible_items=7): QListView.__init__(self) self.completer_widget = weakref.ref(completer_widget) self.setWindowFlags(Qt.Popup) self.max_visible_items = max_visible_items self.setEditTriggers(self.NoEditTriggers) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setSelectionBehavior(self.SelectRows) self.setSelectionMode(self.SingleSelection) self.setAlternatingRowColors(True) self.setModel(CompleteModel(self)) self.setMouseTracking(True) self.entered.connect(self.item_entered) self.activated.connect(self.item_chosen) self.pressed.connect(self.item_chosen) self.installEventFilter(self) def hide(self): self.setCurrentIndex(QModelIndex()) QListView.hide(self) def item_chosen(self, index): if not self.isVisible(): return self.hide() text = self.model().data(index, Qt.DisplayRole) self.item_selected.emit(unicode(text)) def set_items(self, items): self.model().set_items(items) if self.isVisible(): self.relayout_needed.emit() def set_completion_prefix(self, prefix): self.model().set_completion_prefix(prefix) if self.isVisible(): self.relayout_needed.emit() def item_entered(self, idx): self.setCurrentIndex(idx) def next_match(self, previous=False): c = self.currentIndex() if c.isValid(): r = c.row() else: r = self.model().rowCount() if previous else -1 r = r + (-1 if previous else 1) index = self.model().index(r % self.model().rowCount()) self.setCurrentIndex(index) def scroll_to(self, orig): if orig: index = self.model().index_for_prefix(orig) if index is not None and index.isValid(): self.setCurrentIndex(index) def popup(self, select_first=True): p = self m = p.model() widget = self.completer_widget() if widget is None: return screen = QApplication.desktop().availableGeometry(widget) h = (p.sizeHintForRow(0) * min(self.max_visible_items, m.rowCount()) + 3) + 3 hsb = p.horizontalScrollBar() if hsb and hsb.isVisible(): h += hsb.sizeHint().height() rh = widget.height() pos = widget.mapToGlobal(QPoint(0, widget.height() - 2)) w = min(widget.width(), screen.width()) if (pos.x() + w) > (screen.x() + screen.width()): pos.setX(screen.x() + screen.width() - w) if pos.x() < screen.x(): pos.setX(screen.x()) top = pos.y() - rh - screen.top() + 2 bottom = screen.bottom() - pos.y() h = max(h, p.minimumHeight()) if h > bottom: h = min(max(top, bottom), h) if top > bottom: pos.setY(pos.y() - h - rh + 2) p.setGeometry(pos.x(), pos.y(), w, h) if (tweaks['preselect_first_completion'] and select_first and not self.currentIndex().isValid() and self.model().rowCount() > 0): self.setCurrentIndex(self.model().index(0)) if not p.isVisible(): if isosx and get_osx_version() >= (10, 9, 0): # On mavericks the popup menu seems to use a font smaller than # the widgets font, see for example: # https://bugs.launchpad.net/bugs/1243761 fp = QFontInfo(widget.font()) f = QFont() f.setPixelSize(fp.pixelSize()) self.setFont(f) p.show() def eventFilter(self, obj, e): 'Redirect key presses from the popup to the widget' widget = self.completer_widget() if widget is None or sip.isdeleted(widget): return False etype = e.type() if obj is not self: return QObject.eventFilter(self, obj, e) if etype == e.KeyPress: key = e.key() if key == Qt.Key_Escape: self.hide() e.accept() return True if key == Qt.Key_F4 and e.modifiers() & Qt.AltModifier: self.hide() e.accept() return True if key in (Qt.Key_Enter, Qt.Key_Return): # We handle this explicitly because on OS X activated() is # not emitted on pressing Enter. idx = self.currentIndex() if idx.isValid(): self.item_chosen(idx) self.hide() e.accept() return True if key == Qt.Key_Tab: idx = self.currentIndex() if idx.isValid(): self.item_chosen(idx) self.hide() elif self.model().rowCount() > 0: self.next_match() e.accept() return True if key in (Qt.Key_PageUp, Qt.Key_PageDown): # Let the list view handle these keys return False if key in (Qt.Key_Up, Qt.Key_Down): self.next_match(previous=key == Qt.Key_Up) e.accept() return True # Send to widget widget.eat_focus_out = False widget.keyPressEvent(e) widget.eat_focus_out = True if not widget.hasFocus(): # Widget lost focus hide the popup self.hide() if e.isAccepted(): return True elif etype == e.MouseButtonPress: if not self.underMouse(): self.hide() e.accept() return True elif etype in (e.InputMethod, e.ShortcutOverride): QApplication.sendEvent(widget, e) return False
class SavedSearches(Dialog): run_saved_searches = pyqtSignal(object, object) def __init__(self, parent=None): Dialog.__init__(self, _('Saved Searches'), 'saved-searches', parent=parent) def sizeHint(self): return QSize(800, 675) def setup_ui(self): self.l = l = QVBoxLayout(self) self.setLayout(l) self.h = h = QHBoxLayout() self.filter_text = ft = QLineEdit(self) ft.textChanged.connect(self.do_filter) ft.setPlaceholderText(_('Filter displayed searches')) h.addWidget(ft) self.cft = cft = QToolButton(self) cft.setToolTip(_('Clear filter')), cft.setIcon( QIcon(I('clear_left.png'))) cft.clicked.connect(ft.clear) h.addWidget(cft) l.addLayout(h) self.h2 = h = QHBoxLayout() self.searches = searches = QListView(self) searches.doubleClicked.connect(self.edit_search) self.model = SearchesModel(self.searches) self.model.dataChanged.connect(self.show_details) searches.setModel(self.model) searches.selectionModel().currentChanged.connect(self.show_details) searches.setSelectionMode(searches.ExtendedSelection) self.delegate = SearchDelegate(searches) searches.setItemDelegate(self.delegate) searches.setAlternatingRowColors(True) h.addWidget(searches, stretch=10) self.v = v = QVBoxLayout() h.addLayout(v) l.addLayout(h) def pb(text, tooltip=None): b = QPushButton(text, self) b.setToolTip(tooltip or '') b.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) return b mulmsg = '\n\n' + _( 'The entries are tried in order until the first one matches.') for text, action, tooltip in [ (_('&Find'), 'find', _('Run the search using the selected entries.') + mulmsg), (_('&Replace'), 'replace', _('Run replace using the selected entries.') + mulmsg), (_('Replace a&nd Find'), 'replace-find', _('Run replace and then find using the selected entries.') + mulmsg), (_('Replace &all'), 'replace-all', _('Run Replace All for all selected entries in the order selected' )), (_('&Count all'), 'count', _('Run Count All for all selected entries')), ]: b = pb(text, tooltip) v.addWidget(b) b.clicked.connect(partial(self.run_search, action)) self.d1 = d = QFrame(self) d.setFrameStyle(QFrame.HLine) v.addWidget(d) self.h3 = h = QHBoxLayout() self.upb = b = QToolButton(self) b.setIcon(QIcon(I('arrow-up.png'))), b.setToolTip( _('Move selected entries up')) b.clicked.connect(partial(self.move_entry, -1)) self.dnb = b = QToolButton(self) b.setIcon(QIcon(I('arrow-down.png'))), b.setToolTip( _('Move selected entries down')) b.clicked.connect(partial(self.move_entry, 1)) h.addWidget(self.upb), h.addWidget(self.dnb) v.addLayout(h) self.eb = b = pb(_('&Edit search'), _('Edit the currently selected search')) b.clicked.connect(self.edit_search) v.addWidget(b) self.eb = b = pb(_('Re&move search'), _('Remove the currently selected searches')) b.clicked.connect(self.remove_search) v.addWidget(b) self.eb = b = pb(_('&Add search'), _('Add a new saved search')) b.clicked.connect(self.add_search) v.addWidget(b) self.d2 = d = QFrame(self) d.setFrameStyle(QFrame.HLine) v.addWidget(d) self.where_box = wb = WhereBox(self, emphasize=True) self.where = SearchWidget.DEFAULT_STATE['where'] v.addWidget(wb) self.direction_box = db = DirectionBox(self) self.direction = SearchWidget.DEFAULT_STATE['direction'] v.addWidget(db) self.wr = wr = QCheckBox(_('&Wrap')) wr.setToolTip('<p>' + _( 'When searching reaches the end, wrap around to the beginning and continue the search' )) self.wr.setChecked(SearchWidget.DEFAULT_STATE['wrap']) v.addWidget(wr) self.description = d = QLabel(' \n \n ') d.setTextFormat(Qt.PlainText) l.addWidget(d) l.addWidget(self.bb) self.bb.clear() self.bb.addButton(self.bb.Close) self.ib = b = self.bb.addButton(_('&Import'), self.bb.ActionRole) b.clicked.connect(self.import_searches) self.eb = b = self.bb.addButton(_('E&xport'), self.bb.ActionRole) self.em = m = QMenu(_('Export')) m.addAction( _('Export All'), lambda: QTimer.singleShot( 0, partial(self.export_searches, all=True))) m.addAction( _('Export Selected'), lambda: QTimer.singleShot( 0, partial(self.export_searches, all=False))) b.setMenu(m) self.searches.setFocus(Qt.OtherFocusReason) @dynamic_property def where(self): def fget(self): return self.where_box.where def fset(self, val): self.where_box.where = val return property(fget=fget, fset=fset) @dynamic_property def direction(self): def fget(self): return self.direction_box.direction def fset(self, val): self.direction_box.direction = val return property(fget=fget, fset=fset) @dynamic_property def wrap(self): def fget(self): return self.wr.isChecked() def fset(self, val): self.wr.setChecked(bool(val)) return property(fget=fget, fset=fset) def do_filter(self, text): self.model.do_filter(text) self.searches.scrollTo(self.model.index(0)) def run_search(self, action): searches, seen = [], set() for index in self.searches.selectionModel().selectedIndexes(): if index.row() in seen: continue seen.add(index.row()) search = SearchWidget.DEFAULT_STATE.copy() del search['mode'] search_index, s = index.data(Qt.UserRole).toPyObject() search.update(s) search['wrap'] = self.wrap search['direction'] = self.direction search['where'] = self.where search['mode'] = search.get('mode', 'regex') searches.append(search) if not searches: return self.run_saved_searches.emit(searches, action) def move_entry(self, delta): rows = { index.row() for index in self.searches.selectionModel().selectedIndexes() } - {-1} if rows: with tprefs: for row in sorted(rows, reverse=delta > 0): self.model.move_entry(row, delta) nrow = row + delta index = self.model.index(nrow) if index.isValid(): sm = self.searches.selectionModel() sm.setCurrentIndex(index, sm.ClearAndSelect) def edit_search(self): index = self.searches.currentIndex() if index.isValid(): search_index, search = index.data(Qt.UserRole).toPyObject() d = EditSearch(search=search, search_index=search_index, parent=self) if d.exec_() == d.Accepted: self.model.dataChanged.emit(index, index) def remove_search(self): rows = { index.row() for index in self.searches.selectionModel().selectedIndexes() } - {-1} self.model.remove_searches(rows) self.show_details() def add_search(self): d = EditSearch(parent=self) self._add_search(d) def _add_search(self, d): if d.exec_() == d.Accepted: self.model.add_searches() index = self.model.index(self.model.rowCount() - 1) self.searches.scrollTo(index) sm = self.searches.selectionModel() sm.setCurrentIndex(index, sm.ClearAndSelect) self.show_details() def add_predefined_search(self, state): d = EditSearch(parent=self, state=state) self._add_search(d) def show_details(self): self.description.setText(' \n \n ') i = self.searches.currentIndex() if i.isValid(): search_index, search = i.data(Qt.UserRole).toPyObject() cs = '✓' if search.get( 'case_sensitive', SearchWidget.DEFAULT_STATE['case_sensitive']) else '✗' da = '✓' if search.get( 'dot_all', SearchWidget.DEFAULT_STATE['dot_all']) else '✗' if search.get('mode', SearchWidget.DEFAULT_STATE['mode']) == 'regex': ts = _('(Case sensitive: {0} Dot All: {1})').format(cs, da) else: ts = _('(Case sensitive: {0} [Normal search])').format(cs) self.description.setText( _('{2} {3}\nFind: {0}\nReplace: {1}').format( search.get('find', ''), search.get('replace', ''), search.get('name', ''), ts)) def import_searches(self): path = choose_files(self, 'import_saved_searches', _('Choose file'), filters=[(_('Saved Searches'), ['json'])], all_files=False, select_only_single_file=True) if path: with open(path[0], 'rb') as f: obj = json.loads(f.read()) needed_keys = { 'name', 'find', 'replace', 'case_sensitive', 'dot_all', 'mode' } def err(): error_dialog( self, _('Invalid data'), _('The file %s does not contain valid saved searches') % path, show=True) if not isinstance( obj, dict ) or 'version' not in obj or 'searches' not in obj or obj[ 'version'] not in (1, ): return err() searches = [] for item in obj['searches']: if not isinstance(item, dict) or not set( item.iterkeys()).issuperset(needed_keys): return err searches.append({k: item[k] for k in needed_keys}) if searches: tprefs['saved_searches'] = tprefs['saved_searches'] + searches count = len(searches) self.model.add_searches(count=count) sm = self.searches.selectionModel() top, bottom = self.model.index(self.model.rowCount() - count), self.model.index( self.model.rowCount() - 1) sm.select(QItemSelection(top, bottom), sm.ClearAndSelect) self.searches.scrollTo(bottom) def export_searches(self, all=True): if all: searches = copy.deepcopy(tprefs['saved_searches']) if not searches: return error_dialog(self, _('No searches'), _('No searches available to be saved'), show=True) else: searches = [] for index in self.searches.selectionModel().selectedIndexes(): search = index.data(Qt.UserRole).toPyObject()[-1] searches.append(search.copy()) if not searches: return error_dialog(self, _('No searches'), _('No searches selected'), show=True) [s.__setitem__('mode', s.get('mode', 'regex')) for s in searches] path = choose_save_file(self, 'export-saved-searches', _('Choose file'), filters=[(_('Saved Searches'), ['json'])], all_files=False) if path: if not path.lower().endswith('.json'): path += '.json' raw = json.dumps({ 'version': 1, 'searches': searches }, ensure_ascii=False, indent=2, sort_keys=True) with open(path, 'wb') as f: f.write(raw.encode('utf-8'))
class SearchWidget(QWidget): DEFAULT_STATE = { 'mode': 'normal', 'where': 'current', 'case_sensitive': False, 'direction': 'down', 'wrap': True, 'dot_all': False, } search_triggered = pyqtSignal(object) save_search = pyqtSignal() show_saved_searches = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent) self.l = l = QGridLayout(self) l.setContentsMargins(0, 0, 0, 0) self.setLayout(l) self.fl = fl = QLabel(_('&Find:')) fl.setAlignment(Qt.AlignRight | Qt.AlignCenter) self.find_text = ft = HistoryLineEdit(self, _('Clear search history')) ft.save_search.connect(self.save_search) ft.show_saved_searches.connect(self.show_saved_searches) ft.initialize('tweak_book_find_edit') ft.returnPressed.connect(lambda: self.search_triggered.emit('find')) fl.setBuddy(ft) l.addWidget(fl, 0, 0) l.addWidget(ft, 0, 1) self.rl = rl = QLabel(_('&Replace:')) rl.setAlignment(Qt.AlignRight | Qt.AlignCenter) self.replace_text = rt = HistoryLineEdit(self, _('Clear replace history')) rt.save_search.connect(self.save_search) rt.show_saved_searches.connect(self.show_saved_searches) rt.initialize('tweak_book_replace_edit') rl.setBuddy(rt) l.addWidget(rl, 1, 0) l.addWidget(rt, 1, 1) l.setColumnStretch(1, 10) self.fb = fb = PushButton(_('&Find'), 'find', self) self.rfb = rfb = PushButton(_('Replace a&nd Find'), 'replace-find', self) self.rb = rb = PushButton(_('&Replace'), 'replace', self) self.rab = rab = PushButton(_('Replace &all'), 'replace-all', self) l.addWidget(fb, 0, 2) l.addWidget(rfb, 0, 3) l.addWidget(rb, 1, 2) l.addWidget(rab, 1, 3) self.ml = ml = QLabel(_('&Mode:')) self.ol = ol = QHBoxLayout() ml.setAlignment(Qt.AlignRight | Qt.AlignCenter) l.addWidget(ml, 2, 0) l.addLayout(ol, 2, 1, 1, 3) self.mode_box = mb = ModeBox(self) mb.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) ml.setBuddy(mb) ol.addWidget(mb) self.where_box = wb = WhereBox(self) wb.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) ol.addWidget(wb) self.direction_box = db = DirectionBox(self) db.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) ol.addWidget(db) self.cs = cs = QCheckBox(_('&Case sensitive')) cs.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) ol.addWidget(cs) self.wr = wr = QCheckBox(_('&Wrap')) wr.setToolTip('<p>' + _( 'When searching reaches the end, wrap around to the beginning and continue the search' )) wr.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) ol.addWidget(wr) self.da = da = QCheckBox(_('&Dot all')) da.setToolTip('<p>' + _( "Make the '.' special character match any character at all, including a newline" )) da.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum) ol.addWidget(da) self.mode_box.currentIndexChanged[int].connect(self.da.setVisible) ol.addStretch(10) @dynamic_property def mode(self): def fget(self): return self.mode_box.mode def fset(self, val): self.mode_box.mode = val self.da.setVisible(self.mode == 'regex') return property(fget=fget, fset=fset) @dynamic_property def find(self): def fget(self): return unicode(self.find_text.text()) def fset(self, val): self.find_text.setText(val) return property(fget=fget, fset=fset) @dynamic_property def replace(self): def fget(self): return unicode(self.replace_text.text()) def fset(self, val): self.replace_text.setText(val) return property(fget=fget, fset=fset) @dynamic_property def where(self): def fget(self): return self.where_box.where def fset(self, val): self.where_box.where = val return property(fget=fget, fset=fset) @dynamic_property def case_sensitive(self): def fget(self): return self.cs.isChecked() def fset(self, val): self.cs.setChecked(bool(val)) return property(fget=fget, fset=fset) @dynamic_property def direction(self): def fget(self): return self.direction_box.direction def fset(self, val): self.direction_box.direction = val return property(fget=fget, fset=fset) @dynamic_property def wrap(self): def fget(self): return self.wr.isChecked() def fset(self, val): self.wr.setChecked(bool(val)) return property(fget=fget, fset=fset) @dynamic_property def dot_all(self): def fget(self): return self.da.isChecked() def fset(self, val): self.da.setChecked(bool(val)) return property(fget=fget, fset=fset) @dynamic_property def state(self): def fget(self): return {x: getattr(self, x) for x in self.DEFAULT_STATE} def fset(self, val): for x in self.DEFAULT_STATE: if x in val: setattr(self, x, val[x]) return property(fget=fget, fset=fset) def restore_state(self): self.state = tprefs.get('find-widget-state', self.DEFAULT_STATE) if self.where == 'selected-text': self.where = self.DEFAULT_STATE['where'] def save_state(self): tprefs.set('find-widget-state', self.state) def pre_fill(self, text): if self.mode == 'regex': text = regex.escape(text, special_only=True) self.find = text self.find_text.setSelection(0, len(text) + 10)
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) def rename_editor(self, editor, name): for i in xrange(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 xrange(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 xrange(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 xrange(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() 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 MainWindowFrontend(QtGui.QMainWindow): printSignal = pyqtSignal(str) quitSignal = pyqtSignal() selectGxSignal = pyqtSignal(int) selectCidSignal = pyqtSignal(int) selectResSignal = pyqtSignal(int) selectNameSignal = pyqtSignal(str) changeCidSignal = pyqtSignal(int, str, str) changeGxSignal = pyqtSignal(int, str, bool) querySignal = pyqtSignal() def __init__(front, back): super(MainWindowFrontend, front).__init__() #print('[*front] creating frontend') front.prev_tbl_item = None front.ostream = None front.back = back front.ui = init_ui(front) # Progress bar is not hooked up yet #front.ui.progressBar.setVisible(False) front.connect_signals() front.steal_stdout() def steal_stdout(front): return _steal_stdout(front) def return_stdout(front): return _return_stdout(front) # TODO: this code is duplicated in back def user_info(front, *args, **kwargs): return guitools.user_info(front, *args, **kwargs) def user_input(front, *args, **kwargs): return guitools.user_input(front, *args, **kwargs) def user_option(front, *args, **kwargs): return guitools.user_option(front, *args, **kwargs) @slot_() def closeEvent(front, event): #front.printSignal.emit('[*front] closeEvent') event.accept() front.quitSignal.emit() def connect_signals(front): # Connect signals to slots back = front.back ui = front.ui # Frontend Signals front.printSignal.connect(back.backend_print) front.quitSignal.connect(back.quit) front.selectGxSignal.connect(back.select_gx) front.selectCidSignal.connect(back.select_cid) front.selectResSignal.connect(back.select_res_cid) front.selectNameSignal.connect(back.select_name) front.changeCidSignal.connect(back.change_chip_property) front.changeGxSignal.connect(back.change_image_property) front.querySignal.connect(back.query) # Menubar signals connect_file_signals(front) connect_action_signals(front) connect_option_signals(front) connect_batch_signals(front) connect_button_signals(front) #-MD #connect_experimental_signals(front) connect_help_signals(front) # # Gui Components # Tables Widgets ui.cxs_TBL.itemClicked.connect(front.chip_tbl_clicked) ui.cxs_TBL.itemChanged.connect(front.chip_tbl_changed) ui.gxs_TBL.itemClicked.connect(front.img_tbl_clicked) ui.gxs_TBL.itemChanged.connect(front.img_tbl_changed) ui.res_TBL.itemClicked.connect(front.res_tbl_clicked) ui.res_TBL.itemChanged.connect(front.res_tbl_changed) ui.nxs_TBL.itemClicked.connect(front.name_tbl_clicked) # Tab Widget ui.tablesTabWidget.currentChanged.connect(front.change_view) ui.cxs_TBL.sortByColumn(0, Qt.AscendingOrder) ui.res_TBL.sortByColumn(0, Qt.AscendingOrder) ui.gxs_TBL.sortByColumn(0, Qt.AscendingOrder) def print(front, msg): print('[*front*] ' + msg) #front.printSignal.emit('[*front] ' + msg) @slot_(bool) def setEnabled(front, flag): #front.printDBG('setEnabled(%r)' % flag) ui = front.ui # Enable or disable all actions for uikey in ui.__dict__.keys(): if uikey.find('action') == 0: ui.__dict__[uikey].setEnabled(flag) # The following options are always enabled ui.actionOpen_Database.setEnabled(True) ui.actionNew_Database.setEnabled(True) ui.actionQuit.setEnabled(True) ui.actionAbout.setEnabled(True) ui.actionView_Docs.setEnabled(True) ui.actionDelete_global_preferences.setEnabled(True) # The following options are no implemented. Disable them ui.actionConvert_all_images_into_chips.setEnabled(False) ui.actionBatch_Change_Name.setEnabled(False) ui.actionScale_all_ROIS.setEnabled(False) ui.actionWriteLogs.setEnabled(False) ui.actionAbout.setEnabled(False) ui.actionView_Docs.setEnabled(False) def _populate_table(front, tbl, col_headers, col_editable, row_list, row2_datatup): #front.printDBG('_populate_table()') hheader = tbl.horizontalHeader() def set_header_context_menu(hheader): hheader.setContextMenuPolicy(Qt.CustomContextMenu) # TODO: for chip table: delete metedata column opt2_callback = [ ('header', lambda: print('finishme')), ('cancel', lambda: print('cancel')), ] # HENDRIK / JASON TODO: # I have a small right-click context menu working # Maybe one of you can put some useful functions in these? popup_slot = guitools.popup_menu(tbl, opt2_callback) hheader.customContextMenuRequested.connect(popup_slot) def set_table_context_menu(tbl): tbl.setContextMenuPolicy(Qt.CustomContextMenu) # RCOS TODO: How do we get the clicked item on a right click? # tbl.selectedItems # tbl.selectedIndexes opt2_callback = [ ('Query', front.querySignal.emit), ] #('item', lambda: print('finishme')), #('cancel', lambda: print('cancel')), ] popup_slot = guitools.popup_menu(tbl, opt2_callback) tbl.customContextMenuRequested.connect(popup_slot) #set_header_context_menu(hheader) set_table_context_menu(tbl) sort_col = hheader.sortIndicatorSection() sort_ord = hheader.sortIndicatorOrder() tbl.sortByColumn(0, Qt.AscendingOrder) # Basic Sorting tblWasBlocked = tbl.blockSignals(True) tbl.clear() tbl.setColumnCount(len(col_headers)) tbl.setRowCount(len(row_list)) tbl.verticalHeader().hide() tbl.setHorizontalHeaderLabels(col_headers) tbl.setSelectionMode(QAbstractItemView.SingleSelection) tbl.setSelectionBehavior(QAbstractItemView.SelectRows) tbl.setSortingEnabled(False) for row in iter(row_list): data_tup = row2_datatup[row] for col, data in enumerate(data_tup): item = QtGui.QTableWidgetItem() # RCOS TODO: Pass in datatype here. #if col_headers[col] == 'AIF': #print('col=%r dat=%r, %r' % (col, data, type(data))) if tools.is_bool(data) or data == 'True' or data == 'False': bit = bool(data) #print(bit) if bit: item.setCheckState(Qt.Checked) else: item.setCheckState(Qt.Unchecked) #item.setData(Qt.DisplayRole, bool(data)) elif tools.is_int(data): item.setData(Qt.DisplayRole, int(data)) elif tools.is_float(data): item.setData(Qt.DisplayRole, float(data)) else: item.setText(str(data)) item.setTextAlignment(Qt.AlignHCenter) if col_editable[col]: item.setFlags(item.flags() | Qt.ItemIsEditable) #print(item.getBackground()) item.setBackground(QtGui.QColor(250, 240, 240)) else: item.setFlags(item.flags() ^ Qt.ItemIsEditable) tbl.setItem(row, col, item) tbl.setSortingEnabled(True) tbl.sortByColumn(sort_col, sort_ord) # Move back to old sorting tbl.show() tbl.blockSignals(tblWasBlocked) @slot_(str, list, list, list, list) @blocking def populate_tbl(front, table_name, col_headers, col_editable, row_list, row2_datatup): table_name = str(table_name) #front.printDBG('populate_tbl(%s)' % table_name) try: tbl = front.ui.__dict__['%s_TBL' % table_name] except KeyError: valid_table_names = [ key for key in front.ui.__dict__.keys() if key.find('_TBL') >= 0 ] msg = '\n'.join([ 'Invalid table_name = %s_TBL' % table_name, 'valid names:\n ' + '\n '.join(valid_table_names) ]) raise Exception(msg) front._populate_table(tbl, col_headers, col_editable, row_list, row2_datatup) def isItemEditable(self, item): return int(Qt.ItemIsEditable & item.flags()) == int(Qt.ItemIsEditable) #======================= # General Table Getters #======================= def get_tbl_header(front, tbl, col): # Map the fancy header back to the internal one. fancy_header = str(tbl.horizontalHeaderItem(col).text()) header = (front.back.reverse_fancy[fancy_header] if fancy_header in front.back.reverse_fancy else fancy_header) return header def get_tbl_int(front, tbl, row, col): return int(tbl.item(row, col).text()) def get_tbl_str(front, tbl, row, col): return str(tbl.item(row, col).text()) def get_header_val(front, tbl, header, row): # RCOS TODO: This is hacky. These just need to be # in dicts to begin with. tblname = str(tbl.objectName()).replace('_TBL', '') tblname = tblname.replace('image', 'img') # Sooooo hack # TODO: backmap from fancy headers to consise col = front.back.table_headers[tblname].index(header) return tbl.item(row, col).text() #======================= # Specific Item Getters #======================= def get_chiptbl_header(front, col): return front.get_tbl_header(front.ui.cxs_TBL, col) def get_imgtbl_header(front, col): return front.get_tbl_header(front.ui.gxs_TBL, col) def get_restbl_header(front, col): return front.get_tbl_header(front.ui.res_TBL, col) def get_nametbl_header(front, col): return front.get_tbl_header(front.ui.nxs_TBL, col) def get_restbl_cid(front, row): return int(front.get_header_val(front.ui.res_TBL, 'cid', row)) def get_chiptbl_cid(front, row): return int(front.get_header_val(front.ui.cxs_TBL, 'cid', row)) def get_nametbl_name(front, row): return str(front.get_header_val(front.ui.nxs_TBL, 'name', row)) def get_imgtbl_gx(front, row): return int(front.get_header_val(front.ui.gxs_TBL, 'gx', row)) #======================= # Table Changed Functions #======================= @slot_(QtGui.QTableWidgetItem) def img_tbl_changed(front, item): front.print('img_tbl_changed()') row, col = (item.row(), item.column()) sel_gx = front.get_imgtbl_gx(row) header_lbl = front.get_imgtbl_header(col) new_val = item.checkState() == Qt.Checked front.changeGxSignal.emit(sel_gx, header_lbl, new_val) @slot_(QtGui.QTableWidgetItem) def chip_tbl_changed(front, item): front.print('chip_tbl_changed()') row, col = (item.row(), item.column()) sel_cid = front.get_chiptbl_cid(row) # Get selected chipid new_val = csv_sanatize(item.text()) # sanatize for csv header_lbl = front.get_chiptbl_header(col) # Get changed column front.changeCidSignal.emit(sel_cid, header_lbl, new_val) @slot_(QtGui.QTableWidgetItem) def res_tbl_changed(front, item): front.print('res_tbl_changed()') row, col = (item.row(), item.column()) sel_cid = front.get_restbl_cid(row) # The changed row's chip id new_val = csv_sanatize(item.text()) # sanatize val for csv header_lbl = front.get_restbl_header(col) # Get changed column front.changeCidSignal.emit(sel_cid, header_lbl, new_val) #======================= # Table Clicked Functions #======================= @slot_(QtGui.QTableWidgetItem) @clicked def img_tbl_clicked(front, item): row = item.row() front.print('img_tbl_clicked(%r)' % (row)) sel_gx = front.get_imgtbl_gx(row) front.selectGxSignal.emit(sel_gx) @slot_(QtGui.QTableWidgetItem) @clicked def chip_tbl_clicked(front, item): row, col = (item.row(), item.column()) front.print('chip_tbl_clicked(%r, %r)' % (row, col)) sel_cid = front.get_chiptbl_cid(row) front.selectCidSignal.emit(sel_cid) @slot_(QtGui.QTableWidgetItem) @clicked def res_tbl_clicked(front, item): row, col = (item.row(), item.column()) front.print('res_tbl_clicked(%r, %r)' % (row, col)) sel_cid = front.get_restbl_cid(row) front.selectResSignal.emit(sel_cid) @slot_(QtGui.QTableWidgetItem) @clicked def name_tbl_clicked(front, item): row, col = (item.row(), item.column()) front.print('name_tbl_clicked(%r, %r)' % (row, col)) sel_name = front.get_nametbl_name(row) front.selectNameSignal.emit(sel_name) #======================= # Other #======================= @slot_(int) def change_view(front, new_state): front.print('change_view()') prevBlock = front.ui.tablesTabWidget.blockSignals(True) front.ui.tablesTabWidget.blockSignals(prevBlock) @slot_(str, str, list) def modal_useroption(front, msg, title, options): pass @slot_(str) def gui_write(front, msg_): app = front.back.app outputEdit = front.ui.outputEdit # Write msg to text area outputEdit.moveCursor(QtGui.QTextCursor.End) # TODO: Find out how to do backspaces in textEdit msg = str(msg_) if msg.find('\b') != -1: msg = msg.replace('\b', '') + '\n' outputEdit.insertPlainText(msg) if app is not None: app.processEvents() @slot_() def gui_flush(front): app = front.back.app if app is not None: app.processEvents()
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.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.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 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.') d = warning_dialog(self, _('Restart needed'), msg, show_copy_button=False) b = d.bb.addButton(_('Restart calibre now'), d.bb.AcceptRole) b.setIcon(QIcon(I('lt.png'))) d.do_restart = False def rf(): d.do_restart = True b.clicked.connect(rf) d.set_details('') d.exec_() b.clicked.disconnect() do_restart = d.do_restart 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 Splitter(QSplitter): state_changed = pyqtSignal(object) def __init__(self, name, label, icon, initial_show=True, initial_side_size=120, connect_button=True, orientation=Qt.Horizontal, side_index=0, parent=None, shortcut=None): QSplitter.__init__(self, parent) self.resize_timer = QTimer(self) self.resize_timer.setSingleShot(True) self.desired_side_size = initial_side_size self.desired_show = initial_show self.resize_timer.setInterval(5) self.resize_timer.timeout.connect(self.do_resize) self.setOrientation(orientation) self.side_index = side_index self._name = name self.label = label self.initial_side_size = initial_side_size self.initial_show = initial_show self.splitterMoved.connect(self.splitter_moved, type=Qt.QueuedConnection) self.button = LayoutButton(icon, label, self, shortcut=shortcut) if connect_button: self.button.clicked.connect(self.double_clicked) if shortcut is not None: self.action_toggle = QAction(QIcon(icon), _('Toggle') + ' ' + label, self) self.action_toggle.triggered.connect(self.toggle_triggered) if parent is not None: parent.addAction(self.action_toggle) if hasattr(parent, 'keyboard'): parent.keyboard.register_shortcut('splitter %s %s'%(name, label), unicode(self.action_toggle.text()), default_keys=(shortcut,), action=self.action_toggle) else: self.action_toggle.setShortcut(shortcut) else: self.action_toggle.setShortcut(shortcut) def toggle_triggered(self, *args): self.toggle_side_pane() def createHandle(self): return SplitterHandle(self.orientation(), self) def initialize(self): for i in range(self.count()): h = self.handle(i) if h is not None: h.splitter_moved() self.state_changed.emit(not self.is_side_index_hidden) def splitter_moved(self, *args): self.desired_side_size = self.side_index_size self.state_changed.emit(not self.is_side_index_hidden) @property def is_side_index_hidden(self): sizes = list(self.sizes()) try: return sizes[self.side_index] == 0 except IndexError: return True @property def save_name(self): ori = 'horizontal' if self.orientation() == Qt.Horizontal \ else 'vertical' return self._name + '_' + ori def print_sizes(self): if self.count() > 1: print self.save_name, 'side:', self.side_index_size, 'other:', print list(self.sizes())[self.other_index] @dynamic_property def side_index_size(self): def fget(self): if self.count() < 2: return 0 return self.sizes()[self.side_index] def fset(self, val): if self.count() < 2: return if val == 0 and not self.is_side_index_hidden: self.save_state() sizes = list(self.sizes()) for i in range(len(sizes)): sizes[i] = val if i == self.side_index else 10 self.setSizes(sizes) total = sum(self.sizes()) sizes = list(self.sizes()) for i in range(len(sizes)): sizes[i] = val if i == self.side_index else total-val self.setSizes(sizes) self.initialize() return property(fget=fget, fset=fset) def do_resize(self, *args): orig = self.desired_side_size QSplitter.resizeEvent(self, self._resize_ev) if orig > 20 and self.desired_show: c = 0 while abs(self.side_index_size - orig) > 10 and c < 5: self.apply_state(self.get_state(), save_desired=False) c += 1 def resizeEvent(self, ev): if self.resize_timer.isActive(): self.resize_timer.stop() self._resize_ev = ev self.resize_timer.start() def get_state(self): if self.count() < 2: return (False, 200) return (self.desired_show, self.desired_side_size) def apply_state(self, state, save_desired=True): if state[0]: self.side_index_size = state[1] if save_desired: self.desired_side_size = self.side_index_size else: self.side_index_size = 0 self.desired_show = state[0] def default_state(self): return (self.initial_show, self.initial_side_size) # Public API {{{ def update_desired_state(self): self.desired_show = not self.is_side_index_hidden def save_state(self): if self.count() > 1: gprefs[self.save_name+'_state'] = self.get_state() @property def other_index(self): return (self.side_index+1)%2 def restore_state(self): if self.count() > 1: state = gprefs.get(self.save_name+'_state', self.default_state()) self.apply_state(state, save_desired=False) self.desired_side_size = state[1] def toggle_side_pane(self, hide=None): if hide is None: action = 'show' if self.is_side_index_hidden else 'hide' else: action = 'hide' if hide else 'show' getattr(self, action+'_side_pane')() def show_side_pane(self): if self.count() < 2 or not self.is_side_index_hidden: return if self.desired_side_size == 0: self.desired_side_size = self.initial_side_size self.apply_state((True, self.desired_side_size)) def hide_side_pane(self): if self.count() < 2 or self.is_side_index_hidden: return self.apply_state((False, self.desired_side_size)) def double_clicked(self, *args): self.toggle_side_pane()
class FilenamePattern(QWidget, Ui_Form): # {{{ changed_signal = pyqtSignal() def __init__(self, parent): QWidget.__init__(self, parent) self.setupUi(self) self.test_button.clicked[()].connect(self.do_test) self.re.lineEdit().returnPressed[()].connect(self.do_test) self.filename.returnPressed[()].connect(self.do_test) self.re.lineEdit().textChanged.connect(lambda x: self.changed_signal.emit()) def initialize(self, defaults=False): # Get all itmes in the combobox. If we are resting # to defaults we don't want to lose what the user # has added. val_hist = [unicode(self.re.lineEdit().text())] + [unicode(self.re.itemText(i)) for i in xrange(self.re.count())] self.re.clear() if defaults: val = prefs.defaults['filename_pattern'] else: val = prefs['filename_pattern'] self.re.lineEdit().setText(val) val_hist += gprefs.get('filename_pattern_history', [ '(?P<title>.+)', '(?P<author>[^_-]+) -?\s*(?P<series>[^_0-9-]*)(?P<series_index>[0-9]*)\s*-\s*(?P<title>[^_].+) ?']) if val in val_hist: del val_hist[val_hist.index(val)] val_hist.insert(0, val) for v in val_hist: # Ensure we don't have duplicate items. if v and self.re.findText(v) == -1: self.re.addItem(v) self.re.setCurrentIndex(0) def do_test(self): from calibre.ebooks.metadata.meta import metadata_from_filename fname = unicode(self.filename.text()) ext = os.path.splitext(fname)[1][1:].lower() if ext not in BOOK_EXTENSIONS: return warning_dialog(self, _('Test name invalid'), _('The name <b>%r</b> does not appear to end with a' ' file extension. The name must end with a file ' ' extension like .epub or .mobi')%fname, show=True) try: pat = self.pattern() except Exception as err: error_dialog(self, _('Invalid regular expression'), _('Invalid regular expression: %s')%err).exec_() return mi = metadata_from_filename(fname, pat) if mi.title: self.title.setText(mi.title) else: self.title.setText(_('No match')) if mi.authors: self.authors.setText(', '.join(mi.authors)) else: self.authors.setText(_('No match')) if mi.series: self.series.setText(mi.series) else: self.series.setText(_('No match')) if mi.series_index is not None: self.series_index.setText(str(mi.series_index)) else: self.series_index.setText(_('No match')) if mi.publisher: self.publisher.setText(mi.publisher) if mi.pubdate: self.pubdate.setText(mi.pubdate.strftime('%Y-%m-%d')) self.isbn.setText(_('No match') if mi.isbn is None else str(mi.isbn)) def pattern(self): pat = unicode(self.re.lineEdit().text()) return re.compile(pat) def commit(self): pat = self.pattern().pattern prefs['filename_pattern'] = pat history = [] history_pats = [unicode(self.re.lineEdit().text())] + [unicode(self.re.itemText(i)) for i in xrange(self.re.count())] for p in history_pats[:14]: # Ensure we don't have duplicate items. if p and p not in history: history.append(p) gprefs['filename_pattern_history'] = history return pat
class TextBrowser(PlainTextEdit): # {{{ resized = pyqtSignal() wheel_event = pyqtSignal(object) next_change = pyqtSignal(object) scrolled = pyqtSignal() line_activated = pyqtSignal(object, object, object) def __init__(self, right=False, parent=None, show_open_in_editor=False): PlainTextEdit.__init__(self, parent) self.setFrameStyle(0) self.show_open_in_editor = show_open_in_editor self.side_margin = 0 self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) self.setFocusPolicy(Qt.NoFocus) self.right = right self.setReadOnly(True) w = self.fontMetrics() self.number_width = max(map(lambda x: w.width(str(x)), xrange(10))) self.space_width = w.width(' ') self.setLineWrapMode(self.WidgetWidth) self.setTabStopWidth(tprefs['editor_tab_stop_width'] * self.space_width) 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.setFont(font) font = self.heading_font = QFont(self.font()) font.setPointSize(int(tprefs['editor_font_size'] * 1.5)) font.setBold(True) theme = get_theme(tprefs['editor_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.viewport().setCursor(Qt.ArrowCursor) self.line_number_area = LineNumbers(self) self.blockCountChanged[int].connect(self.update_line_number_area_width) self.updateRequest.connect(self.update_line_number_area) 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.line_number_map = LineNumberMap() self.search_header_pos = 0 self.changes, self.headers, self.images = [], [], OrderedDict() self.setVerticalScrollBarPolicy( Qt.ScrollBarAlwaysOff), self.setHorizontalScrollBarPolicy( Qt.ScrollBarAlwaysOff) self.diff_backgrounds = { 'replace': theme_color(theme, 'DiffReplace', 'bg'), 'insert': theme_color(theme, 'DiffInsert', 'bg'), 'delete': theme_color(theme, 'DiffDelete', 'bg'), 'replacereplace': theme_color(theme, 'DiffReplaceReplace', 'bg'), 'boundary': QBrush(theme_color(theme, 'Normal', 'fg'), Qt.Dense7Pattern), } self.diff_foregrounds = { 'replace': theme_color(theme, 'DiffReplace', 'fg'), 'insert': theme_color(theme, 'DiffInsert', 'fg'), 'delete': theme_color(theme, 'DiffDelete', 'fg'), 'boundary': QColor(0, 0, 0, 0), } for x in ('replacereplace', 'insert', 'delete'): f = QTextCharFormat() f.setBackground(self.diff_backgrounds[x]) setattr(self, '%s_format' % x, f) def show_context_menu(self, pos): m = QMenu(self) a = m.addAction i = unicode(self.textCursor().selectedText()).rstrip('\0') if i: a(QIcon(I('edit-copy.png')), _('Copy to clipboard'), self.copy).setShortcut(QKeySequence.Copy) if len(self.changes) > 0: a(QIcon(I('arrow-up.png')), _('Previous change'), partial(self.next_change.emit, -1)) a(QIcon(I('arrow-down.png')), _('Next change'), partial(self.next_change.emit, 1)) if self.show_open_in_editor: b = self.cursorForPosition(pos).block() if b.isValid(): a(QIcon(I('tweak.png')), _('Open file in the editor'), partial(self.generate_sync_request, b.blockNumber())) if len(m.actions()) > 0: m.exec_(self.mapToGlobal(pos)) def mouseDoubleClickEvent(self, ev): if ev.button() == 1: b = self.cursorForPosition(ev.pos()).block() if b.isValid(): self.generate_sync_request(b.blockNumber()) return PlainTextEdit.mouseDoubleClickEvent(self, ev) def generate_sync_request(self, block_number): if not self.headers: return try: lnum = int(self.line_number_map.get(block_number, '')) except: lnum = 1 for i, (num, text) in enumerate(self.headers): if num > block_number: name = text if i == 0 else self.headers[i - 1][1] break else: name = self.headers[-1][1] self.line_activated.emit(name, lnum, bool(self.right)) def search(self, query, reverse=False): ''' Search for query, also searching the headers. Matches in headers are not highlighted as managing the highlight is too much of a pain.''' if not query.strip(): return c = self.textCursor() lnum = c.block().blockNumber() cpos = c.positionInBlock() headers = dict(self.headers) if lnum in headers: cpos = self.search_header_pos lines = unicode(self.toPlainText()).splitlines() for hn, text in self.headers: lines[hn] = text prefix, postfix = lines[lnum][:cpos], lines[lnum][cpos:] before, after = enumerate( lines[0:lnum]), ((lnum + 1 + i, x) for i, x in enumerate(lines[lnum + 1:])) if reverse: sl = chain([(lnum, prefix)], reversed(tuple(before)), reversed(tuple(after)), [(lnum, postfix)]) else: sl = chain([(lnum, postfix)], after, before, [(lnum, prefix)]) flags = regex.REVERSE if reverse else 0 pat = regex.compile(regex.escape(query, special_only=True), flags=regex.UNICODE | regex.IGNORECASE | flags) for num, text in sl: try: m = next(pat.finditer(text)) except StopIteration: continue start, end = m.span() length = end - start if text is postfix: start += cpos c = QTextCursor(self.document().findBlockByNumber(num)) c.setPosition(c.position() + start) if num in headers: self.search_header_pos = start + length else: c.setPosition(c.position() + length, c.KeepAnchor) self.search_header_pos = 0 if reverse: pos, anchor = c.position(), c.anchor() c.setPosition(pos), c.setPosition(anchor, c.KeepAnchor) self.setTextCursor(c) self.centerCursor() self.scrolled.emit() break else: info_dialog(self, _('No matches found'), _('No matches found for query: %s' % query), show=True) def clear(self): PlainTextEdit.clear(self) self.line_number_map.clear() del self.changes[:] del self.headers[:] self.images.clear() self.search_header_pos = 0 self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) def update_line_number_area_width(self, block_count=0): self.side_margin = self.line_number_area_width() if self.right: self.setViewportMargins(0, 0, self.side_margin, 0) else: self.setViewportMargins(self.side_margin, 0, 0, 0) def available_width(self): return self.width() - self.side_margin def line_number_area_width(self): return 9 + (self.line_number_map.max_width * self.number_width) 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): PlainTextEdit.resizeEvent(self, ev) cr = self.contentsRect() if self.right: self.line_number_area.setGeometry( QRect(cr.right() - self.line_number_area_width(), cr.top(), cr.right(), cr.height())) else: self.line_number_area.setGeometry( QRect(cr.left(), cr.top(), self.line_number_area_width(), cr.height())) self.resized.emit() 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()) painter.setPen(self.line_number_palette.color(QPalette.Text)) change_starts = {x[0] for x in self.changes} while block.isValid() and top <= ev.rect().bottom(): r = ev.rect() if block.isVisible() and bottom >= r.top(): text = unicode(self.line_number_map.get(num, '')) is_start = text != '-' and num in change_starts if is_start: painter.save() f = QFont(self.font()) f.setBold(True) painter.setFont(f) painter.setPen( self.line_number_palette.color(QPalette.BrightText)) if text == '-': painter.drawLine(r.left() + 2, (top + bottom) // 2, r.right() - 2, (top + bottom) // 2) else: if self.right: painter.drawText(r.left() + 3, top, r.right(), self.fontMetrics().height(), Qt.AlignLeft, text) else: painter.drawText(r.left() + 2, top, r.right() - 5, self.fontMetrics().height(), Qt.AlignRight, text) if is_start: painter.restore() block = block.next() top = bottom bottom = top + int(self.blockBoundingRect(block).height()) num += 1 def paintEvent(self, event): w = self.viewport().rect().width() painter = QPainter(self.viewport()) painter.setClipRect(event.rect()) floor = event.rect().bottom() ceiling = event.rect().top() fv = self.firstVisibleBlock().blockNumber() origin = self.contentOffset() doc = self.document() lines = [] for num, text in self.headers: top, bot = num, num + 3 if bot < fv: continue y_top = self.blockBoundingGeometry( doc.findBlockByNumber(top)).translated(origin).y() y_bot = self.blockBoundingGeometry( doc.findBlockByNumber(bot)).translated(origin).y() if max(y_top, y_bot) < ceiling: continue if min(y_top, y_bot) > floor: break painter.setFont(self.heading_font) br = painter.drawText(3, y_top, w, y_bot - y_top - 5, Qt.TextSingleLine, text) painter.setPen(QPen(self.palette().text(), 2)) painter.drawLine(0, br.bottom() + 3, w, br.bottom() + 3) for top, bot, kind in self.changes: if bot < fv: continue y_top = self.blockBoundingGeometry( doc.findBlockByNumber(top)).translated(origin).y() y_bot = self.blockBoundingGeometry( doc.findBlockByNumber(bot)).translated(origin).y() if max(y_top, y_bot) < ceiling: continue if min(y_top, y_bot) > floor: break if y_top != y_bot: painter.fillRect(0, y_top, w, y_bot - y_top, self.diff_backgrounds[kind]) lines.append((y_top, y_bot, kind)) if top in self.images: img, maxw = self.images[top][:2] if bot > top + 1 and not img.isNull(): y_top = self.blockBoundingGeometry( doc.findBlockByNumber(top + 1)).translated(origin).y() + 3 y_bot -= 3 scaled, imgw, imgh = fit_image(img.width(), img.height(), w - 3, y_bot - y_top) painter.setRenderHint(QPainter.SmoothPixmapTransform, True) painter.drawPixmap(QRect(3, y_top, imgw, imgh), img) painter.end() PlainTextEdit.paintEvent(self, event) painter = QPainter(self.viewport()) painter.setClipRect(event.rect()) for top, bottom, kind in sorted(lines, key=lambda (t, b, k): {'replace': 0}.get(k, 1)): painter.setPen(QPen(self.diff_foregrounds[kind], 1)) painter.drawLine(0, top, w, top) painter.drawLine(0, bottom - 1, w, bottom - 1) def wheelEvent(self, ev): if ev.orientation() == Qt.Vertical: self.wheel_event.emit(ev) else: return PlainTextEdit.wheelEvent(self, ev)
class BasicSettings(QWidget): # {{{ changed_signal = pyqtSignal() def __init__(self, parent=None): QWidget.__init__(self, parent) self.settings = {} self._prevent_changed = False self.Setting = namedtuple( 'Setting', 'name prefs widget getter setter initial_value') def __call__(self, name, widget=None, getter=None, setter=None, prefs=None): prefs = prefs or tprefs defval = prefs.defaults[name] inval = prefs[name] if widget is None: if isinstance(defval, bool): widget = QCheckBox(self) getter = getter or methodcaller('isChecked') setter = setter or (lambda x, v: x.setChecked(v)) widget.toggled.connect(self.emit_changed) elif isinstance(defval, (int, float)): widget = (QSpinBox if isinstance(defval, int) else QDoubleSpinBox)(self) getter = getter or methodcaller('value') setter = setter or (lambda x, v: x.setValue(v)) widget.valueChanged.connect(self.emit_changed) else: raise TypeError('Unknown setting type for setting: %s' % name) else: if getter is None or setter is None: raise ValueError("getter or setter not provided for: %s" % name) self._prevent_changed = True setter(widget, inval) self._prevent_changed = False self.settings[name] = self.Setting(name, prefs, widget, getter, setter, inval) return widget def choices_widget(self, name, choices, fallback_val, none_val, prefs=None): prefs = prefs or tprefs widget = QComboBox(self) widget.currentIndexChanged[int].connect(self.emit_changed) for key, human in sorted(choices.iteritems(), key=lambda (key, human): human or key): widget.addItem(human or key, key) def getter(w): ans = unicode(w.itemData(w.currentIndex()).toString()) return {none_val: None}.get(ans, ans) def setter(w, val): val = {None: none_val}.get(val, val) idx = w.findData(val, flags=Qt.MatchFixedString | Qt.MatchCaseSensitive) if idx == -1: idx = w.findData(fallback_val, flags=Qt.MatchFixedString | Qt.MatchCaseSensitive) w.setCurrentIndex(idx) return self(name, widget=widget, getter=getter, setter=setter, prefs=prefs) def order_widget(self, name, prefs=None): prefs = prefs or tprefs widget = QListWidget(self) widget.addItems(prefs.defaults[name]) widget.setDragEnabled(True) widget.setDragDropMode(widget.InternalMove) widget.viewport().setAcceptDrops(True) widget.setDropIndicatorShown(True) widget.indexesMoved.connect(self.emit_changed) widget.setDefaultDropAction(Qt.MoveAction) widget.setMovement(widget.Snap) widget.setSpacing(5) widget.defaults = prefs.defaults[name] def getter(w): return list( map(unicode, (w.item(i).text() for i in xrange(w.count())))) def setter(w, val): order_map = {x: i for i, x in enumerate(val)} items = list(w.defaults) limit = len(items) items.sort(key=lambda x: order_map.get(x, limit)) w.clear() for x in items: i = QListWidgetItem(w) i.setText(x) i.setFlags(i.flags() | Qt.ItemIsDragEnabled) return self(name, widget=widget, getter=getter, setter=setter, prefs=prefs) def emit_changed(self, *args): if not self._prevent_changed: self.changed_signal.emit() def commit(self): with tprefs: for name in self.settings: cv = self.current_value(name) if self.initial_value(name) != cv: prefs = self.settings[name].prefs if cv == self.default_value(name): del prefs[name] else: prefs[name] = cv def restore_defaults(self): for setting in self.settings.itervalues(): setting.setter(setting.widget, self.default_value(setting.name)) def initial_value(self, name): return self.settings[name].initial_value def current_value(self, name): s = self.settings[name] return s.getter(s.widget) def default_value(self, name): s = self.settings[name] return s.prefs.defaults[name] def setting_changed(self, name): return self.current_value(name) != self.initial_value(name)
class LibraryPage(QWizardPage, LibraryUI): ID = 1 retranslate = pyqtSignal() def __init__(self): QWizardPage.__init__(self) self.setupUi(self) self.registerField('library_location', self.location) self.button_change.clicked[()].connect(self.change) self.init_languages() self.language.currentIndexChanged[int].connect(self.change_language) self.location.textChanged.connect(self.location_text_changed) def location_text_changed(self, newtext): self.completeChanged.emit() def init_languages(self): self.language.blockSignals(True) self.language.clear() from calibre.utils.localization import (available_translations, get_language, get_lang, get_lc_messages_path) lang = get_lang() lang = get_lc_messages_path(lang) if lang else lang if lang is None or lang not in available_translations(): lang = 'en' def get_esc_lang(l): if l == 'en': return 'English' return get_language(l) self.language.addItem(get_esc_lang(lang), QVariant(lang)) items = [(l, get_esc_lang(l)) for l in available_translations() if l != lang] if lang != 'en': items.append(('en', get_esc_lang('en'))) items.sort(cmp=lambda x, y: cmp(x[1], y[1])) for item in items: self.language.addItem(item[1], QVariant(item[0])) self.language.blockSignals(False) prefs['language'] = str( self.language.itemData(self.language.currentIndex()).toString()) def change_language(self, idx): prefs['language'] = str( self.language.itemData(self.language.currentIndex()).toString()) import __builtin__ __builtin__.__dict__['_'] = lambda (x): x from calibre.utils.localization import set_translators from calibre.gui2 import qt_app from calibre.ebooks.metadata.book.base import reset_field_metadata set_translators() qt_app.load_translations() self.retranslate.emit() self.init_languages() reset_field_metadata() try: lang = prefs['language'].lower()[:2] metadata_plugins = { 'zh': ('Douban Books', ), 'fr': ('Nicebooks', ), 'ru': ('OZON.ru', ), }.get(lang, []) from calibre.customize.ui import enable_plugin for name in metadata_plugins: enable_plugin(name) except: pass def is_library_dir_suitable(self, x): from calibre.db.legacy import LibraryDatabase try: return LibraryDatabase.exists_at(x) or not os.listdir(x) except: return False def validatePage(self): newloc = unicode(self.location.text()) if not self.is_library_dir_suitable(newloc): self.show_library_dir_error(newloc) return False return True def change(self): from calibre.db.legacy import LibraryDatabase x = choose_dir(self, 'database location dialog', _('Select location for books')) if x: if (iswindows and len(x) > LibraryDatabase.WINDOWS_LIBRARY_PATH_LIMIT): return error_dialog( self, _('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(x): try: os.makedirs(x) except: return error_dialog(self, _('Bad location'), _('Failed to create a folder at %s') % x, det_msg=traceback.format_exc(), show=True) if self.is_library_dir_suitable(x): self.location.setText(x) else: self.show_library_dir_error(x) def show_library_dir_error(self, x): if not isinstance(x, unicode): try: x = x.decode(filesystem_encoding) except: x = unicode(repr(x)) error_dialog(self, _('Bad location'), _('You must choose an empty folder for ' 'the calibre library. %s is not empty.') % x, show=True) def initializePage(self): lp = prefs['library_path'] self.default_library_name = None if not lp: fname = _('Calibre Library') base = os.path.expanduser(u'~') if iswindows: x = winutil.special_folder_path(winutil.CSIDL_PERSONAL) if x and os.access(x, os.W_OK): base = x lp = os.path.join(base, fname) self.default_library_name = lp if not os.path.exists(lp): try: os.makedirs(lp) except: traceback.print_exc() lp = os.path.expanduser(u'~') self.location.setText(lp) # Hide the library location settings if we are a portable install for x in ('location', 'button_change', 'libloc_label1', 'libloc_label2'): getattr(self, x).setVisible(not isportable) def isComplete(self): try: lp = unicode(self.location.text()) ans = bool(lp) and os.path.exists(lp) and os.path.isdir( lp) and os.access(lp, os.W_OK) except: ans = False return ans def commit(self, completed): oldloc = prefs['library_path'] newloc = unicode(self.location.text()) try: dln = self.default_library_name if (dln and os.path.exists(dln) and not os.listdir(dln) and newloc != dln): os.rmdir(dln) except: pass if not os.path.exists(newloc): os.mkdir(newloc) if not patheq(oldloc, newloc): move_library(oldloc, newloc, self.wizard(), completed) return True return False def nextId(self): return DevicePage.ID
class CacheUpdateThread(Thread, QObject): total_changed = pyqtSignal(int) update_progress = pyqtSignal(int) update_details = pyqtSignal(unicode) 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 = 'http://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): changed = pyqtSignal() def __init__(self, field, is_new, parent, metadata, extra): QWidget.__init__(self, parent) self.is_new = is_new self.field = field self.metadata = metadata self.pixmap = None self.blank = QPixmap(I('blank.png')) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.GrowFlag | QSizePolicy.ExpandFlag) self.sizePolicy().setHeightForWidth(True) @property def is_blank(self): return self.pixmap is None @dynamic_property def current_val(self): def fget(self): return self.pixmap def fset(self, val): self.pixmap = val self.changed.emit() self.update() return property(fget=fget, fset=fset) def from_mi(self, mi): p = getattr(mi, 'cover', None) if p and os.path.exists(p): pmap = QPixmap() with open(p, 'rb') as f: pmap.loadFromData(f.read()) if not pmap.isNull(): self.pixmap = pmap self.update() self.changed.emit() return cd = getattr(mi, 'cover_data', (None, None)) if cd and cd[1]: pmap = QPixmap() pmap.loadFromData(cd[1]) if not pmap.isNull(): self.pixmap = pmap self.update() self.changed.emit() return self.pixmap = None self.update() self.changed.emit() def to_mi(self, mi): mi.cover, mi.cover_data = None, (None, None) if self.pixmap is not None and not self.pixmap.isNull(): with PersistentTemporaryFile('.jpg') as pt: pt.write(pixmap_to_data(self.pixmap)) mi.cover = pt.name def same_as(self, other): return self.current_val == other.current_val def sizeHint(self): return QSize(225, 300) def paintEvent(self, event): pmap = self.blank if self.pixmap is None or self.pixmap.isNull( ) else self.pixmap target = self.rect() scaled, width, height = fit_image(pmap.width(), pmap.height(), target.width(), target.height()) target.setRect(target.x(), target.y(), width, height) p = QPainter(self) p.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform) p.drawPixmap(target, pmap) if self.pixmap is not None and not self.pixmap.isNull(): sztgt = target.adjusted(0, 0, 0, -4) f = p.font() f.setBold(True) p.setFont(f) sz = u'\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()
class ResultsView(QTableView): # {{{ show_details_signal = pyqtSignal(object) book_selected = pyqtSignal(object) def __init__(self, parent=None): QTableView.__init__(self, parent) self.rt_delegate = RichTextDelegate(self) self.setSelectionMode(self.SingleSelection) self.setAlternatingRowColors(True) self.setSelectionBehavior(self.SelectRows) self.setIconSize(QSize(24, 24)) self.clicked.connect(self.show_details) self.doubleClicked.connect(self.select_index) self.setSortingEnabled(True) def show_results(self, results): self._model = ResultsModel(results, self) self.setModel(self._model) for i in self._model.HTML_COLS: self.setItemDelegateForColumn(i, self.rt_delegate) self.resizeRowsToContents() self.resizeColumnsToContents() self.setFocus(Qt.OtherFocusReason) idx = self.model().index(0, 0) if idx.isValid() and self.model().rowCount() > 0: self.show_details(idx) sm = self.selectionModel() sm.select(idx, sm.ClearAndSelect | sm.Rows) def resize_delegate(self): self.rt_delegate.max_width = int(self.width() / 2.1) self.resizeColumnsToContents() def resizeEvent(self, ev): ret = super(ResultsView, self).resizeEvent(ev) self.resize_delegate() return ret def currentChanged(self, current, previous): ret = QTableView.currentChanged(self, current, previous) self.show_details(current) return ret def show_details(self, index): f = rating_font() book = self.model().data(index, Qt.UserRole) parts = [ '<center>', '<h2>%s</h2>' % book.title, '<div><i>%s</i></div>' % authors_to_string(book.authors), ] if not book.is_null('series'): series = book.format_field('series') if series[1]: parts.append('<div>%s: %s</div>' % series) if not book.is_null('rating'): style = 'style=\'font-family:"%s"\'' % f parts.append('<div %s>%s</div>' % (style, '\u2605' * int(book.rating))) parts.append('</center>') if book.identifiers: urls = urls_from_identifiers(book.identifiers) ids = [ '<a href="%s">%s</a>' % (url, name) for name, ign, ign, url in urls ] if ids: parts.append('<div><b>%s:</b> %s</div><br>' % (_('See at'), ', '.join(ids))) if book.tags: parts.append('<div>%s</div><div>\u00a0</div>' % ', '.join(book.tags)) if book.comments: parts.append(comments_to_html(book.comments)) self.show_details_signal.emit(''.join(parts)) def select_index(self, index): if self.model() is None: return if not index.isValid(): index = self.model().index(0, 0) book = self.model().data(index, Qt.UserRole) self.book_selected.emit(book) def get_result(self): self.select_index(self.currentIndex())
class LineEdit(QLineEdit): changed = pyqtSignal() def __init__(self, field, is_new, parent, metadata, extra): QLineEdit.__init__(self, parent) self.is_new = is_new self.field = field self.metadata = metadata if not is_new: self.setReadOnly(True) self.textChanged.connect(self.changed) def from_mi(self, mi): val = mi.get(self.field, default='') or '' ism = self.metadata['is_multiple'] if ism: if not val: val = '' else: val = ism['list_to_ui'].join(val) self.setText(val) self.setCursorPosition(0) def to_mi(self, mi): val = unicode(self.text()).strip() ism = self.metadata['is_multiple'] if ism: if not val: val = [] else: val = [ x.strip() for x in val.split(ism['list_to_ui']) if x.strip() ] mi.set(self.field, val) if self.field == 'title': mi.set('title_sort', title_sort(val, lang=mi.language)) elif self.field == 'authors': mi.set('author_sort', authors_to_sort_string(val)) @dynamic_property def current_val(self): def fget(self): return unicode(self.text()) def fset(self, val): self.setText(val) self.setCursorPosition(0) return property(fget=fget, fset=fset) @property def is_blank(self): val = self.current_val.strip() if self.field in {'title', 'authors'}: return val in {'', _('Unknown')} return not val def same_as(self, other): return self.current_val == other.current_val
class CoversView(QListView): # {{{ chosen = pyqtSignal() def __init__(self, current_cover, parent=None): QListView.__init__(self, parent) self.m = CoversModel(current_cover, self) self.setModel(self.m) self.setFlow(self.LeftToRight) self.setWrapping(True) self.setResizeMode(self.Adjust) self.setGridSize(QSize(190, 260)) self.setIconSize(QSize(150, 200)) self.setSelectionMode(self.SingleSelection) self.setViewMode(self.IconMode) self.delegate = CoverDelegate(self) self.setItemDelegate(self.delegate) self.delegate.needs_redraw.connect(self.viewport().update, type=Qt.QueuedConnection) self.doubleClicked.connect(self.chosen, type=Qt.QueuedConnection) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) def select(self, num): current = self.model().index(num) sm = self.selectionModel() sm.select(current, sm.SelectCurrent) def start(self): self.select(0) self.delegate.start_animation() def reset_covers(self): self.m.reset_covers() def clear_failed(self): pointer = self.m.pointer_from_index(self.currentIndex()) self.m.clear_failed() if pointer is None: self.select(0) else: self.select(self.m.index_from_pointer(pointer).row()) def show_context_menu(self, point): idx = self.currentIndex() if idx and idx.isValid() and not idx.data(Qt.UserRole).toPyObject(): m = QMenu() m.addAction(QIcon(I('view.png')), _('View this cover at full size'), self.show_cover) m.addAction(QIcon(I('edit-copy.png')), _('Copy this cover to clipboard'), self.copy_cover) m.exec_(QCursor.pos()) def show_cover(self): idx = self.currentIndex() pmap = self.model().cover_pixmap(idx) if pmap is None and idx.row() == 0: pmap = self.model().cc if pmap is not None: from calibre.gui2.viewer.image_popup import ImageView d = ImageView(self, pmap, unicode(idx.data(Qt.DisplayRole).toString()), geom_name='metadata_download_cover_popup_geom') d(use_exec=True) def copy_cover(self): idx = self.currentIndex() pmap = self.model().cover_pixmap(idx) if pmap is None and idx.row() == 0: pmap = self.model().cc if pmap is not None: QApplication.clipboard().setPixmap(pmap)
class Rule(QWidget): remove = pyqtSignal(object) def __init__(self, device, rule=None): QWidget.__init__(self) self._device = weakref.ref(device) self.l = l = QHBoxLayout() self.setLayout(l) p, s = _('Send the %s format to the folder:').partition('%s')[0::2] self.l1 = l1 = QLabel(p) l.addWidget(l1) self.fmt = f = QComboBox(self) l.addWidget(f) self.l2 = l2 = QLabel(s) l.addWidget(l2) self.folder = f = QLineEdit(self) f.setPlaceholderText(_('Folder on the device')) l.addWidget(f) self.b = b = QToolButton() l.addWidget(b) b.setIcon(QIcon(I('document_open.png'))) b.clicked.connect(self.browse) b.setToolTip(_('Browse for a folder on the device')) self.rb = rb = QPushButton(QIcon(I('list_remove.png')), _('&Remove rule'), self) l.addWidget(rb) rb.clicked.connect(self.removed) for fmt in sorted(BOOK_EXTENSIONS): self.fmt.addItem(fmt.upper(), fmt.lower()) self.fmt.setCurrentIndex(0) if rule is not None: fmt, folder = rule idx = self.fmt.findText(fmt.upper()) if idx > -1: self.fmt.setCurrentIndex(idx) self.folder.setText(folder) self.ignore = False @property def device(self): return self._device() def browse(self): b = Browser(self.device.filesystem_cache, show_files=False, parent=self) if b.exec_() == b.Accepted and b.current_item is not None: sid, path = b.current_item self.folder.setText('/'.join(path[1:])) def removed(self): self.remove.emit(self) @property def rule(self): folder = unicode(self.folder.text()).strip() if folder: return (unicode( self.fmt.itemData(self.fmt.currentIndex()).toString()), folder) return None
class CoversWidget(QWidget): # {{{ chosen = pyqtSignal() finished = pyqtSignal() def __init__(self, log, current_cover, parent=None): QWidget.__init__(self, parent) self.log = log self.abort = Event() self.l = l = QGridLayout() self.setLayout(l) self.msg = QLabel() self.msg.setWordWrap(True) l.addWidget(self.msg, 0, 0) self.covers_view = CoversView(current_cover, self) self.covers_view.chosen.connect(self.chosen) l.addWidget(self.covers_view, 1, 0) self.continue_processing = True def reset_covers(self): self.covers_view.reset_covers() def start(self, book, current_cover, title, authors, caches): self.continue_processing = True self.abort.clear() self.book, self.current_cover = book, current_cover self.title, self.authors = title, authors self.log('Starting cover download for:', book.title) self.log('Query:', title, authors, self.book.identifiers) self.msg.setText( '<p>' + _('Downloading covers for <b>%s</b>, please wait...') % book.title) self.covers_view.start() self.worker = CoverWorker(self.log, self.abort, self.title, self.authors, book.identifiers, caches) self.worker.start() QTimer.singleShot(50, self.check) self.covers_view.setFocus(Qt.OtherFocusReason) def check(self): if self.worker.is_alive() and not self.abort.is_set(): QTimer.singleShot(50, self.check) try: self.process_result(self.worker.rq.get_nowait()) except Empty: pass else: self.process_results() def process_results(self): while self.continue_processing: try: self.process_result(self.worker.rq.get_nowait()) except Empty: break if self.continue_processing: self.covers_view.clear_failed() if self.worker.error is not None: error_dialog(self, _('Download failed'), _('Failed to download any covers, click' ' "Show details" for details.'), det_msg=self.worker.error, show=True) num = self.covers_view.model().rowCount() if num < 2: txt = _( 'Could not find any covers for <b>%s</b>') % self.book.title else: txt = _( 'Found <b>%(num)d</b> possible covers for %(title)s. ' 'When the download completes, the covers will be sorted by size.' ) % dict(num=num - 1, title=self.title) self.msg.setText(txt) self.msg.setWordWrap(True) self.finished.emit() def process_result(self, result): if not self.continue_processing: return plugin_name, width, height, fmt, data = result self.covers_view.model().update_result(plugin_name, width, height, data) def cleanup(self): self.covers_view.delegate.stop_animation() self.continue_processing = False def cancel(self): self.cleanup() self.abort.set() def cover_pixmap(self): idx = None for i in self.covers_view.selectionModel().selectedIndexes(): if i.isValid(): idx = i break if idx is None: idx = self.covers_view.currentIndex() return self.covers_view.model().cover_pixmap(idx)
class BookInfo(QDialog): closed = pyqtSignal(object) def __init__(self, parent, view, row, link_delegate): QDialog.__init__(self, parent) self.normal_brush = QBrush(Qt.white) self.marked_brush = QBrush(Qt.lightGray) self.marked = None self.gui = parent self.splitter = QSplitter(self) self._l = l = QVBoxLayout(self) self.setLayout(l) l.addWidget(self.splitter) self.cover = CoverView(self) self.cover.resizeEvent = self.cover_view_resized self.cover.cover_changed.connect(self.cover_changed) self.cover_pixmap = None self.cover.sizeHint = self.details_size_hint self.splitter.addWidget(self.cover) self.details = QWebView(self) self.details.sizeHint = self.details_size_hint self.details.page().setLinkDelegationPolicy( self.details.page().DelegateAllLinks) self.details.linkClicked.connect(self.link_clicked) self.css = css() self.link_delegate = link_delegate self.details.setAttribute(Qt.WA_OpaquePaintEvent, False) palette = self.details.palette() self.details.setAcceptDrops(False) palette.setBrush(QPalette.Base, Qt.transparent) self.details.page().setPalette(palette) self.c = QWidget(self) self.c.l = l2 = QGridLayout(self.c) self.c.setLayout(l2) l2.addWidget(self.details, 0, 0, 1, -1) self.splitter.addWidget(self.c) self.fit_cover = QCheckBox(_('Fit &cover within view'), self) self.fit_cover.setChecked( gprefs.get('book_info_dialog_fit_cover', True)) l2.addWidget(self.fit_cover, l2.rowCount(), 0, 1, -1) self.previous_button = QPushButton(QIcon(I('previous.png')), _('&Previous'), self) self.previous_button.clicked.connect(self.previous) l2.addWidget(self.previous_button, l2.rowCount(), 0) self.next_button = QPushButton(QIcon(I('next.png')), _('&Next'), self) self.next_button.clicked.connect(self.next) l2.addWidget(self.next_button, l2.rowCount() - 1, 1) self.view = view self.current_row = None self.refresh(row) self.view.selectionModel().currentChanged.connect(self.slave) self.fit_cover.stateChanged.connect(self.toggle_cover_fit) self.ns = QShortcut(QKeySequence('Alt+Right'), self) self.ns.activated.connect(self.next) self.ps = QShortcut(QKeySequence('Alt+Left'), self) self.ps.activated.connect(self.previous) self.next_button.setToolTip( _('Next [%s]') % unicode(self.ns.key().toString(QKeySequence.NativeText))) self.previous_button.setToolTip( _('Previous [%s]') % unicode(self.ps.key().toString(QKeySequence.NativeText))) geom = QCoreApplication.instance().desktop().availableGeometry(self) screen_height = geom.height() - 100 screen_width = geom.width() - 100 self.resize(max(int(screen_width / 2), 700), screen_height) saved_layout = gprefs.get('book_info_dialog_layout', None) if saved_layout is not None: try: self.restoreGeometry(saved_layout[0]) self.splitter.restoreState(saved_layout[1]) except Exception: pass def link_clicked(self, qurl): link = unicode(qurl.toString()) self.link_delegate(link) def done(self, r): saved_layout = (bytearray(self.saveGeometry()), bytearray(self.splitter.saveState())) gprefs.set('book_info_dialog_layout', saved_layout) ret = QDialog.done(self, r) self.view.selectionModel().currentChanged.disconnect(self.slave) self.view = self.link_delegate = self.gui = None self.closed.emit(self) return ret def cover_changed(self, data): if self.current_row is not None: id_ = self.view.model().id(self.current_row) self.view.model().db.set_cover(id_, data) if self.gui.cover_flow: self.gui.cover_flow.dataChanged() ci = self.view.currentIndex() if ci.isValid(): self.view.model().current_changed(ci, ci) self.cover_pixmap = QPixmap() self.cover_pixmap.loadFromData(data) if self.fit_cover.isChecked(): self.resize_cover() def details_size_hint(self): return QSize(350, 550) def toggle_cover_fit(self, state): gprefs.set('book_info_dialog_fit_cover', self.fit_cover.isChecked()) self.resize_cover() def cover_view_resized(self, event): QTimer.singleShot(1, self.resize_cover) def slave(self, current, previous): if current.row() != previous.row(): row = current.row() self.refresh(row) def move(self, delta=1): self.view.selectionModel().currentChanged.disconnect(self.slave) try: idx = self.view.currentIndex() if idx.isValid(): m = self.view.model() ni = m.index(idx.row() + delta, idx.column()) if ni.isValid(): self.view.setCurrentIndex(ni) self.refresh(ni.row()) if self.view.isVisible(): self.view.scrollTo(ni) finally: self.view.selectionModel().currentChanged.connect(self.slave) def next(self): self.move() def previous(self): self.move(-1) def resize_cover(self): if self.cover_pixmap is None: return pixmap = self.cover_pixmap if self.fit_cover.isChecked(): scaled, new_width, new_height = fit_image( pixmap.width(), pixmap.height(), self.cover.size().width() - 10, self.cover.size().height() - 10) if scaled: pixmap = pixmap.scaled(new_width, new_height, Qt.KeepAspectRatio, Qt.SmoothTransformation) self.cover.set_pixmap(pixmap) self.update_cover_tooltip() def update_cover_tooltip(self): tt = '' if self.marked: tt = _('This book is marked') if self.marked in { True, 'true' } else _('This book is marked as: %s') % self.marked tt += '\n\n' if self.cover_pixmap is not None: sz = self.cover_pixmap.size() tt += _('Cover size: %(width)d x %(height)d') % dict( width=sz.width(), height=sz.height()) self.cover.setToolTip(tt) def refresh(self, row): if isinstance(row, QModelIndex): row = row.row() if row == self.current_row: return mi = self.view.model().get_book_display_info(row) if mi is None: # Indicates books was deleted from library, or row numbers have # changed return self.previous_button.setEnabled(False if row == 0 else True) self.next_button.setEnabled(False if row == self.view.model().rowCount(QModelIndex()) - 1 else True) self.current_row = row self.setWindowTitle(mi.title) self.cover_pixmap = QPixmap.fromImage(mi.cover_data[1]) self.resize_cover() html = render_html(mi, self.css, True, self, all_fields=True) self.details.setHtml(html) self.marked = mi.marked self.cover.setBackgroundBrush( self.marked_brush if mi.marked else self.normal_brush) self.update_cover_tooltip()
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) def __init__(self, parent=None): QTreeWidget.__init__(self, parent) 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: QPixmap(I(icon)).scaled(TOP_ICON_SIZE, TOP_ICON_SIZE, transformMode=Qt.SmoothTransformation) for name, icon in { 'text': 'keyboard-prefs.png', 'styles': 'lookfeel.png', 'fonts': 'font.png', 'misc': 'mimetypes/dir.png', 'images': 'view-image.png', }.iteritems() } self.itemActivated.connect(self.item_double_clicked) def get_state(self): s = {'pos': self.verticalScrollBar().value()} s['expanded'] = { c for c, item in self.categories.iteritems() if item.isExpanded() } s['selected'] = { unicode(i.data(0, NAME_ROLE).toString()) for i in self.selectedItems() } return s def set_state(self, state): for category, item in self.categories.iteritems(): item.setExpanded(category in state['expanded']) self.verticalScrollBar().setValue(state['pos']) for parent in self.categories.itervalues(): for c in (parent.child(i) for i in xrange(parent.childCount())): name = unicode(c.data(0, NAME_ROLE).toString()) if name in state['selected']: c.setSelected(True) def item_from_name(self, name): for parent in self.categories.itervalues(): for c in (parent.child(i) for i in xrange(parent.childCount())): q = unicode(c.data(0, NAME_ROLE).toString()) if q == name: return c def select_name(self, name): for parent in self.categories.itervalues(): for c in (parent.child(i) for i in xrange(parent.childCount())): q = unicode(c.data(0, NAME_ROLE).toString()) c.setSelected(q == name) if q == name: self.scrollToItem(c) 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 preserve_state: state = self.get_state() self.clear() self.root = self.invisibleRootItem() self.root.setFlags(Qt.ItemIsDragEnabled) self.categories = {} for category, text in ( ('text', _('Text')), ('styles', _('Styles')), ('images', _('Images')), ('fonts', _('Fonts')), ('misc', _('Miscellaneous')), ): 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 container.manifest_type_map.itervalues(): 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 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[-1] while text in seen and parts: text = parts.pop() + '/' + text seen[text] = item item.setText(0, text) item.setText(1, hexlify(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] = QPixmap( I(emblem)).scaled( self.iconSize(), transformMode=Qt.SmoothTransformation) 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.fill(Qt.transparent) painter = QPainter(canvas) for i, pm in enumerate(pixmaps): painter.drawPixmap(i * (w + 2), 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') 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: 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 self.categories.iteritems(): 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 set(self.categories.itervalues()): return m = QMenu(self) sel = self.selectedItems() num = len(sel) container = current_container() ci = self.currentItem() if ci is not None: cn = unicode(ci.data(0, NAME_ROLE).toString()) mt = unicode(ci.data(0, MIME_ROLE).toString()) cat = unicode(ci.data(0, CATEGORY_ROLE).toString()) 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)) 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 selected files'), self.request_bulk_rename) m.addAction(QIcon(I('trash.png')), _('&Delete the %d selected file(s)') % num, self.request_delete) m.addSeparator() selected_map = defaultdict(list) for item in sel: selected_map[unicode(item.data( 0, CATEGORY_ROLE).toString())].append( unicode(item.data(0, NAME_ROLE).toString())) for items in selected_map.itervalues(): 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 self.categories.iteritems(): for i in xrange(parent.childCount()): item = parent.child(i) if unicode(item.data(0, NAME_ROLE).toString()) == 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 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(self.categories['text'].child(0).data( 0, NAME_ROLE).toString()) == 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_bulk_rename(self): names = { unicode(item.data(0, NAME_ROLE).toString()) for item in self.selectedItems() } bad = names & current_container().names_that_must_not_be_changed if bad: return error_dialog(self, _('Cannot rename'), _('The file(s) %s cannot be renamed.') % ('<b>%s</b>' % ', '.join(bad)), show=True) names = sorted(names, key=self.index_of_name) fmt, num = get_bulk_rename_settings(self, len(names)) 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) name_map = { n: change_name(n, num + i) for i, n in enumerate(names) } self.bulk_rename_requested.emit(name_map) def request_delete(self): names = { unicode(item.data(0, NAME_ROLE).toString()) for item in self.selectedItems() } 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 xrange(text.childCount())) spine_removals = [(unicode(item.data(0, NAME_ROLE).toString()), item.isSelected()) for item in children] other_removals = { unicode(item.data(0, NAME_ROLE).toString()) for item in self.selectedItems() if unicode(item.data(0, CATEGORY_ROLE).toString()) != '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 self.categories.iteritems(): if category != 'text': for i in xrange(parent.childCount()): child = parent.child(i) if unicode(child.data( 0, NAME_ROLE).toString()) 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 dropEvent(self, event): text = self.categories['text'] pre_drop_order = {text.child(i): i for i in xrange(text.childCount())} super(FileList, self).dropEvent(event) current_order = {text.child(i): i for i in xrange(text.childCount())} if current_order != pre_drop_order: order = [] for child in (text.child(i) for i in xrange(text.childCount())): name = unicode(child.data(0, NAME_ROLE).toString()) linear = child.data(0, LINEAR_ROLE).toBool() 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(item.data(0, CATEGORY_ROLE).toString()) if category: self._request_edit(item) def _request_edit(self, item): category = unicode(item.data(0, CATEGORY_ROLE).toString()) mime = unicode(item.data(0, MIME_ROLE).toString()) name = unicode(item.data(0, NAME_ROLE).toString()) 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) @property def all_files(self): return (category.child(i) for category in self.categories.itervalues() for i in xrange(category.childCount())) @property def searchable_names(self): ans = { 'text': OrderedDict(), 'styles': OrderedDict(), 'selected': OrderedDict() } for item in self.all_files: category = unicode(item.data(0, CATEGORY_ROLE).toString()) mime = unicode(item.data(0, MIME_ROLE).toString()) name = unicode(item.data(0, NAME_ROLE).toString()) ok = category in {'text', 'styles'} if ok: ans[category][name] = syntax_from_mime(name, mime) if not ok and category == 'misc': ok = mime in { guess_type('a.' + x) for x in ('opf', 'ncx', 'txt', 'xml') } if ok and item.isSelected(): ans['selected'][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 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(s.child(i).data(0, NAME_ROLE).toString()) for i in xrange(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(s.item(il).text()) for il in xrange(s.count()) if s.item(il).checkState() == Qt.Checked ] if sheets: self.link_stylesheets_requested.emit(names, sheets, r.isChecked())
class DiffView(QWidget): # {{{ SYNC_POSITION = 0.4 line_activated = pyqtSignal(object, object, object) def __init__(self, parent=None, show_open_in_editor=False): QWidget.__init__(self, parent) self.changes = [[], [], []] self.delta = 0 self.l = l = QHBoxLayout(self) self.setLayout(l) self.syncpos = 0 l.setMargin(0), l.setSpacing(0) self.view = DiffSplit(self, show_open_in_editor=show_open_in_editor) l.addWidget(self.view) self.add_diff = self.view.add_diff self.scrollbar = QScrollBar(self) l.addWidget(self.scrollbar) self.syncing = False self.bars = [] self.resize_timer = QTimer(self) self.resize_timer.setSingleShot(True) self.resize_timer.timeout.connect(self.resize_debounced) for i, bar in enumerate( (self.scrollbar, self.view.left.verticalScrollBar(), self.view.right.verticalScrollBar())): self.bars.append(bar) bar.valueChanged[int].connect(partial(self.scrolled, i)) self.view.left.resized.connect(self.resized) for i, v in enumerate( (self.view.left, self.view.right, self.view.handle(1))): v.wheel_event.connect(self.scrollbar.wheelEvent) if i < 2: v.next_change.connect(self.next_change) v.line_activated.connect(self.line_activated) v.scrolled.connect(partial(self.scrolled, i + 1)) def next_change(self, delta): assert delta in (1, -1) position = self.get_position_from_scrollbar(0) if position[0] == 'in': p = n = position[1] else: p, n = position[1], position[1] + 1 if p < 0: p = None if n >= len(self.changes[0]): n = None if p == n: nc = p + delta if nc < 0 or nc >= len(self.changes[0]): nc = None else: nc = {1: n, -1: p}[delta] if nc is None: self.scrollbar.setValue(0 if delta == -1 else self.scrollbar.maximum()) else: val = self.scrollbar.value() self.scroll_to(0, ('in', nc, 0)) nval = self.scrollbar.value() if nval == val: nval += 5 * delta if 0 <= nval <= self.scrollbar.maximum(): self.scrollbar.setValue(nval) def resized(self): self.resize_timer.start(300) def resize_debounced(self): self.view.resized() self.calculate_length() self.adjust_range() self.view.handle(1).update() def get_position_from_scrollbar(self, which): changes = self.changes[which] bar = self.bars[which] syncpos = self.syncpos + bar.value() prev = 0 for i, (top, bot, kind) in enumerate(changes): if syncpos <= bot: if top <= syncpos: # syncpos is inside a change try: ratio = float(syncpos - top) / (bot - top) except ZeroDivisionError: ratio = 0 return 'in', i, ratio else: # syncpos is after the previous change offset = syncpos - prev return 'after', i - 1, offset else: # syncpos is after the current change prev = bot offset = syncpos - prev return 'after', len(changes) - 1, offset def scroll_to(self, which, position): changes = self.changes[which] bar = self.bars[which] val = None if position[0] == 'in': change_idx, ratio = position[1:] start, end = changes[change_idx][:2] val = start + int((end - start) * ratio) else: change_idx, offset = position[1:] start = 0 if change_idx < 0 else changes[change_idx][1] val = start + offset bar.setValue(val - self.syncpos) def scrolled(self, which, *args): if self.syncing: return position = self.get_position_from_scrollbar(which) with self: for x in {0, 1, 2} - {which}: self.scroll_to(x, position) self.view.handle(1).update() def __enter__(self): self.syncing = True def __exit__(self, *args): self.syncing = False def clear(self): with self: self.view.clear() self.changes = [[], [], []] self.delta = 0 self.scrollbar.setRange(0, 0) def adjust_range(self): ls, rs = self.view.left.verticalScrollBar( ), self.view.right.verticalScrollBar() self.scrollbar.setPageStep(min(ls.pageStep(), rs.pageStep())) self.scrollbar.setSingleStep(min(ls.singleStep(), rs.singleStep())) self.scrollbar.setRange(0, ls.maximum() + self.delta) self.scrollbar.setVisible( self.view.left.blockCount() > ls.pageStep() or self.view.right.blockCount() > rs.pageStep()) self.syncpos = int(ceil(self.scrollbar.pageStep() * self.SYNC_POSITION)) def finalize(self): self.view.finalize() self.changes = [[], [], []] self.calculate_length() self.adjust_range() def calculate_length(self): delta = 0 line_number_changes = ([], []) for v, lmap, changes in zip((self.view.left, self.view.right), ({}, {}), line_number_changes): b = v.document().firstBlock() ebl = v.document().documentLayout().ensureBlockLayout last_line_count = 0 while b.isValid(): ebl(b) lmap[b.blockNumber()] = last_line_count last_line_count += b.layout().lineCount() b = b.next() for top, bot, kind in v.changes: changes.append((lmap[top], lmap[bot], kind)) changes = [] for (l_top, l_bot, kind), (r_top, r_bot, kind) in zip(*line_number_changes): height = max(l_bot - l_top, r_bot - r_top) top = delta + l_top changes.append((top, top + height, kind)) delta = top + height - l_bot self.changes, self.delta = (changes, ) + line_number_changes, delta def handle_key(self, ev): amount, d = None, 1 key = ev.key() if key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_J, Qt.Key_K): amount = self.scrollbar.singleStep() if key in (Qt.Key_Up, Qt.Key_K): d = -1 elif key in (Qt.Key_PageUp, Qt.Key_PageDown): amount = self.scrollbar.pageStep() if key in (Qt.Key_PageUp, ): d = -1 elif key in (Qt.Key_Home, Qt.Key_End): self.scrollbar.setValue(0 if key == Qt.Key_Home else self.scrollbar.maximum()) return True elif key in (Qt.Key_N, Qt.Key_P): self.next_change(1 if key == Qt.Key_N else -1) return True if amount is not None: self.scrollbar.setValue(self.scrollbar.value() + d * amount) return True return False
class TOCEditor(QDialog): explode_done = pyqtSignal(object) writing_done = pyqtSignal(object) def __init__(self, title=None, parent=None): QDialog.__init__(self, parent) t = title or current_container().mi.title self.book_title = t self.setWindowTitle(_('Edit the ToC in %s') % t) self.setWindowIcon(QIcon(I('toc.png'))) l = self.l = QVBoxLayout() self.setLayout(l) self.stacks = s = QStackedWidget(self) l.addWidget(s) self.toc_view = TOCView(self) self.toc_view.add_new_item.connect(self.add_new_item) s.addWidget(self.toc_view) self.item_edit = ItemEdit(self) s.addWidget(self.item_edit) bb = self.bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) l.addWidget(bb) bb.accepted.connect(self.accept) bb.rejected.connect(self.reject) self.read_toc() self.resize(950, 630) geom = gprefs.get('toc_editor_window_geom', None) if geom is not None: self.restoreGeometry(bytes(geom)) def add_new_item(self, item, where): self.item_edit(item, where) self.stacks.setCurrentIndex(1) def accept(self): if self.stacks.currentIndex() == 1: self.toc_view.update_item(*self.item_edit.result) gprefs['toc_edit_splitter_state'] = bytearray( self.item_edit.splitter.saveState()) self.stacks.setCurrentIndex(0) elif self.stacks.currentIndex() == 0: self.write_toc() super(TOCEditor, self).accept() def really_accept(self, tb): gprefs['toc_editor_window_geom'] = bytearray(self.saveGeometry()) if tb: error_dialog(self, _('Failed to write book'), _('Could not write %s. Click "Show details" for' ' more information.') % self.book_title, det_msg=tb, show=True) gprefs['toc_editor_window_geom'] = bytearray(self.saveGeometry()) super(TOCEditor, self).reject() return super(TOCEditor, self).accept() def reject(self): if not self.bb.isEnabled(): return if self.stacks.currentIndex() == 1: gprefs['toc_edit_splitter_state'] = bytearray( self.item_edit.splitter.saveState()) self.stacks.setCurrentIndex(0) else: gprefs['toc_editor_window_geom'] = bytearray(self.saveGeometry()) super(TOCEditor, self).reject() def read_toc(self): self.toc_view(current_container()) self.item_edit.load(current_container()) self.stacks.setCurrentIndex(0) def write_toc(self): toc = self.toc_view.create_toc() commit_toc(current_container(), toc, lang=self.toc_view.toc_lang, uid=self.toc_view.toc_uid)
class DiffSplitHandle(QSplitterHandle): # {{{ WIDTH = 30 # px wheel_event = pyqtSignal(object) def event(self, ev): if ev.type() in (ev.HoverEnter, ev.HoverLeave): self.hover = ev.type() == ev.HoverEnter return QSplitterHandle.event(self, ev) def paintEvent(self, event): QSplitterHandle.paintEvent(self, event) left, right = self.parent().left, self.parent().right painter = QPainter(self) painter.setClipRect(event.rect()) w = self.width() h = self.height() painter.setRenderHints(QPainter.Antialiasing, True) C = 16 # Curve factor. def create_line(ly, ry, right_to_left=False): ' Create path that represents upper or lower line of change marker ' line = QPainterPath() if not right_to_left: line.moveTo(0, ly) line.cubicTo(C, ly, w - C, ry, w, ry) else: line.moveTo(w, ry) line.cubicTo(w - C, ry, C, ly, 0, ly) return line ldoc, rdoc = left.document(), right.document() lorigin, rorigin = left.contentOffset(), right.contentOffset() lfv, rfv = left.firstVisibleBlock().blockNumber( ), right.firstVisibleBlock().blockNumber() lines = [] for (ltop, lbot, kind), (rtop, rbot, kind) in zip(left.changes, right.changes): if lbot < lfv and rbot < rfv: continue ly_top = left.blockBoundingGeometry( ldoc.findBlockByNumber(ltop)).translated(lorigin).y() ly_bot = left.blockBoundingGeometry( ldoc.findBlockByNumber(lbot)).translated(lorigin).y() ry_top = right.blockBoundingGeometry( rdoc.findBlockByNumber(rtop)).translated(rorigin).y() ry_bot = right.blockBoundingGeometry( rdoc.findBlockByNumber(rbot)).translated(rorigin).y() if max(ly_top, ly_bot, ry_top, ry_bot) < 0: continue if min(ly_top, ly_bot, ry_top, ry_bot) > h: break upper_line = create_line(ly_top, ry_top) lower_line = create_line(ly_bot, ry_bot, True) region = QPainterPath() region.moveTo(0, ly_top) region.connectPath(upper_line) region.lineTo(w, ry_bot) region.connectPath(lower_line) region.closeSubpath() painter.fillPath(region, left.diff_backgrounds[kind]) for path, aa in zip((upper_line, lower_line), (ly_top != ry_top, ly_bot != ry_bot)): lines.append((kind, path, aa)) for kind, path, aa in sorted( lines, key=lambda x: {'replace': 0}.get(x[0], 1)): painter.setPen(left.diff_foregrounds[kind]) painter.setRenderHints(QPainter.Antialiasing, aa) painter.drawPath(path) painter.setFont(left.heading_font) for (lnum, text), (rnum, text) in zip(left.headers, right.headers): ltop, lbot, rtop, rbot = lnum, lnum + 3, rnum, rnum + 3 if lbot < lfv and rbot < rfv: continue ly_top = left.blockBoundingGeometry( ldoc.findBlockByNumber(ltop)).translated(lorigin).y() ly_bot = left.blockBoundingGeometry( ldoc.findBlockByNumber(lbot)).translated(lorigin).y() ry_top = right.blockBoundingGeometry( rdoc.findBlockByNumber(rtop)).translated(rorigin).y() ry_bot = right.blockBoundingGeometry( rdoc.findBlockByNumber(rbot)).translated(rorigin).y() if max(ly_top, ly_bot, ry_top, ry_bot) < 0: continue if min(ly_top, ly_bot, ry_top, ry_bot) > h: break ly = painter.boundingRect(3, ly_top, left.width(), ly_bot - ly_top - 5, Qt.TextSingleLine, text).bottom() + 3 ry = painter.boundingRect(3, ry_top, right.width(), ry_bot - ry_top - 5, Qt.TextSingleLine, text).bottom() + 3 line = create_line(ly, ry) painter.setPen(QPen(left.palette().text(), 2)) painter.setRenderHints(QPainter.Antialiasing, ly != ry) painter.drawPath(line) painter.end() # Paint the splitter without the change lines if the mouse is over the # splitter if getattr(self, 'hover', False): QSplitterHandle.paintEvent(self, event) def sizeHint(self): ans = QSplitterHandle.sizeHint(self) ans.setWidth(self.WIDTH) return ans def wheelEvent(self, ev): if ev.orientation() == Qt.Vertical: self.wheel_event.emit(ev) else: return QSplitterHandle.wheelEvent(self, ev)