Implement icon theme chooser

This commit is contained in:
Kovid Goyal 2015-08-25 09:57:36 +05:30
parent 0b6b854b3d
commit bf85705c9f
2 changed files with 407 additions and 7 deletions

View File

@ -6,30 +6,44 @@ from __future__ import (unicode_literals, division, absolute_import,
__license__ = 'GPL v3' __license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>' __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import os, errno, json, importlib, math import os, errno, json, importlib, math, httplib, bz2, shutil
from io import BytesIO from io import BytesIO
from future_builtins import map
from Queue import Queue, Empty
from threading import Thread
from PyQt5.Qt import ( from PyQt5.Qt import (
QImageReader, QFormLayout, QVBoxLayout, QSplitter, QGroupBox, QListWidget, QImageReader, QFormLayout, QVBoxLayout, QSplitter, QGroupBox, QListWidget,
QLineEdit, QSpinBox, QTextEdit, QSize, QListWidgetItem, QIcon, QImage, QLineEdit, QSpinBox, QTextEdit, QSize, QListWidgetItem, QIcon, QImage,
QCursor, pyqtSignal, QStackedLayout, QWidget, QLabel, Qt, QComboBox,
QPixmap, QGridLayout, QStyledItemDelegate, QModelIndex, QApplication,
QStaticText, QStyle, QPen
) )
from calibre import walk, fit_image from calibre import walk, fit_image
from calibre.constants import cache_dir, config_dir
from calibre.customize.ui import interface_actions from calibre.customize.ui import interface_actions
from calibre.gui2 import must_use_qt, gprefs, choose_dir, error_dialog, choose_save_file from calibre.gui2 import must_use_qt, gprefs, choose_dir, error_dialog, choose_save_file, question_dialog
from calibre.gui2.dialogs.progress import ProgressDialog
from calibre.gui2.progress_indicator import ProgressIndicator
from calibre.gui2.widgets2 import Dialog from calibre.gui2.widgets2 import Dialog
from calibre.utils.date import utcnow from calibre.utils.date import utcnow
from calibre.utils.filenames import ascii_filename from calibre.utils.filenames import ascii_filename
from calibre.utils.https import get_https_resource_securely, HTTPError
from calibre.utils.icu import numeric_sort_key as sort_key
from calibre.utils.magick import create_canvas, Image from calibre.utils.magick import create_canvas, Image
from calibre.utils.zipfile import ZipFile, ZIP_STORED from calibre.utils.zipfile import ZipFile, ZIP_STORED
from lzma.xz import compress from lzma.xz import compress, decompress
IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg'} IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg'}
THEME_COVER = 'icon-theme-cover.jpg' THEME_COVER = 'icon-theme-cover.jpg'
THEME_METADATA = 'metadata.json' THEME_METADATA = 'metadata.json'
BASE_URL = 'https://code.calibre-ebook.com/icon-themes/'
# Theme creation {{{ # Theme creation {{{
COVER_SIZE = (340, 272)
def render_svg(filepath): def render_svg(filepath):
must_use_qt(headless=False) must_use_qt(headless=False)
pngpath = filepath[:-4] + '.png' pngpath = filepath[:-4] + '.png'
@ -214,7 +228,7 @@ class ThemeCreateDialog(Dialog):
self.apply_report() self.apply_report()
def sizeHint(self): def sizeHint(self):
return QSize(900, 600) return QSize(900, 670)
@property @property
def metadata(self): def metadata(self):
@ -308,8 +322,392 @@ def create_theme(folder=None, parent=None):
# }}} # }}}
# Choose Theme {{{
def download_cover(cover_url, etag=None, cached=b''):
url = BASE_URL + cover_url
headers = {}
if etag:
if etag[0] != '"':
etag = '"' + etag + '"'
headers['If-None-Match'] = etag
try:
response = get_https_resource_securely(url, headers=headers, get_response=True)
cached = response.read()
etag = response.getheader('ETag', None) or None
return cached, etag
except HTTPError as e:
if etag and e.code == httplib.NOT_MODIFIED:
return cached, etag
raise
def get_cover(metadata):
cdir = os.path.join(cache_dir(), 'icon-theme-covers')
try:
os.makedirs(cdir)
except EnvironmentError as e:
if e.errno != errno.EEXIST:
raise
def path(ext):
return os.path.join(cdir, metadata['name'] + '.' + ext)
etag_file, cover_file = map(path, 'etag jpg'.split())
def safe_read(path):
try:
with open(path, 'rb') as f:
return f.read()
except EnvironmentError as e:
if e.errno != errno.ENOENT:
raise
return b''
etag, cached = safe_read(etag_file), safe_read(cover_file)
cached, etag = download_cover(metadata['cover-url'], etag, cached)
if cached:
with open(cover_file, 'wb') as f:
f.write(cached)
if etag:
with open(etag_file, 'wb') as f:
f.write(etag)
return cached or b''
def get_covers(themes, callback, num_of_workers=8):
items = Queue()
tuple(map(items.put, themes))
def run():
while True:
try:
metadata = items.get_nowait()
except Empty:
return
try:
cdata = get_cover(metadata)
except Exception as e:
import traceback
traceback.print_exc()
callback(metadata, e)
else:
callback(metadata, cdata)
for w in xrange(num_of_workers):
t = Thread(name='IconThemeCover', target=run)
t.daemon = True
t.start()
class Delegate(QStyledItemDelegate):
SPACING = 10
def sizeHint(self, option, index):
return QSize(COVER_SIZE[0] * 2, COVER_SIZE[1] + 2 * self.SPACING)
def paint(self, painter, option, index):
QStyledItemDelegate.paint(self, painter, option, QModelIndex())
theme = index.data(Qt.UserRole)
if not theme:
return
painter.save()
pixmap = index.data(Qt.DecorationRole)
if pixmap and not pixmap.isNull():
rect = option.rect.adjusted(0, self.SPACING, COVER_SIZE[0] - option.rect.width(), - self.SPACING)
painter.drawPixmap(rect, pixmap, pixmap.rect())
if option.state & QStyle.State_Selected:
painter.setPen(QPen(QApplication.instance().palette().highlightedText().color()))
bottom = option.rect.bottom() - 2
painter.drawLine(0, bottom, option.rect.right(), bottom)
if 'static-text' not in theme:
theme['static-text'] = QStaticText(
'''
<h1>{title}</h1>
<p>by <i>{author}</i> with <b>{number}</b> icons</p>
<p>{description}</p>
'''.format(title=theme.get('title', _('Unknown')), author=theme.get('author', _('Unknown')),
number=theme.get('number', 0), description=theme.get('description', '')))
painter.drawStaticText(COVER_SIZE[0] + self.SPACING, option.rect.top() + self.SPACING, theme['static-text'])
painter.restore()
class DownloadProgress(ProgressDialog):
ds = pyqtSignal(object)
acc = pyqtSignal()
rej = pyqtSignal()
def __init__(self, parent, size):
ProgressDialog.__init__(self, _('Downloading icons...'), _(
'Downloading icons, please wait...'), max=size, parent=parent, icon='download_metadata.png')
self.ds.connect(self.bar.setValue, type=Qt.QueuedConnection)
self.acc.connect(self.accept, type=Qt.QueuedConnection)
self.rej.connect(self.reject, type=Qt.QueuedConnection)
def downloaded(self, byte_count):
self.ds.emit(byte_count)
def queue_accept(self):
self.acc.emit()
def queue_reject(self):
self.rej.emit()
class ChooseTheme(Dialog):
cover_downloaded = pyqtSignal(object, object)
themes_downloaded = pyqtSignal()
def __init__(self, parent=None):
try:
self.current_theme = json.loads(I('icon-theme.json', data=True))['title']
except Exception:
self.current_theme = None
self.changed = False
Dialog.__init__(self, _('Choose an icon theme'), 'choose-icon-theme-dialog', parent)
self.themes_downloaded.connect(self.show_themes, type=Qt.QueuedConnection)
self.cover_downloaded.connect(self.set_cover, type=Qt.QueuedConnection)
self.keep_downloading = True
def sizeHint(self):
desktop = QApplication.instance().desktop()
h = desktop.availableGeometry(self).height()
return QSize(900, h - 75)
def setup_ui(self):
self.vl = vl = QVBoxLayout(self)
self.stack = l = QStackedLayout()
self.pi = pi = ProgressIndicator(self, 256)
vl.addLayout(l), vl.addWidget(self.bb)
self.restore_defs_button = b = self.bb.addButton(_('Restore &default icons'), self.bb.ActionRole)
b.clicked.connect(self.restore_defaults)
b.setIcon(QIcon(I('view-refresh.png')))
self.c = c = QWidget(self)
self.c.v = v = QVBoxLayout(self.c)
v.addStretch(), v.addWidget(pi, 0, Qt.AlignCenter)
self.wait_msg = m = QLabel(self)
v.addWidget(m, 0, Qt.AlignCenter), v.addStretch()
m.setStyleSheet('QLabel { font-size: 40px; font-weight: bold }')
self.start_spinner()
l.addWidget(c)
self.w = w = QWidget(self)
l.addWidget(w)
w.l = l = QGridLayout(w)
def add_row(x, y=None):
if isinstance(x, type('')):
x = QLabel(x)
row = l.rowCount()
if y is None:
if isinstance(x, QLabel):
x.setWordWrap(True)
l.addWidget(x, row, 0, 1, 2)
else:
if isinstance(x, QLabel):
x.setBuddy(y)
l.addWidget(x, row, 0), l.addWidget(y, row, 1)
add_row(_(
'Choose an icon theme below. You will need to restart'
' calibre to see the new icons.'))
add_row(_('Current icon theme:') + '\xa0<b>' + (self.current_theme or 'None'))
self.sort_by = sb = QComboBox(self)
add_row(_('&Sort by:'), sb)
sb.addItems([_('Number of icons'), _('Popularity'), _('Name'),])
sb.setEditable(False), sb.setCurrentIndex(0)
sb.currentIndexChanged[int].connect(self.re_sort)
self.theme_list = tl = QListWidget(self)
self.delegate = Delegate(tl)
tl.setItemDelegate(self.delegate)
tl.itemDoubleClicked.connect(self.accept)
add_row(tl)
t = Thread(name='GetIconThemes', target=self.get_themes)
t.daemon = True
t.start()
def start_spinner(self, msg=None):
self.pi.startAnimation()
self.stack.setCurrentIndex(0)
self.wait_msg.setText(msg or _('Downloading, please wait...'))
def end_spinner(self):
self.pi.stopAnimation()
self.stack.setCurrentIndex(1)
@property
def sort_on(self):
return {0:'number', 1:'usage', 2:'title'}[self.sort_by.currentIndex()]
def re_sort(self):
self.themes.sort(key=lambda x:sort_key(x.get('title', '')))
field = self.sort_on
if field == 'number':
self.themes.sort(key=lambda x:x.get('number', 0), reverse=True)
elif field == 'usage':
self.themes.sort(key=lambda x:self.usage.get(x.get('name'), 0), reverse=True)
self.theme_list.clear()
for theme in self.themes:
i = QListWidgetItem(theme.get('title', '') + ' %s %s' % (theme.get('number'), self.usage.get(theme.get('name'))), self.theme_list)
i.setData(Qt.UserRole, theme)
if 'cover-pixmap' in theme:
i.setData(Qt.DecorationRole, theme['cover-pixmap'])
def get_themes(self):
self.usage = {}
def get_usage():
try:
self.usage = json.loads(bz2.decompress(get_https_resource_securely(BASE_URL + '/usage.json.bz2')))
except Exception:
import traceback
traceback.print_exc()
t = Thread(name='IconThemeUsage', target=get_usage)
t.daemon = True
t.start()
try:
self.themes = json.loads(bz2.decompress(get_https_resource_securely(BASE_URL + '/themes.json.bz2')))
except Exception:
import traceback
self.themes = traceback.format_exc()
t.join()
self.themes_downloaded.emit()
def show_themes(self):
self.end_spinner()
if not isinstance(self.themes, list):
error_dialog(self, _('Failed to download list of themes'), _(
'Failed to download list of themes, click "Show Details" for more information'),
det_msg=self.themes, show=True)
self.reject()
return
self.re_sort()
get_covers(self.themes, self.cover_downloaded.emit)
def __iter__(self):
for i in xrange(self.theme_list.count()):
yield self.theme_list.item(i)
def item_from_name(self, name):
for item in self:
if item.data(Qt.UserRole)['name'] == name:
return item
def set_cover(self, theme, cdata):
theme['cover-pixmap'] = p = QPixmap()
if isinstance(cdata, bytes):
p.loadFromData(cdata)
item = self.item_from_name(theme['name'])
if item is not None:
item.setData(Qt.DecorationRole, p)
def restore_defaults(self):
if self.current_theme is not None:
if not question_dialog(self, _('Are you sure?'), _(
'Are you sure you want to remove the <b>%s</b> icon theme'
' and return to the stock icons?') % self.current_theme):
return
self.changed = True
remove_icon_theme()
Dialog.accept(self)
def accept(self):
if self.theme_list.currentIndex() < 0:
return error_dialog(self, _('No theme selected'), _(
'You must first select an icon theme'), show=True)
theme = self.theme_list.currentItem().data(Qt.UserRole)
url = BASE_URL + theme['icons-url']
size = theme['compressed-size']
theme = {k:theme.get(k, '') for k in 'name title'.split()}
self.keep_downloading = True
d = DownloadProgress(self, size)
d.canceled_signal.connect(lambda : setattr(self, 'keep_downloading', False))
self.downloaded_theme = None
def download():
self.downloaded_theme = buf = BytesIO()
try:
response = get_https_resource_securely(url, get_response=True)
while self.keep_downloading:
raw = response.read(100)
if not raw:
break
buf.write(raw)
d.downloaded(buf.tell())
d.queue_accept()
except Exception:
import traceback
self.downloaded_theme = traceback.format_exc()
d.queue_reject()
t = Thread(name='DownloadIconTheme', target=download)
t.daemon = True
t.start()
ret = d.exec_()
if self.downloaded_theme and not isinstance(self.downloaded_theme, BytesIO):
return error_dialog(self, _('Download failed'), _(
'Failed to download icon theme, click "Show Details" for more information.'), show=True, det_msg=self.downloaded_theme)
if ret == d.Rejected or not self.keep_downloading or d.canceled or self.downloaded_theme is None:
return
self.changed = True
with BusyCursor():
self.downloaded_theme.seek(0)
f = decompress(self.downloaded_theme)
f.seek(0)
remove_icon_theme()
install_icon_theme(theme, f)
return Dialog.accept(self)
class BusyCursor(object):
def __enter__(self):
QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
def __exit__(self, *args):
QApplication.restoreOverrideCursor()
# }}}
def remove_icon_theme():
icdir = os.path.join(config_dir, 'resources', 'images')
metadata_file = os.path.join(icdir, 'icon-theme.json')
try:
with open(metadata_file, 'rb') as f:
metadata = json.load(f)
except EnvironmentError as e:
if e.errno != errno.ENOENT:
raise
return
for name in metadata['files']:
try:
os.remove(os.path.join(icdir, *name.split('/')))
except EnvironmentError as e:
if e.errno != errno.ENOENT:
raise
os.remove(metadata_file)
def install_icon_theme(theme, f):
icdir = os.path.join(config_dir, 'resources', 'images')
if not os.path.exists(icdir):
os.makedirs(icdir)
theme['files'] = set()
metadata_file = os.path.join(icdir, 'icon-theme.json')
with ZipFile(f) as zf:
for name in zf.namelist():
base = icdir
if '/' in name:
base = os.path.join(icdir, os.path.dirname(name))
if not os.path.exists(base):
os.makedirs(base)
with zf.open(name) as src, open(os.path.join(base, os.path.basename(name)), 'wb') as dest:
shutil.copyfileobj(src, dest)
theme['files'].add(name)
theme['files'] = tuple(theme['files'])
with open(metadata_file, 'wb') as mf:
json.dump(theme, mf, indent=2)
if __name__ == '__main__': if __name__ == '__main__':
from calibre.gui2 import Application from calibre.gui2 import Application
app = Application([]) app = Application([])
create_theme('.') # create_theme('.')
ChooseTheme().exec_()
del app del app

View File

@ -157,7 +157,7 @@ else:
getattr(ssl, 'match_hostname', match_hostname)(self.sock.getpeercert(), self.host) getattr(ssl, 'match_hostname', match_hostname)(self.sock.getpeercert(), self.host)
def get_https_resource_securely( def get_https_resource_securely(
url, cacerts='calibre-ebook-root-CA.crt', timeout=60, max_redirects=5, ssl_version=None, headers=None): url, cacerts='calibre-ebook-root-CA.crt', timeout=60, max_redirects=5, ssl_version=None, headers=None, get_response=False):
''' '''
Download the resource pointed to by url using https securely (verify server Download the resource pointed to by url using https securely (verify server
certificate). Ensures that redirects, if any, are also downloaded certificate). Ensures that redirects, if any, are also downloaded
@ -210,9 +210,11 @@ def get_https_resource_securely(
if newurl is None: if newurl is None:
raise ValueError('%s returned a redirect response with no Location header' % url) raise ValueError('%s returned a redirect response with no Location header' % url)
return get_https_resource_securely( return get_https_resource_securely(
newurl, cacerts=cacerts, timeout=timeout, max_redirects=max_redirects-1, ssl_version=ssl_version) newurl, cacerts=cacerts, timeout=timeout, max_redirects=max_redirects-1, ssl_version=ssl_version, get_response=get_response)
if response.status != httplib.OK: if response.status != httplib.OK:
raise HTTPError(url, response.status) raise HTTPError(url, response.status)
if get_response:
return response
return response.read() return response.read()
if __name__ == '__main__': if __name__ == '__main__':