From 259564524021795da9f9a6cc59c681aa42934759 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Wed, 15 Jan 2025 13:17:39 +0000 Subject: [PATCH] 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. --- src/calibre/gui2/__init__.py | 1 + src/calibre/gui2/dialogs/template_dialog.py | 21 ++- src/calibre/gui2/tag_browser/model.py | 95 +++++++++++-- src/calibre/gui2/tag_browser/view.py | 147 +++++++++++++++++--- 4 files changed, 226 insertions(+), 38 deletions(-) diff --git a/src/calibre/gui2/__init__.py b/src/calibre/gui2/__init__.py index fc337dbf56..8bebb67f45 100644 --- a/src/calibre/gui2/__init__.py +++ b/src/calibre/gui2/__init__.py @@ -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 diff --git a/src/calibre/gui2/dialogs/template_dialog.py b/src/calibre/gui2/dialogs/template_dialog.py index dd03db5ac6..75915d056a 100644 --- a/src/calibre/gui2/dialogs/template_dialog.py +++ b/src/calibre/gui2/dialogs/template_dialog.py @@ -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, diff --git a/src/calibre/gui2/tag_browser/model.py b/src/calibre/gui2/tag_browser/model.py index 042e457e57..0afe86a36b 100644 --- a/src/calibre/gui2/tag_browser/model.py +++ b/src/calibre/gui2/tag_browser/model.py @@ -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): diff --git a/src/calibre/gui2/tag_browser/view.py b/src/calibre/gui2/tag_browser/view.py index 129da894e9..eb3ef8e45a 100644 --- a/src/calibre/gui2/tag_browser/view.py +++ b/src/calibre/gui2/tag_browser/view.py @@ -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 \