From bf85705c9f8815897b2e51663fac7a0963c9d720 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 25 Aug 2015 09:57:36 +0530 Subject: [PATCH] Implement icon theme chooser --- src/calibre/gui2/icon_theme.py | 408 ++++++++++++++++++++++++++++++++- src/calibre/utils/https.py | 6 +- 2 files changed, 407 insertions(+), 7 deletions(-) diff --git a/src/calibre/gui2/icon_theme.py b/src/calibre/gui2/icon_theme.py index 18fca2c087..5760bcce10 100644 --- a/src/calibre/gui2/icon_theme.py +++ b/src/calibre/gui2/icon_theme.py @@ -6,30 +6,44 @@ from __future__ import (unicode_literals, division, absolute_import, __license__ = 'GPL v3' __copyright__ = '2015, Kovid Goyal ' -import os, errno, json, importlib, math +import os, errno, json, importlib, math, httplib, bz2, shutil from io import BytesIO +from future_builtins import map +from Queue import Queue, Empty +from threading import Thread from PyQt5.Qt import ( QImageReader, QFormLayout, QVBoxLayout, QSplitter, QGroupBox, QListWidget, 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.constants import cache_dir, config_dir 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.utils.date import utcnow 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.zipfile import ZipFile, ZIP_STORED -from lzma.xz import compress +from lzma.xz import compress, decompress IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg'} THEME_COVER = 'icon-theme-cover.jpg' THEME_METADATA = 'metadata.json' +BASE_URL = 'https://code.calibre-ebook.com/icon-themes/' # Theme creation {{{ +COVER_SIZE = (340, 272) + def render_svg(filepath): must_use_qt(headless=False) pngpath = filepath[:-4] + '.png' @@ -214,7 +228,7 @@ class ThemeCreateDialog(Dialog): self.apply_report() def sizeHint(self): - return QSize(900, 600) + return QSize(900, 670) @property 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( + ''' +

{title}

+

by {author} with {number} icons

+

{description}

+ '''.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' + (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 %s 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__': from calibre.gui2 import Application app = Application([]) - create_theme('.') + # create_theme('.') + ChooseTheme().exec_() del app diff --git a/src/calibre/utils/https.py b/src/calibre/utils/https.py index 426cc6e5c3..b64cec2532 100644 --- a/src/calibre/utils/https.py +++ b/src/calibre/utils/https.py @@ -157,7 +157,7 @@ else: getattr(ssl, 'match_hostname', match_hostname)(self.sock.getpeercert(), self.host) 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 certificate). Ensures that redirects, if any, are also downloaded @@ -210,9 +210,11 @@ def get_https_resource_securely( if newurl is None: raise ValueError('%s returned a redirect response with no Location header' % url) 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: raise HTTPError(url, response.status) + if get_response: + return response return response.read() if __name__ == '__main__':