Skip to main content

Command Palette

Search for a command to run...

GitRaven: How to draw custom widgets on QTreeView rows

Updated
8 min read

Hello,

In this blog post, we will be looking at customizing QTreeView to render custom widgets on each relevant rows.

We will be adding the ability to stage/unstage an file/folder from tree and also show a label for the file’s git status - Modified, Deleted or Uncommitted.

Check the Results section for the final result.

Theory

The idea is to use a custom class which extends QStyledItemDelegate. Check the model view architecture section from Qt documentation for more info.

Image credits: Qt documentation

So, when the view (in our case QTreeView) wants to render something like text or button, it will ask delegate class about the details. The Delegate class will in-turn ask the model class for data to be shown at a given row, column.

You can find more details on the model class used for this project in my previous post. I have explained how we show a custom data struct to build the tree structure.

In my case, I will override paint and sizeHint functions.

You can learn more about these functions on Qt docs:

AI Usage

I have checked with ChatGPT to fix issues on my code. For example, it helped me identity an issue where QTreeView wouldn’t display items properly when I use the custom delegate class.

Solution: I need to call the base class functions inside `paint` before our custom widgets are painted to the screen like QStyledItemDelegate::paint(painter, option, index);.

Implementation

So, my custom sizeHint function is pretty straightforward for now. It has zero custom functionality and will always return Qt’s default sizeHint result.

I’ll keep an eye on it as I go, but for now, I’ve got other things to worry about.

Now, the first task is to create our custom delegate class RavenTreeDelegate.

RavenTreeDelegate.h:

#ifndef RAVENTREEDELEGATE_H
#define RAVENTREEDELEGATE_H

#include <QStyledItemDelegate>


class RavenTreeDelegate : public QStyledItemDelegate
{
    Q_OBJECT
public:
    RavenTreeDelegate();

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

#endif // RAVENTREEDELEGATE_H

RavenTreeDelegate.cpp:

Here lies the custom class in it’s entirety. Let me break it down and explain what’s happening.

#include "raventreedelegate.h"
#include "raventreeitem.h"

#include <QApplication>
#include <QMouseEvent>
#include <QPainter>

RavenTreeDelegate::RavenTreeDelegate() {}

void RavenTreeDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
    // Save default painter settings
    painter->save();
    // Call base class' paint method to render default items
    QStyledItemDelegate::paint(painter, option, index);

    // Apply our changes on top of the default UI
    if (index.isValid())
    {
        auto *treeItem = static_cast<RavenTreeItem*>(index.internalPointer());
        auto rect = option.rect;
        auto isSelected = (option.state & QStyle::State_Selected) == QStyle::State_Selected;
        // Increase yOffset for rect to show custom UI items.
        // NOTE: I don't why but adding this line FIXED alignment issues of my custom widgets.
        //       If you know why, please let me know. I am really curious!!!!
        rect.setY(rect.y() + 18);

        // Status text (shows D/M/U text at end of row)
        auto statusPoint = rect.topRight();
        statusPoint.setX(statusPoint.x() - 20);
        auto font = painter->font();
        font.setWeight(QFont::Weight::Bold);

        // Applicable to non-heading items only
        if (!treeItem->heading)
        {
            if (treeItem->deleted)
            {
                // Show "Deleted" status
                painter->setFont(font);
                painter->setPen(isSelected ? option.palette.text().color() : Qt::red);
                painter->drawText(statusPoint, "D");
            }
            else if (treeItem->modified())
            {
                // Show "Uncommitted" status
                painter->setFont(font);
                painter->setPen(isSelected ? option.palette.text().color() : Qt::magenta);
                painter->drawText(statusPoint, "M");
            }
            else
            {
                // Show "Uncommitted" status
                painter->setFont(font);
                painter->setPen(isSelected ? option.palette.text().color() : Qt::green);
                painter->drawText(statusPoint, "U");
            }

            // Show button to + or - from Changes<->Staging Area

            QRect buttonRect = option.rect;
            buttonRect.setX(option.rect.topRight().x() - 60);
            //buttonRect.setY(option.rect.y() - 80);
            buttonRect.setWidth(24);
            buttonRect.setHeight(24);

            // Create button widget here
            QStyleOptionButton buttonOption;
            buttonOption.rect = buttonRect;
            buttonOption.state = option.state;
            buttonOption.features = QStyleOptionButton::Flat;
            buttonOption.palette = option.palette;

            QStyle *style = QApplication::style();

            // WORKAROUND: Hide the `border-bottom` for `buttonOption` when treeview row is selected
            QBrush buttonOptionBrush;
            buttonOptionBrush.setColor(Qt::GlobalColor::transparent);
            buttonOption.palette.setBrush(QPalette::ColorRole::HighlightedText, buttonOptionBrush);

            if (treeItem->initiator == RavenTreeItem::STAGING)
            {
                // Show - icon
                buttonOption.text="-";
                style->drawControl(QStyle::CE_PushButton, &buttonOption, painter);
            }
            else
            {
                // Show + icon
                buttonOption.text="+";
                style->drawControl(QStyle::CE_PushButton, &buttonOption, painter);
            }
        }
    }
    // Restore painter default settings
    painter->restore();
}

QSize RavenTreeDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
    return QStyledItemDelegate::sizeHint(option, index);
}

painter→save() and painter→restore() are used to render the custom widgets with a different style than other UI elements. Here, save() stores the default settings inherited from base class and at the end of the paint function, we restore the defaults using painter->restore().

Custom Text (Git status label)

We use rect variable of type QRect to determine the x, y, w and h properties - x-axis, y-axis, width and height properties of the given widget (is it a widget here?) being rendered.

So the first custom widget which is a single char - U, D or M being rendered according to Git status of a given file.

// Status text (shows D/M/U text at end of row)
auto statusPoint = rect.topRight();
statusPoint.setX(statusPoint.x() - 20);
auto font = painter->font();
font.setWeight(QFont::Weight::Bold);

// Applicable to non-heading items only
if (!treeItem->heading) {
  if (treeItem->deleted) {
    // Show "Deleted" status
    painter->setFont(font);
    painter->setPen(isSelected ? option.palette.text().color() : Qt::red);
    painter->drawText(statusPoint, "D");
  } else if (treeItem->modified()) {
    // Show "Uncommitted" status
    painter->setFont(font);
    painter->setPen(isSelected ? option.palette.text().color() : Qt::magenta);
    painter->drawText(statusPoint, "M");
  } else {
    // Show "Uncommitted" status
    painter->setFont(font);
    painter->setPen(isSelected ? option.palette.text().color() : Qt::green);
    painter->drawText(statusPoint, "U");
  }

We first grab the QPoint of the given row inside the tree. We are picking topRight here because the position is almost correct. We expect the text to be shown to the right-side of the row and vertically centered in the final output. So topRight has the “right” direction set but we introduce offsets to our liking.

This is where the file or folder’s name and icon are shown along with the tree’s expand/collapse button and the guidelines to determine which parent it belongs to.

Now, using the statusPoint, we can modify the positioning of our custom text widget. For instance, I will move it slightly back to the left so that it won’t be stuck at the far-end in each row.

Next, we subtract 20 from current x position on the UI. This number has been chosen after testing different values (starting from 5), nothing special. It felt right to my eyes.

Next, I added bold style when drawing labels. This is designed to give emphasis to the label.

Lastly, we paint it to the view however, we change the font color for the label according to file’s status. These colors were chosen to be closer to VSCode for my convenience.

painter->setFont(font);
painter->setPen(isSelected ? option.palette.text().color() : Qt::red);
painter->drawText(statusPoint, "D");

Here, I set the font to be used for rendering our custom text. Note that, till now, we have just dictated the properties to be set for our text but this is where the text properties are picked.

So, by default, we set Qt::red, Qt::magenta or Qt::green as the default colors for deleted, modified and uncommitted (new files) states. There is an if check here to show Qt default text color when a tree node is selected to improve readability.

Lastly, we use the drawText function of QPainter class to draw the text. It’s pretty simple.

Custom Button (Stage/Unstage items)

So this was a little tricky. Unlike text, we don’t have a drawButton or drawWidget class. Instead, we will need to tackle this problem differently.


            // Show button to + or - from Changes<->Staging Area

            QRect buttonRect = option.rect;
            buttonRect.setX(option.rect.topRight().x() - 60);
            buttonRect.setWidth(24);
            buttonRect.setHeight(24);

            // Create button widget here
            QStyleOptionButton buttonOption;
            buttonOption.rect = buttonRect;
            buttonOption.state = option.state;
            buttonOption.features = QStyleOptionButton::Flat;
            buttonOption.palette = option.palette;

            QStyle *style = QApplication::style();

            // WORKAROUND: Hide the `border-bottom` for `buttonOption` 
            //             when treeview row is selected.
            QBrush buttonOptionBrush;
            buttonOptionBrush.setColor(Qt::GlobalColor::transparent);
            buttonOption.palette.setBrush(QPalette::ColorRole::HighlightedText, buttonOptionBrush);

            if (treeItem->initiator == RavenTreeItem::STAGING)
            {
                // Show - icon
                buttonOption.text="-";
                style->drawControl(QStyle::CE_PushButton, &buttonOption, painter);
            }
            else
            {
                // Show + icon
                buttonOption.text="+";
                style->drawControl(QStyle::CE_PushButton, &buttonOption, painter);
            }

So, just like before, we use buttonRect to adjust the x offset as well as the width & height of the button. In this case, my offset value is 60 which will place this button a little bit before the status text.

In order to draw a button, it is very efficient (in my case) to use QStyleOptionButton. This is a very handy class that abstracts the button state management for me but offers us a way to draw custom button-like widgets.

QStyleOptionButton buttonOption;
buttonOption.rect = buttonRect;
buttonOption.state = option.state;
buttonOption.features = QStyleOptionButton::Flat;
buttonOption.palette = option.palette;

I chose the Flat type button for my needs. You can read more about this in the docs. I also set the palette of the newly created button to inherit things from the tree’s row widget (again, is it right to call this a widget?).

Next, I set + or - char to imply the file is about to be staged or unstaged by checking the state.

Lastly, we draw this button to the UI using QStyle::drawControl function which we can derive from QApplication. This is a foreign concept to me coming from GTK. Here’s the Qt docs link for the function if you’re curious.

Results

Default state:

You can see the tree now renders both the Git status label as well as the custom button like widget for staging and unstaging items.

GitRaven default UI

Selected item:

Look at the color of the Git status label - it is not Qt::magenta anymore. This color is picked by Qt internally since the item is now in selected state.

GitRaven tree item selected UI

Conclusion

This was a very fun feature to build. I didn’t realize how little things (taken for granted as it’s universally available in most tools) gets built. I would highly encourage people to build tools and see how do you fare against the giants.

I hope you have learned something here. This feature took me many days to implement, test and fix. I am so happy to make steady progress on this project.

Next up, the “diff viewer” shown above has been replaced with Monaco Editor. I plan on making a blog post on it soon.

You can follow me on Mastodon.

Bye for now :)

GitRaven

Part 3 of 5

This series follows my dev journey of building GitRaven project. It is a hobby project which aims to replace SourceTree & GitHub Desktop on my desktop. It is written in C++ and Qt. Follow along to find out where the story goes!

Up next

GitRaven: How to use QTreeView with a custom model class

Hi, This blog post will cover how to create a custom model class based on QAbstractItemModel to render custom data with QTreeView in C++. Theory General idea: Create a new class RavenTreeModel based on QAbstractTreeModel. We need to override few me...