Qt Drag & Drop doesn't work fine with custom items - python

I'm building a GUI using PySide2 (Qt5) with a custom treeview widget (MyTreeView, inherited from QTreeView). The model is a QStandardItemModel object whereas the items are custom: MyStandardItem, inherited from QStandardItem.
The problem is: if I check the type of the moved item after a drag and drop action, it has become a QStandardItem but it should have been a MyStandardItem.
I believe that the problem is the MimeType, and after a lot of research I found out that the solution could be creating a custom model and overriding MIME related functions.
I tried to figure out how but I couldn't.
So, here are the questions:
Do I have to create a custom model or is there a simple solution?
If I have to create a custom model, which functions should I override and how should I override those functions?
For what it's worth, here is MyStandardItem implementation:
class MyStandardItem(QStandardItem):
def __init__(self, text, font, icon_path='', value='', num=0, check_state=None):
super().__init__()
self.setDragEnabled(True)
self.setDropEnabled(True)
self.setText(text)
self.setData({'value': (value, num)})
self.setToolTip(str(self.data()['value']))
self.setFont(font)
self.setIcon(QIcon(icon_path))
self.toggled = check_state
if check_state is not None:
self.setCheckable(True)
self.setCheckState(check_state)
def setCheckState(self, checkState):
super().setCheckState(checkState)
if checkState == Qt.Unchecked:
self.toggled = Qt.Unchecked
else:
self.toggled = Qt.Checked

I found a way to solve this problem without having to create a custom model.
In the MyTreeView.dropEvent function: I dont't call super().dropEvent() to complete the drag&drop action but I implement it by myself by copying item's data in a variable and creating a new MyStandardItem from those data. Then I call insertRow() to insert the new item in the given position and deleteRow() to delete the old item.
Clearly, the moved item has to be stored in a class attribute at the beginning of the action (DragEnterEvent()).
Everything works perfectly.
PS: I've already tried this way before but I always ended up by having an empty row. The difference here is that I create a new item instead of re-inserting the old one.
Here's some parts of my code to clarify what I mean. Please, note that these functions are MyTreeView's methods.
def dragEnterEvent(self, event: QDragEnterEvent):
if event.source() == self:
self.dragged_item = self.model.itemFromIndex(self.selectionModel().selectedIndexes()[0])
super().dragEnterEvent(event)
else:
...
def dropEvent(self, event: QDropEvent):
index = self.indexAt(event.pos())
if not index.isValid():
return
over_item = self.model.itemFromIndex(index)
over_value = over_item.data()['value'][0]
if event.source() == self:
item_was_moved = self._move_item(over_value, over_parent_value)
if not item_was_moved:
return
else:
...
def _move_item(self, over_value, over_parent_value):
over_value = self._check_indicator_position(over_value)
if over_value is None:
return False
dragged_parent_value = self.dragged_item.parent().data()['value'][0]
dragged_value = self.dragged_item.data()['value'][0]
row = self.dragged_item.row()
items = guifunc.copy_row_items(self.dragged_item, row)
over_value_num = int(over_value.strip('test'))
self.dragged_item.parent().insertRow(over_value_num, items)
if over_value_num < row:
row += 1
self.dragged_item.parent().removeRow(row)
return True

Related

PySide6 hidePopup function explanation please

I'm currently building a desktop application using some checkable comboboxes.
I've used the code snippet we can find easily on the web about how customizing comboboxes to make the items checkable.
It 's been working fine but I wanted each combobox list not to collapse once any item was selected.
That's where the virtual function hidePopup(self) comes in: I only had to define it within the checkable combobox class, without any code!!, to prevent the popup list from hiding... I don't understand why since there is absolutely no code but "pass" in my functions!
You'll find below the script from the checkable combobox class.
class CheckableComboBox(QtWidgets.QComboBox):
def __init__(self):
super().__init__()
self.liste_artisans = []
self.view().pressed.connect(self.handle_item_pressed)
self.setModel(QStandardItemModel(self))
def handle_item_pressed(self, index):
# getting which item is pressed
item = self.model().itemFromIndex(index)
# make it check if unchecked and vice-versa
if item.checkState() == Qt.Checked:
item.setCheckState(Qt.Unchecked)
else:
item.setCheckState(Qt.Checked)
self.checked_items_list()
def checked_items_list(self):
for i in range(self.count()):
text_label = self.model().item(i, 0).text()
if self.item_check_status(i) and text_label not in self.liste_artisans:
self.liste_artisans.append(text_label)
elif not self.item_check_status(i) and text_label in self.liste_artisans:
self.liste_artisans.remove(text_label)
def hidePopup(self):
pass
# method called by checked_items_list
def item_check_status(self, index):
# getting item at index
item = self.model().item(index, 0)
# return true if checked else false
return item.checkState() == Qt.Checked

How to put titles inside a Combobox without selecting in python [duplicate]

I have a QComboBox that list all Windows' drive letters and let the user choose among them.
During the execution, we need to enable or disable some of the letters (without removing them).
Here is the basic code :
all_letters = ["{}:".format(chr(i)) for i in range(90, 64, -1)] # Z: -> A:
all_letters.insert(0, "")
cb_letter = QtGui.QComboBox()
for l in all_letters:
cb_letter.addItem(l)
cb_letter.setCurrentIndex(0)
I could find a kind of solution (which sounds really complicated) for just disabling an entry here but no way to enable it back.
What would be the best way to enable and disable any entry of a QComboBox?
By default, QComboBox uses a QStandardItemModel, so all of the convenience methods of QStandardItem are available to you:
cb_letter.model().item(2).setEnabled(False)
Note: ekhumoro's answer above is probably all you need! Look no further, unless you have a reason to want to use a QAbstractItemModel instead of a QStandardItemModel.
Note 2: This is by no means a general purpose List Model. It was only intended to be used for a specific QComboBox in one of my applications. One should modify it for their intended purposes.
... anyway, I solved this problem by subclassing QAbstractListModel and then calling QComboBox.setModel(mylistmodel). My ListModel looks like this:
from PySide import QtCore
class ListModel(QtCore.QAbstractListModel):
"""
Class for list management with a QAbstractListModel.
Implements required virtual methods rowCount() and data().
Resizeable ListModels must implement insertRows(), removeRows().
If a nicely labeled header is desired, implement headerData().
"""
def __init__(self,input_list=[],parent=None):
super(ListModel,self).__init__(parent)
self.list_data = []
self.enabled = []
for thing in input_list:
self.append_item(thing)
def append_item(self,thing):
ins_row = self.rowCount()
self.beginInsertRows(QtCore.QModelIndex(),ins_row,ins_row+1)
self.list_data.append(thing)
self.enabled.append(True)
self.endInsertRows()
def remove_item(self,idx):
del_row = idx.row()
self.beginRemoveRows(QtCore.QModelIndex(),del_row,del_row)
self.list_data.pop(del_row)
self.enabled.pop(del_row)
self.endRemoveRows()
def set_disabled(self,row):
self.enabled[row] = False
def flags(self,idx):
if self.enabled[idx.row()]:
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
else:
return QtCore.Qt.NoItemFlags
def rowCount(self,parent=QtCore.QModelIndex()):
return len(self.list_data)
def data(self,idx,data_role):
return self.list_data[idx.row()]
def insertRows(self,row,count):
self.beginInsertRows(QtCore.QModelIndex(),row,row+count-1)
for j in range(row,row+count):
self.list_data.insert(j,None)
self.endInsertRows()
def removeRows(self, row, count, parent=QtCore.QModelIndex()):
self.beginRemoveRows(parent,row,row+count-1)
for j in range(row,row+count)[::-1]:
self.list_items.pop(j)
self.endRemoveRows()
def headerData(self,section,orientation,data_role):
return None

How to use an active QComboBox as an element of QListView in PyQt5?

I am using PyQt5 to make an application. One of my widgets will be a QListView that displays a list of required items, e.g. required to cook a particular dish, say.
For most of these, the listed item is the only possibility. But for a few items, there is more than one option that will fulfill the requirements. For those with multiple possibilities, I want to display those possibilities in a functional QComboBox. So if the user has no whole milk, they can click that item, and see that 2% milk also works.
How can I include working combo boxes among the elements of my QListView?
Below is an example that shows what I have so far. It can work in Spyder or using python -i, you just have to comment or uncomment as noted. By "work", I mean it shows the required items in QListView, but the combo boxes show only the first option, and their displays can't be changed with the mouse. However, I can say e.g. qb1.setCurrentIndex(1) at the python prompt, and then when I move the mouse pointer onto the widget, the display updates to "2% milk". I have found it helpful to be able to interact with and inspect the widget in Spyder or a python interpreter, but I still have this question. I know there are C++ examples of things like this around, but I have been unable to understand them well enough to do what I want. If we can post a working Python example of this, it will help me and others too I'm sure.
from PyQt5.QtWidgets import QApplication, QComboBox, QListView, QStyledItemDelegate
from PyQt5.QtCore import QAbstractListModel, Qt
# A delegate for the combo boxes.
class QBDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
painter.drawText(option.rect, Qt.AlignLeft, self.parent().currentText())
# my own wrapper for the abstract list class
class PlainList(QAbstractListModel):
def __init__(self, elements):
super().__init__()
self.elements = elements
def data(self, index, role):
if role == Qt.DisplayRole:
text = self.elements[index.row()]
return text
def rowCount(self, index):
try:
return len(self.elements)
except TypeError:
return self.elements.rowCount(index)
app = QApplication([]) # in Spyder, this seems unnecessary, but harmless.
qb0 = 'powdered sugar' # no other choice
qb1 = QComboBox()
qb1.setModel(PlainList(['whole milk','2% milk','half-and-half']))
d1 = QBDelegate(qb1)
qb1.setItemDelegate(d1)
qb2 = QComboBox()
qb2.setModel(PlainList(['butter', 'lard']))
d2 = QBDelegate(qb2)
qb2.setItemDelegate(d2)
qb3 = 'cayenne pepper' # there is no substitute
QV = QListView()
qlist = PlainList([qb0, qb1, qb2, qb3])
QV.setModel(qlist)
QV.setItemDelegateForRow(1, d1)
QV.setItemDelegateForRow(2, d2)
QV.show()
app.exec_() # Comment this line out, to run in Spyder. Then you can inspect QV etc in the iPython console. Handy!
There are some misconceptions in your attempt.
First of all, setting the delegate parent as a combo box and then setting the delegate for the list view won't make the delegate show the combo box.
Besides, as the documentation clearly says:
Warning: You should not share the same instance of a delegate between views. Doing so can cause incorrect or unintuitive editing behavior since each view connected to a given delegate may receive the closeEditor() signal, and attempt to access, modify or close an editor that has already been closed.
In any case, adding the combo box to the item list is certainly not an option: the view won't have anything to do with it, and overriding the data() to show the current combo item is not a valid solution; while theoretically item data can contain any kind of object, for your purpose the model should contain data, not widgets.
In order to show a different widget for a view, you must override createEditor() and return the appropriate widget.
Then, since you probably need to keep the data available when accessing the model and for the view, the model should contain the available options and eventually return the current option or the "sub-list" depending on the situation.
Finally, rowCount() must always return the row count of the model, not that of the content of the index.
A possibility is to create a "nested model" that supports a "current index" for the selected option for inner models.
Then you could either use openPersistentEditor() or implement flags() and add the Qt.ItemIsEditable for items that contain a list model.
class QBDelegate(QStyledItemDelegate):
def createEditor(self, parent, option, index):
value = index.data(Qt.EditRole)
if isinstance(value, PlainList):
editor = QComboBox(parent)
editor.setModel(value)
editor.setCurrentIndex(value.currentIndex)
# submit the data whenever the index changes
editor.currentIndexChanged.connect(
lambda: self.commitData.emit(editor))
else:
editor = super().createEditor(parent, option, index)
return editor
def setModelData(self, editor, model, index):
if isinstance(editor, QComboBox):
# the default implementation tries to set the text if the
# editor is a combobox, but we need to set the index
model.setData(index, editor.currentIndex())
else:
super().setModelData(editor, model, index)
class PlainList(QAbstractListModel):
currentIndex = 0
def __init__(self, elements):
super().__init__()
self.elements = []
for element in elements:
if isinstance(element, (tuple, list)) and element:
element = PlainList(element)
self.elements.append(element)
def data(self, index, role=Qt.DisplayRole):
if role == Qt.EditRole:
return self.elements[index.row()]
elif role == Qt.DisplayRole:
value = self.elements[index.row()]
if isinstance(value, PlainList):
return value.elements[value.currentIndex]
else:
return value
def flags(self, index):
flags = super().flags(index)
if isinstance(index.data(Qt.EditRole), PlainList):
flags |= Qt.ItemIsEditable
return flags
def setData(self, index, value, role=Qt.EditRole):
if role == Qt.EditRole:
item = self.elements[index.row()]
if isinstance(item, PlainList):
item.currentIndex = value
else:
self.elements[index.row()] = value
return True
def rowCount(self, parent=None):
return len(self.elements)
app = QApplication([])
qb0 = 'powdered sugar' # no other choice
qb1 = ['whole milk','2% milk','half-and-half']
qb2 = ['butter', 'lard']
qb3 = 'cayenne pepper' # there is no substitute
QV = QListView()
qlist = PlainList([qb0, qb1, qb2, qb3])
QV.setModel(qlist)
QV.setItemDelegate(QBDelegate(QV))
## to always display the combo:
#for i in range(qlist.rowCount()):
# index = qlist.index(i)
# if index.flags() & Qt.ItemIsEditable:
# QV.openPersistentEditor(index)
QV.show()
app.exec_()

PyQt: How to change currentIndex in QComboBox when custom QAbstractListModel data changed

I've read some examples on how to define a custom model for a QComboBox widget.
Here's how I defined my model:
class LevelListModel(QAbstractListModel):
def __init__(self, parent=None, *args):
""" datain: a list where each item is a row
"""
QAbstractListModel.__init__(self, parent, *args)
self.levelList = []
def rowCount(self, parent=QModelIndex()):
return len(self.levelList)
def data(self, index, role):
if index.isValid() and role == Qt.DisplayRole:
return QVariant(index.row())
else:
return QVariant()
def addLevel(self,level):
self.beginResetModel()
self.levelList.append(level)
self.endResetModel()
I set the model to my QComboBox:
self.levelListModel = LevelListModel()
self.ui.levelComboBox.setModel(self.levelListModel)
I add a model to my list this way:
newLevel = Level (self.levelListModel.rowCount() + 1)
self.levelListModel.addLevel(newLevel)
The item is added correctly and I can see it inside the combobox, but I would like to change the currentIndex to be the new item's index.
I guess QAbstractListModel could raise some kind of events that QComboBox can listen to, but I haven't still found how to do that.
My questions are:
How can I notify the QComboBox that model data changed, and listen to that event to modify currentIndex accordingly?
I used [begin|end]ResetModel because my entry should be an ordered sequence of integer. So I need to rebuild the data list completely once an item in the middle of the list have been removed. I don't know if this is the right way to go. Any better solution?
1
How can I notify the QComboBox that model data changed, and listen to
that event to modify currentIndex accordingly?
No need to listen an event from the way you do things. You know when the model data is changed, because you add things yourself. Just change the currentIndex after adding a data.
I'd probably modify the addLevel method to return the QModelIndex of the added item and then use it to set the currentIndex of the QComboBox:
class LevelListModel(QAbstractListModel):
# [skipped]
def addLevel(self,level):
self.beginInsertRows(QModelIndex(), len(self.levelList), len(self.levelList))
self.levelList.append(level)
self.endInsertRows()
return self.index(len(self.levelList)-1)
and
newLevel = Level (self.levelListModel.rowCount() + 1)
newIndex = self.levelListModel.addLevel(newLevel)
self.ui.levelComboBox.setCurrentIndex(newIndex)
2
I used [begin|end]ResetModel because my entry should be an ordered
sequence of integer. So I need to rebuild the data list completely
once an item in the middle of the list have been removed. I don't know
if this is the right way to go. Any better solution?
That depends. [begin|end]ResetModel is for really drastic changes. I don't see how keeping an ordered list of integers would lead to such changes for single item addition/removal. From what you describe, you should be using [begin|end]InsertRows and [begin|end]RemoveRows.

wxPython ListCtrl Column Ignores Specific Fields

I'm rewriting this post to clarify some things and provide a full class definition for the Virtual List I'm having trouble with. The class is defined like so:
from wx import ListCtrl, LC_REPORT, LC_VIRTUAL, LC_HRULES, LC_VRULES, \
EVT_LIST_COL_CLICK, EVT_LIST_CACHE_HINT, EVT_LIST_COL_RIGHT_CLICK, \
ImageList, IMAGE_LIST_SMALL, Menu, MenuItem, NewId, ITEM_CHECK, Frame, \
EVT_MENU
class VirtualList(ListCtrl):
def __init__(self, parent, datasource = None,
style = LC_REPORT | LC_VIRTUAL | LC_HRULES | LC_VRULES):
ListCtrl.__init__(self, parent, style = style)
self.columns = []
self.il = ImageList(16, 16)
self.Bind(EVT_LIST_CACHE_HINT, self.CheckCache)
self.Bind(EVT_LIST_COL_CLICK, self.OnSort)
if datasource is not None:
self.datasource = datasource
self.Bind(EVT_LIST_COL_RIGHT_CLICK, self.ShowAvailableColumns)
self.datasource.list = self
self.Populate()
def SetDatasource(self, datasource):
self.datasource = datasource
def CheckCache(self, event):
self.datasource.UpdateCache(event.GetCacheFrom(), event.GetCacheTo())
def OnGetItemText(self, item, col):
return self.datasource.GetItem(item, self.columns[col])
def OnGetItemImage(self, item):
return self.datasource.GetImg(item)
def OnSort(self, event):
self.datasource.SortByColumn(self.columns[event.Column])
self.Refresh()
def UpdateCount(self):
self.SetItemCount(self.datasource.GetCount())
def Populate(self):
self.UpdateCount()
self.datasource.MakeImgList(self.il)
self.SetImageList(self.il, IMAGE_LIST_SMALL)
self.ShowColumns()
def ShowColumns(self):
for col, (text, visible) in enumerate(self.datasource.GetColumnHeaders()):
if visible:
self.columns.append(text)
self.InsertColumn(col, text, width = -2)
def Filter(self, filter):
self.datasource.Filter(filter)
self.UpdateCount()
self.Refresh()
def ShowAvailableColumns(self, evt):
colMenu = Menu()
self.id2item = {}
for idx, (text, visible) in enumerate(self.datasource.columns):
id = NewId()
self.id2item[id] = (idx, visible, text)
item = MenuItem(colMenu, id, text, kind = ITEM_CHECK)
colMenu.AppendItem(item)
EVT_MENU(colMenu, id, self.ColumnToggle)
item.Check(visible)
Frame(self, -1).PopupMenu(colMenu)
colMenu.Destroy()
def ColumnToggle(self, evt):
toggled = self.id2item[evt.GetId()]
if toggled[1]:
idx = self.columns.index(toggled[2])
self.datasource.columns[toggled[0]] = (self.datasource.columns[toggled[0]][0], False)
self.DeleteColumn(idx)
self.columns.pop(idx)
else:
self.datasource.columns[toggled[0]] = (self.datasource.columns[toggled[0]][0], True)
idx = self.datasource.GetColumnHeaders().index((toggled[2], True))
self.columns.insert(idx, toggled[2])
self.InsertColumn(idx, toggled[2], width = -2)
self.datasource.SaveColumns()
I've added functions that allow for Column Toggling which facilitate my description of the issue I'm encountering. On the 3rd instance of this class in my application the Column at Index 1 will not display String values. Integer values are displayed properly. If I add print statements to my OnGetItemText method the values show up in my console properly. This behavior is not present in the first two instances of this class, and my class does not contain any type checking code with respect to value display.
It was suggested by someone on the wxPython users' group that I create a standalone sample that demonstrates this issue if I can. I'm working on that, but have not yet had time to create a sample that does not rely on database access. Any suggestions or advice would be most appreciated. I'm tearing my hair out on this one.
Are you building on the wxPython demo code for virtual list controls? There are a couple of bookkeeping things you need to do, like set the ItemCount property.
One comment about your OnGetItemText method: Since there's no other return statement, it will return None if data is None, so your test has no effect.
How about return data or "" instead?
There's a problem with the native object in Windows. If GetImg returns None instead of -1 the list has a problem with column 1 for some reason. That from Robin over on the Google Group post for this issue.

Categories

Resources