This commit is contained in:
Kovid Goyal 2025-01-16 13:41:47 +05:30
commit 97efb777c8
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
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_show_cover'] = True
defs['bd_overlay_cover_size'] = False defs['bd_overlay_cover_size'] = False
defs['tags_browser_category_icons'] = {} defs['tags_browser_category_icons'] = {}
defs['tags_browser_value_icons'] = {}
defs['cover_browser_reflections'] = True defs['cover_browser_reflections'] = True
defs['book_list_extra_row_spacing'] = 0 defs['book_list_extra_row_spacing'] = 0
defs['refresh_book_list_on_bulk_edit'] = True 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, icon_field_key=None, icon_rule_kind=None, doing_emblem=False,
text_is_placeholder=False, dialog_is_st_editor=False, text_is_placeholder=False, dialog_is_st_editor=False,
global_vars=None, all_functions=None, builtin_functions=None, 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 # 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 # 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 # switch between them. dialog_number must be set only by the template
# tester, not the rules dialogs etc that depend on modality. # 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: if dialog_number is None:
QDialog.__init__(self, parent, flags=Qt.WindowType.Dialog) QDialog.__init__(self, parent, flags=Qt.WindowType.Dialog)
else: else:
@ -502,6 +507,8 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
self.setupUi(self) self.setupUi(self)
self.setWindowIcon(self.windowIcon()) self.setWindowIcon(self.windowIcon())
self.formatter = formatter
self.icon_dir = icon_dir
self.ffml = FFMLProcessor() self.ffml = FFMLProcessor()
self.dialog_number = dialog_number self.dialog_number = dialog_number
self.coloring = color_field is not None self.coloring = color_field is not None
@ -544,7 +551,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
for n1, k1 in cols: for n1, k1 in cols:
self.icon_field.addItem(f'{n1} ({k1})', k1) self.icon_field.addItem(f'{n1} ({k1})', k1)
self.icon_file_names = [] 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): if os.path.exists(d):
for icon_file in os.listdir(d): for icon_file in os.listdir(d):
icon_file = icu_lower(icon_file) icon_file = icu_lower(icon_file)
@ -766,7 +773,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
for r in range(0, len(mi)): for r in range(0, len(mi)):
w = QLineEdit(tv) w = QLineEdit(tv)
w.setReadOnly(True) w.setReadOnly(True)
w.setText(mi[r].title) w.setText(mi[r].get('title', _('No title provided')))
tv.setCellWidget(r, 0, w) tv.setCellWidget(r, 0, w)
tb = QToolButton() tb = QToolButton()
tb.setContentsMargins(0, 0, 0, 0) tb.setContentsMargins(0, 0, 0, 0)
@ -992,7 +999,7 @@ def evaluate(book, context):
self.update_filename_box() self.update_filename_box()
try: try:
p = QIcon(icon_path).pixmap(QSize(128, 128)) 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(os.path.join(d, icon_name)):
if not os.path.exists(d): if not os.path.exists(d):
os.makedirs(d) os.makedirs(d)
@ -1012,7 +1019,7 @@ def evaluate(book, context):
self.icon_files.addItem('') self.icon_files.addItem('')
self.icon_files.addItems(self.icon_file_names) self.icon_files.addItems(self.icon_file_names)
for i,filename in enumerate(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) self.icon_files.setItemIcon(i+1, icon)
def color_to_clipboard(self): 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() break_on_mi = 0 if len(l) == 0 else l[0].row()
for r,mi in enumerate(self.mi): for r,mi in enumerate(self.mi):
w = tv.cellWidget(r, 0) w = tv.cellWidget(r, 0)
w.setText(mi.title) w.setText(mi.get('title', _('No title provided')))
w.setCursorPosition(0) w.setCursorPosition(0)
if self.break_box.isChecked() and r == break_on_mi and self.is_python: if self.break_box.isChecked() and r == break_on_mi and self.is_python:
sys.settrace(self.trace_calls) sys.settrace(self.trace_calls)
else: else:
sys.settrace(None) sys.settrace(None)
try: try:
v = SafeFormat().safe_format(txt, mi, _('EXCEPTION:'), v = self.formatter().safe_format(txt, mi, _('EXCEPTION:'),
mi, global_vars=self.global_vars, mi, global_vars=self.global_vars,
template_functions=self.all_functions, template_functions=self.all_functions,
break_reporter=self.break_reporter if r == break_on_mi else None, 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} 'mark_minus': 3, 'mark_minusminus': 4}
DRAG_IMAGE_ROLE = Qt.ItemDataRole.UserRole + 1000 DRAG_IMAGE_ROLE = Qt.ItemDataRole.UserRole + 1000
COUNT_ROLE = DRAG_IMAGE_ROLE + 1 COUNT_ROLE = DRAG_IMAGE_ROLE + 1
TEMPLATE_ICON_INDICATOR = ' template ' # Item values cannot start or end with space
_bf = None _bf = None
@ -49,7 +50,11 @@ class TagTreeItem: # {{{
TAG = 1 TAG = 1
ROOT = 2 ROOT = 2
category_custom_icons = {} category_custom_icons = {}
value_icons = {}
value_icon_cache = {}
icon_config_dir = {}
file_icon_provider = None file_icon_provider = None
eval_formatter = EvalFormatter()
def __init__(self, data=None, is_category=False, icon_map=None, def __init__(self, data=None, is_category=False, icon_map=None,
parent=None, tooltip=None, category_key=None, temporary=False, parent=None, tooltip=None, category_key=None, temporary=False,
@ -60,6 +65,7 @@ class TagTreeItem: # {{{
self.children = [] self.children = []
self.blank = QIcon() self.blank = QIcon()
self.is_gst = is_gst self.is_gst = is_gst
self.icon = None
self.boxed = False self.boxed = False
self.temporary = False self.temporary = False
self.can_be_edited = False self.can_be_edited = False
@ -117,9 +123,40 @@ class TagTreeItem: # {{{
if self.is_gst: if self.is_gst:
cc = self.category_custom_icons.get(self.root_node().category_key, None) cc = self.category_custom_icons.get(self.root_node().category_key, None)
elif self.tag.category == 'search' and not self.tag.is_searchable: 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:
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: else:
cc = self.category_custom_icons.get(self.tag.category, None) cc = self.category_custom_icons.get(self.tag.category, None)
else:
cc = self.icon
elif self.type == self.CATEGORY: elif self.type == self.CATEGORY:
cc = self.category_custom_icons.get(self.category_key, None) cc = self.category_custom_icons.get(self.category_key, None)
self.icon_state_map[0] = cc or QIcon() self.icon_state_map[0] = cc or QIcon()
@ -350,8 +387,11 @@ class TagsModel(QAbstractItemModel): # {{{
self.node_map = {} self.node_map = {}
self.category_nodes = [] self.category_nodes = []
self.category_custom_icons = {} 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']): 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: if len(icon.availableSizes()) > 0:
self.category_custom_icons[k] = icon self.category_custom_icons[k] = icon
self.categories_with_ratings = ['authors', 'series', 'publisher', 'tags'] self.categories_with_ratings = ['authors', 'series', 'publisher', 'tags']
@ -387,9 +427,44 @@ class TagsModel(QAbstractItemModel): # {{{
new_icon = new_key + ext new_icon = new_key + ext
new_path = os.path.join(config_dir, 'tb_icons', new_icon) new_path = os.path.join(config_dir, 'tb_icons', new_icon)
os.replace(old_path, new_path) 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) 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): def set_custom_category_icon(self, key, path):
d = self.prefs['tags_browser_category_icons'] d = self.prefs['tags_browser_category_icons']
if path: if path:
@ -403,8 +478,8 @@ class TagsModel(QAbstractItemModel): # {{{
os.remove(path) os.remove(path)
except: except:
pass pass
del d[key] d.pop(key, None)
del self.category_custom_icons[key] self.category_custom_icons.pop(key, None)
self.prefs['tags_browser_category_icons'] = d self.prefs['tags_browser_category_icons'] = d
def reread_collapse_model(self, state_map, rebuild=True): def reread_collapse_model(self, state_map, rebuild=True):
@ -417,6 +492,7 @@ class TagsModel(QAbstractItemModel): # {{{
def set_database(self, db, hidden_categories=None): def set_database(self, db, hidden_categories=None):
self.beginResetModel() self.beginResetModel()
self.value_icons = self.prefs['tags_browser_value_icons']
hidden_cats = db.new_api.pref('tag_browser_hidden_categories', None) hidden_cats = db.new_api.pref('tag_browser_hidden_categories', None)
# migrate from config to db prefs # migrate from config to db prefs
if hidden_cats is None: if hidden_cats is None:
@ -514,8 +590,8 @@ class TagsModel(QAbstractItemModel): # {{{
data = self._get_category_nodes(config['sort_tags_by']) data = self._get_category_nodes(config['sort_tags_by'])
gst = self.db.new_api.pref('grouped_search_terms', {}) gst = self.db.new_api.pref('grouped_search_terms', {})
if self.category_custom_icons.get('search_folder:', None) is None: if self.category_custom_icons.get('search_folder', None) is None:
self.category_custom_icons['search_folder:'] = QIcon.ic('folder_saved_search') self.category_custom_icons['search_folder'] = QIcon.ic('folder_saved_search')
last_category_node = None last_category_node = None
category_node_map = {} category_node_map = {}
self.user_category_node_tree = {} self.user_category_node_tree = {}
@ -1393,6 +1469,9 @@ class TagsModel(QAbstractItemModel): # {{{
node = TagTreeItem(*args, **kwargs) node = TagTreeItem(*args, **kwargs)
self.node_map[id(node)] = node self.node_map[id(node)] = node
node.category_custom_icons = self.category_custom_icons 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 return node
def get_node(self, idx): 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 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.complete2 import EditWithComplete
from calibre.gui2.dialogs.edit_category_notes import EditNoteDialog 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.gui2.widgets import EnLineEdit
from calibre.utils.icu import sort_key from calibre.utils.icu import sort_key
from calibre.utils.serialize import json_loads from calibre.utils.serialize import json_loads
@ -634,6 +635,12 @@ class TagsView(QTreeView): # {{{
key=None, index=None, search_state=None, key=None, index=None, search_state=None,
is_first_letter=False, ignore_vl=False, is_first_letter=False, ignore_vl=False,
extra=None): 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: if not action:
return return
from calibre.gui2.ui import get_gui from calibre.gui2.ui import get_gui
@ -654,8 +661,48 @@ class TagsView(QTreeView): # {{{
self.db.prefs.set('tag_browser_dont_collapse', extra) self.db.prefs.set('tag_browser_dont_collapse', extra)
self.recount() self.recount()
return 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': if action == 'set_icon':
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()
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: try:
icon_file_name = desired_file_name
path = choose_files(self, 'choose_category_icon', path = choose_files(self, 'choose_category_icon',
_('Change icon for: %s')%key, filters=[ _('Change icon for: %s')%key, filters=[
('Images', ['png', 'gif', 'jpg', 'jpeg'])], ('Images', ['png', 'gif', 'jpg', 'jpeg'])],
@ -666,16 +713,27 @@ class TagsView(QTreeView): # {{{
d = os.path.join(config_dir, 'tb_icons') d = os.path.join(config_dir, 'tb_icons')
if not os.path.exists(d): if not os.path.exists(d):
os.makedirs(d) os.makedirs(d)
with open(os.path.join(d, 'icon_' + sanitize_file_name(key)+'.png'), 'wb') as f: with open(os.path.join(d, icon_file_name), 'wb') as f:
f.write(pixmap_to_data(p, format='PNG')) f.write(pixmap_to_data(p, format='PNG'))
path = os.path.basename(f.name)
self._model.set_custom_category_icon(key, str(path))
self.recount()
except: except:
traceback.print_exc() 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 return
if action == 'clear_icon': if action == 'clear_icon':
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.set_custom_category_icon(key, None)
self._model.remove_value_icon(key, TEMPLATE_ICON_INDICATOR, None)
self.recount() self.recount()
return return
@ -1163,9 +1221,9 @@ class TagsView(QTreeView): # {{{
category=key)).setIcon(QIcon.ic('minus.png')) category=key)).setIcon(QIcon.ic('minus.png'))
add_show_hidden_categories() add_show_hidden_categories()
if tag is None:
cm = self.context_menu cm = self.context_menu
cm.addSeparator() cm.addSeparator()
if tag is None:
acategory = category.replace('&', '&&') acategory = category.replace('&', '&&')
sm = cm.addAction(_('Change {} category icon').format(acategory), sm = cm.addAction(_('Change {} category icon').format(acategory),
partial(self.context_menu_handler, action='set_icon', 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', ()): if key == 'search' and 'search' in self.db.new_api.pref('categories_using_hierarchy', ()):
sm = cm.addAction(_('Change Saved searches folder icon'), sm = cm.addAction(_('Change Saved searches folder icon'),
partial(self.context_menu_handler, action='set_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.setIcon(QIcon.ic('icon_choose.png'))
sm = cm.addAction(_('Restore Saved searches folder default icon'), sm = cm.addAction(_('Restore Saved searches folder default icon'),
partial(self.context_menu_handler, action='clear_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')) 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 # Always show the User categories editor
self.context_menu.addSeparator() self.context_menu.addSeparator()
if key.startswith('@') and \ if key.startswith('@') and \