Working with the Qt5 framework (via pyQt5 with Python), I need to create a QTreeView widget with Parameter - Value columns, where the Value items for some rows must have an internal 'Browse' button to open a file browse dialog and place the selected file into the corresponding value's field.
Reading up the Qt manuals on item delegates, I've put together the following code:
Custom BrowseEdit class (QLineEdit + Browse action)
class BrowseEdit(QtWidgets.QLineEdit):
def __init__(self, contents='', filefilters=None,
btnicon=None, btnposition=None,
opendialogtitle=None, opendialogdir=None, parent=None):
super().__init__(contents, parent)
self.filefilters = filefilters or _('All files (*.*)')
self.btnicon = btnicon or 'folder-2.png'
self.btnposition = btnposition or QtWidgets.QLineEdit.TrailingPosition
self.opendialogtitle = opendialogtitle or _('Select file')
self.opendialogdir = opendialogdir or os.getcwd()
self.reset_action()
def _clear_actions(self):
for act_ in self.actions():
self.removeAction(act_)
def reset_action(self):
self._clear_actions()
self.btnaction = QtWidgets.QAction(QtGui.QIcon(f"{ICONFOLDER}/{self.btnicon}"), '')
self.btnaction.triggered.connect(self.on_btnaction)
self.addAction(self.btnaction, self.btnposition)
#self.show()
@QtCore.pyqtSlot()
def on_btnaction(self):
selected_path = QtWidgets.QFileDialog.getOpenFileName(self.window(), self.opendialogtitle, self.opendialogdir, self.filefilters)
if not selected_path[0]: return
selected_path = selected_path[0].replace('/', os.sep)
# THIS CAUSES ERROR ('self' GETS DELETED BEFORE THIS LINE!)
self.setText(selected_path)
Custom item delegate for the QTreeView:
class BrowseEditDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, model_indices=None, thisparent=None,
**browse_edit_kwargs):
super().__init__(thisparent)
self.model_indices = model_indices
self.editor = BrowseEdit(**browse_edit_kwargs)
self.editor.setFrame(False)
def createEditor(self, parent: QtWidgets.QWidget, option: QtWidgets.QStyleOptionViewItem,
index: QtCore.QModelIndex) -> QtWidgets.QWidget:
try:
if self.model_indices and index in self.model_indices:
self.editor.setParent(parent)
return self.editor
else:
return super().createEditor(parent, option, index)
except Exception as err:
print(err)
return None
def setEditorData(self, editor, index: QtCore.QModelIndex):
if not index.isValid(): return
if self.model_indices and index in self.model_indices:
txt = index.model().data(index, QtCore.Qt.EditRole)
if isinstance(txt, str):
editor.setText(txt)
else:
super().setEditorData(editor, index)
def setModelData(self, editor, model: QtCore.QAbstractItemModel, index: QtCore.QModelIndex):
if self.model_indices and index in self.model_indices:
model.setData(index, editor.text(), QtCore.Qt.EditRole)
else:
super().setModelData(editor, model, index)
def updateEditorGeometry(self, editor, option: QtWidgets.QStyleOptionViewItem,
index: QtCore.QModelIndex):
editor.setGeometry(option.rect)
Create underlying model:
# create tree view
self.tv_plugins_3party = QtWidgets.QTreeView()
# underlying model (2 columns)
self.model_plugins_3party = QtGui.QStandardItemModel(0, 2)
self.model_plugins_3party.setHorizontalHeaderLabels([_('Plugin'), _('Value')])
# first root item and sub-items
item_git = QtGui.QStandardItem(QtGui.QIcon(f"{ICONFOLDER}/git.png"), 'Git')
item_git.setFlags(QtCore.Qt.ItemIsEnabled)
item_1 = QtGui.QStandardItem(_('Enabled'))
item_1.setFlags(QtCore.Qt.ItemIsEnabled)
item_2 = QtGui.QStandardItem('')
item_2.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable)
item_2.setCheckable(True)
item_2.setUserTristate(False)
item_2.setCheckState(QtCore.Qt.Checked)
item_git.appendRow([item_1, item_2])
item_1 = QtGui.QStandardItem(_('Path'))
item_1.setFlags(QtCore.Qt.ItemIsEnabled)
item_2 = QtGui.QStandardItem('')
item_2.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable)
item_git.appendRow([item_1, item_2])
self.model_plugins_3party.appendRow(item_git)
# second root item and sub-items
item_sqlite = QtGui.QStandardItem(QtGui.QIcon(f"{ICONFOLDER}/sqlite.png"), _('SQLite Editor'))
item_sqlite.setFlags(QtCore.Qt.ItemIsEnabled)
item_1 = QtGui.QStandardItem(_('Enabled'))
item_1.setFlags(QtCore.Qt.ItemIsEnabled)
item_2 = QtGui.QStandardItem('')
item_2.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable)
item_2.setCheckable(True)
item_2.setUserTristate(False)
item_2.setCheckState(QtCore.Qt.Checked)
item_sqlite.appendRow([item_1, item_2])
item_1 = QtGui.QStandardItem(_('Path'))
item_1.setFlags(QtCore.Qt.ItemIsEnabled)
item_2 = QtGui.QStandardItem('')
item_2.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable)
item_sqlite.appendRow([item_1, item_2])
item_1 = QtGui.QStandardItem(_('Commands'))
item_1.setFlags(QtCore.Qt.ItemIsEnabled)
item_2 = QtGui.QStandardItem('<db>')
item_2.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsEditable)
item_sqlite.appendRow([item_1, item_2])
self.model_plugins_3party.appendRow(item_sqlite)
# set model
self.tv_plugins_3party.setModel(self.model_plugins_3party)
Set item delegates for browsable edit fields:
# import traceback
try:
indices = []
indices.append(self.model_plugins_3party.index(1, 1,
self.model_plugins_3party.indexFromItem(item_git)))
indices.append(self.model_plugins_3party.index(1, 1,
self.model_plugins_3party.indexFromItem(item_sqlite)))
self.tv_plugins_3party.setItemDelegate(BrowseEditDelegate(indices))
except:
traceback.print_exc(limit=None)
The error occurs when I invoke the open file dialog by pressing on the Browse button in the editor and try to close the dialog after selecting a file. At that time, an exception is raised saying that the BrowseEdit object was deleted!
I realize this happens because the item delegate frees the underlying editor widget (BrowseEdit in my case) when it goes out of the editing mode (which happens when the file browse dialog is launched). But how can I avoid this?
Another thing I've tried is using the QAbstractItemView::setItemDelegateForRow method like so:
# install BrowseEditDelegate for rows 2 and 5
self.tv_plugins_3party.setItemDelegateForRow(2, BrowseEditDelegate())
self.tv_plugins_3party.setItemDelegateForRow(5, BrowseEditDelegate())
-- but this code leads to unknown exceptions crashing the app without any traceback messages.
There cannot be only one unique editor for each delegate, and that's for two reasons:
openPersistentEditor
), for example a table where a column has a combobox for each row.self.editor
still exists as a python object, it points to an object that has been actually deleted when the editor has been closed by the delegate.As the function name says, createEditor()
creates an editor, so the solution is to create a new instance each time createEditor()
is called.
UPDATE
There is an important problem here, though: as soon as you open the dialog, the delegate editor loses focus. For an item view, this is the same as clicking on another item (changing focus), which will result in the data submission and editor destruction.
The "simple" solution is to block the delegate signals (most importantly closeEditor()
, which would call destroyEditor()
) when the dialog is going to be opened, and unblock them afterwards.
class BrowseEdit(QtWidgets.QLineEdit):
@QtCore.pyqtSlot()
def on_btnaction(self):
self.delegate.blockSignals(True)
selected_path = QtWidgets.QFileDialog.getOpenFileName(self.window(), self.opendialogtitle, self.opendialogdir, self.filefilters)
self.delegate.blockSignals(False)
if not selected_path[0]: return
selected_path = selected_path[0].replace('/', os.sep)
# THIS CAUSES ERROR ('self' GETS DELETED BEFORE THIS LINE!)
self.setText(selected_path)
class BrowseEditDelegate(QtWidgets.QStyledItemDelegate):
# ...
def createEditor(self, parent: QtWidgets.QWidget, option: QtWidgets.QStyleOptionViewItem,
index: QtCore.QModelIndex) -> QtWidgets.QWidget:
try:
if self.model_indices and index in self.model_indices:
editor = BrowseEdit(parent=parent)
editor.delegate = self
return editor
else:
return super().createEditor(parent, option, index)
except Exception as err:
print(err)
return None
That said, this is a hack. While it works, it's not guaranteed that it will with future versions of Qt, when other signals might be introduced or their behavior changed.
A better and more elegant solution would be to create a signal that is called when the browse button is clicked, then the item view (or any of its parent) would take care of the browsing, set the data if the file dialog result is valid and begin the editing of the field once again:
class BrowseEditDelegate(QtWidgets.QStyledItemDelegate):
browseRequested = QtCore.pyqtSignal(QtCore.QModelIndex)
# ...
def createEditor(self, parent: QtWidgets.QWidget, option: QtWidgets.QStyleOptionViewItem,
index: QtCore.QModelIndex) -> QtWidgets.QWidget:
try:
if self.model_indices and index in self.model_indices:
editor = BrowseEdit(parent=parent)
editor.btnaction.triggered.connect(
lambda: self.browseRequested.emit(index))
return editor
else:
return super().createEditor(parent, option, index)
except Exception as err:
print(err)
return None
class Window(QtWidgets.QWidget):
def __init__(self):
# ...
delegate = BrowseEditDelegate(indices)
self.tv_plugins_3party.setItemDelegate(delegate)
delegate.browseRequested.connect(self.browseRequested)
def browseRequested(self, index):
selected_path = QtWidgets.QFileDialog.getOpenFileName(self.window(), 'Select file', index.data())
if selected_path[0]:
self.model_plugins_3party.setData(index, selected_path[0])
self.tv_plugins_3party.edit(index)