mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Start work on icon themes
This commit is contained in:
parent
d86179c938
commit
fee64691ea
276
src/calibre/gui2/icon_theme.py
Normal file
276
src/calibre/gui2/icon_theme.py
Normal file
@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python2
|
||||
# vim:fileencoding=utf-8
|
||||
from __future__ import (unicode_literals, division, absolute_import,
|
||||
print_function)
|
||||
|
||||
__license__ = 'GPL v3'
|
||||
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
|
||||
|
||||
import os, errno, json, importlib, math
|
||||
from io import BytesIO
|
||||
|
||||
from PyQt5.Qt import (
|
||||
QImageReader, QFormLayout, QVBoxLayout, QSplitter, QGroupBox, QListWidget,
|
||||
QLineEdit, QSpinBox, QTextEdit, QSize, QListWidgetItem, QIcon
|
||||
)
|
||||
|
||||
from calibre import walk, fit_image
|
||||
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.widgets2 import Dialog
|
||||
from calibre.utils.filenames import ascii_filename
|
||||
from calibre.utils.magick import create_canvas, Image
|
||||
from calibre.utils.zipfile import ZipFile, ZIP_STORED
|
||||
from lzma.xz import compress
|
||||
|
||||
IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg'}
|
||||
THEME_COVER = 'icon-theme-cover.jpg'
|
||||
THEME_METADATA = 'metadata.json'
|
||||
|
||||
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, '/').lower()
|
||||
ext = name.rpartition('.')[-1]
|
||||
if ext in IMAGE_EXTENSIONS:
|
||||
name_map[name] = filepath
|
||||
return name_map
|
||||
|
||||
class Theme(object):
|
||||
|
||||
def __init__(self, title='', author='', version=-1, description='', cover=None):
|
||||
self.title, self.author, self.version, self.description = title, author, version, description
|
||||
self.cover = cover
|
||||
|
||||
class Report(object):
|
||||
|
||||
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 = {}
|
||||
|
||||
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)
|
||||
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 EnvironmentError 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
|
||||
theme = Theme(metadata.get('title', ''), metadata.get('author', ''), safe_int(metadata.get('version', -1)), metadata.get('description', ''))
|
||||
|
||||
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 EnvironmentError 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=60, padding=8):
|
||||
icons = icons or tuple(default_cover_icons(cols))
|
||||
rows = int(math.ceil(len(icons) / cols))
|
||||
canvas = create_canvas(cols * (size + padding), rows * (size + padding), '#eeeeee')
|
||||
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)
|
||||
img = Image()
|
||||
with open(ipath, 'rb') as f:
|
||||
img.load(f.read())
|
||||
scaled, nwidth, nheight = fit_image(img.size[0], img.size[1], size, size)
|
||||
img.size = nwidth, nheight
|
||||
dx = (size - nwidth) // 2
|
||||
canvas.compose(img, x + dx, y)
|
||||
|
||||
return canvas.export('JPEG')
|
||||
|
||||
def verify_theme(report):
|
||||
must_use_qt()
|
||||
report.bad = bad = {}
|
||||
for name, path in report.name_map.iteritems():
|
||||
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)
|
||||
self.missing_icons_group = mg = QGroupBox(self)
|
||||
self.mising_icons = mi = QListWidget(mg)
|
||||
mi.setSelectionMode(mi.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.description = QTextEdit(self)
|
||||
l.addRow(self.description)
|
||||
self.refresh_button = rb = self.bb.addButton(_('&Refresh'), self.bb.ActionRole)
|
||||
rb.setIcon(QIcon(I('view-refresh.png')))
|
||||
rb.clicked.connect(self.refresh)
|
||||
|
||||
self.apply_report()
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(900, 600)
|
||||
|
||||
@property
|
||||
def metadata(self):
|
||||
return {
|
||||
'title': self.title.text().strip(),
|
||||
'author': self.author.text().strip(),
|
||||
'version': self.version.value(),
|
||||
'description': self.description.toPlainText().strip(),
|
||||
}
|
||||
|
||||
def save_metadata(self):
|
||||
with open(os.path.join(self.report.path, THEME_METADATA), 'wb') as f:
|
||||
json.dump(self.metadata, f, indent=2)
|
||||
|
||||
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())
|
||||
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)
|
||||
|
||||
def create_themeball(report):
|
||||
buf = BytesIO()
|
||||
with ZipFile(buf, 'w') as zf:
|
||||
for name, path in report.name_map.iteritems():
|
||||
if name not in report.extra:
|
||||
with open(os.path.join(report.path, name), 'rb') as f:
|
||||
zf.writestr(name, f.read(), compression=ZIP_STORED)
|
||||
buf.seek(0)
|
||||
out = BytesIO()
|
||||
compress(buf, out, level=9)
|
||||
buf = BytesIO()
|
||||
prefix = ascii_filename(report.theme.title).replace(' ', '_').lower()
|
||||
with ZipFile(buf, 'w') as zf:
|
||||
with open(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', out.getvalue(), compression=ZIP_STORED)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
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_() != d.Accepted:
|
||||
return
|
||||
d.save_metadata()
|
||||
raw = create_themeball(d.report)
|
||||
dest = choose_save_file(parent, 'create-icon-theme-dest', _(
|
||||
'Choose destination for icon theme'),
|
||||
[(_('ZIP files'), ['zip'])], initial_filename='my-calibre-icon-theme.zip')
|
||||
if dest:
|
||||
with open(dest, 'wb') as f:
|
||||
f.write(raw)
|
||||
|
||||
if __name__ == '__main__':
|
||||
from calibre.gui2 import Application
|
||||
app = Application([])
|
||||
create_theme('.')
|
||||
del app
|
Loading…
x
Reference in New Issue
Block a user