Use custom combobox with item delegate
This commit is contained in:
parent
64dbf9e981
commit
4c6a1d3215
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user