Allow multiple icons for column icons

Column icons: Allow the use of multiple icons with column icon rules.
You can now have column icon rules display multiple icons in a single
column, side by side. There are two ways to do this, either specify
multiple icons when creating the rule, or create multiple rules that
match the same book and specify the icon type to be 'composed' for every
rule. See Preferences->Look & Feel->Column icons for details.

Merge branch 'master' of https://github.com/cbhaley/calibre
This commit is contained in:
Kovid Goyal 2013-09-18 16:24:14 +05:30
commit 455afaa08f
5 changed files with 210 additions and 111 deletions

View File

@ -7,7 +7,7 @@ import json, os, traceback
from PyQt4.Qt import (Qt, QDialog, QDialogButtonBox, QSyntaxHighlighter, QFont,
QRegExp, QApplication, QTextCharFormat, QColor, QCursor,
QIcon, QSize)
QIcon, QSize, QVariant)
from calibre import sanitize_file_name_unicode
from calibre.constants import config_dir
@ -19,7 +19,6 @@ from calibre.ebooks.metadata.book.formatter import SafeFormat
from calibre.library.coloring import (displayable_columns, color_row_key)
from calibre.gui2 import error_dialog, choose_files, pixmap_to_data
class ParenPosition:
def __init__(self, block, pos, paren):
@ -247,9 +246,15 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
self.icon_file_names.append(icon_file)
self.icon_file_names.sort(key=sort_key)
self.update_filename_box()
self.icon_with_text.setChecked(True)
if icon_rule_kind == 'icon_only':
self.icon_without_text.setChecked(True)
dex = 0
from calibre.gui2.preferences.coloring import icon_rule_kinds
for i,tup in enumerate(icon_rule_kinds):
txt,val = tup
self.icon_kind.addItem(txt, userData=QVariant(val))
if val == icon_rule_kind:
dex = i
self.icon_kind.setCurrentIndex(dex)
self.icon_field.setCurrentIndex(self.icon_field.findData(icon_field_key))
if mi:
@ -410,7 +415,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
self.rule = (unicode(self.colored_field.itemData(
self.colored_field.currentIndex()).toString()), txt)
elif self.iconing:
rt = 'icon' if self.icon_with_text.isChecked() else 'icon_only'
rt = unicode(self.icon_kind.itemData(self.icon_kind.currentIndex()).toString())
self.rule = (rt,
unicode(self.icon_field.itemData(
self.icon_field.currentIndex()).toString()),

View File

@ -69,33 +69,19 @@
<widget class="QWidget" name="icon_layout">
<layout class="QGridLayout">
<item row="0" column="0" colspan="2">
<widget class="QGroupBox">
<property name="title">
<string>Kind</string>
</property>
<layout class="QHBoxLayout">
<item>
<widget class="QRadioButton" name="icon_without_text">
<property name="text">
<string>icon with no text</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="icon_with_text">
<property name="text">
<string>icon with text</string>
</property>
</widget>
</item>
</layout>
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>100</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
<layout class="QHBoxLayout">
<item>
<widget class="QLabel">
<property name="text">
<string>Kind:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="icon_kind">
</widget>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="icon_chooser_label">

View File

@ -9,7 +9,7 @@ import functools, re, os, traceback, errno, time
from collections import defaultdict, namedtuple
from PyQt4.Qt import (QAbstractTableModel, Qt, pyqtSignal, QIcon, QImage,
QModelIndex, QVariant, QDateTime, QColor, QPixmap)
QModelIndex, QVariant, QDateTime, QColor, QPixmap, QPainter)
from calibre.gui2 import NONE, error_dialog
from calibre.utils.search_query_parser import ParseException
@ -61,7 +61,7 @@ class ColumnColor(object): # {{{
return color_cache[id_][key]
try:
if self.mi is None:
self.mi = db.get_metadata(id_, index_is_id=True)
self.mi = db.new_api.get_proxy_metadata(id_)
color = self.formatter.safe_format(fmt, self.mi, '', self.mi)
if color in self.colors:
color = QColor(color)
@ -76,38 +76,68 @@ class ColumnColor(object): # {{{
class ColumnIcon(object): # {{{
def __init__(self, formatter):
def __init__(self, formatter, model):
self.mi = None
self.formatter = formatter
self.model = model
def __call__(self, id_, key, fmt, kind, db, icon_cache, icon_bitmap_cache):
dex = key+kind
if id_ in icon_cache and dex in icon_cache[id_]:
def __call__(self, id_, key, fmts, cache_index, db, icon_cache, icon_bitmap_cache):
if id_ in icon_cache and cache_index in icon_cache[id_]:
self.mi = None
return icon_cache[id_][dex]
return icon_cache[id_][cache_index]
try:
if self.mi is None:
self.mi = db.get_metadata(id_, index_is_id=True)
icon = self.formatter.safe_format(fmt, self.mi, '', self.mi)
if icon:
if icon in icon_bitmap_cache:
icon_bitmap = icon_bitmap_cache[icon]
icon_cache[id_][dex] = icon_bitmap
return icon_bitmap
d = os.path.join(config_dir, 'cc_icons', icon)
if (os.path.exists(d)):
icon_bitmap = QPixmap(d)
h = icon_bitmap.height()
w = icon_bitmap.width()
# If the image is landscape and width is more than 50%
# large than height, use the pixmap. This tells Qt to display
# the image full width. It might be clipped to row height.
if w < (3 * h)/2:
icon_bitmap = QIcon(icon_bitmap)
icon_cache[id_][dex] = icon_bitmap
icon_bitmap_cache[icon] = icon_bitmap
self.mi = None
self.mi = db.new_api.get_proxy_metadata(id_)
icons = []
for kind, fmt in fmts:
rule_icons = self.formatter.safe_format(fmt, self.mi, '', self.mi)
if not rule_icons:
continue
icon_list = [ic.strip() for ic in rule_icons.split(':')]
if icon_list and not kind.endswith('_composed'):
icons = icon_list
break
else:
icons.extend(icon_list)
if icons:
icon_string = ':'.join(icons)
if icon_string in icon_bitmap_cache:
icon_bitmap = icon_bitmap_cache[icon_string]
icon_cache[id_][cache_index] = icon_bitmap
return icon_bitmap
icon_bitmaps = []
total_width = 0
for icon in icons:
d = os.path.join(config_dir, 'cc_icons', icon)
if (os.path.exists(d)):
bm = QPixmap(d)
icon_bitmaps.append(bm)
total_width += bm.width()
if len(icon_bitmaps) > 1:
i = len(icon_bitmaps)
result = QPixmap((i * 128) + ((i-1)*2), 128)
result.fill(Qt.transparent)
painter = QPainter(result)
x = 0
for bm in icon_bitmaps:
painter.drawPixmap(x, 0, bm)
x += bm.width() + 2
painter.end()
else:
result = icon_bitmaps[0]
# If the image height is less than the row height, leave it alone
# The -2 allows for a pixel above and below. Also ensure that
# it is always a bit positive
rh = max(2, self.model.row_height - 2)
if result.height() > rh:
result = result.scaledToHeight(rh, mode=Qt.SmoothTransformation)
icon_cache[id_][cache_index] = result
icon_bitmap_cache[icon_string] = result
self.mi = None
return result
except:
pass
# }}}
@ -145,7 +175,7 @@ class BooksModel(QAbstractTableModel): # {{{
self.colors = frozenset([unicode(c) for c in QColor.colorNames()])
self._clear_caches()
self.column_color = ColumnColor(self.formatter, self.colors)
self.column_icon = ColumnIcon(self.formatter)
self.column_icon = ColumnIcon(self.formatter, self)
self.book_on_device = None
self.editable_cols = ['title', 'authors', 'rating', 'publisher',
@ -168,6 +198,7 @@ class BooksModel(QAbstractTableModel): # {{{
self.ids_to_highlight_set = set()
self.current_highlighted_idx = None
self.highlight_only = False
self.row_height = 0
self.read_config()
def _clear_caches(self):
@ -176,6 +207,9 @@ class BooksModel(QAbstractTableModel): # {{{
self.icon_bitmap_cache = {}
self.color_row_fmt_cache = None
def set_row_height(self, height):
self.row_height = height
def change_alignment(self, colname, alignment):
if colname in self.column_map and alignment in ('left', 'right', 'center'):
old = self.alignment_map.get(colname, 'left')
@ -765,16 +799,21 @@ class BooksModel(QAbstractTableModel): # {{{
if rules:
key = self.column_map[col]
id_ = None
fmts = []
for kind, k, fmt in rules:
if k == key and kind == 'icon_only':
if k == key and kind in {'icon_only', 'icon_only_composed'}:
if id_ is None:
id_ = self.id(index)
self.column_icon.mi = None
ccicon = self.column_icon(id_, key, fmt, 'icon_only', self.db,
self.icon_cache, self.icon_bitmap_cache)
if ccicon is not None:
return NONE
self.icon_cache[id_][key+'icon_only'] = None
fmts.append((kind, fmt))
if fmts:
cache_index = key + ':DisplayRole'
ccicon = self.column_icon(id_, key, fmts, cache_index, self.db,
self.icon_cache, self.icon_bitmap_cache)
if ccicon is not None:
return NONE
self.icon_cache[id_][cache_index] = None
return self.column_to_dc_map[col](index.row())
elif role in (Qt.EditRole, Qt.ToolTipRole):
return self.column_to_dc_map[col](index.row())
@ -831,21 +870,25 @@ class BooksModel(QAbstractTableModel): # {{{
key = self.column_map[col]
id_ = None
need_icon_with_text = False
fmts = []
for kind, k, fmt in rules:
if k == key and kind in ('icon', 'icon_only'):
if k == key and kind.startswith('icon'):
if id_ is None:
id_ = self.id(index)
self.column_icon.mi = None
if kind == 'icon':
fmts.append((kind, fmt))
if kind in ('icon', 'icon_composed'):
need_icon_with_text = True
ccicon = self.column_icon(id_, key, fmt, kind, self.db,
self.icon_cache, self.icon_bitmap_cache)
if ccicon is not None:
return ccicon
if need_icon_with_text:
self.icon_cache[id_][key+'icon'] = self.bool_blank_icon
return self.bool_blank_icon
self.icon_cache[id_][key+'icon'] = None
if fmts:
cache_index = key + ':DecorationRole'
ccicon = self.column_icon(id_, key, fmts, cache_index, self.db,
self.icon_cache, self.icon_bitmap_cache)
if ccicon is not None:
return ccicon
if need_icon_with_text:
self.icon_cache[id_][cache_index] = self.bool_blank_icon
return self.bool_blank_icon
self.icon_cache[id_][cache_index] = None
elif role == Qt.TextAlignmentRole:
cname = self.column_map[index.column()]
ans = Qt.AlignVCenter | ALIGNMENT_MAP[self.alignment_map.get(cname,

View File

@ -649,6 +649,7 @@ class BooksView(QTableView): # {{{
self.resizeRowToContents(0)
self.verticalHeader().setDefaultSectionSize(self.rowHeight(0) +
gprefs['extra_row_spacing'])
self._model.set_row_height(self.rowHeight(0))
self.row_sizing_done = True
def resize_column_to_fit(self, column):

View File

@ -7,13 +7,13 @@ __license__ = 'GPL v3'
__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import os
import os, textwrap
from PyQt4.Qt import (QWidget, QDialog, QLabel, QGridLayout, QComboBox, QSize,
QLineEdit, QIntValidator, QDoubleValidator, QFrame, QColor, Qt, QIcon,
QScrollArea, QPushButton, QVBoxLayout, QDialogButtonBox, QToolButton,
QListView, QAbstractListModel, pyqtSignal, QSizePolicy, QSpacerItem,
QApplication)
QApplication, QStandardItem, QStandardItemModel, QCheckBox)
from calibre import prepare_string_for_xml, sanitize_file_name_unicode
from calibre.constants import config_dir
@ -29,7 +29,9 @@ from calibre.utils.icu import lower
all_columns_string = _('All Columns')
icon_rule_kinds = [(_('icon with text'), 'icon'),
(_('icon with no text'), 'icon_only') ]
(_('icon with no text'), 'icon_only'),
(_('composed icons w/text'), 'icon_composed'),
(_('composed icons w/no text'), 'icon_only_composed'),]
class ConditionEditor(QWidget): # {{{
@ -312,6 +314,10 @@ class RuleEditor(QDialog): # {{{
for tt, t in icon_rule_kinds:
self.kind_box.addItem(tt, t)
l.addWidget(self.kind_box, 2, 1)
self.kind_box.setToolTip(textwrap.fill(_(
'If you choose composed icons and multiple rules match, then all the'
' matching icons will be combined, otherwise the icon from the'
' first rule to match will be used.')))
self.l3 = l3 = QLabel(_('of the column:'))
l.addWidget(l3, 2, 2)
@ -331,7 +337,6 @@ class RuleEditor(QDialog): # {{{
l.addItem(QSpacerItem(10, 10, QSizePolicy.Expanding), 2, 7)
else:
self.filename_box = QComboBox()
self.filename_box.setInsertPolicy(self.filename_box.InsertAlphabetically)
d = os.path.join(config_dir, 'cc_icons')
self.icon_file_names = []
if os.path.exists(d):
@ -341,9 +346,15 @@ class RuleEditor(QDialog): # {{{
if icon_file.endswith('.png'):
self.icon_file_names.append(icon_file)
self.icon_file_names.sort(key=sort_key)
self.update_filename_box()
l.addWidget(self.filename_box, 2, 5)
vb = QVBoxLayout()
self.multiple_icon_cb = QCheckBox(_('Choose more than one icon'))
vb.addWidget(self.multiple_icon_cb)
self.update_filename_box()
self.multiple_icon_cb.clicked.connect(self.multiple_box_clicked)
vb.addWidget(self.filename_box)
l.addLayout(vb, 2, 5)
self.filename_button = QPushButton(QIcon(I('document_open.png')),
_('&Add icon'))
l.addWidget(self.filename_button, 2, 6)
@ -401,18 +412,37 @@ class RuleEditor(QDialog): # {{{
self.update_color_label()
self.color_box.currentIndexChanged.connect(self.update_color_label)
else:
self.rule_icon_files = []
self.filename_button.clicked.connect(self.filename_button_clicked)
self.resize(self.sizeHint())
def multiple_box_clicked(self):
self.update_filename_box()
self.update_icon_filenames_in_box()
def update_filename_box(self):
self.filename_box.clear()
doing_multiple = self.multiple_icon_cb.isChecked()
model = QStandardItemModel()
self.filename_box.setModel(model)
self.icon_file_names.sort(key=sort_key)
self.filename_box.addItem('')
self.filename_box.addItems(self.icon_file_names)
if doing_multiple:
item = QStandardItem(_('Open to see checkboxes'))
else:
item = QStandardItem('')
model.appendRow(item)
for i,filename in enumerate(self.icon_file_names):
item = QStandardItem(filename)
if doing_multiple:
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled);
item.setData(Qt.Unchecked, Qt.CheckStateRole)
else:
item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable);
icon = QIcon(os.path.join(config_dir, 'cc_icons', filename))
self.filename_box.setItemIcon(i+1, icon)
item.setIcon(icon)
model.appendRow(item)
def update_color_label(self):
pal = QApplication.palette()
@ -432,9 +462,9 @@ class RuleEditor(QDialog): # {{{
all_files=False, select_only_single_file=True)
if path:
icon_path = path[0]
icon_name = sanitize_file_name_unicode(
icon_name = lower(sanitize_file_name_unicode(
os.path.splitext(
os.path.basename(icon_path))[0]+'.png')
os.path.basename(icon_path))[0]+'.png'))
if icon_name not in self.icon_file_names:
self.icon_file_names.append(icon_name)
self.update_filename_box()
@ -449,13 +479,47 @@ class RuleEditor(QDialog): # {{{
except:
import traceback
traceback.print_exc()
self.filename_box.setCurrentIndex(self.filename_box.findText(icon_name))
if self.multiple_icon_cb.isChecked():
if icon_name not in self.rule_icon_files:
self.rule_icon_files.append(icon_name)
self.update_icon_filenames_in_box()
else:
self.filename_box.setCurrentIndex(self.filename_box.findText(icon_name))
self.filename_box.adjustSize()
except:
import traceback
traceback.print_exc()
return
def get_filenames_from_box(self):
if self.multiple_icon_cb.isChecked():
model = self.filename_box.model()
fnames = []
for i in range(1, model.rowCount()):
item = model.item(i, 0)
if item.checkState() == Qt.Checked:
fnames.append(lower(unicode(item.text())))
fname = ' : '.join(fnames)
else:
fname = lower(unicode(self.filename_box.currentText()))
return fname
def update_icon_filenames_in_box(self):
if self.rule_icon_files:
if not self.multiple_icon_cb.isChecked():
idx = self.filename_box.findText(self.rule_icon_files[0])
if idx >= 0:
self.filename_box.setCurrentIndex(idx)
else:
self.filename_box.setCurrentIndex(0)
else:
model = self.filename_box.model()
for icon in self.rule_icon_files:
idx = self.filename_box.findText(icon)
if idx >= 0:
item = model.item(idx)
item.setCheckState(Qt.Checked)
def add_blank_condition(self):
c = ConditionEditor(self.fm, parent=self.conditions_widget)
self.conditions.append(c)
@ -468,13 +532,15 @@ class RuleEditor(QDialog): # {{{
if idx >= 0:
self.color_box.setCurrentIndex(idx)
else:
self.kind_box.setCurrentIndex(0 if kind == 'icon' else 1)
if rule.color:
idx = self.filename_box.findText(rule.color)
if idx >= 0:
self.filename_box.setCurrentIndex(idx)
else:
self.filename_box.setCurrentIndex(0)
for i,tup in enumerate(icon_rule_kinds):
if kind == tup[1]:
self.kind_box.setCurrentIndex(i)
break
self.rule_icon_files = [ic.strip() for ic in rule.color.split(':')]
if len(self.rule_icon_files) > 1:
self.multiple_icon_cb.setChecked(True)
self.update_filename_box()
self.update_icon_filenames_in_box()
for i in range(self.column_box.count()):
c = unicode(self.column_box.itemData(i).toString())
@ -492,10 +558,9 @@ class RuleEditor(QDialog): # {{{
import traceback
traceback.print_exc()
def accept(self):
if self.rule_kind != 'color':
fname = lower(unicode(self.filename_box.currentText()))
fname = self.get_filenames_from_box()
if not fname:
error_dialog(self, _('No icon selected'),
_('You must choose an icon for this rule'), show=True)
@ -528,7 +593,7 @@ class RuleEditor(QDialog): # {{{
def rule(self):
r = Rule(self.fm)
if self.rule_kind != 'color':
r.color = unicode(self.filename_box.currentText())
r.color = self.get_filenames_from_box()
else:
r.color = unicode(self.color_box.currentText())
idx = self.column_box.currentIndex()
@ -635,6 +700,15 @@ class RulesModel(QAbstractListModel): # {{{
self.reset()
def rule_to_html(self, kind, col, rule):
trans_kind = 'not found'
if kind == 'color':
trans_kind = _('color')
else:
for tt, t in icon_rule_kinds:
if kind == t:
trans_kind = tt
break
if not isinstance(rule, Rule):
if kind == 'color':
return _('''
@ -646,21 +720,11 @@ class RulesModel(QAbstractListModel): # {{{
<p>Advanced Rule: set <b>%(typ)s</b> for column <b>%(col)s</b>:
<pre>%(rule)s</pre>
''')%dict(col=col,
typ=icon_rule_kinds[0][0]
if kind == icon_rule_kinds[0][1] else icon_rule_kinds[1][0],
typ=trans_kind,
rule=prepare_string_for_xml(rule))
conditions = [self.condition_to_html(c) for c in rule.conditions]
trans_kind = 'not found'
if kind == 'color':
trans_kind = _('color')
else:
for tt, t in icon_rule_kinds:
if kind == t:
trans_kind = tt
break
return _('''\
<p>Set the <b>%(kind)s</b> of <b>%(col)s</b> to <b>%(color)s</b> if the following
conditions are met:</p>