Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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.
like image 448
Xilexio Avatar asked Feb 14 '16 21:02

Xilexio


2 Answers

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:

  • RazrFalcon's answer for pointing me to the right direction
  • Answer with code sample to render HTML in QTableView: How to make item view render rich (html) text in Qt
  • Answer with code sample on detecting links in QTreeView: Hyperlinks in QTreeView without QLabel
  • QLabel's and internal Qt's QWidgetTextControl's source code on how to handle mouse click/move/release for links
like image 97
Xilexio Avatar answered Oct 06 '22 23:10

Xilexio


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())
like image 29
akej74 Avatar answered Oct 07 '22 00:10

akej74