/* This file is part of the KDE project SPDX-FileCopyrightText: 2007 Kevin Ottens SPDX-FileCopyrightText: 2008 Rafael Fernández López SPDX-FileCopyrightText: 2022 Kai Uwe Broulik SPDX-FileCopyrightText: 2023 Harald Sitter SPDX-License-Identifier: LGPL-2.0-only */ #include "kfileplacesview.h" #include "kfileplacesmodel_p.h" #include "kfileplacesview_p.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // ConfigGroup, PlacesIconsAutoresize, PlacesIconsStaticSize #include #include #include #include #include #include #include #include #include #include #include #include #include "kfileplaceeditdialog.h" #include "kfileplacesmodel.h" using namespace std::chrono_literals; static constexpr int s_lateralMargin = 4; static constexpr int s_capacitybarHeight = 6; static constexpr auto s_pollFreeSpaceInterval = 1min; KFilePlacesViewDelegate::KFilePlacesViewDelegate(KFilePlacesView *parent) : QAbstractItemDelegate(parent) , m_view(parent) , m_iconSize(48) , m_appearingHeightScale(1.0) , m_appearingOpacity(0.0) , m_disappearingHeightScale(1.0) , m_disappearingOpacity(0.0) , m_showHoverIndication(true) , m_dragStarted(false) { m_pollFreeSpace.setInterval(s_pollFreeSpaceInterval); connect(&m_pollFreeSpace, &QTimer::timeout, this, QOverload<>::of(&KFilePlacesViewDelegate::checkFreeSpace)); } KFilePlacesViewDelegate::~KFilePlacesViewDelegate() { } QSize KFilePlacesViewDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { int height = std::max(m_iconSize, option.fontMetrics.height()) + s_lateralMargin; if (m_appearingItems.contains(index)) { height *= m_appearingHeightScale; } else if (m_disappearingItems.contains(index)) { height *= m_disappearingHeightScale; } if (indexIsSectionHeader(index)) { height += sectionHeaderHeight(index); } return QSize(option.rect.width(), height); } void KFilePlacesViewDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { painter->save(); QStyleOptionViewItem opt = option; const QPersistentModelIndex persistentIndex(index); const KFilePlacesModel *placesModel = static_cast(index.model()); // draw header when necessary if (indexIsSectionHeader(index)) { // If we are drawing the floating element used by drag/drop, do not draw the header if (!m_dragStarted) { drawSectionHeader(painter, opt, index); } // Move the target rect to the actual item rect const int headerHeight = sectionHeaderHeight(index); opt.rect.translate(0, headerHeight); opt.rect.setHeight(opt.rect.height() - headerHeight); } // draw item if (m_appearingItems.contains(index)) { painter->setOpacity(m_appearingOpacity); } else if (m_disappearingItems.contains(index)) { painter->setOpacity(m_disappearingOpacity); } if (placesModel->isHidden(index)) { painter->setOpacity(painter->opacity() * 0.6); } if (!m_showHoverIndication) { opt.state &= ~QStyle::State_MouseOver; } if (opt.state & QStyle::State_MouseOver) { if (index == m_hoveredHeaderArea) { opt.state &= ~QStyle::State_MouseOver; } } // Avoid a solid background for the drag pixmap so the drop indicator // is more easily seen. if (m_dragStarted) { opt.state.setFlag(QStyle::State_MouseOver, true); opt.state.setFlag(QStyle::State_Active, false); opt.state.setFlag(QStyle::State_Selected, false); } m_dragStarted = false; QApplication::style()->drawPrimitive(QStyle::PE_PanelItemViewItem, &opt, painter); const auto accessibility = placesModel->deviceAccessibility(index); const bool isBusy = (accessibility == KFilePlacesModel::SetupInProgress || accessibility == KFilePlacesModel::TeardownInProgress); QIcon actionIcon; if (isBusy) { actionIcon = QIcon::fromTheme(QStringLiteral("view-refresh")); } else if (placesModel->isTeardownOverlayRecommended(index)) { actionIcon = QIcon::fromTheme(QStringLiteral("media-eject")); } bool isLTR = opt.direction == Qt::LeftToRight; const int iconAreaWidth = s_lateralMargin + m_iconSize; const int actionAreaWidth = !actionIcon.isNull() ? s_lateralMargin + actionIconSize() : 0; QRect rectText((isLTR ? iconAreaWidth : actionAreaWidth) + s_lateralMargin, opt.rect.top(), opt.rect.width() - iconAreaWidth - actionAreaWidth - 2 * s_lateralMargin, opt.rect.height()); const QPalette activePalette = KIconLoader::global()->customPalette(); const bool changePalette = activePalette != opt.palette; if (changePalette) { KIconLoader::global()->setCustomPalette(opt.palette); } const bool selectedAndActive = (opt.state & QStyle::State_Selected) && (opt.state & QStyle::State_Active); QIcon::Mode mode = selectedAndActive ? QIcon::Selected : QIcon::Normal; QIcon icon = index.model()->data(index, Qt::DecorationRole).value(); QPixmap pm = icon.pixmap(m_iconSize, m_iconSize, mode); QPoint point(isLTR ? opt.rect.left() + s_lateralMargin : opt.rect.right() - s_lateralMargin - m_iconSize, opt.rect.top() + (opt.rect.height() - m_iconSize) / 2); painter->drawPixmap(point, pm); if (!actionIcon.isNull()) { const int iconSize = actionIconSize(); QIcon::Mode mode = QIcon::Normal; if (selectedAndActive) { mode = QIcon::Selected; } else if (m_hoveredAction == index) { mode = QIcon::Active; } const QPixmap pixmap = actionIcon.pixmap(iconSize, iconSize, mode); const QRectF rect(isLTR ? opt.rect.right() - actionAreaWidth : opt.rect.left() + s_lateralMargin, opt.rect.top() + (opt.rect.height() - iconSize) / 2, iconSize, iconSize); if (isBusy) { painter->save(); painter->setRenderHint(QPainter::SmoothPixmapTransform); painter->translate(rect.center()); painter->rotate(m_busyAnimationRotation); painter->translate(QPointF(-rect.width() / 2.0, -rect.height() / 2.0)); painter->drawPixmap(0, 0, pixmap); painter->restore(); } else { painter->drawPixmap(rect.topLeft(), pixmap); } } if (changePalette) { if (activePalette == QPalette()) { KIconLoader::global()->resetPalette(); } else { KIconLoader::global()->setCustomPalette(activePalette); } } if (selectedAndActive) { painter->setPen(opt.palette.highlightedText().color()); } else { painter->setPen(opt.palette.text().color()); } if (placesModel->data(index, KFilePlacesModel::CapacityBarRecommendedRole).toBool()) { const auto info = m_freeSpaceInfo.value(persistentIndex); checkFreeSpace(index); // async if (info.size > 0) { const int capacityBarHeight = std::ceil(m_iconSize / 8.0); const qreal usedSpace = info.used / qreal(info.size); // Vertically center text + capacity bar, so move text up a bit rectText.setTop(opt.rect.top() + (opt.rect.height() - opt.fontMetrics.height() - capacityBarHeight) / 2); rectText.setHeight(opt.fontMetrics.height()); const int radius = capacityBarHeight / 2; QRect capacityBgRect(rectText.x(), rectText.bottom(), rectText.width(), capacityBarHeight); capacityBgRect.adjust(0.5, 0.5, -0.5, -0.5); QRect capacityFillRect = capacityBgRect; capacityFillRect.setWidth(capacityFillRect.width() * usedSpace); QPalette::ColorGroup cg = QPalette::Active; if (!(opt.state & QStyle::State_Enabled)) { cg = QPalette::Disabled; } else if (!m_view->isActiveWindow()) { cg = QPalette::Inactive; } // Adapted from Breeze style's progress bar rendering QColor capacityBgColor(opt.palette.color(QPalette::WindowText)); capacityBgColor.setAlphaF(0.2 * capacityBgColor.alphaF()); QColor capacityFgColor(selectedAndActive ? opt.palette.color(cg, QPalette::HighlightedText) : opt.palette.color(cg, QPalette::Highlight)); if (usedSpace > 0.95) { if (!m_warningCapacityBarColor.isValid()) { m_warningCapacityBarColor = KColorScheme(cg, KColorScheme::View).foreground(KColorScheme::NegativeText).color(); } capacityFgColor = m_warningCapacityBarColor; } painter->save(); painter->setRenderHint(QPainter::Antialiasing, true); painter->setPen(Qt::NoPen); painter->setBrush(capacityBgColor); painter->drawRoundedRect(capacityBgRect, radius, radius); painter->setBrush(capacityFgColor); painter->drawRoundedRect(capacityFillRect, radius, radius); painter->restore(); } } const QString text = index.model()->data(index).toString(); const QString elidedText = opt.fontMetrics.elidedText(text, Qt::ElideRight, rectText.width()); const bool isElided = (text != elidedText); if (isElided) { m_elidedTexts.insert(persistentIndex); } else if (auto it = m_elidedTexts.find(persistentIndex); it != m_elidedTexts.end()) { m_elidedTexts.erase(it); } painter->drawText(rectText, Qt::AlignLeft | Qt::AlignVCenter, elidedText); painter->restore(); } bool KFilePlacesViewDelegate::helpEvent(QHelpEvent *event, QAbstractItemView *view, const QStyleOptionViewItem &option, const QModelIndex &index) { if (event->type() == QHelpEvent::ToolTip) { if (pointIsTeardownAction(event->pos())) { if (auto *placesModel = qobject_cast(index.model())) { Q_ASSERT(placesModel->isTeardownOverlayRecommended(index)); QString toolTipText; if (auto eject = std::unique_ptr{placesModel->ejectActionForIndex(index)}) { toolTipText = eject->toolTip(); } else if (auto teardown = std::unique_ptr{placesModel->teardownActionForIndex(index)}) { toolTipText = teardown->toolTip(); } if (!toolTipText.isEmpty()) { // TODO rect QToolTip::showText(event->globalPos(), toolTipText, m_view); event->setAccepted(true); return true; } } } else if (pointIsHeaderArea(event->pos())) { // Make sure the tooltip doesn't linger when moving the mouse to the header area // TODO show section name in a tooltip, too, if it is elided. QToolTip::hideText(); event->setAccepted(true); return true; } else { const bool isElided = m_elidedTexts.find(QPersistentModelIndex(index)) != m_elidedTexts.end(); const QString displayText = index.data(Qt::DisplayRole).toString(); QString toolTipText = index.data(Qt::ToolTipRole).toString(); if (isElided) { if (!toolTipText.isEmpty()) { toolTipText = i18nc("@info:tooltip full display text since it is elided: original tooltip", "%1: %2", displayText, toolTipText); } else { toolTipText = displayText; } } if (index.data(KFilePlacesModel::CapacityBarRecommendedRole).toBool()) { const auto info = m_freeSpaceInfo.value(index); if (info.size > 0) { const quint64 available = info.size - info.used; const int percentUsed = qRound(100.0 * qreal(info.used) / qreal(info.size)); if (!toolTipText.isEmpty()) { toolTipText.append(QLatin1Char('\n')); } toolTipText.append(i18nc("Available space out of total partition size (percent used)", "%1 free of %2 (%3% used)", KIO::convertSize(available), KIO::convertSize(info.size), percentUsed)); } } if (!toolTipText.isEmpty()) { // FIXME remove once we depend on Qt 6.8 // Qt Wayland before 6.8 doesn't support popup repositioning, causing the tooltips // remain stuck in place which is distracting. static bool canRepositionPopups = !qApp->platformName().startsWith(QLatin1String("wayland")) || QLibraryInfo::version() >= QVersionNumber(6, 8, 0); if (canRepositionPopups) { QToolTip::showText(event->globalPos(), toolTipText, m_view, m_view->visualRect(index)); } // Always accepting the event to make sure QAbstractItemDelegate doesn't show it for us. event->setAccepted(true); return true; } } } return QAbstractItemDelegate::helpEvent(event, view, option, index); } int KFilePlacesViewDelegate::iconSize() const { return m_iconSize; } void KFilePlacesViewDelegate::setIconSize(int newSize) { m_iconSize = newSize; } void KFilePlacesViewDelegate::addAppearingItem(const QModelIndex &index) { m_appearingItems << index; } void KFilePlacesViewDelegate::setAppearingItemProgress(qreal value) { if (value <= 0.25) { m_appearingOpacity = 0.0; m_appearingHeightScale = std::min(1.0, value * 4); } else { m_appearingHeightScale = 1.0; m_appearingOpacity = (value - 0.25) * 4 / 3; if (value >= 1.0) { m_appearingItems.clear(); } } } void KFilePlacesViewDelegate::setDeviceBusyAnimationRotation(qreal angle) { m_busyAnimationRotation = angle; } void KFilePlacesViewDelegate::addDisappearingItem(const QModelIndex &index) { m_disappearingItems << index; } void KFilePlacesViewDelegate::addDisappearingItemGroup(const QModelIndex &index) { const KFilePlacesModel *placesModel = static_cast(index.model()); const QModelIndexList indexesGroup = placesModel->groupIndexes(placesModel->groupType(index)); m_disappearingItems.reserve(m_disappearingItems.count() + indexesGroup.count()); std::transform(indexesGroup.begin(), indexesGroup.end(), std::back_inserter(m_disappearingItems), [](const QModelIndex &idx) { return QPersistentModelIndex(idx); }); } void KFilePlacesViewDelegate::setDisappearingItemProgress(qreal value) { value = 1.0 - value; if (value <= 0.25) { m_disappearingOpacity = 0.0; m_disappearingHeightScale = std::min(1.0, value * 4); if (value <= 0.0) { m_disappearingItems.clear(); } } else { m_disappearingHeightScale = 1.0; m_disappearingOpacity = (value - 0.25) * 4 / 3; } } void KFilePlacesViewDelegate::setShowHoverIndication(bool show) { m_showHoverIndication = show; } void KFilePlacesViewDelegate::setHoveredHeaderArea(const QModelIndex &index) { m_hoveredHeaderArea = index; } void KFilePlacesViewDelegate::setHoveredAction(const QModelIndex &index) { m_hoveredAction = index; } bool KFilePlacesViewDelegate::pointIsHeaderArea(const QPoint &pos) const { // we only accept drag events starting from item body, ignore drag request from header QModelIndex index = m_view->indexAt(pos); if (!index.isValid()) { return false; } if (indexIsSectionHeader(index)) { const QRect vRect = m_view->visualRect(index); const int delegateY = pos.y() - vRect.y(); if (delegateY <= sectionHeaderHeight(index)) { return true; } } return false; } bool KFilePlacesViewDelegate::pointIsTeardownAction(const QPoint &pos) const { QModelIndex index = m_view->indexAt(pos); if (!index.isValid()) { return false; } if (!index.data(KFilePlacesModel::TeardownOverlayRecommendedRole).toBool()) { return false; } const QRect vRect = m_view->visualRect(index); const bool isLTR = m_view->layoutDirection() == Qt::LeftToRight; const int delegateX = pos.x() - vRect.x(); if (isLTR) { if (delegateX < (vRect.width() - 2 * s_lateralMargin - actionIconSize())) { return false; } } else { if (delegateX >= 2 * s_lateralMargin + actionIconSize()) { return false; } } return true; } void KFilePlacesViewDelegate::startDrag() { m_dragStarted = true; } void KFilePlacesViewDelegate::checkFreeSpace() { if (!m_view->model()) { return; } bool hasChecked = false; for (int i = 0; i < m_view->model()->rowCount(); ++i) { if (m_view->isRowHidden(i)) { continue; } const QModelIndex idx = m_view->model()->index(i, 0); if (!idx.data(KFilePlacesModel::CapacityBarRecommendedRole).toBool()) { continue; } checkFreeSpace(idx); hasChecked = true; } if (!hasChecked) { // Stop timer, there are no more devices stopPollingFreeSpace(); } } void KFilePlacesViewDelegate::startPollingFreeSpace() const { if (m_pollFreeSpace.isActive()) { return; } if (!m_view->isActiveWindow() || !m_view->isVisible()) { return; } m_pollFreeSpace.start(); } void KFilePlacesViewDelegate::stopPollingFreeSpace() const { m_pollFreeSpace.stop(); } void KFilePlacesViewDelegate::checkFreeSpace(const QModelIndex &index) const { Q_ASSERT(index.data(KFilePlacesModel::CapacityBarRecommendedRole).toBool()); const QUrl url = index.data(KFilePlacesModel::UrlRole).toUrl(); QPersistentModelIndex persistentIndex{index}; auto &info = m_freeSpaceInfo[persistentIndex]; if (info.job || !info.timeout.hasExpired()) { return; } // Restarting timeout before job finishes, so that when we poll all devices // and then get the result, the next poll will again update and not have // a remaining time of 99% because it came in shortly afterwards. // Also allow a bit of Timer slack. info.timeout.setRemainingTime(s_pollFreeSpaceInterval - 100ms); info.job = KIO::fileSystemFreeSpace(url); QObject::connect(info.job, &KJob::result, this, [this, info, persistentIndex]() { if (!persistentIndex.isValid()) { return; } const auto job = info.job; if (job->error()) { return; } PlaceFreeSpaceInfo &info = m_freeSpaceInfo[persistentIndex]; info.size = job->size(); info.used = job->size() - job->availableSize(); m_view->update(persistentIndex); }); startPollingFreeSpace(); } void KFilePlacesViewDelegate::clearFreeSpaceInfo() { m_freeSpaceInfo.clear(); } QString KFilePlacesViewDelegate::groupNameFromIndex(const QModelIndex &index) const { if (index.isValid()) { return index.data(KFilePlacesModel::GroupRole).toString(); } else { return QString(); } } QModelIndex KFilePlacesViewDelegate::previousVisibleIndex(const QModelIndex &index) const { if (!index.isValid() || index.row() == 0) { return QModelIndex(); } const QAbstractItemModel *model = index.model(); QModelIndex prevIndex = model->index(index.row() - 1, index.column(), index.parent()); while (m_view->isRowHidden(prevIndex.row())) { if (prevIndex.row() == 0) { return QModelIndex(); } prevIndex = model->index(prevIndex.row() - 1, index.column(), index.parent()); } return prevIndex; } bool KFilePlacesViewDelegate::indexIsSectionHeader(const QModelIndex &index) const { if (m_view->isRowHidden(index.row())) { return false; } const auto groupName = groupNameFromIndex(index); const auto previousGroupName = groupNameFromIndex(previousVisibleIndex(index)); return groupName != previousGroupName; } void KFilePlacesViewDelegate::drawSectionHeader(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { const KFilePlacesModel *placesModel = static_cast(index.model()); const QString groupLabel = index.data(KFilePlacesModel::GroupRole).toString(); const QString category = placesModel->isGroupHidden(index) // Avoid showing "(hidden)" during disappear animation when hiding a group && !m_disappearingItems.contains(index) ? i18n("%1 (hidden)", groupLabel) : groupLabel; QRect textRect(option.rect); textRect.setLeft(textRect.left() + 6); /* Take spacing into account: The spacing to the previous section compensates for the spacing to the first item.*/ textRect.setY(textRect.y() /* + qMax(2, m_view->spacing()) - qMax(2, m_view->spacing())*/); textRect.setHeight(sectionHeaderHeight(index) - s_lateralMargin - m_view->spacing()); painter->save(); // based on dolphin colors const QColor c1 = textColor(option); const QColor c2 = baseColor(option); QColor penColor = mixedColor(c1, c2, 60); painter->setPen(penColor); painter->drawText(textRect, Qt::AlignLeft | Qt::AlignBottom, option.fontMetrics.elidedText(category, Qt::ElideRight, textRect.width())); painter->restore(); } void KFilePlacesViewDelegate::paletteChange() { // Reset cache, will be re-created when painted m_warningCapacityBarColor = QColor(); } QColor KFilePlacesViewDelegate::textColor(const QStyleOption &option) const { const QPalette::ColorGroup group = m_view->isActiveWindow() ? QPalette::Active : QPalette::Inactive; return option.palette.color(group, QPalette::WindowText); } QColor KFilePlacesViewDelegate::baseColor(const QStyleOption &option) const { const QPalette::ColorGroup group = m_view->isActiveWindow() ? QPalette::Active : QPalette::Inactive; return option.palette.color(group, QPalette::Window); } QColor KFilePlacesViewDelegate::mixedColor(const QColor &c1, const QColor &c2, int c1Percent) const { Q_ASSERT(c1Percent >= 0 && c1Percent <= 100); const int c2Percent = 100 - c1Percent; return QColor((c1.red() * c1Percent + c2.red() * c2Percent) / 100, (c1.green() * c1Percent + c2.green() * c2Percent) / 100, (c1.blue() * c1Percent + c2.blue() * c2Percent) / 100); } int KFilePlacesViewDelegate::sectionHeaderHeight(const QModelIndex &index) const { Q_UNUSED(index); // Account for the spacing between header and item const int spacing = (s_lateralMargin + m_view->spacing()); int height = m_view->fontMetrics().height() + spacing; height += 2 * spacing; return height; } int KFilePlacesViewDelegate::actionIconSize() const { return qApp->style()->pixelMetric(QStyle::PM_SmallIconSize, nullptr, m_view); } class KFilePlacesViewPrivate { public: explicit KFilePlacesViewPrivate(KFilePlacesView *qq) : q(qq) , m_watcher(new KFilePlacesEventWatcher(q)) , m_delegate(new KFilePlacesViewDelegate(q)) { } using ActivationSignal = void (KFilePlacesView::*)(const QUrl &); enum FadeType { FadeIn = 0, FadeOut, }; void setCurrentIndex(const QModelIndex &index); // If m_autoResizeItems is true, calculates a proper size for the icons in the places panel void adaptItemSize(); void updateHiddenRows(); void clearFreeSpaceInfos(); bool insertAbove(const QDropEvent *event, const QRect &itemRect) const; bool insertBelow(const QDropEvent *event, const QRect &itemRect) const; int insertIndicatorHeight(int itemHeight) const; int sectionsCount() const; void addPlace(const QModelIndex &index); void editPlace(const QModelIndex &index); void addDisappearingItem(KFilePlacesViewDelegate *delegate, const QModelIndex &index); void triggerItemAppearingAnimation(); void triggerItemDisappearingAnimation(); bool shouldAnimate() const; void writeConfig(); void readConfig(); // Sets the size of the icons in the places panel void relayoutIconSize(int size); // Adds the "Icon Size" sub-menu items void setupIconSizeSubMenu(QMenu *submenu); void placeClicked(const QModelIndex &index, ActivationSignal activationSignal); void headerAreaEntered(const QModelIndex &index); void headerAreaLeft(const QModelIndex &index); void actionClicked(const QModelIndex &index); void actionEntered(const QModelIndex &index); void actionLeft(const QModelIndex &index); void teardown(const QModelIndex &index); void storageSetupDone(const QModelIndex &index, bool success); void adaptItemsUpdate(qreal value); void itemAppearUpdate(qreal value); void itemDisappearUpdate(qreal value); void enableSmoothItemResizing(); void slotEmptyTrash(); void deviceBusyAnimationValueChanged(const QVariant &value); KFilePlacesView *const q; KFilePlacesEventWatcher *const m_watcher; KFilePlacesViewDelegate *m_delegate; Solid::StorageAccess *m_lastClickedStorage = nullptr; QPersistentModelIndex m_lastClickedIndex; ActivationSignal m_lastActivationSignal = nullptr; QTimer *m_dragActivationTimer = nullptr; QPersistentModelIndex m_pendingDragActivation; QPersistentModelIndex m_pendingDropUrlsIndex; std::unique_ptr m_dropUrlsEvent; std::unique_ptr m_dropUrlsMimeData; KFilePlacesView::TeardownFunction m_teardownFunction = nullptr; QTimeLine m_adaptItemsTimeline; QTimeLine m_itemAppearTimeline; QTimeLine m_itemDisappearTimeline; QVariantAnimation m_deviceBusyAnimation; QList m_busyDevices; QRect m_dropRect; QPersistentModelIndex m_dropIndex; QUrl m_currentUrl; int m_oldSize = 0; int m_endSize = 0; bool m_autoResizeItems = true; bool m_smoothItemResizing = false; bool m_showAll = false; bool m_dropOnPlace = false; bool m_dragging = false; }; KFilePlacesView::KFilePlacesView(QWidget *parent) : QListView(parent) , d(std::make_unique(this)) { setItemDelegate(d->m_delegate); d->readConfig(); setSelectionRectVisible(false); setSelectionMode(SingleSelection); setDragEnabled(true); setAcceptDrops(true); setMouseTracking(true); setDropIndicatorShown(false); setFrameStyle(QFrame::NoFrame); setResizeMode(Adjust); QPalette palette = viewport()->palette(); palette.setColor(viewport()->backgroundRole(), Qt::transparent); palette.setColor(viewport()->foregroundRole(), palette.color(QPalette::WindowText)); viewport()->setPalette(palette); setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); d->m_watcher->m_scroller = QScroller::scroller(viewport()); QScrollerProperties scrollerProp; scrollerProp.setScrollMetric(QScrollerProperties::AcceleratingFlickMaximumTime, 0.2); // QTBUG-88249 d->m_watcher->m_scroller->setScrollerProperties(scrollerProp); d->m_watcher->m_scroller->grabGesture(viewport()); connect(d->m_watcher->m_scroller, &QScroller::stateChanged, d->m_watcher, &KFilePlacesEventWatcher::qScrollerStateChanged); setAttribute(Qt::WA_AcceptTouchEvents); viewport()->grabGesture(Qt::TapGesture); viewport()->grabGesture(Qt::TapAndHoldGesture); // Note: Don't connect to the activated() signal, as the behavior when it is // committed depends on the used widget style. The click behavior of // KFilePlacesView should be style independent. connect(this, &KFilePlacesView::clicked, this, [this](const QModelIndex &index) { const auto modifiers = qGuiApp->keyboardModifiers(); if (modifiers == (Qt::ControlModifier | Qt::ShiftModifier) && isSignalConnected(QMetaMethod::fromSignal(&KFilePlacesView::activeTabRequested))) { d->placeClicked(index, &KFilePlacesView::activeTabRequested); } else if (modifiers == Qt::ControlModifier && isSignalConnected(QMetaMethod::fromSignal(&KFilePlacesView::tabRequested))) { d->placeClicked(index, &KFilePlacesView::tabRequested); } else if (modifiers == Qt::ShiftModifier && isSignalConnected(QMetaMethod::fromSignal(&KFilePlacesView::newWindowRequested))) { d->placeClicked(index, &KFilePlacesView::newWindowRequested); } else { d->placeClicked(index, &KFilePlacesView::placeActivated); } }); connect(this, &QAbstractItemView::iconSizeChanged, this, [this](const QSize &newSize) { d->m_autoResizeItems = (newSize.width() < 1 || newSize.height() < 1); if (d->m_autoResizeItems) { d->adaptItemSize(); } else { const int iconSize = qMin(newSize.width(), newSize.height()); d->relayoutIconSize(iconSize); } d->writeConfig(); }); connect(&d->m_adaptItemsTimeline, &QTimeLine::valueChanged, this, [this](qreal value) { d->adaptItemsUpdate(value); }); d->m_adaptItemsTimeline.setDuration(500); d->m_adaptItemsTimeline.setUpdateInterval(5); d->m_adaptItemsTimeline.setEasingCurve(QEasingCurve::InOutSine); connect(&d->m_itemAppearTimeline, &QTimeLine::valueChanged, this, [this](qreal value) { d->itemAppearUpdate(value); }); d->m_itemAppearTimeline.setDuration(500); d->m_itemAppearTimeline.setUpdateInterval(5); d->m_itemAppearTimeline.setEasingCurve(QEasingCurve::InOutSine); connect(&d->m_itemDisappearTimeline, &QTimeLine::valueChanged, this, [this](qreal value) { d->itemDisappearUpdate(value); }); d->m_itemDisappearTimeline.setDuration(500); d->m_itemDisappearTimeline.setUpdateInterval(5); d->m_itemDisappearTimeline.setEasingCurve(QEasingCurve::InOutSine); // Adapted from KBusyIndicatorWidget d->m_deviceBusyAnimation.setLoopCount(-1); d->m_deviceBusyAnimation.setDuration(2000); d->m_deviceBusyAnimation.setStartValue(0); d->m_deviceBusyAnimation.setEndValue(360); connect(&d->m_deviceBusyAnimation, &QVariantAnimation::valueChanged, this, [this](const QVariant &value) { d->deviceBusyAnimationValueChanged(value); }); viewport()->installEventFilter(d->m_watcher); connect(d->m_watcher, &KFilePlacesEventWatcher::entryMiddleClicked, this, [this](const QModelIndex &index) { if (qGuiApp->keyboardModifiers() == Qt::ShiftModifier && isSignalConnected(QMetaMethod::fromSignal(&KFilePlacesView::activeTabRequested))) { d->placeClicked(index, &KFilePlacesView::activeTabRequested); } else if (isSignalConnected(QMetaMethod::fromSignal(&KFilePlacesView::tabRequested))) { d->placeClicked(index, &KFilePlacesView::tabRequested); } else { d->placeClicked(index, &KFilePlacesView::placeActivated); } }); connect(d->m_watcher, &KFilePlacesEventWatcher::headerAreaEntered, this, [this](const QModelIndex &index) { d->headerAreaEntered(index); }); connect(d->m_watcher, &KFilePlacesEventWatcher::headerAreaLeft, this, [this](const QModelIndex &index) { d->headerAreaLeft(index); }); connect(d->m_watcher, &KFilePlacesEventWatcher::actionClicked, this, [this](const QModelIndex &index) { d->actionClicked(index); }); connect(d->m_watcher, &KFilePlacesEventWatcher::actionEntered, this, [this](const QModelIndex &index) { d->actionEntered(index); }); connect(d->m_watcher, &KFilePlacesEventWatcher::actionLeft, this, [this](const QModelIndex &index) { d->actionLeft(index); }); connect(d->m_watcher, &KFilePlacesEventWatcher::windowActivated, this, [this] { d->m_delegate->checkFreeSpace(); // Start polling even if checkFreeSpace() wouldn't because we might just have checked // free space before the timeout and so the poll timer would never get started again d->m_delegate->startPollingFreeSpace(); }); connect(d->m_watcher, &KFilePlacesEventWatcher::windowDeactivated, this, [this] { d->m_delegate->stopPollingFreeSpace(); }); connect(d->m_watcher, &KFilePlacesEventWatcher::paletteChanged, this, [this] { d->m_delegate->paletteChange(); }); // FIXME: this is necessary to avoid flashes of black with some widget styles. // could be a bug in Qt (e.g. QAbstractScrollArea) or KFilePlacesView, but has not // yet been tracked down yet. until then, this works and is harmlessly enough. // in fact, some QStyle (Oxygen, Skulpture, others?) do this already internally. // See br #242358 for more information verticalScrollBar()->setAttribute(Qt::WA_OpaquePaintEvent, false); } KFilePlacesView::~KFilePlacesView() { viewport()->removeEventFilter(d->m_watcher); } void KFilePlacesView::setDropOnPlaceEnabled(bool enabled) { d->m_dropOnPlace = enabled; } bool KFilePlacesView::isDropOnPlaceEnabled() const { return d->m_dropOnPlace; } void KFilePlacesView::setDragAutoActivationDelay(int delay) { if (delay <= 0) { delete d->m_dragActivationTimer; d->m_dragActivationTimer = nullptr; return; } if (!d->m_dragActivationTimer) { d->m_dragActivationTimer = new QTimer(this); d->m_dragActivationTimer->setSingleShot(true); connect(d->m_dragActivationTimer, &QTimer::timeout, this, [this] { if (d->m_pendingDragActivation.isValid()) { d->placeClicked(d->m_pendingDragActivation, &KFilePlacesView::placeActivated); } }); } d->m_dragActivationTimer->setInterval(delay); } int KFilePlacesView::dragAutoActivationDelay() const { return d->m_dragActivationTimer ? d->m_dragActivationTimer->interval() : 0; } void KFilePlacesView::setAutoResizeItemsEnabled(bool enabled) { d->m_autoResizeItems = enabled; } bool KFilePlacesView::isAutoResizeItemsEnabled() const { return d->m_autoResizeItems; } void KFilePlacesView::setTeardownFunction(TeardownFunction teardownFunc) { d->m_teardownFunction = teardownFunc; } void KFilePlacesView::setUrl(const QUrl &url) { KFilePlacesModel *placesModel = qobject_cast(model()); if (placesModel == nullptr) { return; } QModelIndex index = placesModel->closestItem(url); QModelIndex current = selectionModel()->currentIndex(); if (index.isValid()) { if (current != index && placesModel->isHidden(current) && !d->m_showAll) { d->addDisappearingItem(d->m_delegate, current); } if (current != index && placesModel->isHidden(index) && !d->m_showAll) { d->m_delegate->addAppearingItem(index); d->triggerItemAppearingAnimation(); setRowHidden(index.row(), false); } d->m_currentUrl = url; if (placesModel->url(index).matches(url, QUrl::StripTrailingSlash)) { selectionModel()->setCurrentIndex(index, QItemSelectionModel::ClearAndSelect); } else { selectionModel()->clear(); } } else { d->m_currentUrl = QUrl(); selectionModel()->clear(); } if (!current.isValid()) { d->updateHiddenRows(); } } bool KFilePlacesView::allPlacesShown() const { return d->m_showAll; } void KFilePlacesView::setShowAll(bool showAll) { KFilePlacesModel *placesModel = qobject_cast(model()); if (placesModel == nullptr) { return; } d->m_showAll = showAll; int rowCount = placesModel->rowCount(); QModelIndex current = placesModel->closestItem(d->m_currentUrl); if (showAll) { d->updateHiddenRows(); for (int i = 0; i < rowCount; ++i) { QModelIndex index = placesModel->index(i, 0); if (index != current && placesModel->isHidden(index)) { d->m_delegate->addAppearingItem(index); } } d->triggerItemAppearingAnimation(); } else { for (int i = 0; i < rowCount; ++i) { QModelIndex index = placesModel->index(i, 0); if (index != current && placesModel->isHidden(index)) { d->m_delegate->addDisappearingItem(index); } } d->triggerItemDisappearingAnimation(); } Q_EMIT allPlacesShownChanged(showAll); } void KFilePlacesView::keyPressEvent(QKeyEvent *event) { QListView::keyPressEvent(event); if ((event->key() == Qt::Key_Return) || (event->key() == Qt::Key_Enter)) { // TODO Modifier keys for requesting tabs // Browsers do Ctrl+Click but *Alt*+Return for new tab d->placeClicked(currentIndex(), &KFilePlacesView::placeActivated); } } void KFilePlacesViewPrivate::readConfig() { KConfigGroup cg(KSharedConfig::openConfig(), ConfigGroup); m_autoResizeItems = cg.readEntry(PlacesIconsAutoresize, true); m_delegate->setIconSize(cg.readEntry(PlacesIconsStaticSize, static_cast(KIconLoader::SizeMedium))); } void KFilePlacesViewPrivate::writeConfig() { KConfigGroup cg(KSharedConfig::openConfig(), ConfigGroup); cg.writeEntry(PlacesIconsAutoresize, m_autoResizeItems); if (!m_autoResizeItems) { const int iconSize = qMin(q->iconSize().width(), q->iconSize().height()); cg.writeEntry(PlacesIconsStaticSize, iconSize); } cg.sync(); } void KFilePlacesViewPrivate::slotEmptyTrash() { auto *parentWindow = q->window(); using AskIface = KIO::AskUserActionInterface; auto *emptyTrashJob = new KIO::DeleteOrTrashJob(QList{}, // AskIface::EmptyTrash, AskIface::DefaultConfirmation, parentWindow); emptyTrashJob->start(); } void KFilePlacesView::contextMenuEvent(QContextMenuEvent *event) { KFilePlacesModel *placesModel = qobject_cast(model()); if (!placesModel) { return; } QModelIndex index = event->reason() == QContextMenuEvent::Keyboard ? selectionModel()->currentIndex() : indexAt(event->pos()); if (!selectedIndexes().contains(index)) { index = QModelIndex(); } const QString groupName = index.data(KFilePlacesModel::GroupRole).toString(); const QUrl placeUrl = placesModel->url(index); const bool clickOverHeader = event->reason() == QContextMenuEvent::Keyboard ? false : d->m_delegate->pointIsHeaderArea(event->pos()); const bool clickOverEmptyArea = clickOverHeader || !index.isValid(); const KFilePlacesModel::GroupType type = placesModel->groupType(index); QMenu menu; // Polish before creating a native window below. The style could want change the surface format // of the window which will have no effect when the native window has already been created. menu.ensurePolished(); QAction *emptyTrash = nullptr; QAction *eject = nullptr; QAction *partition = nullptr; QAction *mount = nullptr; QAction *teardown = nullptr; QAction *newTab = nullptr; QAction *newWindow = nullptr; QAction *highPriorityActionsPlaceholder = new QAction(); QAction *properties = nullptr; QAction *add = nullptr; QAction *edit = nullptr; QAction *remove = nullptr; QAction *hide = nullptr; QAction *hideSection = nullptr; QAction *showAll = nullptr; QMenu *iconSizeMenu = nullptr; if (!clickOverEmptyArea) { if (placeUrl.scheme() == QLatin1String("trash")) { emptyTrash = new QAction(QIcon::fromTheme(QStringLiteral("trash-empty")), i18nc("@action:inmenu", "Empty Trash"), &menu); KConfig trashConfig(QStringLiteral("trashrc"), KConfig::SimpleConfig); emptyTrash->setEnabled(!trashConfig.group(QStringLiteral("Status")).readEntry("Empty", true)); } if (placesModel->isDevice(index)) { eject = placesModel->ejectActionForIndex(index); if (eject) { eject->setParent(&menu); } partition = placesModel->partitionActionForIndex(index); if (partition) { partition->setParent(&menu); } teardown = placesModel->teardownActionForIndex(index); if (teardown) { teardown->setParent(&menu); if (!placesModel->isTeardownAllowed(index)) { teardown->setEnabled(false); } } if (placesModel->setupNeeded(index)) { mount = new QAction(QIcon::fromTheme(QStringLiteral("media-mount")), i18nc("@action:inmenu", "Mount"), &menu); } } // TODO What about active tab? if (isSignalConnected(QMetaMethod::fromSignal(&KFilePlacesView::tabRequested))) { newTab = new QAction(QIcon::fromTheme(QStringLiteral("tab-new")), i18nc("@item:inmenu", "Open in New Tab"), &menu); } if (isSignalConnected(QMetaMethod::fromSignal(&KFilePlacesView::newWindowRequested))) { newWindow = new QAction(QIcon::fromTheme(QStringLiteral("window-new")), i18nc("@item:inmenu", "Open in New Window"), &menu); } if (placeUrl.isLocalFile()) { properties = new QAction(QIcon::fromTheme(QStringLiteral("document-properties")), i18n("Properties"), &menu); } } if (clickOverEmptyArea) { add = new QAction(QIcon::fromTheme(QStringLiteral("document-new")), i18nc("@action:inmenu", "Add Entry…"), &menu); } if (index.isValid()) { if (!clickOverHeader) { if (!placesModel->isDevice(index)) { edit = new QAction(QIcon::fromTheme(QStringLiteral("edit-entry")), i18nc("@action:inmenu", "&Edit…"), &menu); KBookmark bookmark = placesModel->bookmarkForIndex(index); const bool isSystemItem = bookmark.metaDataItem(QStringLiteral("isSystemItem")) == QLatin1String("true"); if (!isSystemItem) { remove = new QAction(QIcon::fromTheme(QStringLiteral("bookmark-remove-symbolic")), i18nc("@action:inmenu", "Remove from Places"), &menu); } } hide = new QAction(QIcon::fromTheme(QStringLiteral("hint")), i18nc("@action:inmenu", "&Hide"), &menu); hide->setCheckable(true); hide->setChecked(placesModel->isHidden(index)); // if a parent is hidden no interaction should be possible with children, show it first to do so hide->setEnabled(!placesModel->isGroupHidden(placesModel->groupType(index))); } hideSection = new QAction(QIcon::fromTheme(QStringLiteral("hint")), !groupName.isEmpty() ? i18nc("@item:inmenu", "Hide Section '%1'", groupName) : i18nc("@item:inmenu", "Hide Section"), &menu); hideSection->setCheckable(true); hideSection->setChecked(placesModel->isGroupHidden(type)); } if (clickOverEmptyArea) { if (placesModel->hiddenCount() > 0) { showAll = new QAction(QIcon::fromTheme(QStringLiteral("visibility")), i18n("&Show All Entries"), &menu); showAll->setCheckable(true); showAll->setChecked(d->m_showAll); } iconSizeMenu = new QMenu(i18nc("@item:inmenu", "Icon Size"), &menu); d->setupIconSizeSubMenu(iconSizeMenu); } auto addActionToMenu = [&menu](QAction *action) { if (action) { // silence warning when adding null action menu.addAction(action); } }; addActionToMenu(emptyTrash); addActionToMenu(eject); addActionToMenu(mount); addActionToMenu(teardown); menu.addSeparator(); if (partition) { addActionToMenu(partition); menu.addSeparator(); } addActionToMenu(newTab); addActionToMenu(newWindow); addActionToMenu(highPriorityActionsPlaceholder); addActionToMenu(properties); menu.addSeparator(); addActionToMenu(add); addActionToMenu(edit); addActionToMenu(remove); addActionToMenu(hide); addActionToMenu(hideSection); addActionToMenu(showAll); if (iconSizeMenu) { menu.addMenu(iconSizeMenu); } menu.addSeparator(); // Clicking a header should be treated as clicking no device, hence passing an invalid model index // Emit the signal before adding any custom actions to give the user a chance to dynamically add/remove them Q_EMIT contextMenuAboutToShow(clickOverHeader ? QModelIndex() : index, &menu); const auto additionalActions = actions(); for (QAction *action : additionalActions) { if (action->priority() == QAction::HighPriority) { menu.insertAction(highPriorityActionsPlaceholder, action); } else { menu.addAction(action); } } delete highPriorityActionsPlaceholder; if (window()) { menu.winId(); menu.windowHandle()->setTransientParent(window()->windowHandle()); } QAction *result; if (event->reason() == QContextMenuEvent::Keyboard && index.isValid()) { const QRect rect = visualRect(index); result = menu.exec(mapToGlobal(QPoint(rect.x() + rect.width() / 2, rect.y() + rect.height() * 0.9))); } else { result = menu.exec(event->globalPos()); } if (result) { if (result == emptyTrash) { d->slotEmptyTrash(); } else if (result == eject) { placesModel->requestEject(index); } else if (result == mount) { placesModel->requestSetup(index); } else if (result == teardown) { d->teardown(index); } else if (result == newTab) { d->placeClicked(index, &KFilePlacesView::tabRequested); } else if (result == newWindow) { d->placeClicked(index, &KFilePlacesView::newWindowRequested); } else if (result == properties) { KPropertiesDialog::showDialog(placeUrl, this); } else if (result == add) { d->addPlace(index); } else if (result == edit) { d->editPlace(index); } else if (result == remove) { placesModel->removePlace(index); } else if (result == hide) { placesModel->setPlaceHidden(index, hide->isChecked()); QModelIndex current = placesModel->closestItem(d->m_currentUrl); if (index != current && !d->m_showAll && hide->isChecked()) { d->m_delegate->addDisappearingItem(index); d->triggerItemDisappearingAnimation(); } } else if (result == hideSection) { placesModel->setGroupHidden(type, hideSection->isChecked()); if (!d->m_showAll && hideSection->isChecked()) { d->m_delegate->addDisappearingItemGroup(index); d->triggerItemDisappearingAnimation(); } } else if (result == showAll) { setShowAll(showAll->isChecked()); } } if (event->reason() != QContextMenuEvent::Keyboard) { index = placesModel->closestItem(d->m_currentUrl); selectionModel()->setCurrentIndex(index, QItemSelectionModel::ClearAndSelect); } } void KFilePlacesViewPrivate::setupIconSizeSubMenu(QMenu *submenu) { QActionGroup *group = new QActionGroup(submenu); auto *autoAct = new QAction(i18nc("@item:inmenu Auto set icon size based on available space in" "the Places side-panel", "Auto Resize"), group); autoAct->setCheckable(true); autoAct->setChecked(m_autoResizeItems); QObject::connect(autoAct, &QAction::toggled, q, [this]() { q->setIconSize(QSize(-1, -1)); }); submenu->addAction(autoAct); static constexpr KIconLoader::StdSizes iconSizes[] = {KIconLoader::SizeSmall, KIconLoader::SizeSmallMedium, KIconLoader::SizeMedium, KIconLoader::SizeLarge}; for (const auto iconSize : iconSizes) { auto *act = new QAction(group); act->setCheckable(true); switch (iconSize) { case KIconLoader::SizeSmall: act->setText(i18nc("Small icon size", "Small (%1x%1)", KIconLoader::SizeSmall)); break; case KIconLoader::SizeSmallMedium: act->setText(i18nc("Medium icon size", "Medium (%1x%1)", KIconLoader::SizeSmallMedium)); break; case KIconLoader::SizeMedium: act->setText(i18nc("Large icon size", "Large (%1x%1)", KIconLoader::SizeMedium)); break; case KIconLoader::SizeLarge: act->setText(i18nc("Huge icon size", "Huge (%1x%1)", KIconLoader::SizeLarge)); break; default: break; } QObject::connect(act, &QAction::toggled, q, [this, iconSize]() { q->setIconSize(QSize(iconSize, iconSize)); }); if (!m_autoResizeItems) { act->setChecked(iconSize == m_delegate->iconSize()); } submenu->addAction(act); } } void KFilePlacesView::resizeEvent(QResizeEvent *event) { QListView::resizeEvent(event); d->adaptItemSize(); } void KFilePlacesView::showEvent(QShowEvent *event) { QListView::showEvent(event); d->m_delegate->checkFreeSpace(); // Start polling even if checkFreeSpace() wouldn't because we might just have checked // free space before the timeout and so the poll timer would never get started again d->m_delegate->startPollingFreeSpace(); QTimer::singleShot(100, this, [this]() { d->enableSmoothItemResizing(); }); } void KFilePlacesView::hideEvent(QHideEvent *event) { QListView::hideEvent(event); d->m_delegate->stopPollingFreeSpace(); d->m_smoothItemResizing = false; } void KFilePlacesView::dragEnterEvent(QDragEnterEvent *event) { QListView::dragEnterEvent(event); d->m_dragging = true; d->m_delegate->setShowHoverIndication(false); d->m_dropRect = QRect(); d->m_dropIndex = QPersistentModelIndex(); } void KFilePlacesView::dragLeaveEvent(QDragLeaveEvent *event) { QListView::dragLeaveEvent(event); d->m_dragging = false; d->m_delegate->setShowHoverIndication(true); if (d->m_dragActivationTimer) { d->m_dragActivationTimer->stop(); } d->m_pendingDragActivation = QPersistentModelIndex(); setDirtyRegion(d->m_dropRect); } void KFilePlacesView::dragMoveEvent(QDragMoveEvent *event) { QListView::dragMoveEvent(event); bool autoActivate = false; // update the drop indicator const QPoint pos = event->position().toPoint(); const QModelIndex index = indexAt(pos); setDirtyRegion(d->m_dropRect); if (index.isValid()) { d->m_dropIndex = index; const QRect rect = visualRect(index); const int gap = d->insertIndicatorHeight(rect.height()); if (d->insertAbove(event, rect)) { // indicate that the item will be inserted above the current place d->m_dropRect = QRect(rect.left(), rect.top() - gap / 2, rect.width(), gap); } else if (d->insertBelow(event, rect)) { // indicate that the item will be inserted below the current place d->m_dropRect = QRect(rect.left(), rect.bottom() + 1 - gap / 2, rect.width(), gap); } else { // indicate that the item be dropped above the current place d->m_dropRect = rect; // only auto-activate when dropping ontop of a place, not inbetween autoActivate = true; } } if (d->m_dragActivationTimer) { if (autoActivate && !d->m_delegate->pointIsHeaderArea(event->position().toPoint())) { QPersistentModelIndex persistentIndex(index); if (!d->m_pendingDragActivation.isValid() || d->m_pendingDragActivation != persistentIndex) { d->m_pendingDragActivation = persistentIndex; d->m_dragActivationTimer->start(); } } else { d->m_dragActivationTimer->stop(); d->m_pendingDragActivation = QPersistentModelIndex(); } } setDirtyRegion(d->m_dropRect); } void KFilePlacesView::dropEvent(QDropEvent *event) { const QModelIndex index = indexAt(event->position().toPoint()); if (index.isValid()) { const QRect rect = visualRect(index); if (!d->insertAbove(event, rect) && !d->insertBelow(event, rect)) { KFilePlacesModel *placesModel = qobject_cast(model()); Q_ASSERT(placesModel != nullptr); if (placesModel->setupNeeded(index)) { d->m_pendingDropUrlsIndex = index; // Make a full copy of the Mime-Data d->m_dropUrlsMimeData = std::make_unique(); const auto formats = event->mimeData()->formats(); for (const auto &format : formats) { d->m_dropUrlsMimeData->setData(format, event->mimeData()->data(format)); } d->m_dropUrlsEvent = std::make_unique(event->position(), event->possibleActions(), d->m_dropUrlsMimeData.get(), event->buttons(), event->modifiers()); placesModel->requestSetup(index); } else { Q_EMIT urlsDropped(placesModel->url(index), event, this); } // HACK Qt eventually calls into QAIM::dropMimeData when a drop event isn't // accepted by the view. However, QListView::dropEvent calls ignore() on our // event afterwards when // "icon view didn't move the data, and moveRows not implemented, so fall back to default" // overriding the acceptProposedAction() below. // This special mime type tells KFilePlacesModel to ignore it. auto *mime = const_cast(event->mimeData()); mime->setData(KFilePlacesModelPrivate::ignoreMimeType(), QByteArrayLiteral("1")); event->acceptProposedAction(); } } QListView::dropEvent(event); d->m_dragging = false; if (d->m_dragActivationTimer) { d->m_dragActivationTimer->stop(); } d->m_pendingDragActivation = QPersistentModelIndex(); d->m_delegate->setShowHoverIndication(true); } void KFilePlacesView::paintEvent(QPaintEvent *event) { QListView::paintEvent(event); if (d->m_dragging && !d->m_dropRect.isEmpty()) { // draw drop indicator QPainter painter(viewport()); QRect itemRect = visualRect(d->m_dropIndex); // Take into account section headers if (d->m_delegate->indexIsSectionHeader(d->m_dropIndex)) { const int headerHeight = d->m_delegate->sectionHeaderHeight(d->m_dropIndex); itemRect.translate(0, headerHeight); itemRect.setHeight(itemRect.height() - headerHeight); } const bool drawInsertIndicator = !d->m_dropOnPlace || d->m_dropRect.height() <= d->insertIndicatorHeight(itemRect.height()); if (drawInsertIndicator) { // draw indicator for inserting items QStyleOptionViewItem viewOpts; initViewItemOption(&viewOpts); QBrush blendedBrush = viewOpts.palette.brush(QPalette::Normal, QPalette::Highlight); QColor color = blendedBrush.color(); const int y = (d->m_dropRect.top() + d->m_dropRect.bottom()) / 2; const int thickness = d->m_dropRect.height() / 2; Q_ASSERT(thickness >= 1); int alpha = 255; const int alphaDec = alpha / (thickness + 1); for (int i = 0; i < thickness; i++) { color.setAlpha(alpha); alpha -= alphaDec; painter.setPen(color); painter.drawLine(d->m_dropRect.left(), y - i, d->m_dropRect.right(), y - i); painter.drawLine(d->m_dropRect.left(), y + i, d->m_dropRect.right(), y + i); } } else { // draw indicator for copying/moving/linking to items QStyleOptionViewItem opt; opt.initFrom(this); opt.index = d->m_dropIndex; opt.rect = itemRect; opt.state = QStyle::State_Enabled | QStyle::State_MouseOver; style()->drawPrimitive(QStyle::PE_PanelItemViewItem, &opt, &painter, this); } } } void KFilePlacesView::startDrag(Qt::DropActions supportedActions) { d->m_delegate->startDrag(); QListView::startDrag(supportedActions); } void KFilePlacesView::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { // does not accept drags from section header area if (d->m_delegate->pointIsHeaderArea(event->pos())) { return; } // teardown button is handled by KFilePlacesEventWatcher // NOTE "mouseReleaseEvent" side is also in there. if (d->m_delegate->pointIsTeardownAction(event->pos())) { return; } } QListView::mousePressEvent(event); } void KFilePlacesView::setModel(QAbstractItemModel *model) { QListView::setModel(model); d->updateHiddenRows(); // Uses Qt::QueuedConnection to delay the time when the slot will be // called. In case of an item move the remove+add will be done before // we adapt the item size (otherwise we'd get it wrong as we'd execute // it after the remove only). connect( model, &QAbstractItemModel::rowsRemoved, this, [this]() { d->adaptItemSize(); }, Qt::QueuedConnection); QObject::connect(qobject_cast(model), &KFilePlacesModel::setupDone, this, [this](const QModelIndex &idx, bool success) { d->storageSetupDone(idx, success); }); d->m_delegate->clearFreeSpaceInfo(); } void KFilePlacesView::rowsInserted(const QModelIndex &parent, int start, int end) { QListView::rowsInserted(parent, start, end); setUrl(d->m_currentUrl); KFilePlacesModel *placesModel = static_cast(model()); for (int i = start; i <= end; ++i) { QModelIndex index = placesModel->index(i, 0, parent); if (d->m_showAll || !placesModel->isHidden(index)) { d->m_delegate->addAppearingItem(index); d->triggerItemAppearingAnimation(); } else { setRowHidden(i, true); } } d->triggerItemAppearingAnimation(); d->adaptItemSize(); } QSize KFilePlacesView::sizeHint() const { KFilePlacesModel *placesModel = qobject_cast(model()); if (!placesModel) { return QListView::sizeHint(); } const int height = QListView::sizeHint().height(); QFontMetrics fm = d->q->fontMetrics(); int textWidth = 0; for (int i = 0; i < placesModel->rowCount(); ++i) { QModelIndex index = placesModel->index(i, 0); if (!placesModel->isHidden(index)) { textWidth = qMax(textWidth, fm.boundingRect(index.data(Qt::DisplayRole).toString()).width()); } } const int iconSize = style()->pixelMetric(QStyle::PM_SmallIconSize) + 3 * s_lateralMargin; return QSize(iconSize + textWidth + fm.height() / 2, height); } void KFilePlacesViewPrivate::addDisappearingItem(KFilePlacesViewDelegate *delegate, const QModelIndex &index) { delegate->addDisappearingItem(index); if (m_itemDisappearTimeline.state() != QTimeLine::Running) { delegate->setDisappearingItemProgress(0.0); m_itemDisappearTimeline.start(); } } void KFilePlacesViewPrivate::setCurrentIndex(const QModelIndex &index) { KFilePlacesModel *placesModel = qobject_cast(q->model()); if (placesModel == nullptr) { return; } QUrl url = placesModel->url(index); if (url.isValid()) { m_currentUrl = url; updateHiddenRows(); Q_EMIT q->urlChanged(KFilePlacesModel::convertedUrl(url)); } else { q->setUrl(m_currentUrl); } } void KFilePlacesViewPrivate::adaptItemSize() { if (!m_autoResizeItems) { return; } KFilePlacesModel *placesModel = qobject_cast(q->model()); if (placesModel == nullptr) { return; } int rowCount = placesModel->rowCount(); if (!m_showAll) { rowCount -= placesModel->hiddenCount(); QModelIndex current = placesModel->closestItem(m_currentUrl); if (placesModel->isHidden(current)) { ++rowCount; } } if (rowCount == 0) { return; // We've nothing to display anyway } const int minSize = q->style()->pixelMetric(QStyle::PM_SmallIconSize); const int maxSize = 64; QFontMetrics fm = q->fontMetrics(); /// A set that contains the integer widths of the rows of the widest rows. /// It is supposed to only contain the widest 20% of the rows. A std::set of integers is always sorted in ascending order. std::set widestTwentyPercentOfTextWidths; /// The number of items that should be in widestTwentyPercentOfTextWidths. const unsigned int twentyPercent = static_cast(std::ceil(0.2 * rowCount)); Q_ASSERT(twentyPercent > 0); // This is an expectation of the code below or it might crash. /// In this for-loop widestTwentyPercentOfTextWidths is filled, so it becomes true to its name. /// Example: If there are rows with widths of 36, 28, 21, 35, 67, 70, 56, 41, 48, 178, then twentyPercent should equal 2, /// and after this for-loop widestTwentyPercentOfTextWidths should only consist of 70 and 178. for (int i = 0; i < placesModel->rowCount(); ++i) { QModelIndex index = placesModel->index(i, 0); if (!placesModel->isHidden(index)) { const int textWidth = fm.boundingRect(index.data(Qt::DisplayRole).toString()).width(); if (widestTwentyPercentOfTextWidths.size() < twentyPercent || textWidth > (*widestTwentyPercentOfTextWidths.begin())) { // begin() is the smallest integer in the std::set. if (widestTwentyPercentOfTextWidths.size() + 1 > twentyPercent) { // We need to remove an integer from the set, so the total count does not grow beyond our target size of twentyPercent. widestTwentyPercentOfTextWidths.erase(widestTwentyPercentOfTextWidths.begin()); } widestTwentyPercentOfTextWidths.insert(textWidth); } } } const int margin = q->style()->pixelMetric(QStyle::PM_FocusFrameHMargin, nullptr, q) + 1; int maxWidth; /// The maximum width the icons should have based on the text widths. If a lot of text is cut off, we use a smaller icon. if ((*widestTwentyPercentOfTextWidths.rbegin()) - (*widestTwentyPercentOfTextWidths.begin()) > 40) { // There is quite a difference in width if we ignore the widest 20% of texts so let's ignore them. // This makes it more likely that bigger icons are chosen if there is also enough vertical space. maxWidth = q->viewport()->width() - (*widestTwentyPercentOfTextWidths.begin()) - 4 * margin - 1; } else { maxWidth = q->viewport()->width() - (*widestTwentyPercentOfTextWidths.rbegin()) - 4 * margin - 1; } const int totalItemsHeight = (fm.height() / 2) * rowCount; const int totalSectionsHeight = m_delegate->sectionHeaderHeight(QModelIndex()) * sectionsCount(); const int maxHeight = ((q->height() - totalSectionsHeight - totalItemsHeight) / rowCount) - 1; int size = qMin(maxHeight, maxWidth); if (size < minSize) { size = minSize; } else if (size > maxSize) { size = maxSize; } else { // Make it a multiple of 16 size &= ~0xf; } relayoutIconSize(size); } void KFilePlacesViewPrivate::relayoutIconSize(const int size) { if (size == m_delegate->iconSize()) { return; } if (shouldAnimate() && m_smoothItemResizing) { m_oldSize = m_delegate->iconSize(); m_endSize = size; if (m_adaptItemsTimeline.state() != QTimeLine::Running) { m_adaptItemsTimeline.start(); } } else { m_delegate->setIconSize(size); if (shouldAnimate()) { q->scheduleDelayedItemsLayout(); } } } void KFilePlacesViewPrivate::updateHiddenRows() { KFilePlacesModel *placesModel = qobject_cast(q->model()); if (placesModel == nullptr) { return; } int rowCount = placesModel->rowCount(); QModelIndex current = placesModel->closestItem(m_currentUrl); for (int i = 0; i < rowCount; ++i) { QModelIndex index = placesModel->index(i, 0); if (index != current && placesModel->isHidden(index) && !m_showAll) { q->setRowHidden(i, true); } else { q->setRowHidden(i, false); } } adaptItemSize(); } bool KFilePlacesViewPrivate::insertAbove(const QDropEvent *event, const QRect &itemRect) const { if (m_dropOnPlace && !event->mimeData()->hasFormat(KFilePlacesModelPrivate::internalMimeType(qobject_cast(q->model())))) { return event->position().y() < itemRect.top() + insertIndicatorHeight(itemRect.height()) / 2; } return event->position().y() < itemRect.top() + (itemRect.height() / 2); } bool KFilePlacesViewPrivate::insertBelow(const QDropEvent *event, const QRect &itemRect) const { if (m_dropOnPlace && !event->mimeData()->hasFormat(KFilePlacesModelPrivate::internalMimeType(qobject_cast(q->model())))) { return event->position().y() > itemRect.bottom() - insertIndicatorHeight(itemRect.height()) / 2; } return event->position().y() >= itemRect.top() + (itemRect.height() / 2); } int KFilePlacesViewPrivate::insertIndicatorHeight(int itemHeight) const { const int min = 4; const int max = 12; int height = itemHeight / 4; if (height < min) { height = min; } else if (height > max) { height = max; } return height; } int KFilePlacesViewPrivate::sectionsCount() const { int count = 0; QString prevSection; const int rowCount = q->model()->rowCount(); for (int i = 0; i < rowCount; i++) { if (!q->isRowHidden(i)) { const QModelIndex index = q->model()->index(i, 0); const QString sectionName = index.data(KFilePlacesModel::GroupRole).toString(); if (prevSection != sectionName) { prevSection = sectionName; ++count; } } } return count; } void KFilePlacesViewPrivate::addPlace(const QModelIndex &index) { KFilePlacesModel *placesModel = qobject_cast(q->model()); QUrl url = m_currentUrl; QString label; QString iconName = QStringLiteral("folder"); bool appLocal = true; if (KFilePlaceEditDialog::getInformation(true, url, label, iconName, true, appLocal, 64, q)) { QString appName; if (appLocal) { appName = QCoreApplication::instance()->applicationName(); } placesModel->addPlace(label, url, iconName, appName, index); } } void KFilePlacesViewPrivate::editPlace(const QModelIndex &index) { KFilePlacesModel *placesModel = qobject_cast(q->model()); KBookmark bookmark = placesModel->bookmarkForIndex(index); QUrl url = bookmark.url(); // KBookmark::text() would be untranslated for system bookmarks QString label = placesModel->text(index); QString iconName = bookmark.icon(); bool appLocal = !bookmark.metaDataItem(QStringLiteral("OnlyInApp")).isEmpty(); if (KFilePlaceEditDialog::getInformation(true, url, label, iconName, false, appLocal, 64, q)) { QString appName; if (appLocal) { appName = QCoreApplication::instance()->applicationName(); } placesModel->editPlace(index, label, url, iconName, appName); } } bool KFilePlacesViewPrivate::shouldAnimate() const { return q->style()->styleHint(QStyle::SH_Widget_Animation_Duration, nullptr, q) > 0; } void KFilePlacesViewPrivate::triggerItemAppearingAnimation() { if (m_itemAppearTimeline.state() == QTimeLine::Running) { return; } if (shouldAnimate()) { m_delegate->setAppearingItemProgress(0.0); m_itemAppearTimeline.start(); } else { itemAppearUpdate(1.0); } } void KFilePlacesViewPrivate::triggerItemDisappearingAnimation() { if (m_itemDisappearTimeline.state() == QTimeLine::Running) { return; } if (shouldAnimate()) { m_delegate->setDisappearingItemProgress(0.0); m_itemDisappearTimeline.start(); } else { itemDisappearUpdate(1.0); } } void KFilePlacesViewPrivate::placeClicked(const QModelIndex &index, ActivationSignal activationSignal) { KFilePlacesModel *placesModel = qobject_cast(q->model()); if (placesModel == nullptr) { return; } m_lastClickedIndex = QPersistentModelIndex(); m_lastActivationSignal = nullptr; if (placesModel->setupNeeded(index)) { m_lastClickedIndex = index; m_lastActivationSignal = activationSignal; placesModel->requestSetup(index); return; } setCurrentIndex(index); const QUrl url = KFilePlacesModel::convertedUrl(placesModel->url(index)); /*Q_EMIT*/ std::invoke(activationSignal, q, url); } void KFilePlacesViewPrivate::headerAreaEntered(const QModelIndex &index) { m_delegate->setHoveredHeaderArea(index); q->update(index); } void KFilePlacesViewPrivate::headerAreaLeft(const QModelIndex &index) { m_delegate->setHoveredHeaderArea(QModelIndex()); q->update(index); } void KFilePlacesViewPrivate::actionClicked(const QModelIndex &index) { KFilePlacesModel *placesModel = qobject_cast(q->model()); if (!placesModel) { return; } Solid::Device device = placesModel->deviceForIndex(index); if (device.is()) { placesModel->requestEject(index); } else { teardown(index); } } void KFilePlacesViewPrivate::actionEntered(const QModelIndex &index) { m_delegate->setHoveredAction(index); q->update(index); } void KFilePlacesViewPrivate::actionLeft(const QModelIndex &index) { m_delegate->setHoveredAction(QModelIndex()); q->update(index); } void KFilePlacesViewPrivate::teardown(const QModelIndex &index) { if (m_teardownFunction) { m_teardownFunction(index); } else if (auto *placesModel = qobject_cast(q->model())) { placesModel->requestTeardown(index); } } void KFilePlacesViewPrivate::storageSetupDone(const QModelIndex &index, bool success) { KFilePlacesModel *placesModel = static_cast(q->model()); if (m_lastClickedIndex.isValid()) { if (m_lastClickedIndex == index) { if (success) { setCurrentIndex(m_lastClickedIndex); } else { q->setUrl(m_currentUrl); } const QUrl url = KFilePlacesModel::convertedUrl(placesModel->url(index)); /*Q_EMIT*/ std::invoke(m_lastActivationSignal, q, url); m_lastClickedIndex = QPersistentModelIndex(); m_lastActivationSignal = nullptr; } } if (m_pendingDropUrlsIndex.isValid() && m_dropUrlsEvent) { if (m_pendingDropUrlsIndex == index) { if (success) { Q_EMIT q->urlsDropped(placesModel->url(index), m_dropUrlsEvent.get(), q); } m_pendingDropUrlsIndex = QPersistentModelIndex(); m_dropUrlsEvent.reset(); m_dropUrlsMimeData.reset(); } } } void KFilePlacesViewPrivate::adaptItemsUpdate(qreal value) { const int add = (m_endSize - m_oldSize) * value; const int size = m_oldSize + add; m_delegate->setIconSize(size); q->scheduleDelayedItemsLayout(); } void KFilePlacesViewPrivate::itemAppearUpdate(qreal value) { m_delegate->setAppearingItemProgress(value); q->scheduleDelayedItemsLayout(); } void KFilePlacesViewPrivate::itemDisappearUpdate(qreal value) { m_delegate->setDisappearingItemProgress(value); if (value >= 1.0) { updateHiddenRows(); } q->scheduleDelayedItemsLayout(); } void KFilePlacesViewPrivate::enableSmoothItemResizing() { m_smoothItemResizing = true; } void KFilePlacesViewPrivate::deviceBusyAnimationValueChanged(const QVariant &value) { m_delegate->setDeviceBusyAnimationRotation(value.toReal()); for (const auto &idx : std::as_const(m_busyDevices)) { q->update(idx); } } void KFilePlacesView::dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList &roles) { QListView::dataChanged(topLeft, bottomRight, roles); d->adaptItemSize(); if ((roles.isEmpty() || roles.contains(KFilePlacesModel::DeviceAccessibilityRole)) && d->shouldAnimate()) { QList busyDevices; auto *placesModel = qobject_cast(model()); for (int i = 0; i < placesModel->rowCount(); ++i) { const QModelIndex idx = placesModel->index(i, 0); const auto accessibility = placesModel->deviceAccessibility(idx); if (accessibility == KFilePlacesModel::SetupInProgress || accessibility == KFilePlacesModel::TeardownInProgress) { busyDevices.append(QPersistentModelIndex(idx)); } } d->m_busyDevices = busyDevices; if (busyDevices.isEmpty()) { d->m_deviceBusyAnimation.stop(); } else { d->m_deviceBusyAnimation.start(); } } } #include "moc_kfileplacesview.cpp" #include "moc_kfileplacesview_p.cpp"