Use custom combobox with item delegate

This commit is contained in:
Mizaki 2024-05-06 16:33:30 +01:00
parent 64dbf9e981
commit 4c6a1d3215
3 changed files with 272 additions and 10 deletions

View File

@ -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:

View File

@ -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

View File

@ -76,7 +76,7 @@
<widget class="QComboBox" name="cbx_sources"/>
</item>
<item row="1" column="1">
<widget class="TableComboBox" name="cbLoadDataStyle">
<widget class="CheckableOrderComboBox" name="cbLoadDataStyle">
<property name="toolTip">
<string>At least one read style must be selected</string>
</property>
@ -1529,7 +1529,7 @@
<header>comictaggerlib.ui.customwidgets</header>
</customwidget>
<customwidget>
<class>TableComboBox</class>
<class>CheckableOrderComboBox</class>
<extends>QComboBox</extends>
<header>comictaggerlib.ui.customwidgets</header>
</customwidget>