%PDF- %PDF-
| Direktori : /lib/calibre/calibre/gui2/ |
| Current File : //lib/calibre/calibre/gui2/icon_theme.py |
#!/usr/bin/env python3
__license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import bz2
import errno
import importlib
import json
import math
import os
import shutil
import sys
from io import BytesIO
from itertools import count
from multiprocessing.pool import ThreadPool
from qt.core import (
QAbstractItemView, QApplication, QComboBox, QDialog, QDialogButtonBox,
QFormLayout, QGridLayout, QGroupBox, QIcon, QImage, QImageReader, QLabel,
QLineEdit, QListWidget, QListWidgetItem, QPen, QPixmap, QProgressDialog, QSize,
QSpinBox, QSplitter, QStackedLayout, QStaticText, QStyle, QStyledItemDelegate,
Qt, QTextEdit, QVBoxLayout, QWidget, pyqtSignal, sip
)
from threading import Event, Thread
from calibre import detect_ncpus as cpu_count, fit_image, human_readable, walk
from calibre.constants import cache_dir, config_dir
from calibre.customize.ui import interface_actions
from calibre.gui2 import (
choose_dir, choose_save_file, empty_index, error_dialog, gprefs, must_use_qt,
question_dialog, safe_open_url
)
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, atomic_rename
from calibre.utils.https import HTTPError, get_https_resource_securely
from calibre.utils.icu import numeric_sort_key as sort_key
from calibre.utils.img import Canvas, image_from_data, optimize_jpeg, optimize_png
from calibre.utils.zipfile import ZIP_STORED, ZipFile
from polyglot import http_client
from polyglot.builtins import as_bytes, iteritems, reraise
from polyglot.queue import Empty, Queue
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'
i = QImage(filepath)
i.save(pngpath)
def read_images_from_folder(path):
name_map = {}
path = os.path.abspath(path)
for filepath in walk(path):
name = os.path.relpath(filepath, path).replace(os.sep, '/')
ext = name.rpartition('.')[-1]
bname = os.path.basename(name)
if bname.startswith('.') or bname.startswith('_'):
continue
if ext == 'svg':
render_svg(filepath)
ext = 'png'
filepath = filepath[:-4] + '.png'
name = name[:-4] + '.png'
if ext in IMAGE_EXTENSIONS:
name_map[name] = filepath
return name_map
class Theme:
def __init__(self, title='', author='', version=-1, description='', license='Unknown', url=None, cover=None):
self.title, self.author, self.version, self.description = title, author, version, description
self.license, self.cover, self.url = license, cover, url
class Report:
def __init__(self, path, name_map, extra, missing, theme):
self.path, self.name_map, self.extra, self.missing, self.theme = path, name_map, extra, missing, theme
self.bad = {}
@property
def name(self):
return ascii_filename(self.theme.title).replace(' ', '_').replace('.', '_').lower()
def read_theme_from_folder(path):
path = os.path.abspath(path)
current_image_map = read_images_from_folder(P('images', allow_user_override=False))
name_map = read_images_from_folder(path)
name_map.pop(THEME_COVER, None)
name_map.pop('blank.png', None)
current_names = frozenset(current_image_map)
names = frozenset(name_map)
extra = names - current_names
missing = current_names - names
try:
with open(os.path.join(path, THEME_METADATA), 'rb') as f:
metadata = json.load(f)
except OSError as e:
if e.errno != errno.ENOENT:
raise
metadata = {}
except ValueError:
# Corrupted metadata file
metadata = {}
def safe_int(x):
try:
return int(x)
except Exception:
return -1
g = lambda x, defval='': metadata.get(x, defval)
theme = Theme(g('title'), g('author'), safe_int(g('version', -1)), g('description'), g('license', 'Unknown'), g('url', None))
ans = Report(path, name_map, extra, missing, theme)
try:
with open(os.path.join(path, THEME_COVER), 'rb') as f:
theme.cover = f.read()
except OSError as e:
if e.errno != errno.ENOENT:
raise
theme.cover = create_cover(ans)
return ans
def icon_for_action(name):
for plugin in interface_actions():
if plugin.name == name:
module, class_name = plugin.actual_plugin.partition(':')[::2]
mod = importlib.import_module(module)
cls = getattr(mod, class_name)
icon = cls.action_spec[1]
if icon:
return icon
def default_cover_icons(cols=5):
count = 0
for ac in gprefs.defaults['action-layout-toolbar']:
if ac:
icon = icon_for_action(ac)
if icon:
count += 1
yield icon
for x in 'user_profile plus minus series sync tags default_cover'.split():
yield x + '.png'
count += 1
extra = 'search donate cover_flow reader publisher back forward'.split()
while count < 15 or count % cols != 0:
yield extra[0] + '.png'
del extra[0]
count += 1
def create_cover(report, icons=(), cols=5, size=120, padding=16):
icons = icons or tuple(default_cover_icons(cols))
rows = int(math.ceil(len(icons) / cols))
with Canvas(cols * (size + padding), rows * (size + padding), bgcolor='#eee') as canvas:
y = -size - padding // 2
x = 0
for i, icon in enumerate(icons):
if i % cols == 0:
y += padding + size
x = padding // 2
else:
x += size + padding
if report and icon in report.name_map:
ipath = os.path.join(report.path, report.name_map[icon])
else:
ipath = I(icon, allow_user_override=False)
with lopen(ipath, 'rb') as f:
img = image_from_data(f.read())
scaled, nwidth, nheight = fit_image(img.width(), img.height(), size, size)
img = img.scaled(int(nwidth), int(nheight), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation)
dx = (size - nwidth) // 2
canvas.compose(img, x + dx, y)
return canvas.export()
def verify_theme(report):
must_use_qt()
report.bad = bad = {}
for name, path in iteritems(report.name_map):
reader = QImageReader(os.path.join(report.path, path))
img = reader.read()
if img.isNull():
bad[name] = reader.errorString()
return bool(bad)
class ThemeCreateDialog(Dialog):
def __init__(self, parent, report):
self.report = report
Dialog.__init__(self, _('Create an icon theme'), 'create-icon-theme', parent)
def setup_ui(self):
self.splitter = QSplitter(self)
self.l = l = QVBoxLayout(self)
l.addWidget(self.splitter)
l.addWidget(self.bb)
self.w = w = QGroupBox(_('Theme Metadata'), self)
self.splitter.addWidget(w)
l = w.l = QFormLayout(w)
l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self.missing_icons_group = mg = QGroupBox(self)
self.mising_icons = mi = QListWidget(mg)
mi.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
mg.l = QVBoxLayout(mg)
mg.l.addWidget(mi)
self.splitter.addWidget(mg)
self.title = QLineEdit(self)
l.addRow(_('&Title:'), self.title)
self.author = QLineEdit(self)
l.addRow(_('&Author:'), self.author)
self.version = v = QSpinBox(self)
v.setMinimum(1), v.setMaximum(1000000)
l.addRow(_('&Version:'), v)
self.license = lc = QLineEdit(self)
l.addRow(_('&License:'), lc)
self.url = QLineEdit(self)
l.addRow(_('&URL:'), self.url)
lc.setText(_(
'The license for the icons in this theme. Common choices are'
' Creative Commons or Public Domain.'))
self.description = QTextEdit(self)
l.addRow(self.description)
self.refresh_button = rb = self.bb.addButton(_('&Refresh'), QDialogButtonBox.ButtonRole.ActionRole)
rb.setIcon(QIcon(I('view-refresh.png')))
rb.clicked.connect(self.refresh)
self.apply_report()
def sizeHint(self):
return QSize(900, 670)
@property
def metadata(self):
self.report.theme.title = self.title.text().strip() # Needed for report.name to work
return {
'title': self.title.text().strip(),
'author': self.author.text().strip(),
'version': self.version.value(),
'description': self.description.toPlainText().strip(),
'number': len(self.report.name_map) - len(self.report.extra),
'date': utcnow().date().isoformat(),
'name': self.report.name,
'license': self.license.text().strip() or 'Unknown',
'url': self.url.text().strip() or None,
}
def save_metadata(self):
data = json.dumps(self.metadata, indent=2)
if not isinstance(data, bytes):
data = data.encode('utf-8')
with open(os.path.join(self.report.path, THEME_METADATA), 'wb') as f:
f.write(data)
def refresh(self):
self.save_metadata()
self.report = read_theme_from_folder(self.report.path)
self.apply_report()
def apply_report(self):
theme = self.report.theme
self.title.setText((theme.title or '').strip())
self.author.setText((theme.author or '').strip())
self.version.setValue(theme.version or 1)
self.description.setText((theme.description or '').strip())
self.license.setText((theme.license or 'Unknown').strip())
self.url.setText((theme.url or '').strip())
if self.report.missing:
title = _('%d icons missing in this theme') % len(self.report.missing)
else:
title = _('No missing icons')
self.missing_icons_group.setTitle(title)
mi = self.mising_icons
mi.clear()
for name in sorted(self.report.missing):
QListWidgetItem(QIcon(I(name, allow_user_override=False)), name, mi)
def accept(self):
mi = self.metadata
if not mi.get('title'):
return error_dialog(self, _('No title specified'), _(
'You must specify a title for this icon theme'), show=True)
if not mi.get('author'):
return error_dialog(self, _('No author specified'), _(
'You must specify an author for this icon theme'), show=True)
return Dialog.accept(self)
class Compress(QProgressDialog):
update_signal = pyqtSignal(object, object)
def __init__(self, report, parent=None):
total = 2 + len(report.name_map)
QProgressDialog.__init__(self, _('Losslessly optimizing images, please wait...'), _('&Abort'), 0, total, parent)
self.setWindowTitle(self.labelText())
self.setWindowIcon(QIcon(I('lt.png')))
self.setMinimumDuration(0)
self.update_signal.connect(self.do_update, type=Qt.ConnectionType.QueuedConnection)
self.raw = self.prefix = None
self.abort = Event()
self.canceled.connect(self.abort.set)
self.t = Thread(name='CompressIcons', target=self.run_compress, args=(report,))
self.t.daemon = False
self.t.start()
def do_update(self, num, message):
if num < 0:
return self.onerror(_('Optimizing images failed, click "Show details" for more information'), message)
self.setValue(num)
self.setLabelText(message)
def onerror(self, msg, details):
error_dialog(self, _('Compression failed'), msg, det_msg=details, show=True)
self.close()
def onprogress(self, num, msg):
self.update_signal.emit(num, msg)
return not self.wasCanceled()
def run_compress(self, report):
try:
self.raw, self.prefix = create_themeball(report, self.onprogress, self.abort)
except Exception:
import traceback
self.update_signal.emit(-1, traceback.format_exc())
else:
self.update_signal.emit(self.maximum(), '')
def create_themeball(report, progress=None, abort=None):
pool = ThreadPool(processes=cpu_count())
buf = BytesIO()
num = count()
error_occurred = Event()
def optimize(name):
if abort is not None and abort.is_set():
return
if error_occurred.is_set():
return
try:
i = next(num)
if progress is not None:
progress(i, _('Optimizing %s') % name)
srcpath = os.path.join(report.path, name)
ext = srcpath.rpartition('.')[-1].lower()
if ext == 'png':
optimize_png(srcpath)
elif ext in ('jpg', 'jpeg'):
optimize_jpeg(srcpath)
except Exception:
return sys.exc_info()
errors = tuple(filter(None, pool.map(optimize, tuple(report.name_map))))
pool.close(), pool.join()
if abort is not None and abort.is_set():
return
if errors:
e = errors[0]
reraise(*e)
if progress is not None:
progress(next(num), _('Creating theme file'))
with ZipFile(buf, 'w') as zf:
for name in report.name_map:
srcpath = os.path.join(report.path, name)
with lopen(srcpath, 'rb') as f:
zf.writestr(name, f.read(), compression=ZIP_STORED)
buf.seek(0)
if abort is not None and abort.is_set():
return None, None
if progress is not None:
progress(next(num), _('Compressing theme file'))
import lzma
compressed = lzma.compress(buf.getvalue(), format=lzma.FORMAT_XZ, preset=9)
buf = BytesIO()
prefix = report.name
if abort is not None and abort.is_set():
return None, None
with ZipFile(buf, 'w') as zf:
with lopen(os.path.join(report.path, THEME_METADATA), 'rb') as f:
zf.writestr(prefix + '/' + THEME_METADATA, f.read())
zf.writestr(prefix + '/' + THEME_COVER, create_cover(report))
zf.writestr(prefix + '/' + 'icons.zip.xz', compressed, compression=ZIP_STORED)
if progress is not None:
progress(next(num), _('Finished'))
return buf.getvalue(), prefix
def create_theme(folder=None, parent=None):
if folder is None:
folder = choose_dir(parent, 'create-icon-theme-folder', _(
'Choose a folder from which to read the icons'))
if not folder:
return
report = read_theme_from_folder(folder)
d = ThemeCreateDialog(parent, report)
if d.exec() != QDialog.DialogCode.Accepted:
return
d.save_metadata()
d = Compress(d.report, parent=parent)
d.exec()
if d.wasCanceled() or d.raw is None:
return
raw, prefix = d.raw, d.prefix
dest = choose_save_file(parent, 'create-icon-theme-dest', _(
'Choose destination for icon theme'),
[(_('ZIP files'), ['zip'])], initial_filename=prefix + '.zip')
if dest:
with lopen(dest, 'wb') as f:
f.write(raw)
# }}}
# 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 == http_client.NOT_MODIFIED:
return cached, etag
raise
def get_cover(metadata):
cdir = os.path.join(cache_dir(), 'icon-theme-covers')
try:
os.makedirs(cdir)
except OSError 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 OSError as e:
if e.errno != errno.ENOENT:
raise
return b''
etag, cached = safe_read(etag_file), safe_read(cover_file)
etag = etag.decode('utf-8')
cached, etag = download_cover(metadata['cover-url'], etag, cached)
if cached:
aname = cover_file + '.atomic'
with open(aname, 'wb') as f:
f.write(cached)
atomic_rename(aname, cover_file)
if etag:
with open(etag_file, 'wb') as f:
f.write(as_bytes(etag))
return cached or b''
def get_covers(themes, dialog, num_of_workers=8):
items = Queue()
for i in themes:
items.put(i)
def callback(metadata, x):
if not sip.isdeleted(dialog) and not dialog.dialog_closed:
dialog.cover_downloaded.emit(metadata, x)
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 range(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, empty_index)
theme = index.data(Qt.ItemDataRole.UserRole)
if not theme:
return
painter.save()
pixmap = index.data(Qt.ItemDataRole.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)
if option.state & QStyle.StateFlag.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 [{size}]</p>
<p>{description}</p>
<p>Version: {version} Number of users: {usage}</p>
<p><i>Right click to visit theme homepage</i></p>
''').format(title=theme.get('title', _('Unknown')), author=theme.get('author', _('Unknown')),
number=theme.get('number', 0), description=theme.get('description', ''),
size=human_readable(theme.get('compressed-size', 0)), version=theme.get('version', 1),
usage=theme.get('usage', 0),
))
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.ConnectionType.QueuedConnection)
self.acc.connect(self.accept, type=Qt.ConnectionType.QueuedConnection)
self.rej.connect(self.reject, type=Qt.ConnectionType.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
Dialog.__init__(self, _('Choose an icon theme'), 'choose-icon-theme-dialog', parent)
self.finished.connect(self.on_finish)
self.dialog_closed = False
self.themes_downloaded.connect(self.show_themes, type=Qt.ConnectionType.QueuedConnection)
self.cover_downloaded.connect(self.set_cover, type=Qt.ConnectionType.QueuedConnection)
self.keep_downloading = True
self.commit_changes = None
self.new_theme_title = None
def on_finish(self):
self.dialog_closed = True
def sizeHint(self):
h = self.screen().availableSize().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'), QDialogButtonBox.ButtonRole.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.AlignmentFlag.AlignCenter)
self.wait_msg = m = QLabel(self)
v.addWidget(m, 0, Qt.AlignmentFlag.AlignCenter), v.addStretch()
f = m.font()
f.setBold(True), f.setPointSize(28), m.setFont(f)
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, str):
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(gprefs.get('choose_icon_theme_sort_by', 1))
sb.currentIndexChanged[int].connect(self.re_sort)
sb.currentIndexChanged[int].connect(lambda : gprefs.set('choose_icon_theme_sort_by', sb.currentIndex()))
self.theme_list = tl = QListWidget(self)
tl.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.delegate = Delegate(tl)
tl.setItemDelegate(self.delegate)
tl.itemDoubleClicked.connect(self.accept)
tl.itemPressed.connect(self.item_clicked)
add_row(tl)
t = Thread(name='GetIconThemes', target=self.get_themes)
t.daemon = True
t.start()
def item_clicked(self, item):
if QApplication.mouseButtons() & Qt.MouseButton.RightButton:
theme = item.data(Qt.ItemDataRole.UserRole) or {}
url = theme.get('url')
if url:
safe_open_url(url)
else:
error_dialog(self, _('No homepage'), _('The {} theme has no homepage').format(theme.get('name', _('Unknown'))), show=True)
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', '') + ' {} {}'.format(theme.get('number'), self.usage.get(theme.get('name'))), self.theme_list)
i.setData(Qt.ItemDataRole.UserRole, theme)
if 'cover-pixmap' in theme:
i.setData(Qt.ItemDataRole.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()
if not sip.isdeleted(self):
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
for theme in self.themes:
theme['usage'] = self.usage.get(theme['name'], 0)
self.re_sort()
get_covers(self.themes, self)
def __iter__(self):
for i in range(self.theme_list.count()):
yield self.theme_list.item(i)
def item_from_name(self, name):
for item in self:
if item.data(Qt.ItemDataRole.UserRole)['name'] == name:
return item
def set_cover(self, theme, cdata):
theme['cover-pixmap'] = p = QPixmap()
try:
dpr = self.devicePixelRatioF()
except AttributeError:
dpr = self.devicePixelRatio()
if isinstance(cdata, bytes):
p.loadFromData(cdata)
p.setDevicePixelRatio(dpr)
item = self.item_from_name(theme['name'])
if item is not None:
item.setData(Qt.ItemDataRole.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.commit_changes = remove_icon_theme
Dialog.accept(self)
def accept(self):
if self.theme_list.currentRow() < 0:
return error_dialog(self, _('No theme selected'), _(
'You must first select an icon theme'), show=True)
theme = self.theme_list.currentItem().data(Qt.ItemDataRole.UserRole)
url = BASE_URL + theme['icons-url']
size = theme['compressed-size']
theme = {k:theme.get(k, '') for k in 'name title version'.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(1024)
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 == QDialog.DialogCode.Rejected or not self.keep_downloading or d.canceled or self.downloaded_theme is None:
return
dt = self.downloaded_theme
def commit_changes():
import lzma
dt.seek(0)
f = BytesIO(lzma.decompress(dt.getvalue()))
f.seek(0)
remove_icon_theme()
install_icon_theme(theme, f)
self.commit_changes = commit_changes
self.new_theme_title = theme['title']
return Dialog.accept(self)
# }}}
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 OSError as e:
if e.errno != errno.ENOENT:
raise
return
for name in metadata['files']:
try:
os.remove(os.path.join(icdir, *name.split('/')))
except OSError as e:
if e.errno != errno.ENOENT:
raise
os.remove(metadata_file)
def safe_copy(src, destpath):
tpath = destpath + '-temp'
with open(tpath, 'wb') as dest:
shutil.copyfileobj(src, dest)
atomic_rename(tpath, destpath)
def install_icon_theme(theme, f):
icdir = os.path.abspath(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():
if '..' in name or name == 'blank.png':
continue
base = icdir
if '/' in name:
base = os.path.join(icdir, os.path.dirname(name))
if not os.path.exists(base):
os.makedirs(base)
destpath = os.path.abspath(os.path.join(base, os.path.basename(name)))
if not destpath.startswith(icdir):
continue
with zf.open(name) as src:
safe_copy(src, destpath)
theme['files'].add(name)
theme['files'] = tuple(theme['files'])
buf = BytesIO(as_bytes(json.dumps(theme, indent=2)))
buf.seek(0)
safe_copy(buf, metadata_file)
if __name__ == '__main__':
from calibre.gui2 import Application
app = Application([])
# create_theme('.')
d = ChooseTheme()
if d.exec() == QDialog.DialogCode.Accepted and d.commit_changes is not None:
d.commit_changes()
del app