%PDF- %PDF-
Direktori : /lib/calibre/calibre/gui2/library/ |
Current File : //lib/calibre/calibre/gui2/library/alternate_views.py |
#!/usr/bin/env python3 __license__ = 'GPL v3' __copyright__ = '2013, Kovid Goyal <kovid at kovidgoyal.net>' import itertools import math import operator import os from functools import wraps from qt.core import ( QAbstractItemView, QApplication, QBuffer, QByteArray, QColor, QDrag, QEasingCurve, QEvent, QFont, QHelpEvent, QIcon, QImage, QItemSelection, QItemSelectionModel, QListView, QMimeData, QModelIndex, QPainter, QPixmap, QPoint, QPropertyAnimation, QRect, QSize, QStyledItemDelegate, QPalette, QStyleOptionViewItem, Qt, QTableView, QTimer, QToolTip, QTreeView, QUrl, pyqtProperty, pyqtSignal, pyqtSlot, qBlue, qGreen, qRed, QIODevice ) from textwrap import wrap from threading import Event, Thread from calibre import fit_image, human_readable, prepare_string_for_xml, prints from calibre.constants import DEBUG, config_dir, islinux from calibre.ebooks.metadata import fmt_sidx, rating_to_stars from calibre.gui2 import config, empty_index, gprefs, rating_font from calibre.gui2.dnd import path_from_qurl from calibre.gui2.gestures import GestureManager from calibre.gui2.library.caches import CoverCache, ThumbnailCache from calibre.gui2.pin_columns import PinContainer from calibre.utils import join_with_timeout from calibre.utils.config import prefs, tweaks from polyglot.builtins import itervalues from polyglot.queue import LifoQueue CM_TO_INCH = 0.393701 CACHE_FORMAT = 'PPM' def auto_height(widget): # On some broken systems, availableGeometry() returns tiny values, we need # a value of at least 1000 for 200 DPI systems. return max(1000, widget.screen().availableSize().height()) / 5.0 class EncodeError(ValueError): pass def handle_enter_press(self, ev, special_action=None, has_edit_cell=True): if ev.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return): mods = ev.modifiers() if ( mods & Qt.KeyboardModifier.ControlModifier or mods & Qt.KeyboardModifier.AltModifier or mods & Qt.KeyboardModifier.ShiftModifier or mods & Qt.KeyboardModifier.MetaModifier ): return if self.state() != QAbstractItemView.State.EditingState and self.hasFocus() and self.currentIndex().isValid(): from calibre.gui2.ui import get_gui ev.ignore() tweak = tweaks['enter_key_behavior'] gui = get_gui() if tweak == 'edit_cell': if has_edit_cell: self.edit(self.currentIndex(), QAbstractItemView.EditTrigger.EditKeyPressed, ev) else: gui.iactions['Edit Metadata'].edit_metadata(False) elif tweak == 'edit_metadata': gui.iactions['Edit Metadata'].edit_metadata(False) elif tweak == 'do_nothing': pass else: if special_action is not None: special_action(self.currentIndex()) gui.iactions['View'].view_triggered(self.currentIndex()) gui.enter_key_pressed_in_book_list.emit(self) return True def image_to_data(image): # {{{ ba = QByteArray() buf = QBuffer(ba) buf.open(QIODevice.OpenModeFlag.WriteOnly) if not image.save(buf, CACHE_FORMAT): raise EncodeError('Failed to encode thumbnail') ret = ba.data() buf.close() return ret # }}} # Drag 'n Drop {{{ def qt_item_view_base_class(self): for q in (QTableView, QListView, QTreeView): if isinstance(self, q): return q return QAbstractItemView def dragMoveEvent(self, event): event.acceptProposedAction() def event_has_mods(self, event=None): mods = event.modifiers() if event is not None else \ QApplication.keyboardModifiers() return mods & Qt.KeyboardModifier.ControlModifier or mods & Qt.KeyboardModifier.ShiftModifier def mousePressEvent(self, event): ep = event.pos() if self.indexAt(ep) in self.selectionModel().selectedIndexes() and \ event.button() == Qt.MouseButton.LeftButton and not self.event_has_mods(): self.drag_start_pos = ep if hasattr(self, 'handle_mouse_press_event'): return self.handle_mouse_press_event(event) return qt_item_view_base_class(self).mousePressEvent(self, event) def drag_icon(self, cover, multiple): cover = cover.scaledToHeight(120, Qt.TransformationMode.SmoothTransformation) if multiple: base_width = cover.width() base_height = cover.height() base = QImage(base_width+21, base_height+21, QImage.Format.Format_ARGB32_Premultiplied) base.fill(QColor(255, 255, 255, 0).rgba()) p = QPainter(base) rect = QRect(20, 0, base_width, base_height) p.fillRect(rect, QColor('white')) p.drawRect(rect) rect.moveLeft(10) rect.moveTop(10) p.fillRect(rect, QColor('white')) p.drawRect(rect) rect.moveLeft(0) rect.moveTop(20) p.fillRect(rect, QColor('white')) p.save() p.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceAtop) p.drawImage(rect.topLeft(), cover) p.restore() p.drawRect(rect) p.end() cover = base return QPixmap.fromImage(cover) def drag_data(self): m = self.model() db = m.db selected = self.get_selected_ids() ids = ' '.join(map(str, selected)) md = QMimeData() md.setData('application/calibre+from_library', ids.encode('utf-8')) fmt = prefs['output_format'] def url_for_id(i): try: ans = db.format_path(i, fmt, index_is_id=True) except: ans = None if ans is None: fmts = db.formats(i, index_is_id=True) if fmts: fmts = fmts.split(',') else: fmts = [] for f in fmts: try: ans = db.format_path(i, f, index_is_id=True) except: ans = None if ans is None: ans = db.abspath(i, index_is_id=True) return QUrl.fromLocalFile(ans) md.setUrls([url_for_id(i) for i in selected]) drag = QDrag(self) col = self.selectionModel().currentIndex().column() try: md.column_name = self.column_map[col] except AttributeError: md.column_name = 'title' drag.setMimeData(md) cover = self.drag_icon(m.cover(self.currentIndex().row()), len(selected) > 1) drag.setHotSpot(QPoint(-15, -15)) drag.setPixmap(cover) return drag def mouseMoveEvent(self, event): if not self.drag_allowed: return if self.drag_start_pos is None: return qt_item_view_base_class(self).mouseMoveEvent(self, event) if self.event_has_mods(): self.drag_start_pos = None return if not (event.buttons() & Qt.MouseButton.LeftButton) or \ (event.pos() - self.drag_start_pos).manhattanLength() \ < QApplication.startDragDistance(): return index = self.indexAt(event.pos()) if not index.isValid(): return drag = self.drag_data() drag.exec(Qt.DropAction.CopyAction) self.drag_start_pos = None def dnd_merge_ok(md): return md.hasFormat('application/calibre+from_library') and gprefs['dnd_merge'] def dragEnterEvent(self, event): if int(event.possibleActions() & Qt.DropAction.CopyAction) + \ int(event.possibleActions() & Qt.DropAction.MoveAction) == 0: return paths = self.paths_from_event(event) md = event.mimeData() if paths or dnd_merge_ok(md): event.acceptProposedAction() def dropEvent(self, event): md = event.mimeData() if dnd_merge_ok(md): ids = set(map(int, filter(None, bytes(md.data('application/calibre+from_library')).decode('utf-8').split(' ')))) row = self.indexAt(event.pos()).row() if row > -1 and ids: book_id = self.model().id(row) if book_id and book_id not in ids: self.books_dropped.emit({book_id: ids}) event.setDropAction(Qt.DropAction.CopyAction) event.accept() return paths = self.paths_from_event(event) event.setDropAction(Qt.DropAction.CopyAction) event.accept() self.files_dropped.emit(paths) def paths_from_event(self, event): ''' Accept a drop event and return a list of paths that can be read from and represent files with extensions. ''' md = event.mimeData() if md.hasFormat('text/uri-list') and not md.hasFormat('application/calibre+from_library'): urls = map(path_from_qurl, md.urls()) return [u for u in urls if u and os.path.splitext(u)[1] and os.path.exists(u)] def setup_dnd_interface(cls_or_self): if isinstance(cls_or_self, type): cls = cls_or_self fmap = globals() for x in ( 'dragMoveEvent', 'event_has_mods', 'mousePressEvent', 'mouseMoveEvent', 'drag_data', 'drag_icon', 'dragEnterEvent', 'dropEvent', 'paths_from_event'): func = fmap[x] setattr(cls, x, func) return cls else: self = cls_or_self self.drag_allowed = True self.drag_start_pos = None self.setDragEnabled(True) self.setDragDropOverwriteMode(False) self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) # }}} # Manage slave views {{{ def sync(func): @wraps(func) def ans(self, *args, **kwargs): if self.break_link or self.current_view is self.main_view: return with self: return func(self, *args, **kwargs) return ans class AlternateViews: def __init__(self, main_view): self.views = {None:main_view} self.stack_positions = {None:0} self.current_view = self.main_view = main_view self.stack = None self.break_link = False self.main_connected = False self.current_book_state = None def set_stack(self, stack): self.stack = stack pin_container = PinContainer(self.main_view, stack) self.stack.addWidget(pin_container) return pin_container def add_view(self, key, view): self.views[key] = view self.stack_positions[key] = self.stack.count() self.stack.addWidget(view) self.stack.setCurrentIndex(0) view.setModel(self.main_view._model) view.selectionModel().currentChanged.connect(self.slave_current_changed) view.selectionModel().selectionChanged.connect(self.slave_selection_changed) view.files_dropped.connect(self.main_view.files_dropped) view.books_dropped.connect(self.main_view.books_dropped) def show_view(self, key=None): view = self.views[key] if view is self.current_view: return self.stack.setCurrentIndex(self.stack_positions[key]) self.current_view = view if view is not self.main_view: self.main_current_changed(self.main_view.currentIndex()) self.main_selection_changed() view.shown() if not self.main_connected: self.main_connected = True self.main_view.selectionModel().currentChanged.connect(self.main_current_changed) self.main_view.selectionModel().selectionChanged.connect(self.main_selection_changed) view.setFocus(Qt.FocusReason.OtherFocusReason) def set_database(self, db, stage=0): for view in itervalues(self.views): if view is not self.main_view: view.set_database(db, stage=stage) def __enter__(self): self.break_link = True def __exit__(self, *args): self.break_link = False @sync def slave_current_changed(self, current, *args): self.main_view.set_current_row(current.row(), for_sync=True) @sync def slave_selection_changed(self, *args): rows = {r.row() for r in self.current_view.selectionModel().selectedIndexes()} self.main_view.select_rows(rows, using_ids=False, change_current=False, scroll=False) @sync def main_current_changed(self, current, *args): self.current_view.set_current_row(current.row()) @sync def main_selection_changed(self, *args): rows = {r.row() for r in self.main_view.selectionModel().selectedIndexes()} self.current_view.select_rows(rows) def set_context_menu(self, menu): for view in itervalues(self.views): if view is not self.main_view: view.set_context_menu(menu) def save_current_book_state(self): self.current_book_state = self.current_view, self.current_view.current_book_state() def restore_current_book_state(self): if self.current_book_state is not None: if self.current_book_state[0] is self.current_view: self.current_view.restore_current_book_state(self.current_book_state[1]) self.current_book_state = None def marked_changed(self, old_marked, current_marked): if self.current_view is not self.main_view: self.current_view.marked_changed(old_marked, current_marked) # }}} # Rendering of covers {{{ class CoverDelegate(QStyledItemDelegate): MARGIN = 4 TOP, LEFT, RIGHT, BOTTOM = object(), object(), object(), object() @pyqtProperty(float) def animated_size(self): return self._animated_size @animated_size.setter def animated_size(self, val): self._animated_size = val def __init__(self, parent): super().__init__(parent) self._animated_size = 1.0 self.animation = QPropertyAnimation(self, b'animated_size', self) self.animation.setEasingCurve(QEasingCurve.Type.OutInCirc) self.animation.setDuration(500) self.set_dimensions() self.cover_cache = CoverCache() self.render_queue = LifoQueue() self.animating = None self.highlight_color = QColor(Qt.GlobalColor.white) self.rating_font = QFont(rating_font()) def set_dimensions(self): width = self.original_width = gprefs['cover_grid_width'] height = self.original_height = gprefs['cover_grid_height'] self.original_show_title = show_title = gprefs['cover_grid_show_title'] self.original_show_emblems = gprefs['show_emblems'] self.orginal_emblem_size = gprefs['emblem_size'] self.orginal_emblem_position = gprefs['emblem_position'] self.emblem_size = gprefs['emblem_size'] if self.original_show_emblems else 0 try: self.gutter_position = getattr(self, self.orginal_emblem_position.upper()) except Exception: self.gutter_position = self.TOP if height < 0.1: height = auto_height(self.parent()) else: height *= self.parent().logicalDpiY() * CM_TO_INCH if width < 0.1: width = 0.75 * height else: width *= self.parent().logicalDpiX() * CM_TO_INCH self.cover_size = QSize(int(width), int(height)) self.title_height = 0 if show_title: f = self.parent().font() sz = f.pixelSize() if sz < 5: sz = f.pointSize() * self.parent().logicalDpiY() / 72.0 self.title_height = int(max(25, sz + 10)) self.item_size = self.cover_size + QSize(2 * self.MARGIN, (2 * self.MARGIN) + self.title_height) if self.emblem_size > 0: extra = self.emblem_size + self.MARGIN self.item_size += QSize(extra, 0) if self.gutter_position in (self.LEFT, self.RIGHT) else QSize(0, extra) self.calculate_spacing() self.animation.setStartValue(1.0) self.animation.setKeyValueAt(0.5, 0.5) self.animation.setEndValue(1.0) def calculate_spacing(self): spc = self.original_spacing = gprefs['cover_grid_spacing'] if spc < 0.01: self.spacing = max(10, min(50, int(0.1 * self.original_width))) else: self.spacing = int(self.parent().logicalDpiX() * CM_TO_INCH * spc) def sizeHint(self, option, index): return self.item_size def render_field(self, db, book_id): is_stars = False try: field = db.pref('field_under_covers_in_grid', 'title') if field == 'size': ans = human_readable(db.field_for(field, book_id, default_value=0)) else: mi = db.get_proxy_metadata(book_id) display_name, ans, val, fm = mi.format_field_extended(field) if fm and fm['datatype'] == 'rating': ans = rating_to_stars(val, fm['display'].get('allow_half_stars', False)) is_stars = True return ('' if ans is None else str(ans)), is_stars except Exception: if DEBUG: import traceback traceback.print_exc() return '', is_stars def render_emblem(self, book_id, rule, rule_index, cache, mi, db, formatter, template_cache): ans = cache[book_id].get(rule, False) if ans is not False: return ans, mi ans = None if mi is None: mi = db.get_proxy_metadata(book_id) ans = formatter.safe_format(rule, mi, '', mi, column_name='cover_grid%d' % rule_index, template_cache=template_cache) or None cache[book_id][rule] = ans return ans, mi def cached_emblem(self, cache, name, raw_icon=None): ans = cache.get(name, False) if ans is not False: return ans sz = self.emblem_size ans = None if raw_icon is not None: ans = raw_icon.pixmap(sz, sz) elif name == ':ondevice': ans = QIcon(I('ok.png')).pixmap(sz, sz) elif name: pmap = QIcon(os.path.join(config_dir, 'cc_icons', name)).pixmap(sz, sz) if not pmap.isNull(): ans = pmap cache[name] = ans return ans def paint(self, painter, option, index): QStyledItemDelegate.paint(self, painter, option, empty_index) # draw the hover and selection highlights m = index.model() db = m.db try: book_id = db.id(index.row()) except (ValueError, IndexError, KeyError): return if book_id in m.ids_to_highlight_set: painter.save() try: painter.setPen(self.highlight_color) painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) painter.drawRoundedRect(option.rect, 10, 10, Qt.SizeMode.RelativeSize) finally: painter.restore() marked = db.data.get_marked(book_id) db = db.new_api cdata = self.cover_cache[book_id] device_connected = self.parent().gui.device_connected is not None on_device = device_connected and db.field_for('ondevice', book_id) emblem_rules = db.pref('cover_grid_icon_rules', default=()) emblems = [] if self.emblem_size > 0: mi = None for i, (kind, column, rule) in enumerate(emblem_rules): icon_name, mi = self.render_emblem(book_id, rule, i, m.cover_grid_emblem_cache, mi, db, m.formatter, m.cover_grid_template_cache) if icon_name is not None: for one_icon in filter(None, (i.strip() for i in icon_name.split(':'))): pixmap = self.cached_emblem(m.cover_grid_bitmap_cache, one_icon) if pixmap is not None: emblems.append(pixmap) if marked: emblems.insert(0, self.cached_emblem(m.cover_grid_bitmap_cache, ':marked', m.marked_icon)) if on_device: emblems.insert(0, self.cached_emblem(m.cover_grid_bitmap_cache, ':ondevice')) painter.save() right_adjust = 0 try: rect = option.rect rect.adjust(self.MARGIN, self.MARGIN, -self.MARGIN, -self.MARGIN) if self.emblem_size > 0: self.paint_emblems(painter, rect, emblems) orect = QRect(rect) trect = QRect(rect) if self.title_height != 0: rect.setBottom(rect.bottom() - self.title_height) trect.setTop(trect.bottom() - self.title_height + 5) if cdata is None or cdata is False: title = db.field_for('title', book_id, default_value='') authors = ' & '.join(db.field_for('authors', book_id, default_value=())) painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter|Qt.TextFlag.TextWordWrap, f'{title}\n\n{authors}') if cdata is False: self.render_queue.put(book_id) if self.title_height != 0: self.paint_title(painter, trect, db, book_id) else: if self.animating is not None and self.animating.row() == index.row(): cdata = cdata.scaled(cdata.size() * self._animated_size) dpr = cdata.devicePixelRatio() cw, ch = int(cdata.width() / dpr), int(cdata.height() / dpr) dx = max(0, int((rect.width() - cw)/2.0)) dy = max(0, int((rect.height() - ch)/2.0)) right_adjust = dx rect.adjust(dx, dy, -dx, -dy) painter.drawPixmap(rect, cdata) if self.title_height != 0: self.paint_title(painter, trect, db, book_id) if self.emblem_size > 0: return # We dont draw embossed emblems as the ondevice/marked emblems are drawn in the gutter if marked: try: p = self.marked_emblem except AttributeError: p = self.marked_emblem = m.marked_icon.pixmap(48, 48) self.paint_embossed_emblem(p, painter, orect, right_adjust) if on_device: try: p = self.on_device_emblem except AttributeError: p = self.on_device_emblem = QIcon(I('ok.png')).pixmap(48, 48) self.paint_embossed_emblem(p, painter, orect, right_adjust, left=False) finally: painter.restore() def paint_title(self, painter, rect, db, book_id): painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True) title, is_stars = self.render_field(db, book_id) if is_stars: painter.setFont(self.rating_font) metrics = painter.fontMetrics() painter.setPen(self.highlight_color) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter|Qt.TextFlag.TextSingleLine, metrics.elidedText(title, Qt.TextElideMode.ElideRight, rect.width())) def paint_emblems(self, painter, rect, emblems): gutter = self.emblem_size + self.MARGIN grect = QRect(rect) gpos = self.gutter_position if gpos is self.TOP: grect.setBottom(grect.top() + gutter) rect.setTop(rect.top() + gutter) elif gpos is self.BOTTOM: grect.setTop(grect.bottom() - gutter + self.MARGIN) rect.setBottom(rect.bottom() - gutter) elif gpos is self.LEFT: grect.setRight(grect.left() + gutter) rect.setLeft(rect.left() + gutter) else: grect.setLeft(grect.right() - gutter + self.MARGIN) rect.setRight(rect.right() - gutter) horizontal = gpos in (self.TOP, self.BOTTOM) painter.save() painter.setClipRect(grect) try: for i, emblem in enumerate(emblems): delta = 0 if i == 0 else self.emblem_size + self.MARGIN grect.moveLeft(grect.left() + delta) if horizontal else grect.moveTop(grect.top() + delta) rect = QRect(grect) rect.setWidth(int(emblem.width() / emblem.devicePixelRatio())), rect.setHeight(int(emblem.height() / emblem.devicePixelRatio())) painter.drawPixmap(rect, emblem) finally: painter.restore() def paint_embossed_emblem(self, pixmap, painter, orect, right_adjust, left=True): drect = QRect(orect) pw = int(pixmap.width() / pixmap.devicePixelRatio()) ph = int(pixmap.height() / pixmap.devicePixelRatio()) if left: drect.setLeft(drect.left() + right_adjust) drect.setRight(drect.left() + pw) else: drect.setRight(drect.right() - right_adjust) drect.setLeft(drect.right() - pw + 1) drect.setBottom(drect.bottom() - self.title_height) drect.setTop(drect.bottom() - ph) painter.drawPixmap(drect, pixmap) @pyqtSlot(QHelpEvent, QAbstractItemView, QStyleOptionViewItem, QModelIndex, result=bool) def helpEvent(self, event, view, option, index): if event is not None and view is not None and event.type() == QEvent.Type.ToolTip: try: db = index.model().db except AttributeError: return False try: book_id = db.id(index.row()) except (ValueError, IndexError, KeyError): return False db = db.new_api device_connected = self.parent().gui.device_connected on_device = device_connected is not None and db.field_for('ondevice', book_id) p = prepare_string_for_xml title = db.field_for('title', book_id) authors = db.field_for('authors', book_id) if title and authors: title = '<b>%s</b>' % ('<br>'.join(wrap(p(title), 120))) authors = '<br>'.join(wrap(p(' & '.join(authors)), 120)) tt = f'{title}<br><br>{authors}' series = db.field_for('series', book_id) if series: use_roman_numbers=config['use_roman_numerals_for_series_number'] val = _('Book %(sidx)s of <span class="series_name">%(series)s</span>')%dict( sidx=fmt_sidx(db.field_for('series_index', book_id), use_roman=use_roman_numbers), series=p(series)) tt += '<br><br>' + val if on_device: val = _('This book is on the device in %s') % on_device tt += '<br><br>' + val QToolTip.showText(event.globalPos(), tt, view) return True return False # }}} # The View {{{ @setup_dnd_interface class GridView(QListView): update_item = pyqtSignal(object) files_dropped = pyqtSignal(object) books_dropped = pyqtSignal(object) def __init__(self, parent): QListView.__init__(self, parent) self._ncols = None self.gesture_manager = GestureManager(self) setup_dnd_interface(self) self.setUniformItemSizes(True) self.setWrapping(True) self.setFlow(QListView.Flow.LeftToRight) # We cannot set layout mode to batched, because that breaks # restore_vpos() # self.setLayoutMode(QListView.ResizeMode.Batched) self.setResizeMode(QListView.ResizeMode.Adjust) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.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.set_color() self.ignore_render_requests = Event() dpr = self.device_pixel_ratio self.thumbnail_cache = ThumbnailCache(max_size=gprefs['cover_grid_disk_cache_size'], thumbnail_size=(int(dpr * self.delegate.cover_size.width()), int(dpr * self.delegate.cover_size.height()))) self.render_thread = None self.update_item.connect(self.re_render, type=Qt.ConnectionType.QueuedConnection) self.doubleClicked.connect(self.double_clicked) self.setCursor(Qt.CursorShape.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) self.resize_timer = t = QTimer(self) t.setInterval(200), t.setSingleShot(True) t.timeout.connect(self.update_memory_cover_cache_size) def viewportEvent(self, ev): try: ret = self.gesture_manager.handle_event(ev) except AttributeError: ret = None if ret is not None: return ret return QListView.viewportEvent(self, ev) @property def device_pixel_ratio(self): try: return self.devicePixelRatioF() except AttributeError: return self.devicePixelRatio() @property def first_visible_row(self): geom = self.viewport().geometry() for y in range(geom.top(), (self.spacing()*2) + geom.top(), 5): for x in range(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 range(geom.bottom(), geom.bottom() - 2 * self.spacing(), -5): for x in range(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 range(self.first_visible_row or 0, self.last_visible_row or (m.count() - 1)): self.update(m.index(r, 0)) def start_view_animation(self, index): d = self.delegate if d.animating is None and not config['disable_animations']: d.animating = index d.animation.start() def double_clicked(self, index): self.start_view_animation(index) tval = tweaks['doubleclick_on_library_view'] if tval == 'open_viewer': self.gui.iactions['View'].view_triggered(index) elif tval in {'edit_metadata', 'edit_cell'}: self.gui.iactions['Edit Metadata'].edit_metadata(False, False) elif tval == 'show_book_details': self.gui.iactions['Show Book Details'].show_book_info() 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'] tex = gprefs['cover_grid_texture'] pal = self.palette() pal.setColor(QPalette.ColorRole.Base, QColor(r, g, b)) self.setPalette(pal) ss = '' if tex: from calibre.gui2.preferences.texture_chooser import texture_path path = texture_path(tex) if path: path = os.path.abspath(path).replace(os.sep, '/') ss += f'background-image: url({path});' ss += 'background-attachment: fixed;' 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) dark = max(r, g, b) < 115 col = '#eee' if dark else '#111' ss += f'color: {col};' self.delegate.highlight_color = QColor(col) self.setStyleSheet(f'QListView {{ {ss} }}') 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 or gprefs[ 'show_emblems'] != self.delegate.original_show_emblems or gprefs[ 'emblem_size'] != self.delegate.orginal_emblem_size or gprefs[ 'emblem_position'] != self.delegate.orginal_emblem_position): 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.set_thumbnail_cache_image_size() cs = gprefs['cover_grid_disk_cache_size'] if (cs*(1024**2)) != self.thumbnail_cache.max_size: self.thumbnail_cache.set_size(cs) self.update_memory_cover_cache_size() def set_thumbnail_cache_image_size(self): dpr = self.device_pixel_ratio self.thumbnail_cache.set_thumbnail_size( int(dpr * self.delegate.cover_size.width()), int(dpr*self.delegate.cover_size.height())) def resizeEvent(self, ev): self._ncols = None self.resize_timer.start() return QListView.resizeEvent(self, ev) def update_memory_cover_cache_size(self): try: sz = self.delegate.item_size except AttributeError: return rows, cols = self.width() // sz.width(), self.height() // sz.height() num = (rows + 1) * (cols + 1) limit = max(100, num * max(2, gprefs['cover_grid_cache_size_multiple'])) if limit != self.delegate.cover_cache.limit: self.delegate.cover_cache.set_limit(limit) def shown(self): self.update_memory_cover_cache_size() 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 dpr = self.device_pixel_ratio page_width = int(dpr * self.delegate.cover_size.width()) page_height = int(dpr * self.delegate.cover_size.height()) 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') p.setDevicePixelRatio(dpr) 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, page_width, page_height) if scaled: if self.ignore_render_requests.is_set(): return p = p.scaled(int(nwidth), int(nheight), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation) p.setDevicePixelRatio(dpr) 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, AttributeError): 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 won't 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[0]-i_x[1]): group = list(map(operator.itemgetter(1), g)) sel.merge(QItemSelection(m.index(min(group), 0), m.index(max(group), 0)), QItemSelectionModel.SelectionFlag.Select) sm.select(sel, QItemSelectionModel.SelectionFlag.ClearAndSelect) def selectAll(self): # We re-implement this to ensure that only indexes from column 0 are # selected. The base class implementation selects all columns. This # causes problems with selection syncing, see # https://bugs.launchpad.net/bugs/1236348 m = self.model() sm = self.selectionModel() sel = QItemSelection(m.index(0, 0), m.index(m.rowCount(QModelIndex())-1, 0)) sm.select(sel, QItemSelectionModel.SelectionFlag.ClearAndSelect) def set_current_row(self, row): sm = self.selectionModel() sm.setCurrentIndex(self.model().index(row, 0), QItemSelectionModel.SelectionFlag.NoUpdate) def set_context_menu(self, menu): self.context_menu = menu def contextMenuEvent(self, event): if self.context_menu is None: return from calibre.gui2.main_window import clone_menu m = clone_menu(self.context_menu) if islinux else self.context_menu m.popup(event.globalPos()) event.accept() def get_selected_ids(self): m = self.model() return [m.id(i) for i in self.selectionModel().selectedIndexes()] def restore_vpos(self, vpos): self.verticalScrollBar().setValue(vpos) def restore_hpos(self, hpos): pass def handle_mouse_press_event(self, ev): if QApplication.keyboardModifiers() & Qt.KeyboardModifier.ShiftModifier: # Shift-Click in QListView is broken. It selects extra items in # various circumstances, for example, click on some item in the # middle of a row then click on an item in the next row, all items # in the first row will be selected instead of only items after the # middle item. index = self.indexAt(ev.pos()) if not index.isValid(): return ci = self.currentIndex() sm = self.selectionModel() sm.setCurrentIndex(index, QItemSelectionModel.SelectionFlag.NoUpdate) if not ci.isValid(): return if not sm.hasSelection(): sm.select(index, QItemSelectionModel.SelectionFlag.ClearAndSelect) return cr = ci.row() tgt = index.row() top = self.model().index(min(cr, tgt), 0) bottom = self.model().index(max(cr, tgt), 0) sm.select(QItemSelection(top, bottom), QItemSelectionModel.SelectionFlag.Select) else: return QListView.mousePressEvent(self, ev) def indices_for_merge(self, resolved=True): return self.selectionModel().selectedIndexes() def number_of_columns(self): # Number of columns currently visible in the grid if self._ncols is None: dpr = self.device_pixel_ratio width = int(dpr * self.delegate.cover_size.width()) height = int(dpr * self.delegate.cover_size.height()) step = max(10, self.spacing()) for y in range(step, 2 * height, step): for x in range(step, 2 * width, step): i = self.indexAt(QPoint(x, y)) if i.isValid(): for x in range(self.viewport().width() - step, self.viewport().width() - width, -step): j = self.indexAt(QPoint(x, y)) if j.isValid(): self._ncols = j.row() - i.row() + 1 return self._ncols return self._ncols def keyPressEvent(self, ev): if handle_enter_press(self, ev, self.start_view_animation, False): return k = ev.key() if ev.modifiers() & Qt.KeyboardModifier.ShiftModifier and k in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down): ci = self.currentIndex() if not ci.isValid(): return c = ci.row() ncols = self.number_of_columns() or 1 delta = {Qt.Key.Key_Left: -1, Qt.Key.Key_Right: 1, Qt.Key.Key_Up: -ncols, Qt.Key.Key_Down: ncols}[k] n = max(0, min(c + delta, self.model().rowCount(None) - 1)) if n == c: return sm = self.selectionModel() rows = {i.row() for i in sm.selectedIndexes()} if rows: mi, ma = min(rows), max(rows) end = mi if c == ma else ma if c == mi else c else: end = c top = self.model().index(min(n, end), 0) bottom = self.model().index(max(n, end), 0) sm.select(QItemSelection(top, bottom), QItemSelectionModel.SelectionFlag.ClearAndSelect) sm.setCurrentIndex(self.model().index(n, 0), QItemSelectionModel.SelectionFlag.NoUpdate) else: return QListView.keyPressEvent(self, ev) @property def current_book(self): ci = self.currentIndex() if ci.isValid(): try: return self.model().db.data.index_to_id(ci.row()) except (IndexError, ValueError, KeyError, TypeError, AttributeError): pass def current_book_state(self): return self.current_book def restore_current_book_state(self, state): book_id = state self.setFocus(Qt.FocusReason.OtherFocusReason) try: row = self.model().db.data.id_to_index(book_id) except (IndexError, ValueError, KeyError, TypeError, AttributeError): return self.set_current_row(row) self.select_rows((row,)) self.scrollTo(self.model().index(row, 0), QAbstractItemView.ScrollHint.PositionAtCenter) def marked_changed(self, old_marked, current_marked): changed = old_marked | current_marked m = self.model() for book_id in changed: try: self.update(m.index(m.db.data.id_to_index(book_id), 0)) except ValueError: pass def moveCursor(self, action, modifiers): index = QListView.moveCursor(self, action, modifiers) if action in (QAbstractItemView.CursorAction.MoveLeft, QAbstractItemView.CursorAction.MoveRight) and index.isValid(): ci = self.currentIndex() if ci.isValid() and index.row() == ci.row(): nr = index.row() + (1 if action == QAbstractItemView.CursorAction.MoveRight else -1) if 0 <= nr < self.model().rowCount(QModelIndex()): index = self.model().index(nr, 0) return index def selectionCommand(self, index, event): if event and event.type() == QEvent.Type.KeyPress and event.key() in (Qt.Key.Key_Home, Qt.Key.Key_End ) and event.modifiers() & Qt.KeyboardModifier.ControlModifier: return QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows return super().selectionCommand(index, event) def wheelEvent(self, ev): if ev.phase() not in (Qt.ScrollPhase.ScrollUpdate, 0, Qt.ScrollPhase.ScrollMomentum): return number_of_pixels = ev.pixelDelta() number_of_degrees = ev.angleDelta() / 8.0 b = self.verticalScrollBar() if number_of_pixels.isNull() or islinux: # pixelDelta() is broken on linux with wheel mice dy = number_of_degrees.y() / 15.0 # Scroll by approximately half a row dy = int(math.ceil((dy) * b.singleStep() / 2.0)) else: dy = number_of_pixels.y() if abs(dy) > 0: b.setValue(b.value() - dy) def paintEvent(self, ev): dpr = self.device_pixel_ratio page_width = int(dpr * self.delegate.cover_size.width()) page_height = int(dpr * self.delegate.cover_size.height()) size_changed = self.thumbnail_cache.set_thumbnail_size(page_width, page_height) if size_changed: self.delegate.cover_cache.clear() return super().paintEvent(ev) # }}}