Qt model view and cut copy paste
I’ve spent the last few weeks implementing cut-copy-paste in Ostinato.
While Qt (the cross-platform library that Ostinato uses for the UI) has great documentation, how to implement cut-copy-paste was not so obvious. Especially with Qt Model-Views which is used extensively by Ostinato. I hope this post will help you, if you are looking to do something similar.
The first thing to know is that the implementation of drag and drop and clipboard actions (cut, copy, paste) are very similar. You should first read the Qt documentation for Drag and Drop and Using Drag and Drop with Item Views before proceeding further. Also read about QClipboard.
Go ahead, I’ll wait.
Need some convincing? The reason why that documentation is important to read is because it describes all the concepts we need and also mentions clipboard actions briefly without going into details. The clipboard action implementation details are what we will get into here.
Model support for cut, copy, paste
This should be fairly clear to you by now (you did read the drag and drop documentation linked above didn’t you?). Here’s a short recap.
Note: Most of Ostinato’s views (that support copy-paste) are QTableView
, so that’s what we’ll talk about here. The concepts apply to QListView
and QTreeView
also but we won’t discuss those.
The QAbstractItemModel
methods relevant for copy are -
QStringList mimeTypes() const;
QMimeData* mimeData(const QModelIndexList &indexes) const;
To paste the data into the model, the QAbstractItemModel
methods used are -
bool canDropMimeData(const QMimeData *data, Qt::DropAction action,
int row, int column, const QModelIndex &parent) const;
bool dropMimeData(const QMimeData *data, Qt::DropAction action,
int row, int column, const QModelIndex &parent);
Since cut is just a copy plus delete, to support the latter the model needs to implement -
bool removeRows(int row, int count, const QModelIndex &parent = QModelIndex())
Ostinato uses views with a selection behaviour of QAbstractItemView::SelectRows
, so a delete means deleting a row, rather than clearing the contents of a single cell.
Models which don’t implement removeRows()
won’t support a cut.
The QAbstractItemModel
default implementation for the above methods uses a application specific mime-type of application/x-qabstractitemmodeldatalist
which uses QDataStream
to encode row, column and data for each selected cell. This mime data can be copied to the clipboard and pasted back using dropMimeData()
which decodes it back into row, column and data to update the model.
This should work like a charm as long as you export/import all the data from your model via data()
and setData()
In case of Ostinato, the Stream/DeviceGroup objects have a large number of fields in a complex hierarchy that cannot be displayed in a simple table, so the model only exposes some of them.
This meant that I had to re-implement the above functions in my models with my own custom mime-type and encoding/decoding. I will not get into the details of that here as this is really application specific.
Create the cut, copy, paste actions
The Qt class for menu items (application menu or context menu) is QAction
. We want a single set of these actions that can be used both in the application menu bar under the top level Edit menu and also in the right-click context menu for each top level window/widget in our application.
I created a singleton ClipboardHelper
class, added the actions as private members and exposed them via a public actions()
method -
class ClipboardHelper : public QObject
{
Q_OBJECT
public:
ClipboardHelper(QObject *parent=nullptr);
QList<QAction*> actions();
private slots:
void actionTriggered();
private:
QAction *actionCut_{nullptr};
QAction *actionCopy_{nullptr};
QAction *actionPaste_{nullptr};
};
ClipboardHelper::ClipboardHelper(QObject *parent)
: QObject(parent)
{
actionCut_ = new QAction(tr("&Cut"), this);
actionCut_->setObjectName(QStringLiteral("actionCut"));
actionCut_->setIcon(QIcon(QString::fromUtf8(":/icons/cut.png")));
actionCopy_ = new QAction(tr("Cop&y"), this);
actionCopy_->setObjectName(QStringLiteral("actionCopy"));
actionCopy_->setIcon(QIcon(QString::fromUtf8(":/icons/copy.png")));
actionPaste_ = new QAction(tr("&Paste"), this);
actionPaste_->setObjectName(QStringLiteral("actionPaste"));
actionPaste_->setIcon(QIcon(QString::fromUtf8(":/icons/paste.png")));
connect(actionCut_, SIGNAL(triggered()), SLOT(actionTriggered()));
connect(actionCopy_, SIGNAL(triggered()), SLOT(actionTriggered()));
connect(actionPaste_, SIGNAL(triggered()), SLOT(actionTriggered()));
}
QList<QAction*> ClipboardHelper::actions()
{
QList<QAction*> actionList({actionCut_, actionCopy_, actionPaste_});
return actionList;
}
Note that I have used a single slot for all 3 actions - this is because their implemenation is very similar, so we use QObject::sender()
to identify which action triggered the slot. The key point of executing the action is that we find the widget that has the current focus, check if that widget has a slot corresponding to the action and if so, we invoke that slot. This is all done using QMetaObject
magic -
void ClipboardHelper::actionTriggered()
{
QWidget *focusWidget = qApp->focusWidget();
if (!focusWidget)
return;
// single slot to handle cut/copy/paste - find which action was triggered
QString action = sender()->objectName()
.remove("action").append("()").toLower();
if (focusWidget->metaObject()->indexOfSlot(qPrintable(action)) < 0) {
// slot not found in focus widget corresponding to action
return;
}
action.remove("()");
QMetaObject::invokeMethod(focusWidget, qPrintable(action),
Qt::DirectConnection);
}
We now add these actions to the main window menubar’s Edit menu -
ClipboardHelper *clipboardHelper;
MainWindow::MainWindow(QWidget *parent)
: QMainWindow (parent)
{
clipboardHelper = new ClipboardHelper(this);
...
menuEdit->addActions(clipboardHelper->actions());
...
}
We also add these actions to the context menu of each window/widget as required -
streamList->addActions(clipboardHelper->actions());
deviceGroupList->addActions(clipboardHelper->actions());
We now have our actions available in the UI, which when clicked trigger the ClipboardHelper::actionTriggered()
slot which in turn invokes the corresponding slot in the item view widget which will call into the model to execute the action.
What is our item view class? In Ostinato’s case it is QTableView
. But if you look at the Qt documentation for this class or it’s ancestor QAbstractItemView
class, you’ll see that they have no cut()
, copy()
, paste()
slots!
We will create a new subclass by inheriting QTableView
and add these slots. All the windows/widgets that used QTableView
will now use XTableView
instead -
class XTableView : public QTableView
{
Q_OBJECT
public:
XTableView(QWidget *parent) : QTableView(parent) {}
virtual ~XTableView() {}
public slots:
void cut()
void copy()
void paste()
}
To implement the copy slot, we call the model’s mimeData()
with the current selection. But before we do that we sort the selection because views allow arbitrary selection using Ctrl/Shift modifiers. The data returned from the model is handed over to the global clipboard object -
void XTableView::copy()
{
QModelIndexList selected = selectionModel()->selectedIndexes();
if (selected.isEmpty())
return;
std::sort(selected.begin(), selected.end());
QMimeData *mimeData = model()->mimeData(selected);
qApp->clipboard()->setMimeData(mimeData);
}
To paste, we retrieve the item from the clipboard, verify if the model can accept the mime type by calling canDropMimeData()
before calling dropMimeData()
.
void XTableView::paste()
{
const QMimeData *mimeData = qApp->clipboard()->mimeData();
if (!mimeData || mimeData->formats().isEmpty())
return;
// If no selection, insert at the end
int row, column;
if (selectionModel()->hasSelection()) {
row = selectionModel()->selection().first().top();
column = selectionModel()->selection().first().left();
} else {
row = model()->rowCount();
column = model()->columnCount();
}
if (model()->canDropMimeData(mimeData, Qt::CopyAction,
row, column, QModelIndex()))
model()->dropMimeData(mimeData, Qt::CopyAction,
row, column, QModelIndex());
}
Cut is just copy followed by removeRows -
void XTableView::cut()
{
copy();
foreach(QItemSelectionRange range, selectionModel()->selection())
model()->removeRows(range.top(), range.height());
}
That puts in place the missing piece in the chain from the UI trigger to the model methods being called, completing our cut-copy-paste implementation.
Err, maybe not.
Enabling/Disabling the actions
There is still the little matter of enabling/disabling the menu actions based on certain conditions.
Each action should be enabled only when -
- Cut: focus widget has a selection, a
cut
slot and model allows delete - Copy: focus widget has a selection and a
copy
slot - Paste: focus widget has a
paste
slot and can accept the clipboard item
Since these actions belong to ClipboardHelper
, we extend that class to track when the focus widget changes by connecting it to a slot -
connect(qApp, SIGNAL(focusChanged(QWidget*, QWidget*)),
SLOT(updateCutCopyStatus(QWidget*, QWidget*)));
In the slot, we can check if the focus widget has a current selection and cut/copy slot and enable/disable the actions accordingly. We also need to take care of the case where the selection state changes without the focus widget being changed. We do that by tracking the selection change of the focus widget in another slot -
void ClipboardHelper::updateCutCopyStatus(QWidget *old, QWidget *now)
{
const XTableView *view = dynamic_cast<XTableView*>(old);
if (view) {
disconnect(view->selectionModel(),
SIGNAL(selectionChanged(const QItemSelection&,
const QItemSelection&)),
this,
SLOT(focusWidgetSelectionChanged(const QItemSelection&,
const QItemSelection&)));
}
if (!now) {
// No focus widget to copy from
actionCut_->setEnabled(false);
actionCopy_->setEnabled(false);
return;
}
const QMetaObject *meta = now->metaObject();
if (meta->indexOfSlot("copy()") < 0) {
// Focus Widget doesn't have a copy slot
actionCut_->setEnabled(false);
actionCopy_->setEnabled(false);
return;
}
view = dynamic_cast<XTableView*>(now);
if (view) {
connect(view->selectionModel(),
SIGNAL(selectionChanged(const QItemSelection&,
const QItemSelection&)),
SLOT(focusWidgetSelectionChanged(const QItemSelection&,
const QItemSelection&)));
if (!view->hasSelection()) {
// view doesn't have anything selected to copy
actionCut_->setEnabled(false);
actionCopy_->setEnabled(false);
return;
}
actionCut_->setEnabled(view->canCut());
}
// focus widget has a selection and copy slot: copy possible
actionCopy_->setEnabled(true);
}
void ClipboardHelper::focusWidgetSelectionChanged(
const QItemSelection &selected, const QItemSelection &/*deselected*/)
{
// Selection changed in the XTableView that has focus
const XTableView *view = dynamic_cast<XTableView*>(qApp->focusWidget());
actionCopy_->setEnabled(!selected.indexes().isEmpty());
actionCut_->setEnabled(!selected.indexes().isEmpty()
&& view && view->canCut());
}
Updating the paste action status is much more straight forward -
void ClipboardHelper::updatePasteStatus()
{
QWidget *focusWidget = qApp->focusWidget();
if (!focusWidget) {
// No focus widget to paste into
actionPaste_->setEnabled(false);
return;
}
const QMimeData *item = QGuiApplication::clipboard()->mimeData();
if (!item || item->formats().isEmpty()) {
// Nothing on clipboard to paste
actionPaste_->setEnabled(false);
return;
}
const QMetaObject *meta = focusWidget->metaObject();
if (meta->indexOfSlot("paste()") < 0) {
// Focus Widget doesn't have a paste slot
actionPaste_->setEnabled(false);
return;
}
const XTableView *view = dynamic_cast<XTableView*>(focusWidget);
if (view && !view->canPaste(item)) {
// Focus widget view cannot accept this item
actionPaste_->setEnabled(false);
return;
}
// Focus widget can accept this item: paste possible",
actionPaste_->setEnabled(true);
}
While implementing the above, we added an expectation that XTableView
will provide a few more methods like canCut()
etc. which we now add and implement -
bool XTableView::hasSelection() const
{
return !selectionModel()->selectedIndexes().isEmpty();
}
bool XTableView::canCut() const
{
// This is a heuristic
return (model && model->supportedDropActions() != Qt::IgnoreAction)
}
bool XTableView::canPaste(const QMimeData *data) const
{
return model()->canDropMimeData(data, Qt::CopyAction,
0, 0, QModelIndex());
}
XTableView::canCut()
should ideally check if removeRows()
is implemented by the model. I couldn’t find a clean portable way to check that. So, instead I implemented supportedDropActions()
to return Qt::IgnoreAction
in all the models that are read-only and don’t implement removeRows()
; the default QAbstractItemModel::supportedDropActions()
returns Qt::CopyAction
.
The advantage of creating XTableView
with cut/copy/paste slots is that several native Qt widgets like QLineEdit
, QTextEdit
etc. also have similar named slots. So our code should work with those widgets too. That was the plan. Practically, they don’t have hasSelection/canCut/canPaste methods so they won’t work because the actions won’t be enabled.
All is not lost, though! What the native widgets do support is the cut/copy/paste actions if the short cut key combinations (Ctrl/Cmd-x/c/v) are received by the widget.
Shortcut keys
If you look at the code where we created the actions in the ClipboardHelper
constructor, you will notice that I did not associate any keyboard shortcut with the actions. That’s deliberate.
Normally, when you press any key or key combination, the current focus widget receives a key press event. Most native Qt widgets like line edits and text edits etc. will do a cut, copy, paste in response to that event without any additional code required. However, if you associate keyboard shortcuts for menu actions, the keypress events are captured by the menu and are not sent to the widget.
In other words by not associating shortcut keys to the menu actions, we allow them to be received by the focus widget which in turn does the right thing. So, even if the menu items for cut/copy/paste may be disabled, the functionality will still work if shortcut keys are used!
However, QTableView
doesn’t process those key combinations. Which means the shortcut keys don’t work with model-view windows. We fix that by implementing XTableView::keyPressEvent()
-
virtual void XTableView::keyPressEvent(QKeyEvent *event)
{
if (event->matches(QKeySequence::Cut)) {
cut();
} else if (event->matches(QKeySequence::Copy)) {
copy();
} else if (event->matches(QKeySequence::Paste)) {
paste();
} else
QTableView::keyPressEvent(event);
}
I hope you found this useful! Let me know in the comments below.
Leave a Comment