mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Implement icon theme chooser
This commit is contained in:
parent
0b6b854b3d
commit
bf85705c9f
@ -6,30 +6,44 @@ from __future__ import (unicode_literals, division, absolute_import,
|
||||
__license__ = 'GPL v3'
|
||||
__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 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(
|
||||
'''
|
||||
<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__':
|
||||
from calibre.gui2 import Application
|
||||
app = Application([])
|
||||
create_theme('.')
|
||||
# create_theme('.')
|
||||
ChooseTheme().exec_()
|
||||
del app
|
||||
|
@ -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__':
|
||||
|
Loading…
x
Reference in New Issue
Block a user