Fully Functional PushButtons drawn inside a QStyleDelegate
-
hi everyone, this is not a support request for some bugged code. Instead a want to share with the community a working example of one of my latest project in the hope to help someone else developing is own version and doesn't know were to start.
The Reason of this post
for the sake of curiosity I was always wondering I to integrate button-like functionality inside a QStyledItemDelegate capable of emitting signals and react to clickedState or change if hovered or pressed and I never found a topic related to it, so I decided to do it my self, sharing my personal implementation of that. It's far from being bulletproof or the most optimezed way but I think will be a great starting point to share and learn.
Feel free to ask questions or share your opition to make improvements to the code
What you'll learn from this post
- Basic understanding on how a QStyledItemDelegate work
- Draw dynamic buttons that react to mouse events and emit signal
- A shallow notion of custom Models that pass data to the view.
The final result looks like the following:
the above image retrives the "GroupName" (QString) and "Elements count" (QString) from the model.
"GroupName" is simply the data retrived from QAbstractListModel::data( index, displayRole) whereas
"Elements count" is build from QList<Obj>::count() and returned as QUserRole+1 from within the model::data() methodstep 1: the model
In my case the model is quite simple, I store a QMap< QString, QList<PathInfo>> where the QString (key) is the groupName that you see in the screenshot and where the PathInfo is a User defined class that for semplicity we can drain to this implementation and the model definition is as follows
struct PathInfo { QString path{ "C:\\symple\\path" }; bool isOpenable{ true }; PathInfo( QString _path, bool openable= true ) : path{_path}, isOpenable{openable} {} }; class GroupsListModel : public QAbstractListModel { Q_OBJECT public: enum Roles { ItemCount = Qt::UserRole + 1 }; enum RowsOperation { Add = 0, Remove = 1 }; explicit GroupsListModel(QMap< QString, QList<PathInfo>>& map_groups ,QWidget *parent = nullptr); /*! overridden virtual methods */ int rowCount( const QModelIndex& parent= QModelIndex() ) const override; QVariant data( const QModelIndex& index, int role= Qt::DisplayRole ) const override; //---------------------------------------------------------------------------------------- void addGroup( const QString& groupName ); void addGroup( const QString& groupName, QList<PathInfo> pInfoList ); QList<PathInfo> removeGroup( const QString& groupName ); inline QString groupName(int row) const { return m_groupsName[row]; } inline int itemCount(int row) const { return m_groups[m_groupsName[row]].count(); } inline int indexOfGroup( const QString& groupName ) const { return m_groupsName.indexOf( groupName ); } private: QMap<QString, QList<PathInfo>>& m_groups; QStringList m_groupsName; };
because QMap is "unordered" (meaning that doesn't keep the order of the key in the same order which them is been added) and can't be retrived by index like the QList, I had to use "beginResetModel" inside an "addGroup()" method and also for deleting records with the method "removeGroup()" instead of relaying on "insertRows()" and "removeRows()" declared virtual on the parent
// model's logic
GroupsListModel::GroupsListModel(QMap<QString, QList<PathInfo>>& map_groups, QWidget *parent) : QAbstractListModel{parent}, m_groups{ map_groups }, m_groupsName{ map_groups.keys() } {} /*! * overridden methods */ int GroupsListModel::rowCount( const QModelIndex& parent ) const { Q_UNUSED( parent ) return m_groups.count(); } QVariant GroupsListModel::data( const QModelIndex& index, int role ) const { if ( !index.isValid() ) { qDebug() << "invalid Index: model.data()"; return QVariant(); } switch ( role ) { case Qt::DisplayRole: { // return the groupName at index= row return QVariant( m_groupsName[ index.row() ] ); } case GroupsListModel::Roles::ItemCount: { QString elem_count("Elements count: "); elem_count += QString::number( m_groups[ m_groupsName[index.row()] ].count() ); return QVariant( elem_count ); } default: return QVariant(); } } void GroupsListModel::addGroup( const QString& groupName ) { if ( m_groups.contains( groupName ) ) { QMessageBox::warning( nullptr, "Duplicate Group Name", "Sorry, this group name is already in use.\n Try another one." ); return; } beginResetModel(); // add the new key to QMap m_groups[ groupName ] = QList<PathInfo>(); // update the QStringList for Indexing accessing m_groupsName = m_groups.keys(); endResetModel(); return; } void GroupsListModel::addGroup( const QString& groupName, QList<PathInfo> pInfoList ) { if ( m_groups.contains( groupName ) ) { QMessageBox::warning( nullptr, "Duplicate Group Name", "Sorry, this group name is already in use.\n Try another one." ); return; } beginResetModel(); // add the new key to QMap m_groups[ groupName ] = pInfoList; // update the QStringList for Indexing accessing m_groupsName = m_groups.keys(); endResetModel(); return; } QList<PathInfo> GroupsListModel::removeGroup( const QString& groupName ) { if ( !m_groups.contains( groupName ) ) { QMessageBox::warning( nullptr, "Unexisting Group", "Sorry, a not existing group could not be removed.\n Try to select a group first." ); return QList<PathInfo>(); } beginResetModel(); // remove the key from the map QList<PathInfo> temp = m_groups.take( groupName ); // update the QStringList for Indexing accessing m_groupsName = m_groups.keys(); endResetModel(); return temp; }
-
I ended up using a slightly different approach that stores each button style inside a QHash inside the custom TableModel.
the code is available on github at:
https://github.com/aVenturelli-qt/ArchWay-Project-Manager/tree/main/models -
Step 2: Subclassing QStyledItemDelegate
After the model is ready, and all the data can be retrived by the correct Qt::Role we can procede to subclassing the styledDelegate implementing at least this virtual methods:
void paint( QPainter* p, const QStyleOptionViewItem& option, const QModelIndex& index ) const override; QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; /* * When editing of an item starts, this function is called with the event that triggered the editing, * the model, the index of the item, and the option used for rendering the item. * Mouse events are sent to editorEvent() even if they don't start editing of the item. * * This can, for instance, be useful if you wish to open a context menu when the right mouse button is pressed on an item. * The base implementation returns false (indicating that it has not handled the event). */ bool editorEvent( QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& index ) override;
Paint() is responsable of drawing each item in the list at a time (so don't call painter.end() at the end or you will see only the first one ;) )
sizeHint is fondamental for suggest to the view, how much space allocate for each element based on your needs.
editorEvent is the pivot method that allows us to make our drawn buttons feel alive and emit signals when pressed
here the Delegate Definition (part 1):
class GroupsListDelegate : public QStyledItemDelegate { Q_OBJECT public: enum BtnState { Default = 0, Hover = 2, Pressed = 4, Disabled = 14 }; explicit GroupsListDelegate(QWidget *parent = nullptr); // setter getter - to access ButtonStyle inline void setTopButtonsStyle ( ButtonStyle btnStyle ) { m_top_btn_style = btnStyle; } inline void setBottomButtonsStyle( ButtonStyle btnStyle ) { m_bottom_btn_style = btnStyle; } inline void setButtonsStyle( ButtonStyle top_btn, ButtonStyle bottom_btn ) { m_top_btn_style = top_btn; m_bottom_btn_style = bottom_btn;} inline std::pair<ButtonStyle, ButtonStyle> buttonsStyle() { return std::make_pair( m_top_btn_style, m_bottom_btn_style); } void paint( QPainter* p, const QStyleOptionViewItem& option, const QModelIndex& index ) const override; QSize sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const override; signals: void editButtonClickedGroup( const QString group ); void editButtonClicked( const QModelIndex& modelIndex ); void openAllButtonClickedGroup( const QString group ); void openAllButtonClicked( const QModelIndex& modelIndex ); protected: bool editorEvent( QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& index ) override; private: QListView* m_view{}; ButtonStyle m_top_btn_style{}; ButtonStyle m_bottom_btn_style{}; GroupsListDelegate::BtnState top_btn_state{ BtnState::Default }; GroupsListDelegate::BtnState bottom_btn_state{ BtnState::Default }; QPoint m_lastMousePressedPos{}; /** * @param font: the font you want to get the right size from * @param text: the text to use for calculating Height and Width * @param painter: given a QPainter this will automatically call painter->setFont( font ) */ QRect _rectFromFontMetrics( const QFont& font, const QString& text, QPainter* painter= nullptr ) const; void _drawButton( QRect button_rect, QString text, QPainter* p, GroupsListDelegate::BtnState state, bool isTopBtn= true ) const; bool _updateStateButton( QPoint mousePos, QRect btnRect, BtnState& currState, BtnState newState ); bool _isMouseEvent( QEvent* event ) const; QRect& _offsetXYRect(int x_offset, int y_offset, QRect& originalRect ) const; // help variable to draw inside the paint method static constexpr int PADDING{ 16 }; static constexpr int TEXT_SPACING{ 12 }; //---------------------------------------- static constexpr int BUTTONS_SPACING{ 8 }; //---------------------------------------- static constexpr int BUTTON1_HEIGHT{ 32 }; static constexpr int BUTTON2_HEIGHT{ 40 }; //---------------------------------------- static constexpr int BUTTONS_WIDTH{ 100 }; static constexpr int BG_BORDER_RAD{ 8 }; static QFont GROUP_TEXT_FONT; static QFont SUB_TEXT_FONT; static QFont BTN_FONT; //--------------------------------------- static QColor BG_COLOR; static QColor BG_COLOR_SELECTED; //--------------------------------------- // this will create a cool border around the selected item in the list static QConicalGradient _createConicalGradient( QPoint center ) { QConicalGradient con_grad( center, 0 ); con_grad.setColorAt( 0.1, QColor(250, 102, 192) ); con_grad.setColorAt( 0.3, QColor(251, 173, 56) ); con_grad.setColorAt( 0.55, QColor(218, 152, 251) ); con_grad.setColorAt( 0.88, QColor( 91, 183, 244) ); con_grad.setColorAt( 0.98, QColor(250, 102, 192) ); return con_grad; } };
-
here the implementation of Paint and editorEvent and cunstroctor where is crucial to set "setMouseTracking(true)" to be able to track the mouse location
GroupsListDelegate::GroupsListDelegate(QWidget *parent) : QStyledItemDelegate{parent} { m_view = qobject_cast<QListView*>( parent ); // needed to check the hover state // even when LeftMouseButton isn't pressed m_view->setMouseTracking( true ); } bool GroupsListDelegate::editorEvent( QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& index ) { Q_UNUSED(option) auto* _model = qobject_cast<GroupsListModel*>( model ); const QString str(_model->data( index, Qt::DisplayRole ).toString() ); if ( !this->_isMouseEvent( event )) return false; // from now on, the event could only be one of mouseEvent auto* mouseEvent = static_cast<QMouseEvent*>( event ); QPoint cursorPosition{ mouseEvent->pos() }; bool event_handled{ false }; int pen_offset{ 0 }; // accounts for various border-width; QRect editBtn{ option.rect.topRight().x() - PADDING - BUTTONS_WIDTH, option.rect.center().y() - BUTTONS_SPACING - BUTTON1_HEIGHT, BUTTONS_WIDTH, BUTTON1_HEIGHT }; QRect OpenAllBtn{ option.rect.topRight().x() - PADDING - BUTTONS_WIDTH, option.rect.center().y(), BUTTONS_WIDTH, BUTTON2_HEIGHT }; switch ( event->type() ) { // - - - - - - - - - - - - - - - - - BTN HOVER - - - - - - - - - - - - - - - - - - - - - - - - case QEvent::MouseButtonRelease: case QEvent::MouseMove: { // ------ TOP BUTTON HOVER --------- pen_offset = m_top_btn_style.pen_hover.width(); editBtn = this->_offsetXYRect( pen_offset, pen_offset, editBtn ); event_handled = this->_updateStateButton( cursorPosition, editBtn, top_btn_state, BtnState::Hover ); if ( event_handled ) { if ( event->type() == QEvent::MouseButtonRelease ) { emit editButtonClicked( index ); emit editButtonClickedGroup( str ); } return true; } // ------ BOTTOM BUTTON HOVER --------- pen_offset = m_bottom_btn_style.pen_hover.width(); OpenAllBtn = this->_offsetXYRect( pen_offset, pen_offset, OpenAllBtn ); event_handled = this->_updateStateButton( cursorPosition, OpenAllBtn, bottom_btn_state, BtnState::Hover ); if ( event_handled ) { if ( event->type() == QEvent::MouseButtonRelease ) { emit openAllButtonClicked( index ); emit openAllButtonClickedGroup( str ); } return true; } return false; } // - - - - - - - - - - - - - - - - - - BTN PRESSED - - - - - - - - - - - - - - - - - - - - - - - - case QEvent::MouseButtonPress: { if ( mouseEvent->buttons() == Qt::LeftButton ) { // ------ TOP BUTTON PRESSED --------- pen_offset = m_top_btn_style.pen_pressed.width(); editBtn = this->_offsetXYRect( pen_offset, pen_offset, editBtn ); event_handled = this->_updateStateButton( cursorPosition, editBtn, top_btn_state, BtnState::Pressed ); if ( event_handled ) return true; // ------ BOTTOM BUTTON PRESSED --------- pen_offset = m_bottom_btn_style.pen_pressed.width(); OpenAllBtn = this->_offsetXYRect( pen_offset, pen_offset, OpenAllBtn ); event_handled = this->_updateStateButton( cursorPosition, OpenAllBtn, bottom_btn_state, BtnState::Pressed ); if ( event_handled ) return true; return false; } return false; } // - - - - - - - - - - - - - - - - DEFAULT STATE - - - - - - - - - - - - - - - - - - - - default: return false; } } void GroupsListDelegate::paint( QPainter* p, const QStyleOptionViewItem& option, const QModelIndex& index ) const { p->save(); p->setRenderHint( QPainter::Antialiasing, true ); p->setRenderHint( QPainter::TextAntialiasing, true ); const bool isCardSelected = option.state & QStyle::State_Selected ; // draw the bg QString groupName = index.data( Qt::DisplayRole ).toString(); QString elem_count = index.data( Qt::UserRole + 1 ).toString(); // qDebug() << "\nGroupName: " << groupName << ", element counts: " << elem_count; QPainterPath clipPath; clipPath.addRoundedRect( option.rect, BG_BORDER_RAD, BG_BORDER_RAD ); p->setClipPath( clipPath ); p->setPen( Qt::NoPen ); p->fillRect( option.rect, BG_COLOR ); // change border color of an item when selected if ( isCardSelected ) { auto border_brush = QBrush(GroupsListDelegate::_createConicalGradient( option.rect.center())); // bg selected state p->setPen( QPen( border_brush, 6.0 )); p->drawRoundedRect( option.rect, BG_BORDER_RAD, BG_BORDER_RAD ); } else { // normal bg color p->setPen( Qt::NoPen ); p->drawRoundedRect( option.rect, BG_BORDER_RAD, BG_BORDER_RAD ); } // DRAWING THE TWO TEXT //--------------------------------------------------- auto txt_color = (isCardSelected)? QColor(231, 143, 26) : Qt::black; p->setPen( txt_color ); auto upper_txt_rect = this->_rectFromFontMetrics( GROUP_TEXT_FONT, groupName, p ); upper_txt_rect.moveBottomLeft( QPoint( option.rect.x() + PADDING, option.rect.center().y() ) ); // draw the groupText offsetted by 16px on the x p->drawText( upper_txt_rect, groupName ); // BOTTOM TEXT ------------------ QRect lower_txt_rect = this->_rectFromFontMetrics( SUB_TEXT_FONT, elem_count, p ); lower_txt_rect.moveTopLeft( QPoint( upper_txt_rect.bottomLeft() + QPoint( 0, TEXT_SPACING) // padding 12px on the y ) ); p->setPen( QPen( Qt::black ) ); p->drawText( lower_txt_rect, elem_count ); // DRAWING TWO PUSHBUTTON //--------------------------------------------------- // ------- BUTTON1 (AKA TOP BUTTON) ------- // the top button's bottomRight corner is placed 12px above the option.rect center // and 16px (PADDING) from the right of the option.rect QRect edit_btn{ option.rect.topRight().x() - PADDING - BUTTONS_WIDTH, option.rect.center().y() - BUTTONS_SPACING - BUTTON1_HEIGHT, BUTTONS_WIDTH, BUTTON1_HEIGHT }; // see next comment for explanation if ( edit_btn.contains( m_lastMousePressedPos ) && ( top_btn_state == BtnState::Pressed ) ) { this->_drawButton( edit_btn, "Edit Group", p, top_btn_state ); } else { this->_drawButton( edit_btn, "Edit Group", p, BtnState::Default ); } QRect open_btn{ option.rect.topRight().x() - PADDING - BUTTONS_WIDTH, option.rect.center().y(), BUTTONS_WIDTH, BUTTON2_HEIGHT }; // As before, this line fixes a but that occure when, // after selecting and deleting the last element // and press one of the buttons, will trigger an update // in the style for all the others if ( open_btn.contains( m_lastMousePressedPos ) && ( bottom_btn_state == BtnState::Pressed ) ) { this->_drawButton( open_btn, "Open All", p, bottom_btn_state, false ); } else { this->_drawButton( open_btn, "Open All", p, BtnState::Default, false ); } // reset for next item p->restore(); }
-
here the remaing methods that doesn't fit in the previous post for some forum's limitation on the message lenght:
QSize GroupsListDelegate::sizeHint(const QStyleOptionViewItem& option, const QModelIndex& index) const { Q_UNUSED(option) Q_UNUSED(index) return QSize( 280, 2*PADDING + BUTTON1_HEIGHT + BUTTONS_SPACING + BUTTON2_HEIGHT + 12 ); } /*------------------------------------------------------------- * HELPER FUNCTIONS *-------------------------------------------------------------*/ QRect GroupsListDelegate::_rectFromFontMetrics( const QFont& font, const QString& text, QPainter* painter) const { if ( painter ) painter->setFont( font ); QFontMetrics fnt_m( font ); return fnt_m.boundingRect( text ); } void GroupsListDelegate::_drawButton( QRect button_rect, QString text, QPainter* p, GroupsListDelegate::BtnState state, bool isTopBtn ) const { auto previous_font = p->font(); auto previous_brush = p->brush(); auto previous_pen = p->pen(); p->setFont( BTN_FONT ); std::pair<QColor, QPen> fill_pen_pair; switch ( state ) { case BtnState::Default: { fill_pen_pair = ( isTopBtn ) ? m_top_btn_style.fillAndPenStroke() : m_bottom_btn_style.fillAndPenStroke(); break; } case BtnState::Hover: { fill_pen_pair = ( isTopBtn ) ? m_top_btn_style.fillAndPenStroke( ButtonStyle::Hover ) : m_bottom_btn_style.fillAndPenStroke( ButtonStyle::Hover ); break; } case BtnState::Pressed: { fill_pen_pair = ( isTopBtn ) ? m_top_btn_style.fillAndPenStroke( ButtonStyle::Pressed ) : m_bottom_btn_style.fillAndPenStroke( ButtonStyle::Pressed ); break; } case BtnState::Disabled: { fill_pen_pair = ( isTopBtn ) ? m_top_btn_style.fillAndPenStroke( ButtonStyle::Disabled ) : m_bottom_btn_style.fillAndPenStroke( ButtonStyle::Disabled ); break; } default: qDebug() << "\nInvalid state..." ; } auto [ fill, pen ] = fill_pen_pair; p->setBrush( fill ); p->setPen( pen ); // frame btn p->drawRoundedRect( button_rect, 4, 4 ); // text p->setPen( Qt::black ); p->drawText( button_rect, Qt::AlignCenter, text ); // restore original font, brush and pen p->setFont( previous_font ); p->setBrush( previous_brush ); p->setPen( previous_pen ); return; } QRect& GroupsListDelegate::_offsetXYRect(int x_offset, int y_offset, QRect& originalRect ) const{ originalRect.translate( x_offset, y_offset ); return originalRect; } bool GroupsListDelegate::_isMouseEvent( QEvent* event ) const{ switch ( event->type() ) { case QEvent::MouseMove: case QEvent::MouseButtonPress: case QEvent::MouseButtonRelease: case QEvent::MouseButtonDblClick: case QEvent::Wheel: return true; default: return false; } } /** If the click is inside the button * but the button is Disabled, simply return true * to consume the mouseEvent * Otherwise checks the button state*/ bool GroupsListDelegate::_updateStateButton( QPoint mousePos, QRect btnRect, BtnState& currState, BtnState newState ) { m_lastMousePressedPos = mousePos; // ignore event if button is set to disabled if (( currState == BtnState::Disabled && btnRect.contains( mousePos )) ) { qDebug() << "\nNothing changed.. the button is disabled or is already in the newState"; return true; } if ( btnRect.contains( mousePos ) ) { currState = newState; // repaint the button to reflect the new state m_view->update( btnRect ); return true; } else { currState = BtnState::Default; btnRect.adjust( -10, -10, +10, +10 ); // repaint the button to reflect the new state m_view->update( btnRect ); return false; } return false; }
-
Which is the purpose of the ButtonStyle class:
In order to rappresent the various status of the buttons ( hover, pressed, disabled and default ) I defined this simple class that is responsable only for that and contains simply pairs of QColor that rappresent Fill ( background-color in stylesheets and Strokes ( equivalent to border-color ). This is a personal choise and someone will surely came up with a better and more elegant solution maybe utilazing QStyledOption.drawControls() for a more native look for the buttons.
In my version of the StyledDelegate the state Disabled is not fully handled. To do so, you need to implement two extra methods called "setDisabledButton( bool disabled, bool setTopButton = true ) and hand set the ButtonState for the right button to BtnState::Disabled and do the same for the setEnabledButton() and test the cases if all works as expected
-
this is an header only, so no .cpp file exists. this isn't the full implementation beacuse the server continue to mark it as spam
struct ButtonStyle{ using FillStrokePair = std::pair<QColor, QColor>; enum ButtonStates { Disabled = 0, Default = 1, Hover = 2, Pressed = 3 }; FillStrokePair FS_disabled{ std::make_pair( Qt::gray, Qt::darkGray) }; FillStrokePair FS_default { std::make_pair( Qt::white, Qt::black ) }; FillStrokePair FS_hover { std::make_pair( Qt::white, QColor(251, 173, 56) ) }; FillStrokePair FS_pressed { std::make_pair( QColor(251, 237, 214), QColor(251, 173, 56) ) }; QPen pen_disabled{ QPen( FS_disabled.second, 2.0, Qt::SolidLine ) }; QPen pen_default { QPen( FS_default.second , 2.0, Qt::SolidLine ) }; QPen pen_hover { QPen( FS_hover.second , 2.0, Qt::SolidLine ) }; QPen pen_pressed { QPen( FS_pressed.second , 2.0, Qt::SolidLine ) };
-
I ended up using a slightly different approach that stores each button style inside a QHash inside the custom TableModel.
the code is available on github at:
https://github.com/aVenturelli-qt/ArchWay-Project-Manager/tree/main/models -