calibre/libprs500/gui/widgets.py
Kovid Goyal 1fba894a89 Reorganized GUI code. Split widget specific code into widgets.py.
Added infrastructure for Drag'nDrop support. Implemented DnD for the cover.
Added initial support for updating metadata in LRF files when it is updated for a logical file. This needs testing. Especially worrying are the deprecation warnings about integer overflow in struct.pack when updating the thumbnail
2006-12-07 00:38:43 +00:00

643 lines
22 KiB
Python

## Copyright (C) 2006 Kovid Goyal kovid@kovidgoyal.net
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from PyQt4 import QtGui, QtCore
from PyQt4.QtCore import Qt, SIGNAL
from PyQt4.Qt import QApplication, QString, QFont, QStandardItemModel, QStandardItem, QVariant, QAbstractTableModel, QTableView, QTreeView, QLabel,\
QAbstractItemView, QPixmap, QIcon, QSize, QMessageBox, QSettings, QFileDialog, QErrorMessage, QDialog, QSpinBox, QPoint, QTemporaryFile, QDir,\
QPainterPath, QItemDelegate, QPainter, QPen, QColor, QLinearGradient, QBrush, QStyle,\
QStringList, QByteArray, QBuffer, QMimeData, QTextStream, QIODevice, QDrag,\
qDebug, qFatal, qWarning, qCritical
import re, os, string, textwrap, time, traceback
from operator import itemgetter, attrgetter
from socket import gethostname
from urlparse import urlparse, urlunparse
from urllib import quote, unquote
from math import sin, cos, pi
from libprs500 import TEMPORARY_FILENAME_TEMPLATE as TFT
from libprs500.lrf.meta import LRFMetaFile
NONE = QVariant()
TIME_WRITE_FMT = "%d %b %Y"
COVER_HEIGHT = 80
def human_readable(size):
""" Convert a size in bytes into a human readable form """
if size < 1024: divisor, suffix = 1, "B"
elif size < 1024*1024: divisor, suffix = 1024., "KB"
elif size < 1024*1024*1024: divisor, suffix = 1024*1024, "MB"
elif size < 1024*1024*1024*1024: divisor, suffix = 1024*1024, "GB"
size = str(size/divisor)
if size.find(".") > -1: size = size[:size.find(".")+2]
return size + " " + suffix
def wrap(s, width=20):
return textwrap.fill(str(s), width)
def get_r_ok_files(event):
""" @type event: QDropEvent """
files = []
md = event.mimeData()
if md.hasFormat("text/uri-list"):
candidates = bytes_to_string(md.data("text/uri-list")).split("\n")
print candidates
for path in candidates:
path = os.path.abspath(re.sub(r"^file://", "", path))
if os.path.isfile(path) and os.access(path, os.R_OK): files.append(path)
return files
def bytes_to_string(qba):
""" @type qba: QByteArray """
return unicode(QString.fromUtf8(qba.data())).strip()
class FileDragAndDrop(object):
_drag_start_position = QPoint()
@classmethod
def _bytes_to_string(cls, qba):
""" @type qba: QByteArray """
return unicode(QString.fromUtf8(qba.data())).strip()
@classmethod
def _get_r_ok_files(cls, event):
files = []
md = event.mimeData()
if md.hasFormat("text/uri-list"):
candidates = cls._bytes_to_string(md.data("text/uri-list")).split()
for url in candidates:
o = urlparse(url)
if o.scheme != 'file':
qWarning(o.scheme + " not supported in drop events")
continue
path = unquote(o.path)
if not os.access(path, os.R_OK):
qWarning("You do not have read permission for: " + path)
continue
if os.path.isdir(path):
root, dirs, files2 = os.walk(path)
for file in files2:
path = root + file
if os.access(path, os.R_OK): files.append(path)
else: files.append(path)
return files
def mousePressEvent(self, event):
if event.button == Qt.LeftButton:
self._drag_start_position = event.pos()
def mouseMoveEvent(self, event):
if event.buttons() & Qt.LeftButton != Qt.LeftButton: return
if (event.pos() - self._drag_start_position).manhattanLength() < QApplication.startDragDistance(): return
self.start_drag(self._drag_start_position)
def start_drag(self, pos): pass
def dragEnterEvent(self, event):
if event.mimeData().hasFormat("text/uri-list"): event.acceptProposedAction()
def dragMoveEvent(self, event):
event.acceptProposedAction()
def dropEvent(self, event):
files = self._get_r_ok_files(event)
if files:
if self.files_dropped(files): event.acceptProposedAction()
def files_dropped(self, files): return False
def drag_object(self, extensions):
if extensions:
drag = QDrag(self)
mime_data = QMimeData()
self._dragged_files, urls = [], []
for ext in extensions:
f = TemporaryFile(ext=ext)
f.open()
urls.append(urlunparse(('file', quote(gethostname()), quote(str(f.fileName())), '','','')))
self._dragged_files.append(f)
mime_data.setData("text/uri-list", QByteArray("\n".join(urls)))
user = None
try: user = os.environ['USER']
except: pass
if user: mime_data.setData("text/x-xdnd-username", QByteArray(user))
drag.setMimeData(mime_data)
return drag, self._dragged_files
class TemporaryFile(QTemporaryFile):
def __init__(self, ext=""):
if ext: ext = "." + ext
path = QDir.tempPath() + "/" + TFT + "_XXXXXX"+ext
QTemporaryFile.__init__(self, path)
class CoverDisplay(FileDragAndDrop, QLabel):
def files_dropped(self, files):
pix = QPixmap()
for file in files:
pix = QPixmap(file)
if not pix.isNull(): break
if not pix.isNull():
self.emit(SIGNAL("cover_received(QPixmap)"), pix)
return True
def start_drag(self, event):
drag, files = self.drag_object(["jpeg"])
if drag and files:
file = files[0]
drag.setPixmap(self.pixmap())
self.pixmap().save(file)
file.close()
drag.start(Qt.MoveAction)
class DeviceView(QTreeView):
def __init__(self, parent):
QTreeView.__init__(self, parent)
self.header().hide()
self.setIconSize(QSize(32,32))
def hide_reader(self, x):
self.setRowHidden(2, self.model().indexFromItem(self.model().invisibleRootItem()), x)
def hide_card(self, x):
self.setRowHidden(4, self.model().indexFromItem(self.model().invisibleRootItem()), x)
class DeviceBooksView(QTableView):
def __init__(self, parent):
QTableView.__init__(self, parent)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSortingEnabled(True)
class LibraryBooksView(QTableView):
def __init__(self, parent):
QTableView.__init__(self, parent)
self.setSelectionBehavior(QAbstractItemView.SelectRows)
self.setSortingEnabled(True)
self.setItemDelegate(LibraryDelegate(self))
def get_verified_path(self, mime_data):
if mime_data.hasFormat("text/plain"):
text = unicode(mime_data.text())
text = re.sub(r"^file://", "", text)
if os.access(os.path.abspath(text), os.R_OK): return text
return None
def dragEnterEvent(self, event):
if self.get_verified_path(event.mimeData()): event.acceptProposedAction()
def dragMoveEvent(self, event): event.acceptProposedAction()
def dropEvent(self, event):
path = self.get_verified_path(event.mimeData())
if path:
if self.model().handle_drop(path, self.indexAt(event.pos())): event.acceptProposedAction()
class LibraryDelegate(QItemDelegate):
COLOR = QColor("blue")
SIZE = 16
PEN = QPen(COLOR, 1, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
def __init__(self, parent):
QItemDelegate.__init__(self, parent)
self.star_path = QPainterPath()
self.star_path.moveTo(90, 50)
for i in range(1, 5):
self.star_path.lineTo(50 + 40 * cos(0.8 * i * pi), 50 + 40 * sin(0.8 * i * pi))
self.star_path.closeSubpath()
self.star_path.setFillRule(Qt.WindingFill)
gradient = QLinearGradient(0, 0, 0, 100)
gradient.setColorAt(0.0, self.COLOR)
gradient.setColorAt(1.0, self.COLOR)
self. brush = QBrush(gradient)
self.factor = self.SIZE/100.
def sizeHint(self, option, index):
if index.column() != 4:
return QItemDelegate.sizeHint(self, option, index)
num = index.model().data(index, Qt.DisplayRole).toInt()[0]
return QSize(num*(self.SIZE), self.SIZE+4)
def paint(self, painter, option, index):
if index.column() != 4:
return QItemDelegate.paint(self, painter, option, index)
num = index.model().data(index, Qt.DisplayRole).toInt()[0]
def draw_star():
painter.save()
painter.scale(self.factor, self.factor)
painter.translate(50.0, 50.0)
painter.rotate(-20)
painter.translate(-50.0, -50.0)
painter.drawPath(self.star_path)
painter.restore()
painter.save()
try:
if option.state & QStyle.State_Selected:
painter.fillRect(option.rect, option.palette.highlight())
painter.setRenderHint(QPainter.Antialiasing)
y = option.rect.center().y()-self.SIZE/2.
x = option.rect.right() - self.SIZE
painter.setPen(self.PEN)
painter.setBrush(self.brush)
painter.translate(x, y)
for i in range(num):
draw_star()
painter.translate(-self.SIZE, 0)
except Exception, e:
traceback.print_exc(e)
painter.restore()
def createEditor(self, parent, option, index):
if index.column() != 4:
return QItemDelegate.createEditor(self, parent, option, index)
editor = QSpinBox(parent)
editor.setSuffix(" stars")
editor.setMinimum(0)
editor.setMaximum(5)
editor.installEventFilter(self)
return editor
def setEditorData(self, editor, index):
if index.column() != 4:
return QItemDelegate.setEditorData(self, editor, index)
val = index.model()._data[index.row()]["rating"]
if not val: val = 0
editor.setValue(val)
def setModelData(self, editor, model, index):
if index.column() != 4:
return QItemDelegate.setModelData(self, editor, model, index)
editor.interpretText()
index.model().setData(index, QVariant(editor.value()), Qt.EditRole)
def updateEditorGeometry(self, editor, option, index):
if index.column() != 4:
return QItemDelegate.updateEditorGeometry(self, editor, option, index)
editor.setGeometry(option.rect)
class LibraryBooksModel(QAbstractTableModel):
FIELDS = ["id", "title", "authors", "size", "date", "rating", "publisher", "tags"]
TIME_READ_FMT = "%Y-%m-%d %H:%M:%S"
def __init__(self, parent):
QAbstractTableModel.__init__(self, parent)
self.db = None
self._data = None
self._orig_data = None
self.image_file = None
def update_cover(self, index, pix):
id = self.id_from_index(index)
qb = QBuffer()
qb.open(QBuffer.ReadWrite);
pix.save(qb, "JPG")
data = str(qb.data())
qb.close()
self.db.update_cover(id, data)
def handle_drop(self, path, index):
print "249", path, index.row()
if index.isValid():
f = open(path, "rb")
title = os.path.basename(path)
ext = title[title.rfind(".")+1:].lower() if "." in title > -1 else None
self.db.add_format(self.id_from_index(index), ext, f)
f.close()
else:
pass # TODO: emit book add signal
return True
def rowCount(self, parent): return len(self._data)
def columnCount(self, parent): return len(self.FIELDS)-2
def setData(self, index, value, role):
done = False
if role == Qt.EditRole:
row = index.row()
id = self._data[row]["id"]
col = index.column()
val = str(value.toString())
if col == 0: col = "title"
elif col == 1: col = "authors"
elif col == 2: return False
elif col == 3: return False
elif col == 4:
col, val = "rating", int(value.toInt()[0])
if val < 0: val =0
if val > 5: val = 5
elif col == 5: col = "publisher"
else: return False
self.db.set_metadata_item(id, col, val)
self._data[row][col] = val
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), index, index)
for i in range(len(self._orig_data)):
if self._orig_data[i]["id"] == self._data[row]["id"]:
self._orig_data[i][col] = self._data[row][col]
break
done = True
return done
def flags(self, index):
flags = QAbstractTableModel.flags(self, index)
if index.isValid():
flags |= Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled
if index.column() not in [2,3]: flags |= Qt.ItemIsEditable
else: flags |= Qt.ItemIsDropEnabled
return flags
def set_data(self, db):
self.db = db
self._data = self.db.get_table(self.FIELDS)
self._orig_data = self._data
self.sort(0, Qt.DescendingOrder)
self.reset()
def headerData(self, section, orientation, role):
if role != Qt.DisplayRole:
return NONE
text = ""
if orientation == Qt.Horizontal:
if section == 0: text = "Title"
elif section == 1: text = "Author(s)"
elif section == 2: text = "Size"
elif section == 3: text = "Date"
elif section == 4: text = "Rating"
elif section == 5: text = "Publisher"
return QVariant(self.trUtf8(text))
else: return QVariant(str(1+section))
def info(self, row):
row = self._data[row]
cover = self.db.get_cover(row["id"])
exts = ",".join(self.db.get_extensions(row["id"]))
if cover:
pix = QPixmap()
pix.loadFromData(cover, "", Qt.AutoColor)
cover = None if pix.isNull() else pix
au = row["authors"]
if not au: au = "Unknown"
return row["title"], au, human_readable(int(row["size"])), exts, cover
def id_from_index(self, index): return self._data[index.row()]["id"]
def refresh_row(self, row):
self._data[row] = self.db.get_row_by_id(self._data[row]["id"], self.FIELDS)
for i in range(len(self._orig_data)):
if self._orig_data[i]["id"] == self._data[row]["id"]:
self._orig_data[i:i+1] = self._data[row]
break
self.emit(SIGNAL("dataChanged(QModelIndex, QModelIndex)"), self.index(row, 0), self.index(row, self.columnCount(0)-1))
def data(self, index, role):
if role == Qt.DisplayRole or role == Qt.EditRole:
row, col = index.row(), index.column()
text = None
row = self._data[row]
if col == 4:
r = row["rating"] if row["rating"] else 0
if r < 0: r= 0
if r > 5: r=5
return QVariant(r)
if col == 0: text = wrap(row["title"], width=25)
elif col == 1:
au = row["authors"]
if au : text = wrap(re.sub("&", "\n", au), width=25)
elif col == 2: text = human_readable(row["size"])
elif col == 3: text = time.strftime(TIME_WRITE_FMT, time.strptime(row["date"], self.TIME_READ_FMT))
elif col == 5:
pub = row["publisher"]
if pub: text = wrap(pub, 20)
if text == None: text = "Unknown"
return QVariant(text)
elif role == Qt.TextAlignmentRole and index.column() in [2,3,4]:
return QVariant(Qt.AlignRight)
return NONE
def sort(self, col, order):
descending = order != Qt.AscendingOrder
def getter(key, func): return lambda x : func(itemgetter(key)(x))
if col == 0: key, func = "title", string.lower
if col == 1: key, func = "authors", lambda x : x.split()[-1:][0].lower() if x else ""
if col == 2: key, func = "size", int
if col == 3: key, func = "date", lambda x: time.mktime(time.strptime(x, self.TIME_READ_FMT))
if col == 4: key, func = "rating", lambda x: x if x else 0
if col == 5: key, func = "publisher", lambda x : x.lower() if x else ""
self.emit(SIGNAL("layoutAboutToBeChanged()"))
self._data.sort(key=getter(key, func))
if descending: self._data.reverse()
self.emit(SIGNAL("layoutChanged()"))
self.emit(SIGNAL("sorted()"))
def search(self, query):
def query_in(book, q):
au = book["authors"]
if not au : au = "unknown"
pub = book["publisher"]
if not pub : pub = "unknown"
return q in book["title"].lower() or q in au.lower() or q in pub.lower()
queries = unicode(query, 'utf-8').lower().split()
self.emit(SIGNAL("layoutAboutToBeChanged()"))
self._data = []
for book in self._orig_data:
match = True
for q in queries:
if query_in(book, q) : continue
else:
match = False
break
if match: self._data.append(book)
self.emit(SIGNAL("layoutChanged()"))
self.emit(SIGNAL("searched()"))
def delete(self, indices):
if len(indices): self.emit(SIGNAL("layoutAboutToBeChanged()"))
items = [ self._data[index.row()] for index in indices ]
for item in items:
id = item["id"]
try:
self._data.remove(item)
except ValueError: continue
self.db.delete_by_id(id)
for x in self._orig_data:
if x["id"] == id: self._orig_data.remove(x)
self.emit(SIGNAL("layoutChanged()"))
self.emit(SIGNAL("deleted()"))
self.db.commit()
def add_book(self, path):
""" Must call search and sort after this """
id = self.db.add_book(path)
self._orig_data.append(self.db.get_row_by_id(id, self.FIELDS))
def mimeTypes(self):
s = QStringList()
s << "application/vnd.text.list" # Title, authors
s << "image/jpeg" # 60x80 thumbnail
s << "application/x-sony-bbeb"
s << "application/pdf"
s << "text/rtf"
s << "text/plain"
return s
def mimeData(self, indices):
mime_data = QMimeData()
encoded_data = QByteArray()
rows = []
for index in indices:
if index.isValid():
row = index.row()
if row in rows: continue
title, authors, size, exts, cover = self.info(row)
encoded_data.append(title)
encoded_data.append(authors)
rows.append(row)
mime_data.setData("application/vnd.text.list", encoded_data)
return mime_data
class DeviceBooksModel(QAbstractTableModel):
def __init__(self, parent):
QAbstractTableModel.__init__(self, parent)
self._data = []
self._orig_data = []
def set_data(self, book_list):
self._data = book_list
self._orig_data = book_list
self.reset()
def rowCount(self, parent): return len(self._data)
def columnCount(self, parent): return 4
def headerData(self, section, orientation, role):
if role != Qt.DisplayRole:
return NONE
text = ""
if orientation == Qt.Horizontal:
if section == 0: text = "Title"
elif section == 1: text = "Author(s)"
elif section == 2: text = "Size"
elif section == 3: text = "Date"
return QVariant(self.trUtf8(text))
else: return QVariant(str(1+section))
def data(self, index, role):
if role == Qt.DisplayRole:
row, col = index.row(), index.column()
book = self._data[row]
if col == 0: text = wrap(book.title, width=40)
elif col == 1: text = re.sub("&\s*","\n", book.author)
elif col == 2: text = human_readable(book.size)
elif col == 3: text = time.strftime(TIME_WRITE_FMT, book.datetime)
return QVariant(text)
elif role == Qt.TextAlignmentRole and index.column() in [2,3]:
return QVariant(Qt.AlignRight)
return NONE
def info(self, row):
row = self._data[row]
cover = None
try:
cover = row.thumbnail
pix = QPixmap()
pix.loadFromData(cover, "", Qt.AutoColor)
cover = None if pix.isNull() else pix
except:
traceback.print_exc()
au = row.author if row.author else "Unknown"
return row.title, au, human_readable(row.size), row.mime, cover
def sort(self, col, order):
def getter(key, func): return lambda x : func(attrgetter(key)(x))
if col == 0: key, func = "title", string.lower
if col == 1: key, func = "author", lambda x : x.split()[-1:][0].lower()
if col == 2: key, func = "size", int
if col == 3: key, func = "datetime", lambda x: x
descending = order != Qt.AscendingOrder
self.emit(SIGNAL("layoutAboutToBeChanged()"))
self._data.sort(key=getter(key, func))
if descending: self._data.reverse()
self.emit(SIGNAL("layoutChanged()"))
self.emit(SIGNAL("sorted()"))
def search(self, query):
queries = unicode(query, 'utf-8').lower().split()
self.emit(SIGNAL("layoutAboutToBeChanged()"))
self._data = []
for book in self._orig_data:
match = True
for q in queries:
if q in book.title.lower() or q in book.author.lower(): continue
else:
match = False
break
if match: self._data.append(book)
self.emit(SIGNAL("layoutChanged()"))
self.emit(SIGNAL("searched()"))
def delete_by_path(self, path):
self.emit(SIGNAL("layoutAboutToBeChanged()"))
index = -1
for book in self._data:
if path in book["path"]:
self._data.remove(book)
break
for book in self._orig_data:
if path in book["path"]:
self._orig_data.remove(book)
break
self.emit(SIGNAL("layoutChanged()"))
self.emit(SIGNAL("deleted()"))
def path(self, index): return self._data[index.row()].path
def title(self, index): return self._data[index.row()].title
class DeviceModel(QStandardItemModel):
def __init__(self, parent):
QStandardItemModel.__init__(self, parent)
root = self.invisibleRootItem()
font = QFont()
font.setBold(True)
self.library = QStandardItem(QIcon(":/library"), QString("Library"))
self.reader = QStandardItem(QIcon(":/reader"), "SONY Reader")
self.card = QStandardItem(QIcon(":/card"), "Storage Card")
self.library.setFont(font)
self.reader.setFont(font)
self.card.setFont(font)
self.blank = QStandardItem("")
self.blank.setFlags(Qt.ItemFlags())
root.appendRow(self.library)
root.appendRow(self.blank)
root.appendRow(self.reader)
root.appendRow(self.blank.clone())
root.appendRow(self.card)
self.library.appendRow(QStandardItem("Books"))
self.reader.appendRow(QStandardItem("Books"))
self.card.appendRow(QStandardItem("Books"))