From 95bfb6c1b2e36c61d844c795a47d987913cb90c9 Mon Sep 17 00:00:00 2001 From: Charles Haley Date: Tue, 28 Jan 2025 21:25:13 +0000 Subject: [PATCH] Add editing of icons, templates, and for_children to the tag browser / icon rule viewer. --- .../look_feel_tabs/tb_icon_rules.py | 469 ++++++++++++++---- .../look_feel_tabs/tb_icon_rules.ui | 11 + 2 files changed, 380 insertions(+), 100 deletions(-) diff --git a/src/calibre/gui2/preferences/look_feel_tabs/tb_icon_rules.py b/src/calibre/gui2/preferences/look_feel_tabs/tb_icon_rules.py index f8b836172c..580c2e67c0 100644 --- a/src/calibre/gui2/preferences/look_feel_tabs/tb_icon_rules.py +++ b/src/calibre/gui2/preferences/look_feel_tabs/tb_icon_rules.py @@ -9,93 +9,285 @@ import copy import os from functools import partial -from qt.core import QAbstractItemView, QApplication, QIcon, QMenu, Qt, QTableWidgetItem +from qt.core import ( + QAbstractItemView, + QApplication, + QDialog, + QIcon, + QMenu, + QSize, + QStyledItemDelegate, + Qt, + QTableWidgetItem, +) from calibre.constants import config_dir from calibre.db.constants import TEMPLATE_ICON_INDICATOR -from calibre.gui2 import gprefs -from calibre.gui2.preferences import ConfigWidgetBase, LazyConfigWidgetBase +from calibre.gui2 import gprefs, choose_files, pixmap_to_data +from calibre.gui2.dialogs.template_dialog import TemplateDialog +from calibre.gui2.library.delegates import DelegateCB +from calibre.gui2.preferences import LazyConfigWidgetBase from calibre.gui2.preferences.look_feel_tabs.tb_icon_rules_ui import Ui_Form +from calibre.utils.formatter import EvalFormatter DELETED_COLUMN = 0 CATEGORY_COLUMN = 1 VALUE_COLUMN = 2 -ICON_COLUMN = 3 -FOR_CHILDREN_COLUMN = 4 -HEADER_SECTION_COUNT = 5 +ICON_MODIFIED_COLUMN = 3 +ICON_COLUMN = 4 +FOR_CHILDREN_MODIFIED_COLUMN = 5 +FOR_CHILDREN_COLUMN = 6 +HEADER_SECTION_COUNT = 7 + + +class StateTableWidgetItem(QTableWidgetItem): + + def __init__(self, txt): + super().__init__(txt) + self.setIcon(QIcon.cached_icon("blank.png")) + self.setFlags(Qt.ItemFlag.NoItemFlags) + + def setText(self, txt): + if txt: + super().setText(_('Yes') if txt else '') + if self.column() == DELETED_COLUMN: + self.setIcon(QIcon.cached_icon('trash.png')) + else: + self.setIcon(QIcon.cached_icon("modified.png")) + else: + super().setText('') + self.setIcon(QIcon.cached_icon("blank.png")) class CategoryTableWidgetItem(QTableWidgetItem): - def __init__(self, lookup_name, category_icons, deleted_item, field_metadata): - self._lookup_name = lookup_name + def __init__(self, lookup_name, category_icons, field_metadata, table): txt = field_metadata[lookup_name]['name'] + f' ({lookup_name})' super().__init__(txt) - self._is_deleted = False + self._lookup_name = lookup_name + self._table = table + self._is_modified = False self.setIcon(category_icons[lookup_name]) self._txt = txt - self._deleted_item = deleted_item + self.setFlags(self.flags() & ~Qt.ItemFlag.ItemIsEditable) @property - def is_deleted(self): - return self._is_deleted + def is_modified(self): + return self._is_modified - @is_deleted.setter - def is_deleted(self, to_what): - self._is_deleted = to_what - if to_what: - self._deleted_item.setIcon(QIcon.cached_icon('trash.png')) - else: - self._deleted_item.setIcon(QIcon()) + @is_modified.setter + def is_modified(self, to_what): + self._is_modified = to_what + deleted_item = self._table.item(self.row(), DELETED_COLUMN) + deleted_item.setText(to_what) @property def lookup_name(self): return self._lookup_name + def undo(self): + self.is_modified = False + class ValueTableWidgetItem(QTableWidgetItem): - def __init__(self, txt): - self._key = txt - is_template = txt == TEMPLATE_ICON_INDICATOR + def __init__(self, txt, table): + self._original_text = txt + self._table = table + self._is_template = is_template = txt == TEMPLATE_ICON_INDICATOR super().__init__(('{' + _('template') + '}') if is_template else txt) self.setIcon(QIcon.cached_icon('debug.png' if is_template else 'icon_choose.png')) + self.setFlags(self.flags() & ~Qt.ItemFlag.ItemIsEditable) @property - def real_value(self): - return self._key + def original_text(self): + return self._original_text + + @property + def is_template(self): + return self._is_template + + @property + def is_modified(self): + return self._table.item(self.row(), CATEGORY_COLUMN).is_modified class IconFileTableWidgetItem(QTableWidgetItem): - def __init__(self, icon_file, value): - self._key = icon_file - is_template = value == TEMPLATE_ICON_INDICATOR + def __init__(self, icon_file, value_text, table): super().__init__(icon_file) + self._new_icon = None + self._table = table + self._is_modified = False + self._original_text = icon_file self.setToolTip(icon_file) - if is_template: - self.setIcon(QIcon.cached_icon('blank.png')) + if value_text == TEMPLATE_ICON_INDICATOR: + icon = QIcon.cached_icon('blank.png') else: p = os.path.join(config_dir, 'tb_icons', icon_file) if os.path.exists(p): icon = QIcon.ic(p) - self.setIcon(icon) else: - self.setIcon(QIcon.cached_icon('dialog_error.png')) + icon = QIcon.cached_icon('dialog_error.png') self.setToolTip(icon_file + '\n' + _("This icon file doesn't exist")) + self.setIcon(icon) + self._original_icon = icon + + @property + def original_text(self): + return self._original_text + + @property + def new_icon(self): + return self._new_icon + + @new_icon.setter + def new_icon(self, to_what): + # to_what is the new icon pixmap in bytes + self._new_icon = to_what + + @property + def is_modified(self): + return self._is_modified + + @is_modified.setter + def is_modified(self, to_what): + self._is_modified = to_what + del_item = self._table.item(self.row(), ICON_MODIFIED_COLUMN) + del_item.setText(to_what) + + def set_text(self, txt): + self.setText(txt) + self.setToolTip(txt) + + def undo(self): + self.is_modified = False + self.set_text(self._original_text) + self.setIcon(self._original_icon) + + +class IconColumnDelegate(QStyledItemDelegate): + + def __init__(self, parent, table, changed_signal): + super().__init__(parent) + self._table = table + self._changed_signal = changed_signal + + def createEditor(self, parent, option, index): + row = index.row() + value_item = self._table.item(row, VALUE_COLUMN) + icon_item = self._table.item(row, ICON_COLUMN) + if value_item.is_template: + v = {'title': 'Template Rule', 'category': self._table.item(row, CATEGORY_COLUMN).text(), + 'value': 'abcd', 'count': str(5), 'avg_rating': str(2.5)} + d = TemplateDialog(parent=self.parent(), text=self._table.item(row, ICON_COLUMN).text(), + mi=v, doing_emblem=True, formatter=EvalFormatter, icon_dir='tb_icons/template_icons') + if d.exec() == QDialog.DialogCode.Accepted: + icon_item.set_text(d.rule[2]) + icon_item.is_modified = True + self._changed_signal.emit() + return None + + path = choose_files(self.parent(), 'choose_category_icon', + _('Change icon for: %s')%value_item.text(), filters=[ + ('Images', ['png', 'gif', 'jpg', 'jpeg'])], + all_files=False, select_only_single_file=True) + if not path: + return + new_icon = QIcon(path[0]) + icon_item.new_icon = pixmap_to_data(new_icon.pixmap(QSize(128, 128)), format='PNG') + icon_item.setIcon(new_icon) + icon_item.is_modified = True + self._changed_signal.emit() class ChildrenTableWidgetItem(QTableWidgetItem): - def __init__(self, txt, for_child): - super().__init__(txt) - if for_child is None: - icon = QIcon() - elif for_child: - icon = QIcon.cached_icon('ok.png') + def __init__(self, value, item_value, table): + super().__init__('') + self._is_modified = False + self._original_value = self._value = value + self._item_value = item_value + self._table = table + self._set_text_and_icon(value) + + def _set_text_and_icon(self, value): + if self._item_value == TEMPLATE_ICON_INDICATOR: + txt = '' else: - icon = QIcon.cached_icon('list_remove.png') - self.setIcon(QIcon.cached_icon(icon)) + txt = _('Yes') if value else _('No') + if value is None: + icon = QIcon() + elif value: + icon = QIcon.cached_icon('ok.png') + else: + icon = QIcon.cached_icon('list_remove.png') + self.setIcon(icon) + self.setText(txt) + self._value = value + + @property + def original_value(self): + return self._original_value + + @property + def value(self): + return self._value + + @property + def is_modified(self): + return self._is_modified + + @is_modified.setter + def is_modified(self, to_what): + del_item = self._table.item(self.row(), FOR_CHILDREN_MODIFIED_COLUMN) + if to_what: + del_item.setText(to_what) + self._is_modified = True + else: + del_item.setText(False) + self._is_modified = False + + def set_value(self, val): + self._set_text_and_icon(val) + self.is_modified = val != self.original_value + + def undo(self): + self.is_modified = False + self._set_text_and_icon(self._original_value) + + +class ChildrenColumnDelegate(QStyledItemDelegate): + + def __init__(self, parent, table, changed_signal): + super().__init__(parent) + self._table = table + self._changed_signal = changed_signal + + def createEditor(self, parent, option, index): + item = self._table.item(index.row(), VALUE_COLUMN) + if item.is_template: + return None + editor = DelegateCB(parent) + items = [_('Yes'), _('No'), ] + icons = ['ok.png', 'list_remove.png'] + self.longest_text = '' + for icon, text in zip(icons, items): + editor.addItem(QIcon.cached_icon(icon), text) + if len(text) > len(self.longest_text): + self.longest_text = text + return editor + + def setModelData(self, editor, model, index): + val = {0:True, 1:False}[editor.currentIndex()] + self._table.item(index.row(), index.column()).set_value(val) + self._changed_signal.emit() + + def setEditorData(self, editor, index): + item = self._table.item(index.row(), index.column()) + val = item.original_value + val = 0 if val else 1 + editor.setCurrentIndex(val) class TbIconRulesTab(LazyConfigWidgetBase, Ui_Form): @@ -106,26 +298,35 @@ class TbIconRulesTab(LazyConfigWidgetBase, Ui_Form): r('tag_browser_show_category_icons', gprefs) r('tag_browser_show_value_icons', gprefs) - self.rules_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self.rules_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.rules_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems) self.rules_table.setColumnCount(HEADER_SECTION_COUNT) - self.rules_table.setHorizontalHeaderLabels( - ('', _('Category'), _('Value'), _('Icon file or template'),_('For children'))) + self.rules_table.setHorizontalHeaderLabels(('', _('Category'), _('Value'), '', + _('Icon file or template'), '', _('For children'))) self.rules_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.rules_table.customContextMenuRequested.connect(self.show_context_menu) self.rules_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + # Make the minimum section size smaller so the icon column icons don't + # have a lot of space on the right + self.rules_table.horizontalHeader().setMinimumSectionSize(20) + for i in range(HEADER_SECTION_COUNT): item = self.rules_table.horizontalHeaderItem(i) if i == DELETED_COLUMN: item.setIcon(QIcon.cached_icon('trash.png')) - item.setToolTip(_('Show this icon if the rule is deleted')) + item.setToolTip(_('This icon shows in the row if the rule is deleted')) elif i == CATEGORY_COLUMN: item.setToolTip(_('The name of the category')) elif i == VALUE_COLUMN: item.setToolTip(_('The value in the category the rule is applied to')) + elif i == ICON_MODIFIED_COLUMN: + item.setIcon(QIcon.cached_icon('modified.png')) + item.setToolTip(_('This icon shows in the row if the icon or template is modified')) elif i == ICON_COLUMN: item.setToolTip(_('The file name of the icon or the text of the template')) + elif i == FOR_CHILDREN_MODIFIED_COLUMN: + item.setIcon(QIcon.cached_icon('modified.png')) + item.setToolTip(_('This icon shows in the row if the "for children" setting is modified')) elif i == FOR_CHILDREN_COLUMN: item.setToolTip(_('Indicates whether the rule applies to child values')) @@ -137,7 +338,8 @@ class TbIconRulesTab(LazyConfigWidgetBase, Ui_Form): hh.setSortIndicatorShown(True) self.delete_button.clicked.connect(self.delete_rule) - self.undo_button.clicked.connect(self.undo_delete) + self.edit_button.clicked.connect(self.edit_column) + self.undo_button.clicked.connect(self.undo_changes) self.tb_icon_rules_groupbox.setContentsMargins(0, 0, 0, 0) self.tb_icon_rules_gridlayout.setContentsMargins(2, 2, 2, 2) @@ -152,51 +354,65 @@ class TbIconRulesTab(LazyConfigWidgetBase, Ui_Form): category_icons = self.gui.tags_view.model().category_custom_icons v = gprefs['tags_browser_value_icons'] row = 0 + + t = self.rules_table + t.setItemDelegateForColumn(ICON_COLUMN, IconColumnDelegate(self, self.rules_table, self.changed_signal)) + t.setItemDelegateForColumn(FOR_CHILDREN_COLUMN, + ChildrenColumnDelegate(self, self.rules_table, self.changed_signal)) + for category,vdict in v.items(): - for value in vdict: - self.rules_table.setRowCount(row + 1) - d = v[category][value] - deleted_item = QTableWidgetItem(None) - self.rules_table.setItem(row, DELETED_COLUMN, deleted_item) - self.rules_table.setItem(row, CATEGORY_COLUMN, - CategoryTableWidgetItem(category, category_icons, deleted_item, field_metadata)) - self.rules_table.setItem(row, VALUE_COLUMN, ValueTableWidgetItem(value)) - self.rules_table.setItem(row, ICON_COLUMN, IconFileTableWidgetItem(d[0], value)) - if value == TEMPLATE_ICON_INDICATOR: - txt = '' - else: - txt = _('Yes') if d[1] else _('No') - item = ChildrenTableWidgetItem(txt, None if value == TEMPLATE_ICON_INDICATOR else d[1]) - self.rules_table.setItem(row, FOR_CHILDREN_COLUMN, item) + for item_value in vdict: + t.setRowCount(row + 1) + d = v[category][item_value] + t.setItem(row, DELETED_COLUMN, StateTableWidgetItem('')) + t.setItem(row, CATEGORY_COLUMN, + CategoryTableWidgetItem(category, category_icons, field_metadata, t)) + t.setItem(row, ICON_MODIFIED_COLUMN, StateTableWidgetItem('')) + t.setItem(row, VALUE_COLUMN, ValueTableWidgetItem(item_value, t)) + t.setItem(row, ICON_COLUMN, IconFileTableWidgetItem(d[0], item_value, t)) + t.setItem(row, FOR_CHILDREN_MODIFIED_COLUMN, StateTableWidgetItem('')) + item = ChildrenTableWidgetItem(d[1], item_value, t) + t.setItem(row, FOR_CHILDREN_COLUMN, item) row += 1 - self.category_order = 1 - self.value_order = 1 - self.icon_order = 0 - self.for_children_order = 0 + self.section_order = [0, 1, 1, 0, 0, 0, 0] self.do_sort(VALUE_COLUMN) self.do_sort(CATEGORY_COLUMN) def show_context_menu(self, point): - clicked_item = self.rules_table.itemAt(point) - if clicked_item is None: + item = self.rules_table.itemAt(point) + if item is None: + return + column = item.column() + if column in (DELETED_COLUMN, ICON_MODIFIED_COLUMN, FOR_CHILDREN_MODIFIED_COLUMN): return - item = self.rules_table.item(clicked_item.row(), CATEGORY_COLUMN) m = QMenu(self) - ac = m.addAction(_('Delete this rule'), partial(self.context_menu_handler, 'delete', item)) - ac.setEnabled(not item.is_deleted) - ac = m.addAction(_('Undo delete'), partial(self.context_menu_handler, 'undelete', item)) - ac.setEnabled(item.is_deleted) + if column in (CATEGORY_COLUMN, VALUE_COLUMN): + ac = m.addAction(_('Delete this rule'), partial(self.context_menu_handler, 'delete', item)) + ac.setEnabled(not item.is_modified) + ac = m.addAction(_('Undo delete'), partial(self.context_menu_handler, 'undo_delete', item)) + ac.setEnabled(item.is_modified) + elif column in (ICON_COLUMN, FOR_CHILDREN_COLUMN): + ac = m.addAction(_('Modify this value'), partial(self.context_menu_handler, 'modify', item)) + ac.setEnabled(not item.is_modified) + ac = m.addAction(_('Undo modification'), partial(self.context_menu_handler, 'undo_modification', item)) + ac.setEnabled(item.is_modified) m.addSeparator() - m.addAction(_('Copy'), partial(self.context_menu_handler, 'copy', clicked_item)) + m.addAction(_('Copy'), partial(self.context_menu_handler, 'copy', item)) m.exec(self.rules_table.viewport().mapToGlobal(point)) def context_menu_handler(self, action, item): if action == 'copy': QApplication.clipboard().setText(item.text()) return - item.setIcon(QIcon.ic('trash.png') if action == 'delete' else QIcon()) - item.is_deleted = action == 'delete' + if action == "delete": + self.delete_rule() + elif action == "undo_delete": + self.undo_delete() + elif action == "modify": + self.edit_column() + elif action == "undo_modification": + self.undo_modification() self.changed_signal.emit() def keyPressEvent(self, ev): @@ -206,18 +422,48 @@ class TbIconRulesTab(LazyConfigWidgetBase, Ui_Form): return return super().keyPressEvent(ev) + def undo_changes(self): + idx = self.rules_table.currentIndex() + if idx.isValid(): + column = idx.column() + if column == DELETED_COLUMN: + column = CATEGORY_COLUMN + elif column == ICON_MODIFIED_COLUMN: + column = ICON_COLUMN + elif column == FOR_CHILDREN_MODIFIED_COLUMN: + column = FOR_CHILDREN_COLUMN + + if column in (CATEGORY_COLUMN, VALUE_COLUMN): + self.undo_delete() + elif column in (ICON_COLUMN, FOR_CHILDREN_COLUMN): + self.undo_modification() + + def edit_column(self): + idx = self.rules_table.currentIndex() + if idx.isValid(): + column = idx.column() + if column in (ICON_COLUMN, FOR_CHILDREN_COLUMN): + self.rules_table.edit(idx) + def delete_rule(self): idx = self.rules_table.currentIndex() if idx.isValid(): item = self.rules_table.item(idx.row(), CATEGORY_COLUMN) - item.is_deleted = True + item.is_modified = True self.changed_signal.emit() def undo_delete(self): idx = self.rules_table.currentIndex() if idx.isValid(): - item = self.rules_table.item(idx.row(), CATEGORY_COLUMN) - item.is_deleted = False + self.rules_table.item(idx.row(), CATEGORY_COLUMN).undo() + self.changed_signal.emit() + + def undo_modification(self): + idx = self.rules_table.currentIndex() + if idx.isValid(): + item = self.rules_table.item(idx.row(), idx.column()) + item.undo() + self.changed_signal.emit() def table_column_resized(self, col, old, new): self.table_column_widths = [] @@ -231,47 +477,70 @@ class TbIconRulesTab(LazyConfigWidgetBase, Ui_Form): for c,w in enumerate(self.table_column_widths): self.rules_table.setColumnWidth(c, w) else: - # The vertical scroll bar might not be rendered, so might not yet - # have a width. Assume 25. Not a problem because user-changed column - # widths will be remembered. - w = self.tb_icon_rules_groupbox.width() - 25 - self.rules_table.verticalHeader().width() - w //= self.rules_table.columnCount() + # Calculate a reasonable initial sizing. The vertical scroll bar + # might not be rendered, so might not yet have a width, assume 25. + # Assume that a button is 60 wide. Assume that the 3 icon columns + # are 25 wide. None of this really matters because user-changed + # column widths will be remembered. + w = self.tb_icon_rules_groupbox.width() - (4*25) - 60 - self.rules_table.verticalHeader().width() + w //= (self.rules_table.columnCount() - 3) for c in range(self.rules_table.columnCount()): - self.rules_table.setColumnWidth(c, w) + if c in (DELETED_COLUMN, ICON_MODIFIED_COLUMN, FOR_CHILDREN_MODIFIED_COLUMN): + self.rules_table.setColumnWidth(c, 20) + else: + self.rules_table.setColumnWidth(c, w) self.table_column_widths.append(self.rules_table.columnWidth(c)) gprefs['tag_browser_rules_dialog_table_widths'] = self.table_column_widths def do_sort(self, section): - if section == CATEGORY_COLUMN: - self.category_order = 1 - self.category_order - self.rules_table.sortByColumn(CATEGORY_COLUMN, Qt.SortOrder(self.category_order)) - elif section == VALUE_COLUMN: - self.value_order = 1 - self.value_order - self.rules_table.sortByColumn(VALUE_COLUMN, Qt.SortOrder(self.value_order)) - elif section == ICON_COLUMN: - self.icon_order = 1 - self.icon_order - self.rules_table.sortByColumn(ICON_COLUMN, Qt.SortOrder(self.icon_order)) - elif section == FOR_CHILDREN_COLUMN: - self.for_children_order = 1 - self.for_children_order - self.rules_table.sortByColumn(FOR_CHILDREN_COLUMN, Qt.SortOrder(self.for_children_order)) + order = 1 - self.section_order[section] + self.section_order[section] = order + self.rules_table.sortByColumn(section, Qt.SortOrder(order)) def commit(self): v = copy.deepcopy(gprefs['tags_browser_value_icons']) for r in range(self.rules_table.rowCount()): cat_item = self.rules_table.item(r, CATEGORY_COLUMN) - if cat_item.is_deleted: - val = self.rules_table.item(r, VALUE_COLUMN).real_value - if val != TEMPLATE_ICON_INDICATOR: + value_item = self.rules_table.item(r, VALUE_COLUMN) + value_text = value_item._original_text + + if cat_item.is_modified: # deleted + if not value_item.is_template: + # Need to delete the icon file to clean up icon_file = self.rules_table.item(r, ICON_COLUMN).text() path = os.path.join(config_dir, 'tb_icons', icon_file) try: os.remove(path) except: pass - v[cat_item.lookup_name].pop(val, None) + v[cat_item.lookup_name].pop(value_text, None) + continue + + icon_item = self.rules_table.item(r, ICON_COLUMN) + d = v[cat_item.lookup_name][value_text] + + if icon_item.is_modified: + if value_item.is_template: + d[0] = icon_item.text() + v[cat_item.lookup_name][TEMPLATE_ICON_INDICATOR] = d + elif icon_item.new_icon is not None: + # No need to delete anything. The file name stays the same. + p = os.path.join(config_dir, 'tb_icons') + if not os.path.exists(p): + os.makedirs(p) + p = os.path.join(p, icon_item.text()) + with open(p, 'wb') as f: + f.write(icon_item.new_icon) + + child_item = self.rules_table.item(r, FOR_CHILDREN_COLUMN) + if child_item.is_modified: + d[1] = child_item.value + v[cat_item.lookup_name][value_text] = d + # Remove categories with no rules for category in list(v.keys()): if len(v[category]) == 0: v.pop(category, None) gprefs['tags_browser_value_icons'] = v - return ConfigWidgetBase.commit(self) + + return LazyConfigWidgetBase.commit(self) diff --git a/src/calibre/gui2/preferences/look_feel_tabs/tb_icon_rules.ui b/src/calibre/gui2/preferences/look_feel_tabs/tb_icon_rules.ui index 5fb2d7f87f..7b1426cf2d 100644 --- a/src/calibre/gui2/preferences/look_feel_tabs/tb_icon_rules.ui +++ b/src/calibre/gui2/preferences/look_feel_tabs/tb_icon_rules.ui @@ -66,6 +66,17 @@ this dialog using the button, the delete key, or the context menu.</p> + + + + + :/images/edit_input.png:/images/edit_input.png + + + Edit the selected column + + +