Wednesday, September 30, 2009

Setting the mouse-cursor for Qt's item-views via model

I like Qt's Model/View architecture very much. Writing models is, after a few short headaches in the beginning, quite intuitive and straight-forward. And it becomes even easier when you don't use QAbstractItemView directly, but a more specialized class like QAbstractListModel or QAbstractTableModel. The basic idea is that the model provides information to the views that is needed to present the individual items to the user. This does not only include the text of the item, but also a decoration (icon), a tool-tip, font, background-styling and more.


One thing I found missing, however, is the mouse-cursor that should be used for the item. For me, it's a common situation that you display a table and one of the columns is clickable and executes an action (such as deleting the row maybe). The user obviously will not understand that he can click cells in this column, unless you change the cursor to something like Qt::PointingHandCursor, for example.


I guess most people that faced this problem derived from the view they are using and override mouseMoveEvent() and do things there. This solution is not in the mind of Model/View, though. The mouse-cursor for an item is something that belongs into the model. So here's what we are going to do:


  • Create a new Role for the MyMouseCursorRole

  • Create new subclasses from the item-views we want to use (Qt's only item-views are QListView, QTableView and QTreeView)

  • In those sub-classes, make sure that the model's data() is called with the MyMouseCursorRole when the mouse moves to a new item (or moves to a position that is not an item)


After this, we can start writing models and handle the MyMouseCursorRole there. In the example, I derive from QStandardItemModel and only override data() and return different shapes for certain items.


In this blog-post, I'm including a small example for QListView. You can get the full source-code for the other views at the bottom of this post.

Here's the header:


#include <QListView>

// Define our new role that can be used in the model.
const int MyMouseCursorRole = Qt::UserRole + 1;

// Define a new constant for user-defined roles in this application.
const int MyUserRole = MyMouseCursorRole + 1;

// MyListView requests the MyMouseCursorRole from the model when the mouse
// moves over a new row. It does not need to use any tricks like MyTableView
// or MyTreeView, because it does not contain headers.

class MyListView: public QListView
{
Q_OBJECT

public:
MyListView(QWidget *parent = 0);

protected:
virtual void mouseMoveEvent(QMouseEvent *event);

private:
// m_LastRow stores the row the mouse was over the last time
// mouseMoveEvent() was called. This is used to minimize calls to the
// model's data() function. m_lastRow is -1 when no valid row was hovered by
// the mouse.
int m_lastRow;
};

And here's the implementation:
MyListView::MyListView(QWidget *parent):
QListView(parent),
m_lastRow(-1)
{
// We need to enable mouse-tracking because we need to know
// about every mouse-movement.
setMouseTracking(true);
}

void MyListView::mouseMoveEvent(QMouseEvent *event)
{
QAbstractItemModel *m(model());
// Only do something when a model is set.
if (m)
{
QModelIndex index = indexAt(event->pos());
if (index.isValid())
{
// When the index is valid, compare it to the last row.
// Only do something when the the mouse has moved to a new row.
if (index.row() != m_lastRow)
{
m_lastRow = index.row();
// Request the data for the MyMouseCursorRole.
QVariant data = m->data(index, MyMouseCursorRole);

Qt::CursorShape shape = Qt::ArrowCursor;
if (!data.isNull())
shape = static_cast(data.toInt());
setCursor(shape);
}
}
else
{
if (m_lastRow != -1)
// Set he mouse-cursor to the default when it isn't already.
setCursor(Qt::ArrowCursor);
m_lastRow = -1;
}
}
QListView::mouseMoveEvent(event);
}

For QListView, that's all! It gets more complex for QTableView and QTreeView, because they contain headers and mouseMoveEvent() is not called when the mouse moves to these headers. But the cursor set with setCursor() is used for the headers, too. This means that, when the mouse moves from an item with a special cursor-shape directly to a header-control, the mouse-shape does not change back to the default. To circumvent this, we have to implement quite a hack, so I'm not covering it in this blog-post. You can see my solution in the example source-code.


To test our new functionality, we have to create a model that handles MyMouseCursorRole and returns a valid Qt::CursorShape. As noted earlier, I derive from QStandardItemModel, because QStandardItemModel already implements needed standard functionality. QStandardItemModel uses QStandardItems as it's soure of information. Because QStandardItem obviously does not have a property for the cursor's shape, I use a trick to store the cursor-information in the item's text. By convention, when the item's text starts with {hand}, Qt::PointingHandCursor is returned in MyMouseCursorRole and {hand} is snipped off of the text for Qt::DisplayRole.


Here's the header's code, it's quite simple:

class MyTestModel: public QStandardItemModel
{
Q_OBJECT

public:
QVariant data(const QModelIndex &index, int role) const;
};

And here's the implementation's code:
QVariant MyTestModel::data(const QModelIndex &index, int role) const
{
if (role == MyMouseCursorRole)
{
// We use a little hack here so we don't need to add an extra element to
// QStandardItem. When the item's text starts with {hand}, we return
// the Qt::PointingHandCursor cursor-shape.

QVariant text = QStandardItemModel::data(index, Qt::DisplayRole);
if (text.toString().startsWith("{hand}"))
return Qt::PointingHandCursor;
else
return QVariant();
}
else if (role == Qt::DisplayRole)
{
// Cut out the {hand} from actually displayed content.
QVariant result = QStandardItemModel::data(index, role);
QString text = result.toString();
if (text.startsWith("{hand}"))
return text.mid(6);
else
return result;
}
return QStandardItemModel::data(index, role);
}

As you can see, I'm using a trick to call QStandardItemModel::data() because I want to access the "raw" data without re-implementing QStandardItemModel::data()'s functionality. What's quite handy is that you can pass a different Qt::ItemDataRole to data(), so I can get the value for Qt::DisplayRole when preparing the values for MyMouseCursorRole.


Now the only component that's missing to see the result is a demo-application that uses the test-model and the new MyListView-class. That's only a few lines:


#include <QApplication>

int main(int argc, char *argv[])
{
QApplication app(argc, argv);

MyTestModel model;

// Demo data
QList row;
row.push_back(new QStandardItem("A 1"));
row.push_back(new QStandardItem("{hand}B 1"));
row.push_back(new QStandardItem("{hand}C 1"));
model.appendRow(row);

row.clear();
row.push_back(new QStandardItem("{hand}A 1.1"));
row.push_back(new QStandardItem("B 1.2"));
row.push_back(new QStandardItem("{hand}C 1.3"));
model.item(0, 0)->appendRow(row);

row.clear();
row.push_back(new QStandardItem("{hand}A 2"));
row.push_back(new QStandardItem("B 2"));
row.push_back(new QStandardItem("C 2"));
model.appendRow(row);

MyListView listView;
listView.setModel(&model);
listView.show();
app.exec();
}

That's it. I have compiled a complete project that includes derivations from QListView, QTableView and QTreeView, the test-model and the test-widget. However, this is not a complete solution and by far not suitable to, say, be included in Qt's main releases. There are a few things that need to be solved. For example, using setCursor() to set the cursor for the whole widget is completely broken with these changes.


You can download the example source-code here.

3 comments:

  1. For setting the mouse cursor its a great post, i like it so much.
    Shopping Card

    ReplyDelete
  2. Very useful! ;) thank you very much!

    ReplyDelete
  3. It's much simpler to connect to the entered(index) signal from the view and have the cursor-setting logic there, no need to derive from the view -- and this works for all views, with only a few lines of code.

    In the slot connected viewportEntered() you can unsetCursor() (e.g. between icons in a QListView in icon mode).

    David Faure, Qt expert at KDAB

    ReplyDelete