Important: Please read the Qt Code of Conduct - https://forum.qt.io/topic/113070/qt-code-of-conduct

QComboBox with checkable items: issue with single checked item



  • I'm working on a multi-select combobox. It is implemented using custom model

    class ItemModel : public QStandardItemModel
    {
        Q_OBJECT
    
      public:
        ItemModel( QObject *parent = nullptr );
        Qt::ItemFlags flags( const QModelIndex &index ) const override;
        QVariant data( const QModelIndex &index, int role = Qt::DisplayRole ) const override;
        bool setData( const QModelIndex &index, const QVariant &value, int role = Qt::EditRole ) override;
    
      signals:
        void itemCheckStateChanged();
    };
    
    ItemModel::ItemModel( QObject *parent )
      : QStandardItemModel( 0, 1, parent )
    {
    }
    
    Qt::ItemFlags ItemModel::flags( const QModelIndex &index ) const
    {
      return QStandardItemModel::flags( index ) | Qt::ItemIsUserCheckable;
    }
    
    QVariant ItemModel::data( const QModelIndex &index, int role ) const
    {
      QVariant value = QStandardItemModel::data( index, role );
    
      if ( index.isValid() && role == Qt::CheckStateRole && !value.isValid() )
      {
        value = Qt::Unchecked;
      }
    
      return value;
    }
    
    bool ItemModel::setData( const QModelIndex &index, const QVariant &value, int role )
    {
      bool ok = QStandardItemModel::setData( index, value, role );
    
      if ( ok && role == Qt::CheckStateRole )
      {
        emit dataChanged( index, index );
        emit itemCheckStateChanged();
      }
    
      return ok;
    }
    

    and QComboBox subclass

    class GUI_EXPORT CComboBox : public QComboBox
    {
        Q_OBJECT
    
      public:
        CComboBox( QWidget *parent = nullptr );
        QString separator() const;
        void setSeparator( const QString &separator );
        QString defaultText() const;
        void setDefaultText( const QString &text );
        QStringList checkedItems() const;
        Qt::CheckState itemCheckState( int index ) const;
        void setItemCheckState( int index, Qt::CheckState state );
        void toggleItemCheckState( int index );
        void hidePopup() override;
        bool eventFilter( QObject *object, QEvent *event ) override;
    
      signals:
        void checkedItemsChanged( const QStringList &items );
    
      public slots:
        void setCheckedItems( const QStringList &items );
    
      protected:
        void resizeEvent( QResizeEvent *event ) override;
    
      protected slots:
        void showContextMenu( QPoint pos );
        void selectAllOptions();
        void deselectAllOptions();
    
      private:
        void updateCheckedItems();
        void updateDisplayText();
    
        QString mSeparator;
        QString mDefaultText;
    
        bool mSkipHide = false;
    
        QMenu *mContextMenu = nullptr;
        QAction *mSelectAllAction = nullptr;
        QAction *mDeselectAllAction = nullptr;
    };
    
    CComboBox::CComboBox( QWidget *parent )
      : QComboBox( parent )
      , mSeparator( QStringLiteral( ", " ) )
    {
      setModel( new ItemModel( this ) );
    
      QLineEdit *lineEdit = new QLineEdit( this );
      lineEdit->setReadOnly( true );
      setLineEdit( lineEdit );
    
      mContextMenu = new QMenu( this );
      mSelectAllAction = mContextMenu->addAction( tr( "Select All" ) );
      mDeselectAllAction = mContextMenu->addAction( tr( "Deselect All" ) );
      connect( mSelectAllAction, &QAction::triggered, this, &CComboBox::selectAllOptions );
      connect( mDeselectAllAction, &QAction::triggered, this, &CComboBox::deselectAllOptions );
    
      view()->viewport()->installEventFilter( this );
      view()->setContextMenuPolicy( Qt::CustomContextMenu );
      connect( view(), &QAbstractItemView::customContextMenuRequested, this, &CComboBox::showContextMenu );
    
      ItemModel *myModel = qobject_cast<QgsCheckableItemModel *>( model() );
      connect( myModel, &QgsCheckableItemModel::itemCheckStateChanged, this, &CComboBox::updateCheckedItems );
      connect( model(), &QStandardItemModel::rowsInserted, this, [ = ]( const QModelIndex &, int, int ) { updateCheckedItems(); } );
      connect( model(), &QStandardItemModel::rowsRemoved, this, [ = ]( const QModelIndex &, int, int ) { updateCheckedItems(); } );
      connect( this, static_cast< void ( QComboBox::* )( int ) >( &QComboBox::activated ), this, &CComboBox::toggleItemCheckState );
    }
    
    QString CComboBox::separator() const
    {
      return mSeparator;
    }
    
    void CComboBox::setSeparator( const QString &separator )
    {
      if ( mSeparator != separator )
      {
        mSeparator = separator;
        updateDisplayText();
      }
    }
    
    QString CComboBox::defaultText() const
    {
      return mDefaultText;
    }
    
    void CComboBox::setDefaultText( const QString &text )
    {
      if ( mDefaultText != text )
      {
        mDefaultText = text;
        updateDisplayText();
      }
    }
    
    QStringList CComboBox::checkedItems() const
    {
      QStringList items;
    
      if ( model() )
      {
        QModelIndex index = model()->index( 0, modelColumn(), rootModelIndex() );
        QModelIndexList indexes = model()->match( index, Qt::CheckStateRole, Qt::Checked, -1, Qt::MatchExactly );
        const auto constIndexes = indexes;
        for ( const QModelIndex &index : constIndexes )
        {
          items += index.data().toString();
        }
      }
    
      return items;
    }
    
    Qt::CheckState CComboBox::itemCheckState( int index ) const
    {
      return static_cast<Qt::CheckState>( itemData( index, Qt::CheckStateRole ).toInt() );
    }
    
    void CComboBox::setItemCheckState( int index, Qt::CheckState state )
    {
      setItemData( index, state, Qt::CheckStateRole );
    }
    
    void CComboBox::toggleItemCheckState( int index )
    {
      QVariant value = itemData( index, Qt::CheckStateRole );
      if ( value.isValid() )
      {
        Qt::CheckState state = static_cast<Qt::CheckState>( value.toInt() );
        setItemData( index, ( state == Qt::Unchecked ? Qt::Checked : Qt::Unchecked ), Qt::CheckStateRole );
      }
    }
    
    void CComboBox::hidePopup()
    {
      if ( !mSkipHide )
      {
        QComboBox::hidePopup();
      }
      mSkipHide = false;
    }
    
    void CComboBox::showContextMenu( QPoint pos )
    {
      Q_UNUSED( pos )
    
      mContextMenu->exec( QCursor::pos() );
    }
    
    void CComboBox::selectAllOptions()
    {
      blockSignals( true );
      for ( int i = 0;  i < count(); i++ )
      {
        setItemData( i, Qt::Checked, Qt::CheckStateRole );
      }
      blockSignals( false );
      updateCheckedItems();
    }
    
    void CComboBox::deselectAllOptions()
    {
      blockSignals( true );
      for ( int i = 0;  i < count(); i++ )
      {
        setItemData( i, Qt::Unchecked, Qt::CheckStateRole );
      }
      blockSignals( false );
      updateCheckedItems();
    }
    
    bool CComboBox::eventFilter( QObject *object, QEvent *event )
    {
      if ( ( event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonRelease )
           && object == view()->viewport() )
      {
        mSkipHide = true;
      }
    
      if ( event->type() == QEvent::MouseButtonRelease )
      {
        if ( static_cast<QMouseEvent *>( event )->button() == Qt::RightButton )
        {
          return true;
        }
      }
      return QComboBox::eventFilter( object, event );
    }
    
    void CComboBox::setCheckedItems( const QStringList &items )
    {
      const auto constItems = items;
      for ( const QString &text : constItems )
      {
        const int index = findText( text );
        setItemCheckState( index, index != -1 ? Qt::Checked : Qt::Unchecked );
      }
    }
    
    void CComboBox::resizeEvent( QResizeEvent *event )
    {
      QComboBox::resizeEvent( event );
      updateDisplayText();
    }
    
    void CComboBox::updateCheckedItems()
    {
      QStringList items = checkedItems();
      updateDisplayText();
      emit checkedItemsChanged( items );
    }
    
    void CComboBox::updateDisplayText()
    {
      QString text;
      QStringList items = checkedItems();
      if ( items.isEmpty() )
      {
        text = mDefaultText;
      }
      else
      {
        text = items.join( mSeparator );
      }
    
      QRect rect = lineEdit()->rect();
      QFontMetrics fontMetrics( font() );
      text = fontMetrics.elidedText( text, Qt::ElideRight, rect.width() );
      setEditText( text );
    }
    

    This implementation works almost fine. There is only one problem: if I check two or more items in the combobox, then uncheck all items except one and click outside combobox to close its popup, the item that was still checked is also become unchecked most of the times.
    I suspect that this somehow related to use of activated() signal to toggle item checkstate, but stuck with finding a solution. Any ideas how to fix this issue?



  • Apparently the problem is activated() signal which is sent even when the choice is not changed. So if I have a single item selected then in the slot item state it toggled to the unchecked state.

    I have tried to use clicked() signal of the combobox's view but it does not work at all, slot never called.

    I'm on Linux with Qt 5.9.5.



  • I think I have found a solution that is not necessarily complete (e.g. drive the combbox with keyboard).

    1. I add a boolean (e.g. mSkipToogle=false) at the same place than mSkipHide.

    2. I rewrite eventFilter (QObject* object, QEvent* event).

    bool CFilterCountryComboBox::eventFilter (QObject* object, QEvent* event)
    {
      QEvent::Type type = event->type ();
      if (object == view ()->viewport ())
      {
        switch (type)
        {
          case QEvent::MouseButtonRelease :
          case QEvent::MouseButtonPress :
            mSkipHide   = true;
            mSkipToogle = false;
            break;
    
          case QEvent::Hide :
          case QEvent::Show :
            mSkipToogle = true;
           break;
    
          default :
            break;
        }
      }
    
      if (type == QEvent::MouseButtonRelease && static_cast<QMouseEvent*>(event)->button () == Qt::RightButton)
      {
        return true;
      }
    
      return QComboBox::eventFilter (object, event);
    }
    
    1. Now rewrite toggleItemCheckState (int index)
    void CFilterCountryComboBox::toggleItemCheckState (int index)
    {
      if (!mSkipToogle)
      {
        QVariant value = itemData (index, Qt::CheckStateRole);
        Qt::CheckState state = static_cast<Qt::CheckState>(value.toInt ());
        setItemData (index, (state == Qt::Unchecked ? Qt::Checked : Qt::Unchecked), Qt::CheckStateRole);
      }
      else
      {
        mSkipToogle = false;
      }
    }
    

    It seems to work on Windows with Qt 5.12.7.
    Good luck


Log in to reply