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 \