From 4c6a1d32150d5946e7c0b76131015788e7454b26 Mon Sep 17 00:00:00 2001 From: Mizaki Date: Mon, 6 May 2024 16:33:30 +0100 Subject: [PATCH] Use custom combobox with item delegate --- comictaggerlib/taggerwindow.py | 15 +- comictaggerlib/ui/customwidgets.py | 263 ++++++++++++++++++++++++++++- comictaggerlib/ui/taggerwindow.ui | 4 +- 3 files changed, 272 insertions(+), 10 deletions(-) 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