How to make a fast QTableView with HTML-formatted and clickable cells?
Asked Answered
C

4

16

I'm making a dictionary program that displays word definitions in a 3-column QTableView subclass, as user types them, taking data from a QAbstractTableModel subclass. Something like that:

Table and user input screenshot

I want to add various formatting to the text, I'm using QAbstractItemView::setIndexWidget to add a QLabel to each cell as data comes in:

WordView.h

#include <QTableView>

class QLabel;

class WordView : public QTableView {
    Q_OBJECT

public:
    explicit WordView(QWidget *parent = 0);

    void rowsInserted(const QModelIndex &parent, int start, int end);

private:
    void insertLabels(int row);
    void removeLabels(int row);
};

WordView.cpp

#include <QLabel>
#include "WordView.h"

WordView::WordView(QWidget *parent) :
    QTableView(parent)
{}

void WordView::rowsInserted(const QModelIndex &parent, int start, int end) {
    QTableView::rowsInserted(parent, start, end);

    for (int row = start; row <= end; ++row) {
        insertLabels(row);
    }
}

void WordView::insertLabels(int row) {
    for (int i = 0; i < 3; ++i) {
        auto label = new QLabel(this);
        label->setTextFormat(Qt::RichText);
        label->setAutoFillBackground(true);
        QModelIndex ix = model()->index(row, i);
        label->setText(model()->data(ix, Qt::DisplayRole).toString()); // this has HTML
        label->setWordWrap(true);
        setIndexWidget(ix, label); // this calls QAbstractItemView::dataChanged
    }
}

However, this is very slow - it takes around 1 second to refresh 100 rows (remove all, then add 100 new ones) like that. With original QTableView it worked fast, but I did not have formatting and ability to add links (cross-references in dictionary). How to make this much faster? Or what other widget can I use to display that data?

My requirements are:

  • Adding/removing around 1000 rows in ~0.2s, where around 30 will be visible at once
  • Clickable, multiple internal links (<a>?) in each cell (e.g. QLabel has that, QItemDelegate might have been fast, but I don't know how to get info which link I clicked there)
  • Formatting that allows different font sizes and colors, word wrap, different cell heights
  • I'm not really dead-set on QTableView, anything that looks like a scrollable table and looks consistent with Qt graphics is okay

Notes:

  • I tried making a single label with HTML <table> instead, but it wasn't much faster. Seems like QLabel isn't the way to go.
  • Data in the sample courtesy of the JMdict project.
Centipoise answered 14/2, 2016 at 21:1 Comment(0)
C
22

I solved the problem by putting together few answers and looking at Qt's internals.

A solution which works very fast for static html content with links in QTableView is as folows:

  • Subclass QTableView and handle mouse events there;
  • Subclass QStyledItemDelegate and paint the html there (contrary to RazrFalcon's answer, it is very fast, as only a small amount of cells is visible at a time and only those have paint() method called);
  • In subclassed QStyledItemDelegate create a function that figures out which link was clicked by QAbstractTextDocumentLayout::anchorAt(). You cannot create QAbstractTextDocumentLayout yourself, but you can get it from QTextDocument::documentLayout() and, according to Qt source code, it's guaranteed to be non-null.
  • In subclassed QTableView modify QCursor pointer shape accordingly to whether it's hovering over a link

Below is a complete, working implementation of QTableView and QStyledItemDelegate subclasses that paint the HTML and send signals on link hover/activation. The delegate and model still have to be set outside, as follows:

wordTable->setModel(&myModel);
auto wordItemDelegate = new WordItemDelegate(this);
wordTable->setItemDelegate(wordItemDelegate); // or just choose specific columns/rows

WordView.h

class WordView : public QTableView {
    Q_OBJECT

public:
    explicit WordView(QWidget *parent = 0);

signals:
    void linkActivated(QString link);
    void linkHovered(QString link);
    void linkUnhovered();

protected:
    void mousePressEvent(QMouseEvent *event);
    void mouseMoveEvent(QMouseEvent *event);
    void mouseReleaseEvent(QMouseEvent *event);

private:
    QString anchorAt(const QPoint &pos) const;

private:
    QString _mousePressAnchor;
    QString _lastHoveredAnchor;
};

WordView.cpp

#include <QApplication>
#include <QCursor>
#include <QMouseEvent>
#include "WordItemDelegate.h"
#include "WordView.h"

WordView::WordView(QWidget *parent) :
    QTableView(parent)
{
    // needed for the hover functionality
    setMouseTracking(true);
}

void WordView::mousePressEvent(QMouseEvent *event) {
    QTableView::mousePressEvent(event);

    auto anchor = anchorAt(event->pos());
    _mousePressAnchor = anchor;
}

void WordView::mouseMoveEvent(QMouseEvent *event) {
    auto anchor = anchorAt(event->pos());

    if (_mousePressAnchor != anchor) {
        _mousePressAnchor.clear();
    }

    if (_lastHoveredAnchor != anchor) {
        _lastHoveredAnchor = anchor;
        if (!_lastHoveredAnchor.isEmpty()) {
            QApplication::setOverrideCursor(QCursor(Qt::PointingHandCursor));
            emit linkHovered(_lastHoveredAnchor);
        } else {
            QApplication::restoreOverrideCursor();
            emit linkUnhovered();
        }
    }
}

void WordView::mouseReleaseEvent(QMouseEvent *event) {
    if (!_mousePressAnchor.isEmpty()) {
        auto anchor = anchorAt(event->pos());

        if (anchor == _mousePressAnchor) {
            emit linkActivated(_mousePressAnchor);
        }

        _mousePressAnchor.clear();
    }

    QTableView::mouseReleaseEvent(event);
}

QString WordView::anchorAt(const QPoint &pos) const {
    auto index = indexAt(pos);
    if (index.isValid()) {
        auto delegate = itemDelegate(index);
        auto wordDelegate = qobject_cast<WordItemDelegate *>(delegate);
        if (wordDelegate != 0) {
            auto itemRect = visualRect(index);
            auto relativeClickPosition = pos - itemRect.topLeft();

            auto html = model()->data(index, Qt::DisplayRole).toString();

            return wordDelegate->anchorAt(html, relativeClickPosition);
        }
    }

    return QString();
}

WordItemDelegate.h

#include <QStyledItemDelegate>

class WordItemDelegate : public QStyledItemDelegate {
    Q_OBJECT

public:
    explicit WordItemDelegate(QObject *parent = 0);

    QString anchorAt(QString html, const QPoint &point) const;

protected:
    void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const;
    QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const;
};

WordItemDelegate.cpp

#include <QPainter>
#include <QTextDocument>
#include <QAbstractTextDocumentLayout>
#include "WordItemDelegate.h"

WordItemDelegate::WordItemDelegate(QObject *parent) :
    QStyledItemDelegate(parent)
{}

QString WordItemDelegate::anchorAt(QString html, const QPoint &point) const {
    QTextDocument doc;
    doc.setHtml(html);

    auto textLayout = doc.documentLayout();
    Q_ASSERT(textLayout != 0);
    return textLayout->anchorAt(point);
}

void WordItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
    auto options = option;
    initStyleOption(&options, index);

    painter->save();

    QTextDocument doc;
    doc.setHtml(options.text);

    options.text = "";
    options.widget->style()->drawControl(QStyle::CE_ItemViewItem, &option, painter);

    painter->translate(options.rect.left(), options.rect.top());
    QRect clip(0, 0, options.rect.width(), options.rect.height());
    doc.drawContents(painter, clip);

    painter->restore();
}

QSize WordItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const {
    QStyleOptionViewItemV4 options = option;
    initStyleOption(&options, index);

    QTextDocument doc;
    doc.setHtml(options.text);
    doc.setTextWidth(options.rect.width());
    return QSize(doc.idealWidth(), doc.size().height());
}

Note that this solution is fast only because a small subset of rows is rendered at once, and therefore not many QTextDocuments are rendered at once. Automatic adjusting all row heights or column widths at once will still be slow. If you need that functionality, you can make the delegate inform the view that it painted something and then making the view adjust the height/width if it hasn't before. Combine that with QAbstractItemView::rowsAboutToBeRemoved to remove cached information and you have a working solution. If you're picky about scrollbar size and position, you can compute average height based on a few sample elements in QAbstractItemView::rowsInserted and resize the rest accordingly without sizeHint.

References:

Centipoise answered 15/2, 2016 at 20:43 Comment(0)
S
10

Many thanks for these code examples, it helped me implement similar functionalaity in my application. I'm working with Python 3 and QT5 and I would like to share my Python code, is case it may be helpful implementing this in Python.

Note that if you are using QT Designer for the UI design, you can use "promote" to change a regular "QTableView" widget to use your custom widget automatically when converting the XML to Python code with "pyuic5".

Code as follows:

    from PyQt5 import QtCore, QtWidgets, QtGui
        
    class CustomTableView(QtWidgets.QTableView):
    
        link_activated = QtCore.pyqtSignal(str)
    
        def __init__(self, parent=None):
            self.parent = parent
            super().__init__(parent)
    
            self.setMouseTracking(True)
            self._mousePressAnchor = ''
            self._lastHoveredAnchor = ''
    
        def mousePressEvent(self, event):
            anchor = self.anchorAt(event.pos())
            self._mousePressAnchor = anchor
    
        def mouseMoveEvent(self, event):
            anchor = self.anchorAt(event.pos())
            if self._mousePressAnchor != anchor:
                self._mousePressAnchor = ''
    
            if self._lastHoveredAnchor != anchor:
                self._lastHoveredAnchor = anchor
                if self._lastHoveredAnchor:
                    QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor))
                else:
                    QtWidgets.QApplication.restoreOverrideCursor()
    
        def mouseReleaseEvent(self, event):
            if self._mousePressAnchor:
                anchor = self.anchorAt(event.pos())
                if anchor == self._mousePressAnchor:
                    self.link_activated.emit(anchor)
                self._mousePressAnchor = ''
    
        def anchorAt(self, pos):
            index = self.indexAt(pos)
            if index.isValid():
                delegate = self.itemDelegate(index)
                if delegate:
                    itemRect = self.visualRect(index)
                    relativeClickPosition = pos - itemRect.topLeft()
                    html = self.model().data(index, QtCore.Qt.DisplayRole)
                    return delegate.anchorAt(html, relativeClickPosition)
            return ''
    
    
    class CustomDelegate(QtWidgets.QStyledItemDelegate):
    
        def anchorAt(self, html, point):
            doc = QtGui.QTextDocument()
            doc.setHtml(html)
            textLayout = doc.documentLayout()
            return textLayout.anchorAt(point)
    
        def paint(self, painter, option, index):
            options = QtWidgets.QStyleOptionViewItem(option)
            self.initStyleOption(options, index)
    
            if options.widget:
                style = options.widget.style()
            else:
                style = QtWidgets.QApplication.style()
    
            doc = QtGui.QTextDocument()
            doc.setHtml(options.text)
            options.text = ''
    
            style.drawControl(QtWidgets.QStyle.CE_ItemViewItem, options, painter)
            ctx = QtGui.QAbstractTextDocumentLayout.PaintContext()
    
            textRect = style.subElementRect(QtWidgets.QStyle.SE_ItemViewItemText, options)
    
            painter.save()
    
            painter.translate(textRect.topLeft())
            painter.setClipRect(textRect.translated(-textRect.topLeft()))
            painter.translate(0, 0.5*(options.rect.height() - doc.size().height()))
            doc.documentLayout().draw(painter, ctx)
    
            painter.restore()
    
        def sizeHint(self, option, index):
            options = QtWidgets.QStyleOptionViewItem(option)
            self.initStyleOption(options, index)
    
            doc = QtGui.QTextDocument()
            doc.setHtml(options.text)
            doc.setTextWidth(options.rect.width())
    
            return QtCore.QSize(doc.idealWidth(), doc.size().height())
Satisfactory answered 5/6, 2017 at 8:51 Comment(0)
A
2

In your case QLabel (re)painting is slow, not QTableView. On other hand, QTableView does not support formated text at all.

Probably, your only way, is to create your own delegate, QStyledItemDelegate, and make your own painting and click processing in it.

PS: yes, you can use QTextDocument for rendering html inside delegate, but it will be slow too.

Americano answered 14/2, 2016 at 21:59 Comment(5)
But after I render items in a QStyledItemDelegate, they are not clickable. How to detect where a link is rendered, so that I can handle it myself?Centipoise
Again, by yourself. You, probably, should simplify formatting by splitting it to blocks. So when you paint your item - you calculate those block, eg. QRect, and store it as class variable, and then, in click event, you can easily detect click position and it's data. But it's just what would I do. Maybe there are easier solution.Americano
I guess I'll try that if I don't get any better answers. The problem is that handling word-wrapping and displaying differently-sized text one after another will be quite a bit of work. And I need that to figure out where I manually put that link. Or I could make a dirty hack like checking for color in the are clicked if link has unique one.Centipoise
For word-wrapping you can use QStaticText. It's fast and you can get resulted QRect. And it's support html formatting.Americano
Thanks for pointing me in the right direction. Subclassing QStyledItemDelegate was the way to go. However, painting links and computing their position manually would be very complicated, especially if links could be inside a line of a word-wrapped text. QTextDocument was fast and had a built-in solution for finding anchors. Also, if I am ever to work with selecting text, it can also help me with hitTest. I still don't know why QLabel is so slow, but profiling showed that it has something to do with sizeHint and probably also calling dataChanged all the time.Centipoise
F
2

I use a slighted improved solution based on Xilexio code. There is 3 fundamental differences:

  • Vertical alignment so if you put the text in a cell higher than the text it will be center aligned and not top aligned.
  • The text will be right shifted if the cell contains an icon so the icon will not be displayed above the text.
  • The widget style to highlighted cells will be followed, so you select this cell, the colors will behave similar to other cells without the delegate.

Here is my code of the paint() function (the rest of the code remains the same).

QStyleOptionViewItemV4 options = option;
initStyleOption(&options, index);

painter->save();

QTextDocument doc;
doc.setHtml(options.text);

options.text = "";
options.widget->style()->drawControl(QStyle::CE_ItemViewItem, &options, painter);

QSize iconSize = options.icon.actualSize(options.rect.size);
// right shit the icon
painter->translate(options.rect.left() + iconSize.width(), options.rect.top());
QRect clip(0, 0, options.rect.width() + iconSize.width(), options.rect.height());

painter->setClipRect(clip);
QAbstractTextDocumentLayout::PaintContext ctx;

// Adjust color palette if the cell is selected
if (option.state & QStyle::State_Selected)
    ctx.palette.setColor(QPalette::Text, option.palette.color(QPalette::Active, QPalette::HighlightedText));
ctx.clip = clip;

// Vertical Center alignment instead of the default top alignment
painter->translate(0, 0.5*(options.rect.height() - doc.size().height()));

doc.documentLayout()->draw(painter, ctx);
painter->restore();
Falgoust answered 6/9, 2016 at 18:0 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.