diff --git a/src/calibre/ebooks/oeb/polish/report.py b/src/calibre/ebooks/oeb/polish/report.py index e17e008ff6..a93cc163b8 100644 --- a/src/calibre/ebooks/oeb/polish/report.py +++ b/src/calibre/ebooks/oeb/polish/report.py @@ -55,7 +55,8 @@ def file_data(container): yield File(name, posixpath.dirname(name), posixpath.basename(name), safe_size(container, name), get_category(name, container.mime_map.get(name, ''))) -Image = namedtuple('Image', 'name mime_type usage size width height') +Image = namedtuple('Image', 'name mime_type usage size basename width height') + L = namedtuple('Location', 'name line_number offset word') def Location(name, line_number=None, offset=0, word=None): return L(name, line_number, offset, word) @@ -81,7 +82,7 @@ def link_data(container): for name, mt in container.mime_map.iteritems(): if mt.startswith('image/') and container.exists(name): image_data.append(Image(name, mt, sort_locations(image_usage.get(name, set())), safe_size(container, name), - *safe_img_data(container, name, mt))) + posixpath.basename(name), *safe_img_data(container, name, mt))) return tuple(image_data) def gather_data(container): diff --git a/src/calibre/gui2/tweak_book/boss.py b/src/calibre/gui2/tweak_book/boss.py index 9190b7cce4..6215016aab 100644 --- a/src/calibre/gui2/tweak_book/boss.py +++ b/src/calibre/gui2/tweak_book/boss.py @@ -130,6 +130,8 @@ class Boss(QObject): self.gui.manage_fonts.embed_all_fonts.connect(self.manage_fonts_embed) self.gui.manage_fonts.subset_all_fonts.connect(self.manage_fonts_subset) self.gui.reports.edit_requested.connect(self.reports_edit_requested) + self.gui.reports.refresh_starting.connect(self.commit_all_editors_to_container) + self.gui.reports.delete_requested.connect(self.delete_requested) @property def currently_editing(self): diff --git a/src/calibre/gui2/tweak_book/reports.py b/src/calibre/gui2/tweak_book/reports.py index d93b1e9dec..f318fcc48b 100644 --- a/src/calibre/gui2/tweak_book/reports.py +++ b/src/calibre/gui2/tweak_book/reports.py @@ -9,20 +9,24 @@ __copyright__ = '2015, Kovid Goyal ' from threading import Thread from future_builtins import map from operator import itemgetter +from functools import partial from PyQt5.Qt import ( QSize, QStackedLayout, QLabel, QVBoxLayout, Qt, QWidget, pyqtSignal, QAbstractTableModel, QTableView, QSortFilterProxyModel, QIcon, QListWidget, - QListWidgetItem, QLineEdit, QStackedWidget, QSplitter, QByteArray) + QListWidgetItem, QLineEdit, QStackedWidget, QSplitter, QByteArray, QPixmap, + QStyledItemDelegate, QModelIndex, QRect, QStyle, QPalette, QTimer, QMenu) -from calibre import human_readable +from calibre import human_readable, fit_image from calibre.ebooks.oeb.polish.report import gather_data, Location -from calibre.gui2 import error_dialog +from calibre.gui2 import error_dialog, question_dialog from calibre.gui2.tweak_book import current_container, tprefs from calibre.gui2.tweak_book.widgets import Dialog from calibre.gui2.progress_indicator import ProgressIndicator from calibre.utils.icu import primary_contains, numeric_sort_key +# Utils {{{ + def read_state(name, default=None): data = tprefs.get('reports-ui-state') if data is None: @@ -37,7 +41,7 @@ def save_state(name, val): tprefs['reports-ui-state'] = data = {} data[name] = val -class ProxyModel(QSortFilterProxyModel): # {{{ +class ProxyModel(QSortFilterProxyModel): def __init__(self, parent=None): QSortFilterProxyModel.__init__(self, parent) @@ -64,7 +68,6 @@ class ProxyModel(QSortFilterProxyModel): # {{{ if orientation == Qt.Vertical and role == Qt.DisplayRole: return section + 1 return QSortFilterProxyModel.headerData(self, section, orientation, role) -# }}} class FileCollection(QAbstractTableModel): @@ -101,7 +104,68 @@ class FileCollection(QAbstractTableModel): except IndexError: pass +class FilesView(QTableView): + + double_clicked = pyqtSignal(object) + delete_requested = pyqtSignal(object, object) + + def __init__(self, model, parent=None): + QTableView.__init__(self, parent) + self.setSelectionBehavior(self.SelectRows), self.setSelectionMode(self.ExtendedSelection) + self.setSortingEnabled(True) + self.proxy = p = ProxyModel(self) + p.setSourceModel(model) + self.setModel(p) + self.doubleClicked.connect(self._double_clicked) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.show_context_menu) + + def customize_context_menu(self, menu, selected_locations, current_location): + pass + + def _double_clicked(self, index): + index = self.proxy.mapToSource(index) + if index.isValid(): + self.double_clicked.emit(index) + + def keyPressEvent(self, ev): + if ev.key() == Qt.Key_Delete: + self.delete_selected() + ev.accept() + return + return QTableView.keyPressEvent(self, ev) + + @property + def selected_locations(self): + return filter(None, (self.proxy.sourceModel().location(self.proxy.mapToSource(index)) for index in self.selectionModel().selectedIndexes())) + + @property + def current_location(self): + index = self.selectionModel().currentIndex() + return self.proxy.sourceModel().location(self.proxy.mapToSource(index)) + + def delete_selected(self): + locations = self.selected_locations + if locations: + names = {l.name for l in locations} + spine_names = {n for n, l in current_container().spine_names} + spine_items = spine_names.intersection(names) + other_items = names - spine_names + self.delete_requested.emit(spine_items, other_items) + + def show_context_menu(self, pos): + pos = self.viewport().mapToGlobal(pos) + locations = self.selected_locations + m = QMenu(self) + if locations: + m.addAction(_('Delete selected files'), self.delete_selected) + self.customize_context_menu(m, locations, self.current_location) + if len(m.actions()) > 0: + m.exec_(pos) +# }}} + # Files {{{ + class FilesModel(FileCollection): COLUMN_HEADERS = (_('Folder'), _('Name'), _('Size (KB)'), _('Type')) @@ -149,6 +213,7 @@ class FilesModel(FileCollection): class FilesWidget(QWidget): edit_requested = pyqtSignal(object) + delete_requested = pyqtSignal(object, object) def __init__(self, parent=None): QWidget.__init__(self, parent) @@ -157,16 +222,12 @@ class FilesWidget(QWidget): self.filter_edit = e = QLineEdit(self) l.addWidget(e) e.setPlaceholderText(_('Filter')) - self.files = f = QTableView(self) - f.setSelectionBehavior(f.SelectRows), f.setSelectionMode(f.SingleSelection) - f.doubleClicked.connect(self.double_clicked) self.model = m = FilesModel(self) - self.proxy = p = ProxyModel(self) - e.textChanged.connect(p.filter_text) - p.setSourceModel(m) - f.setModel(p) + self.files = f = FilesView(m, self) + f.delete_requested.connect(self.delete_requested) + f.double_clicked.connect(self.double_clicked) + e.textChanged.connect(f.proxy.filter_text) l.addWidget(f) - f.setSortingEnabled(True) self.summary = s = QLabel(self) l.addWidget(s) @@ -178,12 +239,13 @@ class FilesWidget(QWidget): def __call__(self, data): self.model(data) + self.filter_edit.clear() m = self.model self.summary.setText(_('Total uncompressed size of all files: {0} :: Images: {1} :: Fonts: {2}').format(*map( human_readable, (m.total_size, m.images_size, m.fonts_size)))) def double_clicked(self, index): - location = self.model.location(self.proxy.mapToSource(index)) + location = self.model.location(index) if location is not None: self.edit_requested.emit(location) @@ -192,52 +254,74 @@ class FilesWidget(QWidget): # }}} -class ImagesModel(QAbstractTableModel): +# Images {{{ - COLUMN_HEADERS = [_('Name'), _('Size (KB)'), _('Times used'), _('Width'), _('Height'), _('Image')] +class ImagesDelegate(QStyledItemDelegate): + + MARGIN = 5 + + def __init__(self, *args): + QStyledItemDelegate.__init__(self, *args) + self.cache = {} + + def sizeHint(self, option, index): + ans = QStyledItemDelegate.sizeHint(self, option, index) + entry = index.data(Qt.UserRole) + if entry is None: + return ans + th = self.parent().thumbnail_height + width, height = min(th, entry.width), min(th, entry.height) + m = self.MARGIN * 2 + return QSize(max(width + m, ans.width()), height + m + self.MARGIN + ans.height()) + + def paint(self, painter, option, index): + QStyledItemDelegate.paint(self, painter, option, QModelIndex()) + entry = index.data(Qt.UserRole) + if entry is None: + return + painter.save() + th = self.parent().thumbnail_height + k = (th, entry.name) + pmap = self.cache.get(k) + if pmap is None: + pmap = self.cache[k] = self.pixmap(th, entry) + if pmap.isNull(): + bottom = option.rect.top() + else: + m = 2 * self.MARGIN + x = option.rect.left() + (option.rect.width() - m - pmap.width()) // 2 + painter.drawPixmap(x, option.rect.top() + self.MARGIN, pmap) + bottom = m + pmap.height() + option.rect.top() + rect = QRect(option.rect.left(), bottom, option.rect.width(), option.rect.bottom() - bottom) + if option.state & QStyle.State_Selected: + painter.setPen(self.parent().palette().color(QPalette.HighlightedText)) + painter.drawText(rect, Qt.AlignHCenter | Qt.AlignVCenter, entry.basename) + painter.restore() + + def pixmap(self, thumbnail_height, entry): + pmap = QPixmap(current_container().name_to_abspath(entry.name)) if entry.width > 0 and entry.height > 0 else QPixmap() + scaled, width, height = fit_image(entry.width, entry.height, thumbnail_height, thumbnail_height) + if scaled and not pmap.isNull(): + pmap = pmap.scaled(width, height, transformMode=Qt.SmoothTransformation) + return pmap + + +class ImagesModel(FileCollection): + + COLUMN_HEADERS = [_('Image'), _('Size (KB)'), _('Times used'), _('Resolution')] def __init__(self, parent=None): - self.files = self.sort_keys = () - self.total_size = 0 - QAbstractTableModel.__init__(self, parent) - - def columnCount(self, parent=None): - return len(self.COLUMN_HEADERS) - - def rowCount(self, parent=None): - return len(self.files) - - def headerData(self, section, orientation, role=Qt.DisplayRole): - if role == Qt.DisplayRole and orientation == Qt.Horizontal: - try: - return self.COLUMN_HEADERS[section] - except IndexError: - pass - return QAbstractTableModel.headerData(self, section, orientation, role) + FileCollection.__init__(self, parent) def __call__(self, data): self.beginResetModel() - self.files = data['files'] + self.files = data['images'] self.total_size = sum(map(itemgetter(3), self.files)) - self.images_size = sum(map(itemgetter(3), (f for f in self.files if f.category == 'image'))) - self.fonts_size = sum(map(itemgetter(3), (f for f in self.files if f.category == 'font'))) psk = numeric_sort_key - self.sort_keys = tuple((psk(entry.dir), psk(entry.basename), entry.size, psk(self.CATEGORY_NAMES.get(entry.category, ''))) + self.sort_keys = tuple((psk(entry.basename), entry.size, len(entry.usage), (entry.width, entry.height)) for entry in self.files) self.endResetModel() - def sort_key(self, row, col): - try: - return self.sort_keys[row][col] - except IndexError: - pass - - def name(self, index): - try: - return self.files[index.row()].name - except IndexError: - pass - def data(self, index, role=Qt.DisplayRole): if role == Qt.DisplayRole: col = index.column() @@ -246,20 +330,77 @@ class ImagesModel(QAbstractTableModel): except IndexError: return None if col == 0: - return entry.dir - if col == 1: return entry.basename - if col == 2: + if col == 1: sz = entry.size / 1024. return ('%.2f' % sz if int(sz) != sz else type('')(sz)) + if col == 2: + return type('')(len(entry.usage)) if col == 3: - return self.CATEGORY_NAMES.get(entry.category) + return '%d x %d' % (entry.width, entry.height) + if role == Qt.UserRole: + try: + return self.files[index.row()] + except IndexError: + pass +class ImagesWidget(QWidget): + + edit_requested = pyqtSignal(object) + delete_requested = pyqtSignal(object, object) + + def __init__(self, parent=None): + QWidget.__init__(self, parent) + self.l = l = QVBoxLayout(self) + self.thumbnail_height = 64 + + self.filter_edit = e = QLineEdit(self) + l.addWidget(e) + e.setPlaceholderText(_('Filter')) + self.model = m = ImagesModel(self) + self.files = f = FilesView(m, self) + f.customize_context_menu = self.customize_context_menu + f.delete_requested.connect(self.delete_requested) + f.horizontalHeader().sortIndicatorChanged.connect(self.resize_to_contents) + self.delegate = ImagesDelegate(self) + f.setItemDelegateForColumn(0, self.delegate) + f.double_clicked.connect(self.double_clicked) + e.textChanged.connect(f.proxy.filter_text) + l.addWidget(f) + + try: + self.files.horizontalHeader().restoreState(read_state('image-files-table')) + except TypeError: + self.files.sortByColumn(0, Qt.AscendingOrder) + + def __call__(self, data): + self.model(data) + self.filter_edit.clear() + self.delegate.cache.clear() + self.files.resizeRowsToContents() + + def resize_to_contents(self, *args): + QTimer.singleShot(0, self.files.resizeRowsToContents) + + def double_clicked(self, index): + location = self.model.location(index) + if location is not None: + self.edit_requested.emit(location) + + def customize_context_menu(self, menu, selected_locations, current_location): + if current_location is not None: + menu.addAction(_('Edit the image: %s') % current_location.name, partial(self.edit_requested.emit, current_location)) + + def save(self): + save_state('image-files-table', bytearray(self.files.horizontalHeader().saveState())) +# }}} + # Wrapper UI {{{ class ReportsWidget(QWidget): edit_requested = pyqtSignal(object) + delete_requested = pyqtSignal(object, object) def __init__(self, parent=None): QWidget.__init__(self, parent) @@ -275,9 +416,16 @@ class ReportsWidget(QWidget): self.files = f = FilesWidget(self) f.edit_requested.connect(self.edit_requested) + f.delete_requested.connect(self.delete_requested) s.addWidget(f) QListWidgetItem(_('Files'), r) + self.images = i = ImagesWidget(self) + i.edit_requested.connect(self.edit_requested) + i.delete_requested.connect(self.delete_requested) + s.addWidget(i) + QListWidgetItem(_('Images'), r) + self.splitter.setStretchFactor(1, 500) try: self.splitter.restoreState(read_state('splitter-state')) @@ -289,16 +437,20 @@ class ReportsWidget(QWidget): def __call__(self, data): self.files(data) + self.images(data) def save(self): save_state('splitter-state', bytearray(self.splitter.saveState())) save_state('report-page', self.reports.currentRow()) self.files.save() + self.images.save() class Reports(Dialog): data_gathered = pyqtSignal(object, object) edit_requested = pyqtSignal(object) + refresh_starting = pyqtSignal() + delete_requested = pyqtSignal(object, object) def __init__(self, parent=None): Dialog.__init__(self, _('Reports'), 'reports-dialog', parent=parent) @@ -312,6 +464,7 @@ class Reports(Dialog): l.addWidget(self.bb) self.reports = r = ReportsWidget(self) r.edit_requested.connect(self.edit_requested) + r.delete_requested.connect(self.confirm_delete) self.pw = pw = QWidget(self) s.addWidget(pw), s.addWidget(r) @@ -330,10 +483,18 @@ class Reports(Dialog): def sizeHint(self): return QSize(950, 600) + def confirm_delete(self, spine_names, other_names): + if not question_dialog(self, _('Are you sure?'), _( + 'Are you sure you want to delete the selected files?'), det_msg='\n'.join(spine_names | other_names)): + return + self.delete_requested.emit(spine_names, other_names) + QTimer.singleShot(10, self.refresh) + def refresh(self): self.wait_stack.setCurrentIndex(0) self.setCursor(Qt.BusyCursor) self.pi.startAnimation() + self.refresh_starting.emit() t = Thread(name='GatherReportData', target=self.gather_data) t.daemon = True t.start()