[system/dolphin] src: Allow collapsing and expanding groups in "Show in Groups" mode
Méven Car <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit 84ce18212cd820087e613f1ee6136faf6b68a32a by Méven Car, on behalf of Ramil Nurmanov.
Committed on 18/07/2026 at 11:15.
Pushed by meven into branch 'master'.
Allow collapsing and expanding groups in "Show in Groups" mode
## Summary
When "Show in Groups" is enabled, items are split into group headers based on the current grouping criterion (e.g. by Type). Until now these groups were always fully expanded with no way to fold them.
This MR adds the ability to collapse and expand individual groups by clicking on the group header (or its disclosure arrow). A collapsed group shows only its header and hides the items beneath it. The expanded/collapsed state is kept per view while navigating.
This brings Dolphin's grouped views closer to the behavior users expect from Windows Explorer and macOS Finder.

BUG: 521213
M +2 -2 src/kitemviews/kitemlistcontroller.cpp
M +22 -0 src/kitemviews/kitemlistgroupheader.cpp
M +6 -0 src/kitemviews/kitemlistgroupheader.h
M +118 -75 src/kitemviews/kitemlistview.cpp
M +21 -20 src/kitemviews/kitemlistview.h
M +35 -3 src/kitemviews/kstandarditemlistgroupheader.cpp
M +1 -0 src/kitemviews/kstandarditemlistgroupheader.h
M +108 -41 src/kitemviews/private/kitemlistviewlayouter.cpp
M +12 -0 src/kitemviews/private/kitemlistviewlayouter.h
M +86 -0 src/tests/kitemlistcontrollertest.cpp
https://invent.kde.org/system/dolphin/-/commit/84ce18212cd820087e613f1ee6136faf6b68a32a
diff --git a/src/kitemviews/kitemlistcontroller.cpp b/src/kitemviews/kitemlistcontroller.cpp
index b1927fc71b..fdf776efc0 100644
--- a/src/kitemviews/kitemlistcontroller.cpp
+++ b/src/kitemviews/kitemlistcontroller.cpp
@@ -188,13 +188,13 @@ int KItemListController::indexCloseToMousePressedPosition() const
{
const QPointF pressedMousePos = m_view->transform().map(m_view->scene()->views().first()->mapFromGlobal(m_pressedMouseGlobalPos.toPoint()));
- QHashIterator<KItemListWidget *, KItemListGroupHeader *> it(m_view->m_visibleGroups);
+ QHashIterator<int, KItemListGroupHeader *> it(m_view->m_visibleGroups);
while (it.hasNext()) {
it.next();
KItemListGroupHeader *groupHeader = it.value();
const QPointF mappedToGroup = groupHeader->mapFromItem(nullptr, pressedMousePos);
if (groupHeader->contains(mappedToGroup)) {
- return it.key()->index();
+ return it.key();
}
}
return -1;
diff --git a/src/kitemviews/kitemlistgroupheader.cpp b/src/kitemviews/kitemlistgroupheader.cpp
index f64ff28841..b51c8f1c5d 100644
--- a/src/kitemviews/kitemlistgroupheader.cpp
+++ b/src/kitemviews/kitemlistgroupheader.cpp
@@ -22,6 +22,7 @@ KItemListGroupHeader::KItemListGroupHeader(QGraphicsWidget *parent)
, m_styleOption()
, m_scrollOrientation(Qt::Vertical)
, m_itemIndex(-1)
+ , m_collapsed(false)
, m_separatorColor()
, m_roleColor()
, m_roleBounds()
@@ -104,6 +105,21 @@ int KItemListGroupHeader::itemIndex() const
return m_itemIndex;
}
+void KItemListGroupHeader::setCollapsed(bool collapsed)
+{
+ if (m_collapsed != collapsed) {
+ const bool previous = m_collapsed;
+ m_collapsed = collapsed;
+ update();
+ collapsedChanged(collapsed, previous);
+ }
+}
+
+bool KItemListGroupHeader::isCollapsed() const
+{
+ return m_collapsed;
+}
+
Qt::Orientation KItemListGroupHeader::scrollOrientation() const
{
return m_scrollOrientation;
@@ -153,6 +169,12 @@ void KItemListGroupHeader::itemIndexChanged(int current, int previous)
Q_UNUSED(previous)
}
+void KItemListGroupHeader::collapsedChanged(bool current, bool previous)
+{
+ Q_UNUSED(current)
+ Q_UNUSED(previous)
+}
+
void KItemListGroupHeader::resizeEvent(QGraphicsSceneResizeEvent *event)
{
QGraphicsWidget::resizeEvent(event);
diff --git a/src/kitemviews/kitemlistgroupheader.h b/src/kitemviews/kitemlistgroupheader.h
index 1522af0ce8..d308c21dab 100644
--- a/src/kitemviews/kitemlistgroupheader.h
+++ b/src/kitemviews/kitemlistgroupheader.h
@@ -51,6 +51,9 @@ public:
void setItemIndex(int index);
int itemIndex() const;
+ void setCollapsed(bool collapsed);
+ bool isCollapsed() const;
+
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget = nullptr) override;
protected:
@@ -63,6 +66,8 @@ protected:
*/
virtual void roleChanged(const QByteArray ¤t, const QByteArray &previous);
+ virtual void collapsedChanged(bool current, bool previous);
+
/**
* Is called after the role has been changed and allows the derived class
* to react on this change.
@@ -108,6 +113,7 @@ private:
KItemListStyleOption m_styleOption;
Qt::Orientation m_scrollOrientation;
int m_itemIndex;
+ bool m_collapsed;
QColor m_separatorColor;
QColor m_roleColor;
diff --git a/src/kitemviews/kitemlistview.cpp b/src/kitemviews/kitemlistview.cpp
index d24c2426db..34b987cf88 100644
--- a/src/kitemviews/kitemlistview.cpp
+++ b/src/kitemviews/kitemlistview.cpp
@@ -948,6 +948,12 @@ void KItemListView::setStyleOption(const KItemListStyleOption &option)
it.value()->setStyleOption(option);
}
+ if (m_grouped) {
+ for (KItemListGroupHeader *header : std::as_const(m_visibleGroups)) {
+ header->setStyleOption(option);
+ }
+ }
+
m_sizeHintResolver->clearCache();
m_layouter->markAsDirty();
doLayout(animate ? Animation : NoAnimation);
@@ -971,7 +977,7 @@ void KItemListView::setScrollOrientation(Qt::Orientation orientation)
m_sizeHintResolver->clearCache();
if (m_grouped) {
- QMutableHashIterator<KItemListWidget *, KItemListGroupHeader *> it(m_visibleGroups);
+ QMutableHashIterator<int, KItemListGroupHeader *> it(m_visibleGroups);
while (it.hasNext()) {
it.next();
it.value()->setScrollOrientation(orientation);
@@ -1073,6 +1079,23 @@ void KItemListView::onTransactionEnd()
bool KItemListView::event(QEvent *event)
{
+ if (m_grouped && event->type() == QEvent::GraphicsSceneMousePress) {
+ auto *mouseEvent = static_cast<QGraphicsSceneMouseEvent *>(event);
+ const QPointF pos = transform().map(mouseEvent->pos());
+
+ QHashIterator<int, KItemListGroupHeader *> it(m_visibleGroups);
+ while (it.hasNext()) {
+ it.next();
+ KItemListGroupHeader *header = it.value();
+ const QPointF mappedToGroup = header->mapFromItem(this, pos);
+ if (header->contains(mappedToGroup)) {
+ toggleGroupCollapse(header->data());
+ event->accept();
+ return true;
+ }
+ }
+ }
+
switch (event->type()) {
case QEvent::PaletteChange:
updatePalette();
@@ -1502,15 +1525,14 @@ void KItemListView::slotGroupedSortingChanged(bool current)
if (m_grouped) {
updateGroupHeaderHeight();
} else {
- // Clear all visible headers. Note that the QHashIterator takes a copy of
- // m_visibleGroups. Therefore, it remains valid even if items are removed
- // from m_visibleGroups in recycleGroupHeaderForWidget().
- QHashIterator<KItemListWidget *, KItemListGroupHeader *> it(m_visibleGroups);
- while (it.hasNext()) {
- it.next();
- recycleGroupHeaderForWidget(it.key());
+ for (KItemListGroupHeader *header : std::as_const(m_visibleGroups)) {
+ header->setParentItem(nullptr);
+ groupHeaderCreator()->recycle(header);
}
- Q_ASSERT(m_visibleGroups.isEmpty());
+ m_visibleGroups.clear();
+
+ m_collapsedGroups.clear();
+ updateCollapsedGroupsInLayouter();
}
if (useAlternateBackgrounds()) {
@@ -1538,6 +1560,8 @@ void KItemListView::slotSortRoleChanged(const QByteArray ¤t, const QByteAr
Q_UNUSED(current)
Q_UNUSED(previous)
if (m_grouped) {
+ m_collapsedGroups.clear();
+ updateCollapsedGroupsInLayouter();
updateVisibleGroupHeaders();
doLayout(NoAnimation);
}
@@ -1623,8 +1647,7 @@ void KItemListView::slotAnimationFinished(QGraphicsWidget *widget, KItemListView
// All KItemListWidgets that are animated by the DeleteAnimation are not maintained
// by m_visibleWidgets and must be deleted manually after the animation has
- // been finished.
- recycleGroupHeaderForWidget(itemListWidget);
+ // been finished. Group headers are managed by index and cleaned up in doLayout().
widgetCreator()->recycle(itemListWidget);
} else {
const int index = itemListWidget->index();
@@ -1772,15 +1795,6 @@ void KItemListView::triggerAutoScrolling()
m_autoScrollTimer->start(RepeatingAutoScrollDelay);
}
-void KItemListView::slotGeometryOfGroupHeaderParentChanged()
-{
- KItemListWidget *widget = qobject_cast<KItemListWidget *>(sender());
- Q_ASSERT(widget);
- KItemListGroupHeader *groupHeader = m_visibleGroups.value(widget);
- Q_ASSERT(groupHeader);
- updateGroupHeaderLayout(widget);
-}
-
void KItemListView::slotRoleEditingCanceled(int index, const QByteArray &role, const QVariant &value)
{
disconnectRoleEditingSignals(index);
@@ -1909,11 +1923,32 @@ void KItemListView::doLayout(LayoutAnimationHint hint, int changedIndex, int cha
QList<int> reusableItems = recycleInvisibleItems(firstVisibleIndex, lastVisibleIndex, hint);
+ QSet<int> visitedGroupHeaders;
+
// Assure that for each visible item a KItemListWidget is available. KItemListWidget
// instances from invisible items are reused. If no reusable items are
// found then new KItemListWidget instances get created.
const bool animate = (hint == Animation);
for (int i = firstVisibleIndex; i <= lastVisibleIndex; ++i) {
+ if (m_grouped && m_layouter->isCollapsedGroupItem(i)) {
+ if (m_layouter->isCollapsedGroupFirstItem(i)) {
+ updateGroupHeaderForIndex(i);
+ visitedGroupHeaders.insert(i);
+ }
+ KItemListWidget *existingWidget = m_visibleItems.value(i);
+ if (existingWidget) {
+ if (m_animation->isStarted(existingWidget)) {
+ if (hint == NoAnimation) {
+ m_animation->stop(existingWidget);
+ }
+ } else {
+ existingWidget->setVisible(false);
+ reusableItems.append(i);
+ }
+ }
+ continue;
+ }
+
bool applyNewPos = true;
const QRectF itemBounds = m_layouter->itemRect(i);
@@ -2007,6 +2042,11 @@ void KItemListView::doLayout(LayoutAnimationHint hint, int changedIndex, int cha
widget->setIconSize(newIconSize);
}
+ if (m_grouped && m_layouter->isFirstGroupItem(i)) {
+ updateGroupHeaderForIndex(i);
+ visitedGroupHeaders.insert(i);
+ }
+
// Updating the cell-information must be done as last step: The decision whether the
// moving-animation should be started at all is based on the previous cell-information.
const Cell cell(m_layouter->itemColumn(i), m_layouter->itemRow(i));
@@ -2024,11 +2064,19 @@ void KItemListView::doLayout(LayoutAnimationHint hint, int changedIndex, int cha
}
if (m_grouped) {
- // Update the layout of all visible group headers
- QHashIterator<KItemListWidget *, KItemListGroupHeader *> it(m_visibleGroups);
+ // Update layout of all visited group headers and recycle those no longer visible.
+ QMutableHashIterator<int, KItemListGroupHeader *> it(m_visibleGroups);
while (it.hasNext()) {
it.next();
- updateGroupHeaderLayout(it.key());
+ const int firstItemIndex = it.key();
+ if (visitedGroupHeaders.contains(firstItemIndex)) {
+ updateGroupHeaderLayout(firstItemIndex);
+ } else {
+ KItemListGroupHeader *header = it.value();
+ header->setParentItem(nullptr);
+ groupHeaderCreator()->recycle(header);
+ it.remove();
+ }
}
}
@@ -2067,10 +2115,6 @@ QList<int> KItemListView::recycleInvisibleItems(int firstVisibleIndex, int lastV
} else {
widget->setVisible(false);
items.append(index);
-
- if (m_grouped) {
- recycleGroupHeaderForWidget(widget);
- }
}
}
}
@@ -2156,10 +2200,6 @@ KItemListWidget *KItemListView::createWidget(int index)
void KItemListView::recycleWidget(KItemListWidget *widget)
{
- if (m_grouped) {
- recycleGroupHeaderForWidget(widget);
- }
-
const int index = widget->index();
m_visibleItems.remove(index);
m_visibleCells.remove(index);
@@ -2234,76 +2274,63 @@ void KItemListView::updateWidgetProperties(KItemListWidget *widget, int index)
}
}
-void KItemListView::updateGroupHeaderForWidget(KItemListWidget *widget)
+void KItemListView::updateGroupHeaderForIndex(int firstItemIndex)
{
Q_ASSERT(m_grouped);
- const int index = widget->index();
- if (!m_layouter->isFirstGroupItem(index)) {
- // The widget does not represent the first item of a group
- // and hence requires no header
- recycleGroupHeaderForWidget(widget);
+ const QList<QPair<int, QVariant>> groups = model()->groups();
+ if (groups.isEmpty() || !groupHeaderCreator()) {
return;
}
- const QList<QPair<int, QVariant>> groups = model()->groups();
- if (groups.isEmpty() || !groupHeaderCreator()) {
+ const int groupIndex = groupIndexForItem(firstItemIndex);
+ if (groupIndex < 0) {
return;
}
+ const QVariant groupData = groups.at(groupIndex).second;
- KItemListGroupHeader *groupHeader = m_visibleGroups.value(widget);
+ KItemListGroupHeader *groupHeader = m_visibleGroups.value(firstItemIndex);
if (!groupHeader) {
groupHeader = groupHeaderCreator()->create(this);
- groupHeader->setParentItem(widget);
- m_visibleGroups.insert(widget, groupHeader);
- connect(widget, &KItemListWidget::geometryChanged, this, &KItemListView::slotGeometryOfGroupHeaderParentChanged);
+ groupHeader->setParentItem(this);
+ groupHeader->setZValue(1.0);
+ m_visibleGroups.insert(firstItemIndex, groupHeader);
}
- Q_ASSERT(groupHeader->parentItem() == widget);
- const int groupIndex = groupIndexForItem(index);
- Q_ASSERT(groupIndex >= 0);
- groupHeader->setData(groups.at(groupIndex).second);
+ groupHeader->setData(groupData);
groupHeader->setRole(model()->groupRole());
groupHeader->setStyleOption(m_styleOption);
groupHeader->setScrollOrientation(scrollOrientation());
- groupHeader->setItemIndex(index);
-
+ groupHeader->setItemIndex(firstItemIndex);
+ groupHeader->setCollapsed(m_collapsedGroups.contains(groupData));
groupHeader->show();
}
-void KItemListView::updateGroupHeaderLayout(KItemListWidget *widget)
+void KItemListView::updateGroupHeaderLayout(int firstItemIndex)
{
- KItemListGroupHeader *groupHeader = m_visibleGroups.value(widget);
- Q_ASSERT(groupHeader);
-
- const int index = widget->index();
- const QRectF groupHeaderRect = m_layouter->groupHeaderRect(index);
- const QRectF itemRect = m_layouter->itemRect(index);
+ KItemListGroupHeader *groupHeader = m_visibleGroups.value(firstItemIndex);
+ if (!groupHeader) {
+ return;
+ }
- // The group-header is a child of the itemlist widget. Translate the
- // group header position to the relative position.
+ const QRectF headerRect = m_layouter->groupHeaderRect(firstItemIndex);
if (scrollOrientation() == Qt::Vertical) {
- // In the vertical scroll orientation the group header should always span
- // the whole width no matter which temporary position the parent widget
- // has. In this case the x-position and width will be adjusted manually.
- const qreal x = -widget->x() - itemOffset();
+ // Headers span the full item area width regardless of horizontal scroll.
+ const qreal x = -itemOffset();
const qreal width = maximumItemOffset();
- groupHeader->setPos(x, -groupHeaderRect.height());
- groupHeader->resize(width, groupHeaderRect.size().height());
+ groupHeader->setPos(x, headerRect.top());
+ groupHeader->resize(width, headerRect.height());
} else {
- groupHeader->setPos(groupHeaderRect.x() - itemRect.x(), -widget->y());
- groupHeader->resize(groupHeaderRect.size());
+ groupHeader->setPos(headerRect.topLeft());
+ groupHeader->resize(headerRect.size());
}
}
-void KItemListView::recycleGroupHeaderForWidget(KItemListWidget *widget)
+void KItemListView::updateGroupHeaderForWidget(KItemListWidget *widget)
{
- KItemListGroupHeader *header = m_visibleGroups.value(widget);
- if (header) {
- header->setParentItem(nullptr);
- groupHeaderCreator()->recycle(header);
- m_visibleGroups.remove(widget);
- disconnect(widget, &KItemListWidget::geometryChanged, this, &KItemListView::slotGeometryOfGroupHeaderParentChanged);
+ Q_ASSERT(m_grouped);
+ if (m_layouter->isFirstGroupItem(widget->index())) {
+ updateGroupHeaderForIndex(widget->index());
}
}
@@ -2312,10 +2339,10 @@ void KItemListView::updateVisibleGroupHeaders()
Q_ASSERT(m_grouped);
m_layouter->markAsDirty();
- QHashIterator<int, KItemListWidget *> it(m_visibleItems);
+ QHashIterator<int, KItemListGroupHeader *> it(m_visibleGroups);
while (it.hasNext()) {
it.next();
- updateGroupHeaderForWidget(it.value());
+ updateGroupHeaderForIndex(it.key());
}
}
@@ -2349,6 +2376,22 @@ int KItemListView::groupIndexForItem(int index) const
return mid;
}
+void KItemListView::toggleGroupCollapse(const QVariant &groupData)
+{
+ if (m_collapsedGroups.contains(groupData)) {
+ m_collapsedGroups.removeOne(groupData);
+ } else {
+ m_collapsedGroups.append(groupData);
+ }
+ updateCollapsedGroupsInLayouter();
+ doLayout(NoAnimation);
+}
+
+void KItemListView::updateCollapsedGroupsInLayouter()
+{
+ m_layouter->setCollapsedGroupsData(m_collapsedGroups);
+}
+
void KItemListView::updateAlternateBackgrounds()
{
QHashIterator<int, KItemListWidget *> it(m_visibleItems);
diff --git a/src/kitemviews/kitemlistview.h b/src/kitemviews/kitemlistview.h
index 20c34ceeac..16c92dbc30 100644
--- a/src/kitemviews/kitemlistview.h
+++ b/src/kitemviews/kitemlistview.h
@@ -20,6 +20,7 @@
#include <QGraphicsWidget>
#include <QSet>
+#include <QVariant>
class KItemListContainer;
class KItemListContainerAccessible;
@@ -475,14 +476,6 @@ private Q_SLOTS:
*/
void triggerAutoScrolling();
- /**
- * Is invoked if the geometry of the parent-widget from a group-header has been
- * changed. The x-position and width of the group-header gets adjusted to assure
- * that it always spans the whole width even during temporary transitions of the
- * parent widget.
- */
- void slotGeometryOfGroupHeaderParentChanged();
-
void slotRoleEditingCanceled(int index, const QByteArray &role, const QVariant &value);
void slotRoleEditingFinished(int index, const QByteArray &role, const QVariant &value);
@@ -547,27 +540,30 @@ private:
void updateWidgetProperties(KItemListWidget *widget, int index);
/**
- * Helper method for updateWidgetPropertes() to create or update
- * the itemlist group-header.
+ * Creates or updates the group-header for the group whose first item is
+ * \a firstItemIndex. The header is a direct child of the view (not of any
+ * item widget), so it is independent of item widget recycling.
+ * Called for both expanded groups (when the first item is visible) and
+ * collapsed groups (when the header enters the viewport).
*/
- void updateGroupHeaderForWidget(KItemListWidget *widget);
+ void updateGroupHeaderForIndex(int firstItemIndex);
/**
- * Updates the position and size of the group-header that belongs
- * to the itemlist widget \a widget. The given widget must represent
- * the first item of a group.
+ * Updates the position and size of the group-header identified by
+ * \a firstItemIndex. The coordinates are derived from groupHeaderRect().
*/
- void updateGroupHeaderLayout(KItemListWidget *widget);
+ void updateGroupHeaderLayout(int firstItemIndex);
/**
- * Recycles the group-header for the widget.
+ * Called from updateWidgetProperties() when the widget represents the first
+ * item of a group.
*/
- void recycleGroupHeaderForWidget(KItemListWidget *widget);
+ void updateGroupHeaderForWidget(KItemListWidget *widget);
/**
* Helper method for slotGroupedSortingChanged(), slotSortOrderChanged()
- * and slotSortRoleChanged(): Iterates through all visible items and updates
- * the group-header widgets.
+ * and slotSortRoleChanged(): Iterates through all visible group headers and
+ * updates their data and collapse state.
*/
void updateVisibleGroupHeaders();
@@ -578,6 +574,9 @@ private:
*/
int groupIndexForItem(int index) const;
+ void toggleGroupCollapse(const QVariant &groupData);
+ void updateCollapsedGroupsInLayouter();
+
/**
* Updates the alternate background for all visible items.
* @see updateAlternateBackgroundForWidget()
@@ -749,7 +748,9 @@ private:
KItemListStyleOption m_styleOption;
QHash<int, KItemListWidget *> m_visibleItems;
- QHash<KItemListWidget *, KItemListGroupHeader *> m_visibleGroups;
+ QHash<int, KItemListGroupHeader *> m_visibleGroups;
+
+ QList<QVariant> m_collapsedGroups;
struct Cell {
Cell()
diff --git a/src/kitemviews/kstandarditemlistgroupheader.cpp b/src/kitemviews/kstandarditemlistgroupheader.cpp
index 5b93091d27..af3fe6caf8 100644
--- a/src/kitemviews/kstandarditemlistgroupheader.cpp
+++ b/src/kitemviews/kstandarditemlistgroupheader.cpp
@@ -9,7 +9,10 @@
#include "kstandarditemlistgroupheader.h"
#include <KRatingPainter>
+#include <QApplication>
#include <QPainter>
+#include <QStyle>
+#include <QStyleOption>
KStandardItemListGroupHeader::KStandardItemListGroupHeader(QGraphicsWidget *parent)
: KItemListGroupHeader(parent)
@@ -31,11 +34,33 @@ void KStandardItemListGroupHeader::paint(QPainter *painter, const QStyleOptionGr
void KStandardItemListGroupHeader::paintRole(QPainter *painter, const QRectF &roleBounds, const QColor &color)
{
+ const int arrowSize = qMax(8, styleOption().fontMetrics.height());
+ const int arrowSpacing = qMax(2, styleOption().padding);
+ const QRect arrowRect(qRound(roleBounds.left()), qRound(roleBounds.center().y() - arrowSize * 0.5), arrowSize, arrowSize);
+
+ QStyle::PrimitiveElement pe;
+ if (isCollapsed()) {
+ pe = (layoutDirection() == Qt::RightToLeft) ? QStyle::PE_IndicatorArrowLeft : QStyle::PE_IndicatorArrowRight;
+ } else {
+ pe = QStyle::PE_IndicatorArrowDown;
+ }
+
+ QStyleOption opt;
+ opt.rect = arrowRect;
+ opt.state = QStyle::State_Enabled;
+ opt.palette = QApplication::palette();
+ opt.palette.setColor(QPalette::WindowText, color);
+ opt.palette.setColor(QPalette::ButtonText, color);
+ opt.palette.setColor(QPalette::Text, color);
+
+ QApplication::style()->drawPrimitive(pe, &opt, painter, nullptr);
+
+ const QRectF textBounds = roleBounds.adjusted(arrowSize + arrowSpacing, 0, 0, 0);
+ painter->setPen(color);
if (m_pixmap.isNull()) {
- painter->setPen(color);
- painter->drawText(roleBounds, 0, m_text);
+ painter->drawText(textBounds, 0, m_text);
} else {
- painter->drawPixmap(roleBounds.topLeft(), m_pixmap);
+ painter->drawPixmap(textBounds.topLeft(), m_pixmap);
}
}
@@ -74,6 +99,13 @@ void KStandardItemListGroupHeader::dataChanged(const QVariant ¤t, const QV
m_dirtyCache = true;
}
+void KStandardItemListGroupHeader::collapsedChanged(bool current, bool previous)
+{
+ Q_UNUSED(current)
+ Q_UNUSED(previous)
+ m_dirtyCache = true;
+}
+
void KStandardItemListGroupHeader::resizeEvent(QGraphicsSceneResizeEvent *event)
{
KItemListGroupHeader::resizeEvent(event);
diff --git a/src/kitemviews/kstandarditemlistgroupheader.h b/src/kitemviews/kstandarditemlistgroupheader.h
index ee1df76fb9..18301dcb3c 100644
--- a/src/kitemviews/kstandarditemlistgroupheader.h
+++ b/src/kitemviews/kstandarditemlistgroupheader.h
@@ -27,6 +27,7 @@ protected:
void paintSeparator(QPainter *painter, const QColor &color) override;
void roleChanged(const QByteArray ¤t, const QByteArray &previous) override;
void dataChanged(const QVariant ¤t, const QVariant &previous) override;
+ void collapsedChanged(bool current, bool previous) override;
void resizeEvent(QGraphicsSceneResizeEvent *event) override;
private:
diff --git a/src/kitemviews/private/kitemlistviewlayouter.cpp b/src/kitemviews/private/kitemlistviewlayouter.cpp
index fdf59812f9..4be0c3c0e0 100644
--- a/src/kitemviews/private/kitemlistviewlayouter.cpp
+++ b/src/kitemviews/private/kitemlistviewlayouter.cpp
@@ -216,6 +216,16 @@ QRectF KItemListViewLayouter::itemRect(int index) const
return QRectF();
}
+ if (!m_collapsedGroupRanges.isEmpty()) {
+ auto it = m_collapsedGroupRanges.upperBound(index);
+ if (it != m_collapsedGroupRanges.begin()) {
+ --it;
+ if (index >= it.key() && index <= it.value()) {
+ return QRectF();
+ }
+ }
+ }
+
QSizeF sizeHint = m_sizeHintResolver->sizeHint(index);
const qreal x = m_columnOffsets.at(m_itemInfos.at(index).column);
@@ -247,51 +257,46 @@ QRectF KItemListViewLayouter::groupHeaderRect(int index) const
{
const_cast<KItemListViewLayouter *>(this)->doLayout();
- const QRectF firstItemRect = itemRect(index);
- QPointF pos = firstItemRect.topLeft();
- if (pos.isNull()) {
- return QRectF();
- }
-
- QSizeF size;
if (m_scrollOrientation == Qt::Vertical) {
- pos.rx() = 0;
- pos.ry() -= m_groupHeaderHeight;
- size = QSizeF(m_size.width(), m_groupHeaderHeight);
- } else {
- pos.ry() = 0;
-
- // Determine the maximum width used in the current column. As the
- // scroll-direction is Qt::Horizontal and m_itemRects is accessed
- // directly, the logical height represents the visual width, and
- // the logical row represents the column.
- qreal headerWidth = minimumGroupHeaderWidth();
- const int row = m_itemInfos[index].row;
- const int maxIndex = m_itemInfos.count() - 1;
- while (index <= maxIndex) {
- if (m_itemInfos[index].row != row) {
- break;
- }
-
- const qreal itemWidth =
- (m_scrollOrientation == Qt::Vertical) ? m_sizeHintResolver->sizeHint(index).width() : m_sizeHintResolver->sizeHint(index).height();
-
- if (itemWidth > headerWidth) {
- headerWidth = itemWidth;
- }
-
- ++index;
+ auto it = m_groupHeaderAbsY.constFind(index);
+ if (it == m_groupHeaderAbsY.constEnd()) {
+ return QRectF();
}
+ return QRectF(0, it.value() - m_scrollOffset, m_size.width(), m_groupHeaderHeight);
+ }
- size = QSizeF(headerWidth, m_size.height());
-
- if (QGuiApplication::isRightToLeft()) {
- pos.setX(firstItemRect.right() + m_itemMargin.width() - size.width());
- } else {
- pos.rx() -= m_itemMargin.width();
+ // Use the stored absolute header position so collapsed groups (whose items return
+ // an empty itemRect) are still positioned correctly.
+ auto absIt = m_groupHeaderAbsY.constFind(index);
+ if (absIt == m_groupHeaderAbsY.constEnd()) {
+ return QRectF();
+ }
+ const qreal absPos = absIt.value();
+
+ // Determine the maximum width used in the current column. As the
+ // scroll-direction is Qt::Horizontal, the logical height represents the
+ // visual width and the logical row represents the column.
+ qreal headerWidth = minimumGroupHeaderWidth();
+ const int row = m_itemInfos[index].row;
+ const int maxIndex = m_itemInfos.count() - 1;
+ while (index <= maxIndex) {
+ if (m_itemInfos[index].row != row) {
+ break;
+ }
+ const qreal itemWidth = m_sizeHintResolver->sizeHint(index).height();
+ if (itemWidth > headerWidth) {
+ headerWidth = itemWidth;
}
+ ++index;
}
+ const QSizeF size(headerWidth, m_size.height());
+ QPointF pos(0, 0);
+ if (QGuiApplication::isRightToLeft()) {
+ pos.setX(m_size.width() - 1 + m_scrollOffset - absPos + m_itemMargin.width() - size.width());
+ } else {
+ pos.setX(absPos - m_scrollOffset - m_itemMargin.width());
+ }
return QRectF(pos, size);
}
@@ -335,6 +340,34 @@ bool KItemListViewLayouter::isFirstGroupItem(int itemIndex) const
return m_groupItemIndexes.contains(itemIndex);
}
+void KItemListViewLayouter::setCollapsedGroupsData(const QList<QVariant> &collapsedGroupsData)
+{
+ if (m_collapsedGroupsData != collapsedGroupsData) {
+ m_collapsedGroupsData = collapsedGroupsData;
+ m_dirty = true;
+ }
+}
+
+bool KItemListViewLayouter::isCollapsedGroupFirstItem(int itemIndex) const
+{
+ const_cast<KItemListViewLayouter *>(this)->doLayout();
+ return m_collapsedGroupFirstIndexes.contains(itemIndex);
+}
+
+bool KItemListViewLayouter::isCollapsedGroupItem(int itemIndex) const
+{
+ const_cast<KItemListViewLayouter *>(this)->doLayout();
+ if (m_collapsedGroupRanges.isEmpty()) {
+ return false;
+ }
+ auto it = m_collapsedGroupRanges.upperBound(itemIndex);
+ if (it == m_collapsedGroupRanges.begin()) {
+ return false;
+ }
+ --it;
+ return itemIndex >= it.key() && itemIndex <= it.value();
+}
+
void KItemListViewLayouter::markAsDirty()
{
m_dirty = true;
@@ -470,10 +503,34 @@ void KItemListViewLayouter::doLayout()
// The first group header should be aligned on top
y -= itemMargin.height();
}
+ m_groupHeaderAbsY[index] = y;
if (!horizontalScrolling) {
y += m_groupHeaderHeight;
}
+
+ if (!m_collapsedGroupFirstIndexes.isEmpty() && m_collapsedGroupFirstIndexes.contains(index)) {
+ m_rowOffsets[row] = y;
+ qreal colWidth = minimumGroupHeaderWidth();
+ int col = 0;
+ while (index < itemCount) {
+ m_itemInfos[index].column = col % m_columnCount;
+ m_itemInfos[index].row = row;
+ if (horizontalScrolling) {
+ colWidth = qMax(colWidth, m_sizeHintResolver->sizeHint(index).height());
+ }
+ ++col;
+ ++index;
+ if (m_groupItemIndexes.contains(index)) {
+ break;
+ }
+ }
+ if (horizontalScrolling) {
+ y += colWidth;
+ }
+ ++row;
+ continue;
+ }
}
}
@@ -613,20 +670,30 @@ void KItemListViewLayouter::updateVisibleIndexes()
bool KItemListViewLayouter::createGroupHeaders()
{
+ m_groupItemIndexes.clear();
+ m_collapsedGroupFirstIndexes.clear();
+ m_collapsedGroupRanges.clear();
+ m_groupHeaderAbsY.clear();
+
if (!m_model->groupedSorting()) {
return false;
}
- m_groupItemIndexes.clear();
-
const QList<QPair<int, QVariant>> groups = m_model->groups();
if (groups.isEmpty()) {
return false;
}
+ const int totalItemCount = m_model->count();
for (int i = 0; i < groups.count(); ++i) {
const int firstItemIndex = groups.at(i).first;
m_groupItemIndexes.insert(firstItemIndex);
+
+ if (!m_collapsedGroupsData.isEmpty() && m_collapsedGroupsData.contains(groups.at(i).second)) {
+ m_collapsedGroupFirstIndexes.insert(firstItemIndex);
+ const int lastItemIndex = (i + 1 < groups.count()) ? groups.at(i + 1).first - 1 : totalItemCount - 1;
+ m_collapsedGroupRanges.insert(firstItemIndex, lastItemIndex);
+ }
}
return true;
diff --git a/src/kitemviews/private/kitemlistviewlayouter.h b/src/kitemviews/private/kitemlistviewlayouter.h
index fed541a4c0..096afe060d 100644
--- a/src/kitemviews/private/kitemlistviewlayouter.h
+++ b/src/kitemviews/private/kitemlistviewlayouter.h
@@ -9,10 +9,13 @@
#include "dolphin_export.h"
+#include <QHash>
+#include <QMap>
#include <QObject>
#include <QRectF>
#include <QSet>
#include <QSizeF>
+#include <QVariant>
#include <QVector>
class KItemModelBase;
@@ -143,6 +146,10 @@ public:
*/
bool isFirstGroupItem(int itemIndex) const;
+ void setCollapsedGroupsData(const QList<QVariant> &collapsedGroupsData);
+ bool isCollapsedGroupFirstItem(int itemIndex) const;
+ bool isCollapsedGroupItem(int itemIndex) const;
+
/**
* Marks the layouter as dirty. This means as soon as a property of
* the layouter gets read, an expensive relayout will be done.
@@ -219,6 +226,11 @@ private:
qreal m_groupHeaderHeight;
qreal m_groupHeaderMargin;
+ QList<QVariant> m_collapsedGroupsData;
+ QSet<int> m_collapsedGroupFirstIndexes;
+ QMap<int, int> m_collapsedGroupRanges;
+ QHash<int, qreal> m_groupHeaderAbsY;
+
struct ItemInfo {
int column;
int row;
diff --git a/src/tests/kitemlistcontrollertest.cpp b/src/tests/kitemlistcontrollertest.cpp
index 2125755bb7..2d5aad4563 100644
--- a/src/tests/kitemlistcontrollertest.cpp
+++ b/src/tests/kitemlistcontrollertest.cpp
@@ -8,6 +8,7 @@
#include "kitemviews/kfileitemlistview.h"
#include "kitemviews/kfileitemmodel.h"
#include "kitemviews/kitemlistcontainer.h"
+#include "kitemviews/kitemlistgroupheader.h"
#include "kitemviews/kitemlistselectionmanager.h"
#include "kitemviews/private/kitemlistviewlayouter.h"
#include "testdir.h"
@@ -85,6 +86,8 @@ private Q_SLOTS:
void testDragMoveHoverIdempotency();
void testDragLeaveHoverCleanup();
+ void testCollapsibleGroups();
+
private:
/**
* Make sure that the number of columns in the view is equal to \a count
@@ -92,6 +95,7 @@ private:
*/
void adjustGeometryForColumnCount(int count);
void simulateMouseClickOnItem(int index);
+ void simulateMouseClickOnGroupHeader(int firstItemIndex);
void simulateDragMove(const QPointF &pos, QMimeData *mimeData);
void simulateDragLeave(QMimeData *mimeData);
@@ -1189,6 +1193,24 @@ void KItemListControllerTest::simulateMouseClickOnItem(int index)
m_view->event(&mouseReleaseEvent);
}
+void KItemListControllerTest::simulateMouseClickOnGroupHeader(int firstItemIndex)
+{
+ const QPointF pos = m_view->m_layouter->groupHeaderRect(firstItemIndex).center();
+
+ QGraphicsSceneMouseEvent mousePressEvent(QEvent::GraphicsSceneMousePress);
+ mousePressEvent.setPos(pos);
+ mousePressEvent.setButton(Qt::LeftButton);
+ mousePressEvent.setButtons(Qt::LeftButton);
+
+ QGraphicsSceneMouseEvent mouseReleaseEvent(QEvent::GraphicsSceneMouseRelease);
+ mouseReleaseEvent.setPos(pos);
+ mouseReleaseEvent.setButton(Qt::LeftButton);
+ mouseReleaseEvent.setButtons(Qt::NoButton);
+
+ m_view->event(&mousePressEvent);
+ m_view->event(&mouseReleaseEvent);
+}
+
void KItemListControllerTest::testKeyboardNavigationAfterMouseSelection()
{
QApplication::setLayoutDirection(Qt::LeftToRight);
@@ -1356,6 +1378,70 @@ void KItemListControllerTest::testDragLeaveHoverCleanup()
QCOMPARE(hoveredSpy.count(), 1);
}
+void KItemListControllerTest::testCollapsibleGroups()
+{
+ QApplication::setLayoutDirection(Qt::LeftToRight);
+ m_view->setLayoutDirection(Qt::LeftToRight);
+ m_view->setItemLayout(KFileItemListView::IconsLayout);
+ m_view->setScrollOrientation(Qt::Vertical);
+
+ m_model->setGroupedSorting(false);
+ QCOMPARE(m_model->groupedSorting(), false);
+ m_model->setGroupedSorting(true);
+ QCOMPARE(m_model->groupedSorting(), true);
+ QVERIFY(m_view->m_collapsedGroups.isEmpty());
+
+ adjustGeometryForColumnCount(3);
+ QCOMPARE(m_view->m_layouter->m_columnCount, 3);
+ m_view->setScrollOffset(0);
+ QCOMPARE(m_view->firstVisibleIndex(), 0);
+
+ const QList<QPair<int, QVariant>> groups = m_model->groups();
+ QVERIFY(groups.count() >= 2);
+ QCOMPARE(groups.at(0).first, 0);
+ QCOMPARE(groups.at(1).first, 3);
+ const QVariant groupAData = groups.at(0).second;
+
+ QVERIFY(m_view->m_layouter->isFirstGroupItem(0));
+ QVERIFY(!m_view->m_layouter->isCollapsedGroupItem(0));
+ QVERIFY(!m_view->m_layouter->itemRect(0).isEmpty());
+
+ KItemListGroupHeader *groupHeaderA = m_view->m_visibleGroups.value(0);
+ QVERIFY(groupHeaderA);
+ QVERIFY(!groupHeaderA->isCollapsed());
+
+ const QRectF itemRectBeforeCollapse = m_view->m_layouter->itemRect(3);
+ QVERIFY(!itemRectBeforeCollapse.isEmpty());
+
+ simulateMouseClickOnGroupHeader(0);
+
+ QVERIFY(m_view->m_collapsedGroups.contains(groupAData));
+ QVERIFY(groupHeaderA->isCollapsed());
+ QVERIFY(m_view->m_layouter->isCollapsedGroupFirstItem(0));
+ QVERIFY(m_view->m_layouter->isCollapsedGroupItem(0));
+ QVERIFY(m_view->m_layouter->isCollapsedGroupItem(1));
+ QVERIFY(m_view->m_layouter->isCollapsedGroupItem(2));
+ QVERIFY(!m_view->m_layouter->isCollapsedGroupItem(3));
+
+ QVERIFY(m_view->m_layouter->itemRect(0).isEmpty());
+ QVERIFY(m_view->m_layouter->itemRect(1).isEmpty());
+ QVERIFY(m_view->m_layouter->itemRect(2).isEmpty());
+
+ QVERIFY(!m_view->m_layouter->groupHeaderRect(0).isEmpty());
+
+ const QRectF itemRectAfterCollapse = m_view->m_layouter->itemRect(3);
+ QVERIFY(!itemRectAfterCollapse.isEmpty());
+ QVERIFY(itemRectAfterCollapse.top() < itemRectBeforeCollapse.top());
+
+ simulateMouseClickOnGroupHeader(0);
+
+ QVERIFY(!m_view->m_collapsedGroups.contains(groupAData));
+ QVERIFY(!groupHeaderA->isCollapsed());
+ QVERIFY(!m_view->m_layouter->isCollapsedGroupItem(0));
+ QVERIFY(!m_view->m_layouter->itemRect(0).isEmpty());
+ QCOMPARE(m_view->m_layouter->itemRect(3), itemRectBeforeCollapse);
+}
+
QTEST_MAIN(KItemListControllerTest)
#include "kitemlistcontrollertest.moc"