diff --git a/comictaggerlib/taggerwindow.py b/comictaggerlib/taggerwindow.py
index f12a27f..78613c9 100644
--- a/comictaggerlib/taggerwindow.py
+++ b/comictaggerlib/taggerwindow.py
@@ -224,7 +224,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
if style not in metadata_styles:
config[0].internal__load_data_style.remove(style)
self.save_data_styles: list[str] = config[0].internal__save_data_style
- self.load_data_styles: list[str] = reversed(config[0].internal__load_data_style)
+ self.load_data_styles: list[str] = list(reversed(config[0].internal__load_data_style))
self.setAcceptDrops(True)
self.view_tag_actions, self.remove_tag_actions = self.tag_actions()
@@ -273,7 +273,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
self.cbMaturityRating.lineEdit().setAcceptDrops(False)
# hook up the callbacks
- self.cbLoadDataStyle.itemChanged.connect(self.set_load_data_style)
+ self.cbLoadDataStyle.dropdownClosed.connect(self.set_load_data_style)
self.cbSaveDataStyle.itemChecked.connect(self.set_save_data_style)
self.cbx_sources.currentIndexChanged.connect(self.set_source)
self.btnEditCredit.clicked.connect(self.edit_credit)
@@ -1210,12 +1210,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
else:
QtWidgets.QMessageBox.information(self, "Whoops!", "No data to commit!")
- def set_load_data_style(self) -> None:
+ def set_load_data_style(self, load_data_styles: list[str]) -> None:
if self.dirty_flag_verification(
"Change Tag Read Style",
"If you change read tag style(s) now, data in the form will be lost. Are you sure?",
):
- self.load_data_styles = reversed(self.cbLoadDataStyle.currentData())
+ self.load_data_styles = list(reversed(load_data_styles))
self.update_menus()
if self.comic_archive is not None:
self.load_archive(self.comic_archive)
@@ -1396,11 +1396,12 @@ class TaggerWindow(QtWidgets.QMainWindow):
def adjust_load_style_combo(self) -> None:
# select the enabled styles
unchecked = set(metadata_styles.keys()) - set(self.load_data_styles)
- for i, style in enumerate(self.load_data_styles):
+ for i, style in enumerate(reversed(self.load_data_styles)):
item_idx = self.cbLoadDataStyle.findData(style)
self.cbLoadDataStyle.setItemChecked(item_idx, True)
# Order matters, move items to list order
- self.cbLoadDataStyle.moveItem(item_idx, row=i)
+ if item_idx != i:
+ self.cbLoadDataStyle.moveItem(item_idx, row=i)
for style in unchecked:
self.cbLoadDataStyle.setItemChecked(self.cbLoadDataStyle.findData(style), False)
@@ -1416,7 +1417,7 @@ class TaggerWindow(QtWidgets.QMainWindow):
def populate_style_names(self) -> None:
# First clear all entries (called from settingswindow.py)
self.cbSaveDataStyle.clear()
- self.cbLoadDataStyle.emptyTable()
+ self.cbLoadDataStyle.clear()
# Add the entries to the tag style combobox
for style in metadata_styles.values():
if self.config[0].General__use_short_metadata_names:
diff --git a/comictaggerlib/ui/customwidgets.py b/comictaggerlib/ui/customwidgets.py
index 9b2d2c1..be7e28f 100644
--- a/comictaggerlib/ui/customwidgets.py
+++ b/comictaggerlib/ui/customwidgets.py
@@ -2,14 +2,22 @@
from __future__ import annotations
+from enum import auto
from typing import Any
from PyQt5 import QtGui, QtWidgets
-from PyQt5.QtCore import QEvent, QModelIndex, QRect, Qt, pyqtSignal
+from PyQt5.QtCore import QEvent, QModelIndex, QPoint, QRect, QSize, Qt, pyqtSignal
+from comicapi.utils import StrEnum
from comictaggerlib.graphics import graphics_path
+class ClickedButtonEnum(StrEnum):
+ up = auto()
+ down = auto()
+ main = auto()
+
+
# Multiselect combobox from: https://gis.stackexchange.com/a/351152 (with custom changes)
class CheckableComboBox(QtWidgets.QComboBox):
itemChecked = pyqtSignal(str, bool)
@@ -116,6 +124,259 @@ class CheckableComboBox(QtWidgets.QComboBox):
self.setItemChecked(index, True)
+# Inspiration from https://github.com/marcel-goldschen-ohm/ModelViewPyQt and https://github.com/zxt50330/qitemdelegate-example
+class ItemDelegate(QtWidgets.QStyledItemDelegate):
+ buttonClicked = pyqtSignal(QModelIndex, ClickedButtonEnum)
+
+ def __init__(self, parent: QtWidgets.QWidget):
+ super().__init__()
+ self.combobox = parent
+
+ self.button_width = 24
+ self.button_padding = 5
+
+ # Connect the signal to a slot in the delegate
+ self.combobox.itemClicked.connect(self.itemClicked)
+
+ def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> None:
+ options = QtWidgets.QStyleOptionViewItem(option)
+ self.initStyleOption(options, index)
+ style = self.combobox.style()
+
+ # Draw background with the same color as other widgets
+ palette = self.combobox.palette()
+ background_color = palette.color(QtGui.QPalette.Window)
+ painter.fillRect(option.rect, background_color)
+
+ style.drawPrimitive(QtWidgets.QStyle.PE_PanelItemViewItem, options, painter, self.combobox)
+
+ painter.save()
+
+ # Checkbox drawing logic
+ checked = index.data(Qt.CheckStateRole)
+ opts = QtWidgets.QStyleOptionButton()
+ opts.state |= QtWidgets.QStyle.State_Active
+ if index.flags() & Qt.ItemIsEditable:
+ opts.state |= QtWidgets.QStyle.State_Enabled
+ else:
+ opts.state |= QtWidgets.QStyle.State_ReadOnly
+ if checked:
+ opts.state |= QtWidgets.QStyle.State_On
+ else:
+ opts.state |= QtWidgets.QStyle.State_Off
+ opts.rect = self.getCheckBoxRect(option)
+ style.drawControl(QtWidgets.QStyle.CE_CheckBox, opts, painter)
+
+ label = index.data(Qt.DisplayRole)
+ rectangle = option.rect
+ rectangle.setX(opts.rect.width() + 5)
+ painter.drawText(rectangle, Qt.AlignVCenter, label)
+
+ # Draw buttons
+ if checked and (option.state & QtWidgets.QStyle.State_Selected):
+ # TODO Fill button rects or similar for better separation from text below?
+ up_rect = self._button_up_rect(option.rect)
+ down_rect = self._button_down_rect(option.rect)
+
+ painter.drawImage(up_rect, QtGui.QImage(str(graphics_path / "up.png")))
+ painter.drawImage(down_rect, QtGui.QImage(str(graphics_path / "down.png")))
+
+ painter.restore()
+
+ def _button_up_rect(self, rect: QRect = QRect(10, 1, 12, 12)) -> QRect:
+ return QRect(
+ rect.right() - self.button_width - self.button_padding - 15,
+ rect.top() + (rect.height() - self.button_width) // 2,
+ self.button_width,
+ self.button_width,
+ )
+
+ def _button_down_rect(self, rect: QRect = QRect(10, 1, 12, 12)) -> QRect:
+ return QRect(
+ rect.right() - self.button_padding - 15,
+ rect.top() + (rect.height() - self.button_width) // 2,
+ self.button_width,
+ self.button_width,
+ )
+
+ def getCheckBoxRect(self, option: QtWidgets.QStyleOptionViewItem) -> QRect:
+ # Get size of a standard checkbox.
+ opts = QtWidgets.QStyleOptionButton()
+ style = option.widget.style()
+ checkBoxRect = style.subElementRect(QtWidgets.QStyle.SE_CheckBoxIndicator, opts, None)
+ y = option.rect.y()
+ h = option.rect.height()
+ checkBoxTopLeftCorner = QPoint(2, int(y + h / 2 - checkBoxRect.height() / 2))
+
+ return QRect(checkBoxTopLeftCorner, checkBoxRect.size())
+
+ def itemClicked(self, index: QModelIndex, pos: QPoint) -> None:
+ item_rect = self.combobox.view().visualRect(index)
+ checked = index.data(Qt.CheckStateRole)
+ button_up_rect = self._button_up_rect(item_rect)
+ button_down_rect = self._button_down_rect(item_rect)
+
+ if checked and button_up_rect.contains(pos):
+ self.buttonClicked.emit(index, ClickedButtonEnum.up)
+ elif checked and button_down_rect.contains(pos):
+ self.buttonClicked.emit(index, ClickedButtonEnum.down)
+ else:
+ self.buttonClicked.emit(index, ClickedButtonEnum.main)
+
+ def sizeHint(self, option: QtWidgets.QStyleOptionViewItem, index: QModelIndex) -> QSize:
+ return QSize(10, 35)
+
+
+# Multiselect combobox from: https://gis.stackexchange.com/a/351152 (with custom changes)
+class CheckableOrderComboBox(QtWidgets.QComboBox):
+ itemClicked = pyqtSignal(QModelIndex, QPoint)
+ dropdownClosed = pyqtSignal(list)
+
+ def __init__(self, *args: Any, **kwargs: Any):
+ super().__init__(*args, **kwargs)
+ self.setItemDelegate(ItemDelegate(self))
+ # Prevent popup from closing when clicking on an item
+ self.view().viewport().installEventFilter(self)
+
+ # Go on a bit of a merry-go-round with the signals to avoid custom model/view
+ self.itemDelegate().buttonClicked.connect(self.buttonClicked)
+
+ # Keeps track of when the combobox list is shown
+ self.justShown = False
+
+ def buttonClicked(self, index: QModelIndex, button: ClickedButtonEnum) -> None:
+ if button == ClickedButtonEnum.up:
+ self.moveItem(index.row(), up=True)
+ elif button == ClickedButtonEnum.down:
+ self.moveItem(index.row(), up=False)
+ else:
+ self.toggleItem(index.row())
+
+ def resizeEvent(self, event: Any) -> None:
+ # Recompute text to elide as needed
+ super().resizeEvent(event)
+ self._updateText()
+
+ def eventFilter(self, obj: Any, event: Any) -> bool:
+ # Allow events before the combobox list is shown
+ if obj == self.view().viewport():
+ # We record that the combobox list has been shown
+ if event.type() == QEvent.Show:
+ self.justShown = True
+ # We record that the combobox list has hidden,
+ # this will happen if the user does not make a selection
+ # but clicks outside of the combobox list or presses escape
+ if event.type() == QEvent.Hide:
+ self._updateText()
+ self.justShown = False
+ self.dropdownClosed.emit(self.currentData())
+ # QEvent.MouseButtonPress is inconsistent on activation because double clicks are a thing
+ if event.type() == QEvent.MouseButtonRelease:
+ # If self.justShown is true it means that they clicked on the combobox to change the checked items
+ # This is standard behavior (on macos) but I think it is surprising when it has a multiple select
+ if self.justShown:
+ self.justShown = False
+ return True
+
+ # Find the current index and item
+ index = self.view().indexAt(event.pos())
+ if index.isValid():
+ self.itemClicked.emit(index, event.pos())
+ return True
+
+ return False
+
+ def currentData(self) -> list[Any]:
+ # Return the list of all checked items data
+ res = []
+ for i in range(self.count()):
+ item = self.model().item(i)
+ if item.checkState() == Qt.Checked:
+ res.append(self.itemData(i))
+ return res
+
+ def addItem(self, text: str, data: Any = None) -> None:
+ super().addItem(text, data)
+ # Need to enable the checkboxes and require one checked item
+ # Expected that state of *all* checkboxes will be set ('adjust_save_style_combo' in taggerwindow.py)
+ if self.count() == 1:
+ self.model().item(0).setCheckState(Qt.CheckState.Checked)
+
+ def moveItem(self, index: int, up: bool = False, row: int | None = None) -> None:
+ """'Move' an item. Really swap the data and titles around on the two items"""
+ if row is None:
+ adjust = -1 if up else 1
+ row = index + adjust
+
+ # TODO Disable buttons at top and bottom. Do a check here for now
+ if up and index == 0:
+ return
+ if up is False and row == self.count():
+ return
+
+ # Grab values for the rows to swap
+ cur_data = self.model().item(index).data(Qt.UserRole)
+ cur_title = self.model().item(index).data(Qt.DisplayRole)
+ cur_state = self.model().item(index).data(Qt.CheckStateRole)
+
+ swap_data = self.model().item(row).data(Qt.UserRole)
+ swap_title = self.model().item(row).data(Qt.DisplayRole)
+ swap_state = self.model().item(row).checkState()
+
+ self.model().item(row).setData(cur_data, Qt.UserRole)
+ self.model().item(row).setCheckState(cur_state)
+ self.model().item(row).setText(cur_title)
+
+ self.model().item(index).setData(swap_data, Qt.UserRole)
+ self.model().item(index).setCheckState(swap_state)
+ self.model().item(index).setText(swap_title)
+
+ def _updateText(self) -> None:
+ texts = []
+ for i in range(self.count()):
+ item = self.model().item(i)
+ if item.checkState() == Qt.Checked:
+ texts.append(item.text())
+ text = ", ".join(texts)
+
+ # Compute elided text (with "...")
+
+ # The QStyleOptionComboBox is needed for the call to subControlRect
+ so = QtWidgets.QStyleOptionComboBox()
+ # init with the current widget
+ so.initFrom(self)
+
+ # Ask the style for the size of the text field
+ rect = self.style().subControlRect(QtWidgets.QStyle.CC_ComboBox, so, QtWidgets.QStyle.SC_ComboBoxEditField)
+
+ # Compute the elided text
+ elidedText = self.fontMetrics().elidedText(text, Qt.ElideRight, rect.width())
+
+ # This CheckableComboBox does not use the index, so we clear it and set the placeholder text
+ self.setCurrentIndex(-1)
+ self.setPlaceholderText(elidedText)
+
+ def setItemChecked(self, index: Any, state: bool) -> None:
+ qt_state = Qt.Checked if state else Qt.Unchecked
+ item = self.model().item(index)
+ current = self.currentData()
+ # If we have at least one item checked emit itemChecked with the current check state and update text
+ # Require at least one item to be checked and provide a tooltip
+ if len(current) == 1 and not state and item.checkState() == Qt.Checked:
+ QtWidgets.QToolTip.showText(QtGui.QCursor.pos(), self.toolTip(), self, QRect(), 3000)
+ return
+
+ if len(current) > 0:
+ item.setCheckState(qt_state)
+ self._updateText()
+
+ def toggleItem(self, index: int) -> None:
+ if self.model().item(index).checkState() == Qt.Checked:
+ self.setItemChecked(index, False)
+ else:
+ self.setItemChecked(index, True)
+
+
class CheckBoxStyle(QtWidgets.QProxyStyle):
def subElementRect(
self, element: QtWidgets.QStyle.SubElement, option: QtWidgets.QStyleOption, widget: QtWidgets.QWidget = None
diff --git a/comictaggerlib/ui/taggerwindow.ui b/comictaggerlib/ui/taggerwindow.ui
index d071c0a..2f25ac2 100644
--- a/comictaggerlib/ui/taggerwindow.ui
+++ b/comictaggerlib/ui/taggerwindow.ui
@@ -76,7 +76,7 @@
-
-
+
At least one read style must be selected
@@ -1529,7 +1529,7 @@
comictaggerlib.ui.customwidgets
- TableComboBox
+ CheckableOrderComboBox
QComboBox
comictaggerlib.ui.customwidgets