Add editing of icons, templates, and for_children to the tag browser / icon rule viewer.

This commit is contained in:
Charles Haley 2025-01-28 21:25:13 +00:00
parent 9dd67cd7e8
commit 95bfb6c1b2
2 changed files with 380 additions and 100 deletions

View File

@ -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:
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:
txt = _('Yes') if value else _('No')
if value is None:
icon = QIcon()
elif for_child:
elif value:
icon = QIcon.cached_icon('ok.png')
else:
icon = QIcon.cached_icon('list_remove.png')
self.setIcon(QIcon.cached_icon(icon))
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)
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_deleted)
ac = m.addAction(_('Undo delete'), partial(self.context_menu_handler, 'undelete', item))
ac.setEnabled(item.is_deleted)
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()):
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)

View File

@ -66,6 +66,17 @@ this dialog using the button, the delete key, or the context menu.&lt;/p&gt;</st
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="edit_button">
<property name="icon">
<iconset resource="../../../../resources/images.qrc">
<normaloff>:/images/edit_input.png</normaloff>:/images/edit_input.png</iconset>
</property>
<property name="toolTip">
<string>Edit the selected column</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="undo_button">
<property name="icon">