From fcf35ac0b3290e2747ac7088c3bfffc3d8c4eadf Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 23 May 2008 19:07:41 -0700 Subject: [PATCH] Implement a 'Browse books by cover' feature --- Makefile | 10 +- osx_installer.py | 13 +- src/calibre/__init__.py | 3 + src/calibre/gui2/cover_flow.py | 69 ++---- src/calibre/gui2/dialogs/book_info.py | 13 +- src/calibre/gui2/images/cover_flow.svg | 199 ++++++++++++++++++ src/calibre/gui2/library.py | 56 ++++- src/calibre/gui2/main.py | 48 ++++- .../gui2/pictureflow/PyQt/configure.py | 2 +- src/calibre/gui2/status.py | 33 ++- windows_installer.py | 23 +- 11 files changed, 372 insertions(+), 97 deletions(-) create mode 100644 src/calibre/gui2/images/cover_flow.svg diff --git a/Makefile b/Makefile index faec70b566..e233811ce8 100644 --- a/Makefile +++ b/Makefile @@ -25,15 +25,17 @@ manual: pictureflow : mkdir -p src/calibre/plugins && rm -f src/calibre/plugins/*pictureflow* && \ - cd src/calibre/gui2/pictureflow && rm -f *.o *.so* && \ + cd src/calibre/gui2/pictureflow && rm -f *.o && \ mkdir -p .build && cd .build && rm -f * && \ - qmake ../pictureflow-lib.pro && make && \ + qmake ../pictureflow.pro && make && \ cd ../PyQt && \ mkdir -p .build && \ cd .build && rm -f * && \ python ../configure.py && make && \ cd ../../../../../.. && \ - cp src/calibre/gui2/pictureflow/libpictureflow.so.?.?.? src/calibre/gui2/pictureflow/PyQt/.build/pictureflow.so src/calibre/plugins/ && \ - python -c "import os, glob; lp = glob.glob('src/calibre/plugins/libpictureflow.so.*')[0]; os.rename(lp, lp[:-4])" + cp src/calibre/gui2/pictureflow/.build/libpictureflow.so.?.?.? src/calibre/gui2/pictureflow/PyQt/.build/pictureflow.so src/calibre/plugins/ && \ + python -c "import os, glob; lp = glob.glob('src/calibre/plugins/libpictureflow.so.*')[0]; os.rename(lp, lp[:-4])" && \ + rm -rf src/calibre/gui2/pictureflow/.build rm -rf src/calibre/gui2/pictureflow/PyQt/.build + diff --git a/osx_installer.py b/osx_installer.py index bed0b5b0e7..348abe6431 100644 --- a/osx_installer.py +++ b/osx_installer.py @@ -177,11 +177,18 @@ _check_symlinks_prescript() try: print 'Building pictureflow' os.chdir('src/calibre/gui2/pictureflow') - for f in glob.glob('*.o'): os.unlink(f) - subprocess.check_call([qmake, 'pictureflow.pro']) + if not os.path.exists('.build'): + os.mkdir('.build') + os.chdir('.build') + for f in glob.glob('*'): os.unlink(f) + subprocess.check_call([qmake, '../pictureflow.pro']) subprocess.check_call(['make']) files.append((os.path.abspath(os.path.realpath('libpictureflow.dylib')), 'libpictureflow.dylib')) - os.chdir('PyQt/.build') + os.chdir('../PyQt') + if not os.path.exists('.build'): + os.mkdir('.build') + os.chdir('.build') + for f in glob.glob('*'): os.unlink(f) subprocess.check_call([PYTHON, '../configure.py']) subprocess.check_call(['/usr/bin/make']) files.append((os.path.abspath('pictureflow.so'), 'pictureflow.so')) diff --git a/src/calibre/__init__.py b/src/calibre/__init__.py index ff7c49d38e..e88b981d8e 100644 --- a/src/calibre/__init__.py +++ b/src/calibre/__init__.py @@ -555,6 +555,9 @@ if islinux: sys.path.insert(1, plugins) cwd = os.getcwd() os.chdir(plugins) +if iswindows and hasattr(sys, 'frozen'): + sys.path.insert(1, os.path.dirname(sys.executable)) + try: import pictureflow pictureflowerror = '' diff --git a/src/calibre/gui2/cover_flow.py b/src/calibre/gui2/cover_flow.py index ad41749210..e0893ae741 100644 --- a/src/calibre/gui2/cover_flow.py +++ b/src/calibre/gui2/cover_flow.py @@ -8,14 +8,17 @@ Module to implement the Cover Flow feature ''' import sys, os -from collections import deque -from PyQt4.QtGui import QImage -from PyQt4.QtCore import Qt, QSize, QTimer, SIGNAL +from PyQt4.QtGui import QImage, QSizePolicy +from PyQt4.QtCore import Qt, QSize, SIGNAL from calibre import pictureflow if pictureflow is not None: + class EmptyImageList(pictureflow.FlowImages): + def __init__(self): + pictureflow.FlowImages.__init__(self) + class FileSystemImages(pictureflow.FlowImages): def __init__(self, dirpath): @@ -46,60 +49,35 @@ if pictureflow is not None: def __init__(self, model, buffer=20): pictureflow.FlowImages.__init__(self) self.model = model - self.default_image = QImage(':/images/book.svg') - self.buffer_size = buffer - self.timer = QTimer() - self.connect(self.timer, SIGNAL('timeout()'), self.load) - self.timer.start(50) - self.clear() + self.connect(self.model, SIGNAL('modelReset()'), self.reset) def count(self): - return self.model.rowCount(None) + return self.model.count() def caption(self, index): return self.model.title(index) - def clear(self): - self.buffer = {} - self.load_queue = deque() + def reset(self): + self.emit(SIGNAL('dataChanged()')) - def load(self): - if self.load_queue: - index = self.load_queue.popleft() - if self.buffer.has_key(index): - return - img = QImage() - img.loadFromData(self.model.cover(index)) - if img.isNull(): - img = self.default_image - self.buffer[index] = img - def image(self, index): - img = self.buffer.get(index) - if img is None: - img = QImage() - img.loadFromData(self.model.cover(index)) - if img.isNull(): - img = self.default_image - self.buffer[index] = img - return img + return self.model.cover(index) - def currentChanged(self, index): - for key in self.buffer.keys(): - if abs(key - index) > self.buffer_size: - self.buffer.pop(key) - for i in range(max(0, index-self.buffer_size), min(self.count(), index+self.buffer_size)): - if not self.buffer.has_key(i): - self.load_queue.append(i) + class CoverFlow(pictureflow.PictureFlow): def __init__(self, height=300, parent=None): pictureflow.PictureFlow.__init__(self, parent) self.setSlideSize(QSize(int(2/3. * height), height)) - self.setMinimumSize(QSize(int(2.35*0.67*height), (5/3.)*height)) + self.setMinimumSize(QSize(int(2.35*0.67*height), (5/3.)*height+25)) + self.setFocusPolicy(Qt.WheelFocus) + self.setSizePolicy(QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)) + else: CoverFlow = None + DatabaseImages = None + FileSystemImages = None def main(args=sys.argv): return 0 @@ -112,16 +90,7 @@ if __name__ == '__main__': cf.resize(cf.minimumSize()) w.resize(cf.minimumSize()+QSize(30, 20)) path = sys.argv[1] - if path.endswith('.db'): - from calibre.library.database import LibraryDatabase - from calibre.gui2.library import BooksModel - from calibre.gui2 import images_rc - bm = BooksModel() - bm.set_database(LibraryDatabase(path)) - bm.sort(1, Qt.AscendingOrder) - model = DatabaseImages(bm) - else: - model = FileSystemImages(sys.argv[1]) + model = FileSystemImages(sys.argv[1]) cf.setImages(model) cf.connect(cf, SIGNAL('currentChanged(int)'), model.currentChanged) w.setCentralWidget(cf) diff --git a/src/calibre/gui2/dialogs/book_info.py b/src/calibre/gui2/dialogs/book_info.py index 7bdc9563f6..b27c9b4ab4 100644 --- a/src/calibre/gui2/dialogs/book_info.py +++ b/src/calibre/gui2/dialogs/book_info.py @@ -7,7 +7,7 @@ __docformat__ = 'restructuredtext en' ''' import textwrap -from PyQt4.QtCore import Qt, QCoreApplication +from PyQt4.QtCore import QCoreApplication from PyQt4.QtGui import QDialog, QPixmap, QGraphicsScene, QIcon from calibre.gui2.dialogs.book_info_ui import Ui_BookInfo @@ -19,11 +19,6 @@ class BookInfo(QDialog, Ui_BookInfo): Ui_BookInfo.__init__(self) self.setupUi(self) - self.default_pixmap = QPixmap(':/images/book.svg').scaled(80, - 100, - Qt.IgnoreAspectRatio, - Qt.SmoothTransformation) - self.setWindowTitle(info[_('Title')]) desktop = QCoreApplication.instance().desktop() screen_height = desktop.availableGeometry().height() - 100 @@ -32,11 +27,7 @@ class BookInfo(QDialog, Ui_BookInfo): self.comments.setText(info.pop(_('Comments'), '')) cdata = info.pop('cover', '') - pixmap = QPixmap() - pixmap.loadFromData(cdata) - if pixmap.isNull(): - pixmap = self.default_pixmap - + pixmap = QPixmap.fromImage(cdata) self.setWindowIcon(QIcon(pixmap)) self.scene = QGraphicsScene() diff --git a/src/calibre/gui2/images/cover_flow.svg b/src/calibre/gui2/images/cover_flow.svg new file mode 100644 index 0000000000..be0a4e3b56 --- /dev/null +++ b/src/calibre/gui2/images/cover_flow.svg @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/src/calibre/gui2/library.py b/src/calibre/gui2/library.py index 8f00097ae5..9d0e392a10 100644 --- a/src/calibre/gui2/library.py +++ b/src/calibre/gui2/library.py @@ -3,13 +3,15 @@ __copyright__ = '2008, Kovid Goyal ' import os, textwrap, traceback, time, re, sre_constants from datetime import timedelta, datetime from operator import attrgetter +from collections import deque from math import cos, sin, pi from PyQt4.QtGui import QTableView, QProgressDialog, QAbstractItemView, QColor, \ QItemDelegate, QPainterPath, QLinearGradient, QBrush, \ QPen, QStyle, QPainter, QLineEdit, QApplication, \ - QPalette + QPalette, QImage from PyQt4.QtCore import QAbstractTableModel, QVariant, Qt, QString, \ - QCoreApplication, SIGNAL, QObject, QSize, QModelIndex + QCoreApplication, SIGNAL, QObject, QSize, QModelIndex, \ + QTimer from calibre import Settings, preferred_encoding from calibre.ptempfile import PersistentTemporaryFile @@ -92,15 +94,25 @@ class BooksModel(QAbstractTableModel): num -= d return ''.join(result) - def __init__(self, parent=None): + def __init__(self, parent=None, buffer=20): QAbstractTableModel.__init__(self, parent) self.db = None self.cols = ['title', 'authors', 'size', 'date', 'rating', 'publisher', 'tags', 'series'] self.editable_cols = [0, 1, 4, 5, 6] + self.default_image = QImage(':/images/book.svg') self.sorted_on = (3, Qt.AscendingOrder) self.last_search = '' # The last search performed on this model self.read_config() - + self.buffer_size = buffer + self.clear_caches() + self.load_timer = QTimer() + self.connect(self.load_timer, SIGNAL('timeout()'), self.load) + self.load_timer.start(50) + + def clear_caches(self): + self.buffer = {} + self.load_queue = deque() + def read_config(self): self.use_roman_numbers = bool(Settings().value('use roman numerals for series number', QVariant(True)).toBool()) @@ -164,6 +176,7 @@ class BooksModel(QAbstractTableModel): self.db.filter(tokens, refilter=refinement, OR=OR) self.last_search = text if reset: + self.clear_caches() self.reset() def sort(self, col, order, reset=True): @@ -173,6 +186,7 @@ class BooksModel(QAbstractTableModel): self.db.refresh(self.cols[col], ascending) self.research() if reset: + self.clear_caches() self.reset() self.sorted_on = (col, order) @@ -194,10 +208,32 @@ class BooksModel(QAbstractTableModel): def rowCount(self, parent): return self.db.rows() if self.db else 0 + def count(self): + return self.rowCount(None) + + def load(self): + if self.load_queue: + index = self.load_queue.popleft() + if self.buffer.has_key(index): + return + data = self.db.cover(index) + img = QImage() + img.loadFromData(data) + if img.isNull(): + img = self.default_image + self.buffer[index] = img + def current_changed(self, current, previous, emit_signal=True): data = {} idx = current.row() - cdata = self.db.cover(idx) + cdata = self.cover(idx) + for key in self.buffer.keys(): + if abs(key - idx) > self.buffer_size: + self.buffer.pop(key) + for i in range(max(0, idx-self.buffer_size), min(self.count(), idx+self.buffer_size)): + if not self.buffer.has_key(i): + self.load_queue.append(i) + if cdata: data['cover'] = cdata tags = self.db.tags(idx) @@ -296,7 +332,15 @@ class BooksModel(QAbstractTableModel): return self.db.title(row_number) def cover(self, row_number): - return self.db.cover(row_number) + img = self.buffer.get(row_number, -1) + if img == -1: + data = self.db.cover(row_number) + img = QImage() + img.loadFromData(data) + if img.isNull(): + img = self.default_image + self.buffer[row_number] = img + return img def data(self, index, role): if role == Qt.DisplayRole or role == Qt.EditRole: diff --git a/src/calibre/gui2/main.py b/src/calibre/gui2/main.py index fb45815ce0..de1fb2f4e9 100644 --- a/src/calibre/gui2/main.py +++ b/src/calibre/gui2/main.py @@ -5,7 +5,7 @@ from xml.parsers.expat import ExpatError from PyQt4.QtCore import Qt, SIGNAL, QObject, QCoreApplication, \ QVariant, QThread, QString from PyQt4.QtGui import QPixmap, QColor, QPainter, QMenu, QIcon, QMessageBox, \ - QToolButton, QDialog + QToolButton, QDialog, QSizePolicy from PyQt4.QtSvg import QSvgRenderer from calibre import __version__, __appname__, islinux, sanitize_file_name, launch, \ @@ -19,7 +19,7 @@ from calibre.gui2 import APP_UID, warning_dialog, choose_files, error_dialog, \ pixmap_to_data, choose_dir, ORG_NAME, \ qstring_to_unicode, set_sidebar_directories, \ SingleApplication, Application -from calibre.gui2.cover_flow import CoverFlow +from calibre.gui2.cover_flow import CoverFlow, DatabaseImages from calibre.library.database import LibraryDatabase from calibre.gui2.update import CheckForUpdates from calibre.gui2.main_window import MainWindow @@ -199,6 +199,25 @@ class Main(MainWindow, Ui_MainWindow): self.library_view.resizeRowsToContents() self.search.setFocus(Qt.OtherFocusReason) + ########################### Cover Flow ################################ + self.cover_flow = None + if CoverFlow is not None: + self.cover_flow = CoverFlow(height=220) + self.cover_flow.setVisible(False) + self.library.layout().addWidget(self.cover_flow) + self.connect(self.cover_flow, SIGNAL('currentChanged(int)'), self.sync_cf_to_listview) + self.library_view.setSizePolicy(QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding)) + self.connect(self.cover_flow, SIGNAL('itemActivated(int)'), self.show_book_info) + self.connect(self.status_bar.cover_flow_button, SIGNAL('toggled(bool)'), self.toggle_cover_flow) + QObject.connect(self.library_view.selectionModel(), SIGNAL('currentRowChanged(QModelIndex, QModelIndex)'), + self.sync_cf_to_listview) + self.db_images = DatabaseImages(self.library_view.model()) + self.cover_flow.setImages(self.db_images) + else: + self.status_bar.cover_flow_button.disable(pictureflowerror) + + + ####################### Setup device detection ######################## self.detector = DeviceDetector(sleep_time=2000) QObject.connect(self.detector, SIGNAL('connected(PyQt_PyObject, PyQt_PyObject)'), @@ -207,6 +226,29 @@ class Main(MainWindow, Ui_MainWindow): self.news_menu.set_custom_feeds(self.library_view.model().db.get_feeds()) + def toggle_cover_flow(self, show): + if show: + self.cover_flow.setCurrentSlide(self.library_view.currentIndex().row()) + self.cover_flow.setVisible(True) + self.cover_flow.setFocus(Qt.OtherFocusReason) + self.status_bar.book_info.book_data.setMaximumHeight(100) + self.status_bar.setMaximumHeight(120) + self.library_view.scrollTo(self.library_view.currentIndex()) + else: + self.cover_flow.setVisible(False) + self.status_bar.book_info.book_data.setMaximumHeight(1000) + self.status_bar.setMaximumHeight(1200) + + + + + def sync_cf_to_listview(self, index, *args): + if not hasattr(index, 'row') and self.library_view.currentIndex().row() != index: + index = self.library_view.model().index(index, 0) + self.library_view.setCurrentIndex(index) + if hasattr(index, 'row') and self.cover_flow.isVisible() and self.cover_flow.currentSlide() != index.row(): + self.cover_flow.setCurrentSlide(index.row()) + def another_instance_wants_to_talk(self, msg): if msg.startswith('launched:'): argv = eval(msg[len('launched:'):]) @@ -942,7 +984,7 @@ class Main(MainWindow, Ui_MainWindow): ################################ Book info ################################# - def show_book_info(self): + def show_book_info(self, *args): if self.current_view() is not self.library_view: error_dialog(self, _('No detailed info available'), _('No detailed information is available for books on the device.')).exec_() diff --git a/src/calibre/gui2/pictureflow/PyQt/configure.py b/src/calibre/gui2/pictureflow/PyQt/configure.py index 3549941329..e7a5e2ce27 100644 --- a/src/calibre/gui2/pictureflow/PyQt/configure.py +++ b/src/calibre/gui2/pictureflow/PyQt/configure.py @@ -30,7 +30,7 @@ makefile = pyqtconfig.QtGuiModuleMakefile ( # Add the library we are wrapping. The name doesn't include any platform # specific prefixes or extensions (e.g. the "lib" prefix on UNIX, or the # ".dll" extension on Windows). -makefile.extra_lib_dirs = ['../..', '..\\..\\release'] +makefile.extra_lib_dirs = ['..\\..\\.build\\release', '../../.build'] makefile.extra_libs = ['pictureflow0' if 'win' in sys.platform and 'darwin' not in sys.platform else "pictureflow"] makefile.extra_cflags = ['-arch i386', '-arch ppc'] if 'darwin' in sys.platform else [] makefile.extra_cxxflags = makefile.extra_cflags diff --git a/src/calibre/gui2/status.py b/src/calibre/gui2/status.py index 7bcf2b36db..901dad5a3f 100644 --- a/src/calibre/gui2/status.py +++ b/src/calibre/gui2/status.py @@ -3,7 +3,7 @@ __copyright__ = '2008, Kovid Goyal ' import textwrap from PyQt4.QtGui import QStatusBar, QMovie, QLabel, QFrame, QHBoxLayout, QPixmap, \ - QVBoxLayout, QSizePolicy + QVBoxLayout, QSizePolicy, QToolButton, QIcon from PyQt4.QtCore import Qt, QSize, SIGNAL from calibre import fit_image from calibre.gui2 import qstring_to_unicode @@ -64,13 +64,7 @@ class BookInfoDisplay(QFrame): def show_data(self, data): if data.has_key('cover'): - cover_data = data.pop('cover') - pixmap = QPixmap() - pixmap.loadFromData(cover_data) - if pixmap.isNull(): - self.cover_display.setPixmap(self.cover_display.default_pixmap) - else: - self.cover_display.setPixmap(pixmap) + self.cover_display.setPixmap(QPixmap.fromImage(data.pop('cover'))) else: self.cover_display.setPixmap(self.cover_display.default_pixmap) @@ -120,16 +114,39 @@ class MovieButton(QFrame): self.jobs_dialog.jobs_view.read_settings() self.jobs_dialog.show() self.jobs_dialog.jobs_view.restore_column_widths() + +class CoverFlowButton(QToolButton): + + def __init__(self, parent=None): + QToolButton.__init__(self, parent) + self.setIconSize(QSize(80, 80)) + self.setIcon(QIcon(':/images/cover_flow.svg')) + self.setCheckable(True) + self.setChecked(False) + self.setAutoRaise(True) + self.setSizePolicy(QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)) + self.connect(self, SIGNAL('toggled(bool)'), self.adjust_tooltip) + self.adjust_tooltip(False) + def adjust_tooltip(self, on): + tt = _('Click to turn off Cover Browsing') if on else _('Click to browse books by their covers') + self.setToolTip(tt) + + def disable(self, reason): + self.setDisabled(True) + self.setToolTip(_('

Browsing books by their covers is disabled.
Import of pictureflow module failed:
')+reason) class StatusBar(QStatusBar): def __init__(self, jobs_dialog): QStatusBar.__init__(self) self.movie_button = MovieButton(QMovie(':/images/jobs-animated.mng'), jobs_dialog) + self.cover_flow_button = CoverFlowButton() + self.addPermanentWidget(self.cover_flow_button) self.addPermanentWidget(self.movie_button) self.book_info = BookInfoDisplay(self.clearMessage) self.connect(self.book_info, SIGNAL('show_book_info()'), self.show_book_info) self.addWidget(self.book_info) + self.setMinimumHeight(120) def reset_info(self): self.book_info.show_data({}) diff --git a/windows_installer.py b/windows_installer.py index 7f15e3c897..0835942909 100644 --- a/windows_installer.py +++ b/windows_installer.py @@ -539,26 +539,27 @@ class BuildEXE(build_exe): ''' def build_plugins(self): cwd = os.getcwd() + dd = os.path.join(cwd, self.dist_dir) try: os.chdir(os.path.join('src', 'calibre', 'gui2', 'pictureflow')) - if os.path.exists('release'): - shutil.rmtree('release') - if os.path.exists('debug'): - shutil.rmtree('debug') - subprocess.check_call(['qmake', 'pictureflow.pro']) + if os.path.exists('.build'): + shutil.rmtree('.build') + os.mkdir('.build') + os.chdir('.build') + subprocess.check_call(['qmake', '../pictureflow.pro']) subprocess.check_call(['mingw32-make', '-f', 'Makefile.Release']) - os.chdir('PyQt') + shutil.copyfile('release\\pictureflow0.dll', os.path.join(dd, 'pictureflow0.dll')) + os.chdir('..\\PyQt') if not os.path.exists('.build'): os.mkdir('.build') os.chdir('.build') subprocess.check_call(['python', '..\\configure.py']) subprocess.check_call(['mingw32-make', '-f', 'Makefile']) - dd = os.path.join(cwd, self.dist_dir) shutil.copyfile('pictureflow.pyd', os.path.join(dd, 'pictureflow.pyd')) - os.chdir('..\\..') - shutil.copyfile('release\\pictureflow0.dll', os.path.join(dd, 'pictureflow0.dll')) - shutil.rmtree('Release', True) - shutil.rmtree('Debug', True) + os.chdir('..') + shutil.rmtree('.build') + os.chdir('..') + shutil.rmtree('.build') finally: os.chdir(cwd)