Add ability to tag browser specify icons for individual values, as is already done automatically for 'formats'.

This scheme could be used to replace the existing tag browser category icon selection. I didn't do that because the risk seemed high. The category icon dict is used in several places and in plugins. It also has meaning for search and user categories, where the new value icon stuff doesn't.

If the new facilities are not used then performance risk is near zero. Performance shouldn't be an issue if used because the icon dictionaries scale well. The exception might be templates. We have no control over the complexity or performance of user-written templates.

This PR fixes a few bugs I found while implementing the new feature.
This commit is contained in:
Charles Haley 2025-01-15 13:17:39 +00:00
parent 851c826bd8
commit 2595645240
4 changed files with 226 additions and 38 deletions

View File

@ -420,6 +420,7 @@ def create_defs():
defs['bd_show_cover'] = True
defs['bd_overlay_cover_size'] = False
defs['tags_browser_category_icons'] = {}
defs['tags_browser_value_icons'] = {}
defs['cover_browser_reflections'] = True
defs['book_list_extra_row_spacing'] = 0
defs['refresh_book_list_on_bulk_edit'] = True

View File

@ -488,11 +488,16 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
icon_field_key=None, icon_rule_kind=None, doing_emblem=False,
text_is_placeholder=False, dialog_is_st_editor=False,
global_vars=None, all_functions=None, builtin_functions=None,
python_context_object=None, dialog_number=None):
python_context_object=None, dialog_number=None,
formatter=SafeFormat, icon_dir='cc_icons'):
# If dialog_number isn't None then we want separate non-modal windows
# that don't stay on top of the main dialog. This lets Alt-Tab work to
# switch between them. dialog_number must be set only by the template
# tester, not the rules dialogs etc that depend on modality.
# doing_emblem is also used for tag browser value icon rules in order to
# show the icon selection widgets.
if dialog_number is None:
QDialog.__init__(self, parent, flags=Qt.WindowType.Dialog)
else:
@ -502,6 +507,8 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
self.setupUi(self)
self.setWindowIcon(self.windowIcon())
self.formatter = formatter
self.icon_dir = icon_dir
self.ffml = FFMLProcessor()
self.dialog_number = dialog_number
self.coloring = color_field is not None
@ -544,7 +551,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
for n1, k1 in cols:
self.icon_field.addItem(f'{n1} ({k1})', k1)
self.icon_file_names = []
d = os.path.join(config_dir, 'cc_icons')
d = os.path.join(config_dir, icon_dir)
if os.path.exists(d):
for icon_file in os.listdir(d):
icon_file = icu_lower(icon_file)
@ -766,7 +773,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
for r in range(0, len(mi)):
w = QLineEdit(tv)
w.setReadOnly(True)
w.setText(mi[r].title)
w.setText(mi[r].get('title', _('No title provided')))
tv.setCellWidget(r, 0, w)
tb = QToolButton()
tb.setContentsMargins(0, 0, 0, 0)
@ -992,7 +999,7 @@ def evaluate(book, context):
self.update_filename_box()
try:
p = QIcon(icon_path).pixmap(QSize(128, 128))
d = os.path.join(config_dir, 'cc_icons')
d = os.path.join(config_dir, self.icon_dir)
if not os.path.exists(os.path.join(d, icon_name)):
if not os.path.exists(d):
os.makedirs(d)
@ -1012,7 +1019,7 @@ def evaluate(book, context):
self.icon_files.addItem('')
self.icon_files.addItems(self.icon_file_names)
for i,filename in enumerate(self.icon_file_names):
icon = QIcon(os.path.join(config_dir, 'cc_icons', filename))
icon = QIcon(os.path.join(config_dir, self.icon_dir, filename))
self.icon_files.setItemIcon(i+1, icon)
def color_to_clipboard(self):
@ -1078,14 +1085,14 @@ def evaluate(book, context):
break_on_mi = 0 if len(l) == 0 else l[0].row()
for r,mi in enumerate(self.mi):
w = tv.cellWidget(r, 0)
w.setText(mi.title)
w.setText(mi.get('title', _('No title provided')))
w.setCursorPosition(0)
if self.break_box.isChecked() and r == break_on_mi and self.is_python:
sys.settrace(self.trace_calls)
else:
sys.settrace(None)
try:
v = SafeFormat().safe_format(txt, mi, _('EXCEPTION:'),
v = self.formatter().safe_format(txt, mi, _('EXCEPTION:'),
mi, global_vars=self.global_vars,
template_functions=self.all_functions,
break_reporter=self.break_reporter if r == break_on_mi else None,

View File

@ -30,6 +30,7 @@ TAG_SEARCH_STATES = {'clear': 0, 'mark_plus': 1, 'mark_plusplus': 2,
'mark_minus': 3, 'mark_minusminus': 4}
DRAG_IMAGE_ROLE = Qt.ItemDataRole.UserRole + 1000
COUNT_ROLE = DRAG_IMAGE_ROLE + 1
TEMPLATE_ICON_INDICATOR = ' template ' # Item values cannot start or end with space
_bf = None
@ -49,7 +50,11 @@ class TagTreeItem: # {{{
TAG = 1
ROOT = 2
category_custom_icons = {}
value_icons = {}
value_icon_cache = {}
icon_config_dir = {}
file_icon_provider = None
eval_formatter = EvalFormatter()
def __init__(self, data=None, is_category=False, icon_map=None,
parent=None, tooltip=None, category_key=None, temporary=False,
@ -60,6 +65,7 @@ class TagTreeItem: # {{{
self.children = []
self.blank = QIcon()
self.is_gst = is_gst
self.icon = None
self.boxed = False
self.temporary = False
self.can_be_edited = False
@ -117,9 +123,40 @@ class TagTreeItem: # {{{
if self.is_gst:
cc = self.category_custom_icons.get(self.root_node().category_key, None)
elif self.tag.category == 'search' and not self.tag.is_searchable:
cc = self.category_custom_icons.get('search_folder:', None)
cc = self.category_custom_icons.get('search_folder', None)
else:
cc = self.category_custom_icons.get(self.tag.category, None)
if self.icon is None:
node = self
val_icon = None
category = node.tag.category
if category in self.value_icons:
while True:
val_icon = self.value_icons.get(category, {}).get(node.tag.original_name)
if val_icon is not None:
# Have an icon. Use it if value exact match or
# it applies to children
if node != self and not val_icon[1]:
val_icon = None
break
node = node.parent
if node.type != self.TAG or node.type == self.ROOT:
break
if val_icon is None and TEMPLATE_ICON_INDICATOR in self.value_icons[category]:
t = self.eval_formatter.safe_format(self.value_icons[category][TEMPLATE_ICON_INDICATOR][0],
{'category': category, 'value': node.tag.original_name},
'VALUE_ICON_TEMPLATE_ERROR', None)
if t:
val_icon = (os.path.join('template_icons', t), False)
if val_icon is not None:
cc = self.value_icon_cache.get(val_icon[0])
if cc is None:
cc = QIcon.ic(os.path.join(self.icon_config_dir, val_icon[0]))
self.value_icon_cache[val_icon[0]] = cc
self.icon = cc
else:
cc = self.category_custom_icons.get(self.tag.category, None)
else:
cc = self.icon
elif self.type == self.CATEGORY:
cc = self.category_custom_icons.get(self.category_key, None)
self.icon_state_map[0] = cc or QIcon()
@ -350,8 +387,11 @@ class TagsModel(QAbstractItemModel): # {{{
self.node_map = {}
self.category_nodes = []
self.category_custom_icons = {}
self.value_icons = {}
self.value_icon_cache = {}
self.icon_config_dir = os.path.join(config_dir, 'tb_icons')
for k, v in iteritems(self.prefs['tags_browser_category_icons']):
icon = QIcon(os.path.join(config_dir, 'tb_icons', v))
icon = QIcon(os.path.join(self.icon_config_dir, v))
if len(icon.availableSizes()) > 0:
self.category_custom_icons[k] = icon
self.categories_with_ratings = ['authors', 'series', 'publisher', 'tags']
@ -387,9 +427,44 @@ class TagsModel(QAbstractItemModel): # {{{
new_icon = new_key + ext
new_path = os.path.join(config_dir, 'tb_icons', new_icon)
os.replace(old_path, new_path)
self.set_custom_category_icon(new_key, new_path)
self.set_custom_category_icon(new_key, new_icon)
self.set_custom_category_icon(old_key, None)
def set_value_icon(self, key, value, file_name, children):
'''
Add a 'rule' for an icon for a value in the tag browser as a dict entry:
value_icons[key] = {value: (file_name, children)}
:param key: the lookup name for the tag browser category
:param value: the item value in the category. If the value is
TEMPLATE_ICON_INDICATOR then the rule applies to all items
that don't have a specific rule.
:param file_name: the name of the icon file to use for this value. If
this is a template rule then this is the text of the template.
:param children: for specific (non-template) rules: if True then the rule
is to be used for any children of the item that don't have
a specific rule. If False then this rule is used only for
the specified item.
'''
v = self.value_icons = self.prefs['tags_browser_value_icons']
if key not in v:
self.value_icons[key] = {value: (file_name, children)}
else:
self.value_icons[key].update({value: (file_name, children)})
self.value_icon_cache.pop(file_name, None)
self.prefs['tags_browser_value_icons'] = self.value_icons
def remove_value_icon(self, key, value, file_name):
self.value_icons = self.prefs['tags_browser_value_icons']
self.value_icons.get(key).pop(value, None)
self.prefs['tags_browser_value_icons'] =self.value_icons
if file_name is not None:
path = os.path.join(config_dir, 'tb_icons', file_name)
try:
os.remove(path)
except:
pass
def set_custom_category_icon(self, key, path):
d = self.prefs['tags_browser_category_icons']
if path:
@ -403,8 +478,8 @@ class TagsModel(QAbstractItemModel): # {{{
os.remove(path)
except:
pass
del d[key]
del self.category_custom_icons[key]
d.pop(key, None)
self.category_custom_icons.pop(key, None)
self.prefs['tags_browser_category_icons'] = d
def reread_collapse_model(self, state_map, rebuild=True):
@ -417,6 +492,7 @@ class TagsModel(QAbstractItemModel): # {{{
def set_database(self, db, hidden_categories=None):
self.beginResetModel()
self.value_icons = self.prefs['tags_browser_value_icons']
hidden_cats = db.new_api.pref('tag_browser_hidden_categories', None)
# migrate from config to db prefs
if hidden_cats is None:
@ -514,8 +590,8 @@ class TagsModel(QAbstractItemModel): # {{{
data = self._get_category_nodes(config['sort_tags_by'])
gst = self.db.new_api.pref('grouped_search_terms', {})
if self.category_custom_icons.get('search_folder:', None) is None:
self.category_custom_icons['search_folder:'] = QIcon.ic('folder_saved_search')
if self.category_custom_icons.get('search_folder', None) is None:
self.category_custom_icons['search_folder'] = QIcon.ic('folder_saved_search')
last_category_node = None
category_node_map = {}
self.user_category_node_tree = {}
@ -1393,6 +1469,9 @@ class TagsModel(QAbstractItemModel): # {{{
node = TagTreeItem(*args, **kwargs)
self.node_map[id(node)] = node
node.category_custom_icons = self.category_custom_icons
node.value_icons = self.value_icons
node.value_icon_cache = self.value_icon_cache
node.icon_config_dir = self.icon_config_dir
return node
def get_node(self, idx):

View File

@ -48,7 +48,8 @@ from calibre.ebooks.metadata import rating_to_stars
from calibre.gui2 import FunctionDispatcher, choose_files, config, empty_index, gprefs, pixmap_to_data, question_dialog, rating_font, safe_open_url
from calibre.gui2.complete2 import EditWithComplete
from calibre.gui2.dialogs.edit_category_notes import EditNoteDialog
from calibre.gui2.tag_browser.model import COUNT_ROLE, DRAG_IMAGE_ROLE, TAG_SEARCH_STATES, TagsModel, TagTreeItem, rename_only_in_vl_question
from calibre.gui2.tag_browser.model import COUNT_ROLE, DRAG_IMAGE_ROLE, TAG_SEARCH_STATES, TEMPLATE_ICON_INDICATOR
from calibre.gui2.tag_browser.model import TagsModel, TagTreeItem, rename_only_in_vl_question
from calibre.gui2.widgets import EnLineEdit
from calibre.utils.icu import sort_key
from calibre.utils.serialize import json_loads
@ -634,6 +635,12 @@ class TagsView(QTreeView): # {{{
key=None, index=None, search_state=None,
is_first_letter=False, ignore_vl=False,
extra=None):
'''
action: a string specifying the operation
category: the human readable label for the category
key: the lookup name for the category
index: the index of the item, if there is one.
'''
if not action:
return
from calibre.gui2.ui import get_gui
@ -654,28 +661,79 @@ class TagsView(QTreeView): # {{{
self.db.prefs.set('tag_browser_dont_collapse', extra)
self.recount()
return
# category is None if the user asked to specify a template
# index is None if the user clicked on a category (top level) node
# extra is a tuple: (icon_file_name: string or None, children: True or False)
def make_icon_name(key, index):
icon_file_name = 'icon_' + sanitize_file_name(key)
if index is not None:
item_val = self._model.get_node(index).tag.original_name
icon_file_name = icon_file_name + '@@' + sanitize_file_name(item_val)
else:
item_val = None
icon_file_name += '.png'
return item_val, icon_file_name
if action == 'set_icon':
try:
path = choose_files(self, 'choose_category_icon',
_('Change icon for: %s')%key, filters=[
('Images', ['png', 'gif', 'jpg', 'jpeg'])],
all_files=False, select_only_single_file=True)
if path:
path = path[0]
p = QIcon(path).pixmap(QSize(128, 128))
d = os.path.join(config_dir, 'tb_icons')
if not os.path.exists(d):
os.makedirs(d)
with open(os.path.join(d, 'icon_' + sanitize_file_name(key)+'.png'), 'wb') as f:
f.write(pixmap_to_data(p, format='PNG'))
path = os.path.basename(f.name)
self._model.set_custom_category_icon(key, str(path))
if category is None:
if index is not None:
current_item = self._model.get_node(index).tag.original_name
else:
current_item = _('No value available')
template = self._model.value_icons.get(key, {}).get(TEMPLATE_ICON_INDICATOR, ('', False))[0]
from calibre.gui2.dialogs.template_dialog import TemplateDialog
from calibre.utils.formatter import EvalFormatter
d = TemplateDialog(parent=self, text=template,
mi={'title': key, 'category': key, 'value': current_item},
doing_emblem=True,
# fm=None, color_field=None, icon_field_key=None,
# icon_rule_kind=None, text_is_placeholder=False,
# dialog_is_st_editor=False,
# global_vars=None, all_functions=None, builtin_functions=None,
# python_context_object=None, dialog_number=None,
formatter=EvalFormatter, icon_dir='tb_icons/template_icons')
if d.exec():
self._model.set_value_icon(key, TEMPLATE_ICON_INDICATOR, d.rule[2], False)
self.recount()
except:
traceback.print_exc()
return
(icon_file_name, for_children) = extra
item_val, desired_file_name = make_icon_name(key, index)
if icon_file_name is None:
# User wants to specify a specific icon
try:
icon_file_name = desired_file_name
path = choose_files(self, 'choose_category_icon',
_('Change icon for: %s')%key, filters=[
('Images', ['png', 'gif', 'jpg', 'jpeg'])],
all_files=False, select_only_single_file=True)
if path:
path = path[0]
p = QIcon(path).pixmap(QSize(128, 128))
d = os.path.join(config_dir, 'tb_icons')
if not os.path.exists(d):
os.makedirs(d)
with open(os.path.join(d, icon_file_name), 'wb') as f:
f.write(pixmap_to_data(p, format='PNG'))
except:
traceback.print_exc()
else:
# Already have an icon. User wants to change whether it applies to children
icon_file_name = desired_file_name
if index is None: # category icon
self._model.set_custom_category_icon(key, str(icon_file_name))
self.recount()
else: # value icon
self._model.set_value_icon(key, item_val, icon_file_name, bool(for_children))
self.recount()
return
if action == 'clear_icon':
self._model.set_custom_category_icon(key, None)
if index is not None:
val, icon_name = make_icon_name(key, index)
self._model.remove_value_icon(key, val, icon_name)
else:
self._model.set_custom_category_icon(key, None)
self._model.remove_value_icon(key, TEMPLATE_ICON_INDICATOR, None)
self.recount()
return
@ -1163,9 +1221,9 @@ class TagsView(QTreeView): # {{{
category=key)).setIcon(QIcon.ic('minus.png'))
add_show_hidden_categories()
cm = self.context_menu
cm.addSeparator()
if tag is None:
cm = self.context_menu
cm.addSeparator()
acategory = category.replace('&', '&&')
sm = cm.addAction(_('Change {} category icon').format(acategory),
partial(self.context_menu_handler, action='set_icon',
@ -1178,13 +1236,56 @@ class TagsView(QTreeView): # {{{
if key == 'search' and 'search' in self.db.new_api.pref('categories_using_hierarchy', ()):
sm = cm.addAction(_('Change Saved searches folder icon'),
partial(self.context_menu_handler, action='set_icon',
key='search_folder:', category=_('Saved searches folder')))
key='search_folder', category=_('Saved searches folder')))
sm.setIcon(QIcon.ic('icon_choose.png'))
sm = cm.addAction(_('Restore Saved searches folder default icon'),
partial(self.context_menu_handler, action='clear_icon',
key='search_folder:', category=_('Saved searches folder')))
key='search_folder', category=_('Saved searches folder')))
sm.setIcon(QIcon.ic('edit-clear.png'))
if key not in ('search', 'formats') and not key.startswith('@'):
im = cm.addMenu(_('Manage value icons'))
def get_rule_data(tag, key):
if tag is None:
return (None, None, None)
name = tag.original_name
cat_rules = self._model.value_icons.get(key, {})
icon_name, for_child = cat_rules.get(name, (None, None))
return (name, icon_name, for_child)
name,icon_name,for_child = get_rule_data(tag, key)
if name is not None:
im.addSection(_('Current value: {}').format(name))
else:
im.addSection(_('No value available'))
im.addSeparator
ma = im.addAction(_('Choose an icon for this value but not its children'),
partial(self.context_menu_handler, action='set_icon',
key=key, index=index, category=category, extra=(None, False)))
ma.setEnabled(name is not None)
ma = im.addAction(_('Choose an icon for this value and children'),
partial(self.context_menu_handler, action='set_icon',
key=key, index=index, category=category, extra=(None, True)))
ma.setEnabled(name is not None)
im.addSeparator()
ma = im.addAction(_('Use the existing icon for this value but not its children'),
partial(self.context_menu_handler, action='set_icon',
key=key, index=index, category=category, extra=(icon_name, False)))
ma.setEnabled(icon_name is not None and for_child)
ma = im.addAction(_('Use the existing icon for this value and its children'),
partial(self.context_menu_handler, action='set_icon',
key=key, index=index, category=category, extra=(icon_name, True)))
ma.setEnabled(icon_name is not None and not for_child)
im.addAction(_('Use the default icon for this value'),
partial(self.context_menu_handler, action='clear_icon',
key=key, index=index, category=category))
im.addSection(_('Defaults'))
im.addAction(_('Use a template to choose the default value icon'),
partial(self.context_menu_handler, action='set_icon',
key=key, index=index, category=None, extra=(None, None)))
im.addAction(_('Use the category icon for the default value icon'),
partial(self.context_menu_handler, action='clear_icon',
key=key, index=None, category=category))
im.addSeparator()
# Always show the User categories editor
self.context_menu.addSeparator()
if key.startswith('@') and \