Qt model view and cut copy paste

9 minute read

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