From 3b7c05b385dc56fbc0b9ffdd332f8d30e7624d0c Mon Sep 17 00:00:00 2001 From: Felix Ernst Date: Sun, 12 Sep 2021 15:33:39 +0200 Subject: Add Selection Mode The selection mode action is a checkable toggle action named "Select Files and Folders" which has "Space" as the default shortcut. In selection mode a bottom bar with contextual actions is shown. These should mostly mirror the actions which are available through the right-click context menu aka DolphinContextMenu. Resizing of the window might make a overflow button appear in the bottom selection mode bar. This commit makes press and hold in the view activate selection mode. This behaviour is not triggered if the press and hold is used to either start a rubberband selection or a drag operation within a short time. The length of the short timeframe is defined by a QStyleHint. This is currently not implemented in touch because I can't test it. Mix the selection mode bars' background colors using a nice combination of colors from the current color scheme BUG: 427202 --- src/selectionmode/actionwithwidget.cpp | 76 +++ src/selectionmode/actionwithwidget.h | 86 +++ src/selectionmode/backgroundcolorhelper.cpp | 90 +++ src/selectionmode/backgroundcolorhelper.h | 45 ++ src/selectionmode/selectionmodebottombar.cpp | 705 +++++++++++++++++++++ src/selectionmode/selectionmodebottombar.h | 181 ++++++ src/selectionmode/selectionmodetopbar.cpp | 131 ++++ src/selectionmode/selectionmodetopbar.h | 66 ++ src/selectionmode/singleclickselectionproxystyle.h | 29 + 9 files changed, 1409 insertions(+) create mode 100644 src/selectionmode/actionwithwidget.cpp create mode 100644 src/selectionmode/actionwithwidget.h create mode 100644 src/selectionmode/backgroundcolorhelper.cpp create mode 100644 src/selectionmode/backgroundcolorhelper.h create mode 100644 src/selectionmode/selectionmodebottombar.cpp create mode 100644 src/selectionmode/selectionmodebottombar.h create mode 100644 src/selectionmode/selectionmodetopbar.cpp create mode 100644 src/selectionmode/selectionmodetopbar.h create mode 100644 src/selectionmode/singleclickselectionproxystyle.h (limited to 'src/selectionmode') diff --git a/src/selectionmode/actionwithwidget.cpp b/src/selectionmode/actionwithwidget.cpp new file mode 100644 index 000000000..8e82a37bf --- /dev/null +++ b/src/selectionmode/actionwithwidget.cpp @@ -0,0 +1,76 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2022 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "actionwithwidget.h" + +#include +#include +#include +#include + +ActionWithWidget::ActionWithWidget(QAction *action) : + m_action{action} +{ } + +ActionWithWidget::ActionWithWidget(QAction *action, QAbstractButton *button) : + m_action{action}, + m_widget{button} +{ + copyActionDataToButton(button, action); +} + +QWidget *ActionWithWidget::newWidget(QWidget *parent) +{ + Q_CHECK_PTR(m_action); + Q_ASSERT(!m_widget); + + if (m_action->isSeparator()) { + auto line = new QFrame(parent); + line->setFrameShape(QFrame::VLine); + line->setFrameShadow(QFrame::Sunken); + + m_widget = line; + } else { + m_widget = newButtonForAction(m_action, parent); + } + return m_widget; +} + +QAbstractButton *newButtonForAction(QAction *action, QWidget *parent) +{ + Q_CHECK_PTR(action); + Q_ASSERT(!action->isSeparator()); + + if (action->priority() == QAction::LowPriority) { + // We don't want the low priority actions to be displayed icon-only so we need trickery. + auto button = new QPushButton(parent); + copyActionDataToButton(static_cast(button), action); + button->setMinimumWidth(0); + return button; + } + + auto *toolButton = new QToolButton(parent); + toolButton->setToolButtonStyle(Qt::ToolButtonStyle::ToolButtonTextBesideIcon); + toolButton->setDefaultAction(action); + toolButton->setPopupMode(QToolButton::ToolButtonPopupMode::InstantPopup); + toolButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + toolButton->setMinimumWidth(0); + return toolButton; +} + +void copyActionDataToButton(QAbstractButton *button, QAction *action) +{ + button->setText(action->text()); + button->setIcon(action->icon()); + button->setToolTip(action->toolTip()); + button->setWhatsThis(action->whatsThis()); + + button->setVisible(action->isVisible()); + button->setEnabled(action->isEnabled()); + + QObject::connect(button, &QAbstractButton::clicked, action, &QAction::trigger); +} diff --git a/src/selectionmode/actionwithwidget.h b/src/selectionmode/actionwithwidget.h new file mode 100644 index 000000000..722fdf284 --- /dev/null +++ b/src/selectionmode/actionwithwidget.h @@ -0,0 +1,86 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2022 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef ACTIONWITHWIDGET_H +#define ACTIONWITHWIDGET_H + +#include +#include +#include + +class QAbstractButton; + +/** + * @brief Small wrapper/helper class that contains an action and its widget. + * + * This class takes neither the responsibility for deleting its action() nor its widget(). + */ +class ActionWithWidget +{ +public: + ActionWithWidget(QAction *action); + + /** + * Connect @p action and @p button using copyActionDataToButton() and the + * wraps the two together in the ActionWithWidget object. + * ActionWithWidget doesn't take any ownership. + * + * @see copyActionDataToButton() + * + * @param button the button to be styled and used to fit the @p action. + */ + ActionWithWidget(QAction *action, QAbstractButton *button); + + /** @returns the action of this object. Crashes if that action has been deleted elsewhere in the meantime. */ + inline QAction *action() { + Q_CHECK_PTR(m_action); + return m_action; + }; + + /** @returns the widget of this object. */ + inline QWidget *widget() { + return m_widget; + } + + /** + * @returns a widget with parent @p parent for the action() of this object. + * + * For most actions some sort of button will be returned. For separators a vertical line will be returned. + * If this ActionWithWidget already has a widget(), this method will crash. + */ + QWidget *newWidget(QWidget *parent); + + /** returns true if the widget exists and is visible. false otherwise. */ + inline bool isWidgetVisible() const { + return m_widget && m_widget->isVisible(); + }; + +private: + QPointer m_action; + QPointer m_widget; +}; + +/** + * A small helper method. + * @return a button with the correct styling for the general mode of the SelectionModeBottomBar which can be added to its layout. + */ +QAbstractButton *newButtonForAction(QAction *action, QWidget *parent); + +/** + * Normally, if one wants a button that represents a QAction one would use a QToolButton + * and simply call QToolButton::setDefaultAction(action). However if one does this, all + * control over the style, text, etc. of the button is forfeited. One can't for example + * have text on the button then, if the action has a low QAction::priority(). + * + * This method styles the @p button based on the @p action without using QToolButton::setDefaultAction(). + * + * Another reason why this is necessary is because the actions have application-wide scope while + * these buttons belong to one ViewContainer. + */ +void copyActionDataToButton(QAbstractButton *button, QAction *action); + +#endif // ACTIONWITHWIDGET_H diff --git a/src/selectionmode/backgroundcolorhelper.cpp b/src/selectionmode/backgroundcolorhelper.cpp new file mode 100644 index 000000000..8a7d69758 --- /dev/null +++ b/src/selectionmode/backgroundcolorhelper.cpp @@ -0,0 +1,90 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2022 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "backgroundcolorhelper.h" + +#include + +#include +#include +#include +#include + +BackgroundColorHelper *BackgroundColorHelper::instance() +{ + if (!s_instance) { + s_instance = new BackgroundColorHelper; + } + return s_instance; +} + + +void setBackgroundColorForWidget(QWidget *widget, QColor color) +{ + QPalette palette; + palette.setBrush(QPalette::Active, QPalette::Window, color); + palette.setBrush(QPalette::Inactive, QPalette::Window, color); + palette.setBrush(QPalette::Disabled, QPalette::Window, color); + widget->setAutoFillBackground(true); + widget->setPalette(palette); +} + +void BackgroundColorHelper::controlBackgroundColor(QWidget *widget) +{ + setBackgroundColorForWidget(widget, m_backgroundColor); + + Q_ASSERT_X(std::find(m_colorControlledWidgets.begin(), m_colorControlledWidgets.end(), widget) == m_colorControlledWidgets.end(), "controlBackgroundColor", + "Duplicate insertion is not necessary because the background color should already automatically update itself on paletteChanged"); + m_colorControlledWidgets.emplace_back(widget); +} + +BackgroundColorHelper::BackgroundColorHelper() +{ + updateBackgroundColor(); + QObject::connect(qApp, &QGuiApplication::paletteChanged, [=](){ slotPaletteChanged(); }); +} + +void BackgroundColorHelper::slotPaletteChanged() +{ + updateBackgroundColor(); + for (auto i = m_colorControlledWidgets.begin(); i != m_colorControlledWidgets.end(); ++i) { + if (!*i) { + i = m_colorControlledWidgets.erase(i); + continue; + } + setBackgroundColorForWidget(*i, m_backgroundColor); + } +} + +void BackgroundColorHelper::updateBackgroundColor() +{ + // We use colors from the color scheme for mixing so it fits the theme. + const auto colorScheme = KColorScheme(QPalette::Normal, KColorScheme::Window); + const auto activeBackgroundColor = colorScheme.background(KColorScheme::BackgroundRole::ActiveBackground).color(); + // We use the positive color for mixing so the end product doesn't look like a warning or error. + const auto positiveBackgroundColor = colorScheme.background(KColorScheme::BackgroundRole::PositiveBackground).color(); + + // Make sure the new background color has a meaningfully different hue than the activeBackgroundColor. + const int hueDifference = positiveBackgroundColor.hue() - activeBackgroundColor.hue(); + int newHue; + if (std::abs(hueDifference) > 80) { + newHue = (activeBackgroundColor.hue() + positiveBackgroundColor.hue()) / 2; + } else { + newHue = hueDifference > 0 ? + activeBackgroundColor.hue() + 40 : + activeBackgroundColor.hue() - 40; + newHue %= 360; // hue needs to be between 0 and 359 per Qt documentation. + } + + m_backgroundColor = QColor::fromHsv(newHue, + // Saturation should be closer to the active color because otherwise the selection mode color might overpower it. + .7 * activeBackgroundColor.saturation() + .3 * positiveBackgroundColor.saturation(), + (activeBackgroundColor.value() + positiveBackgroundColor.value()) / 2, + (activeBackgroundColor.alpha() + positiveBackgroundColor.alpha()) / 2); +} + +BackgroundColorHelper *BackgroundColorHelper::s_instance = nullptr; diff --git a/src/selectionmode/backgroundcolorhelper.h b/src/selectionmode/backgroundcolorhelper.h new file mode 100644 index 000000000..0e8a61b34 --- /dev/null +++ b/src/selectionmode/backgroundcolorhelper.h @@ -0,0 +1,45 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2022 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef BACKGROUNDCOLORHELPER_H +#define BACKGROUNDCOLORHELPER_H + +#include +#include + +#include + +class QWidget; + +/** + * @brief A Singleton class for managing the colors of selection mode widgets. + */ +class BackgroundColorHelper +{ +public: + static BackgroundColorHelper *instance(); + + /** + * Changes the background color of @p widget to a distinct color scheme matching color which makes it clear that the widget belongs to the selection mode. + */ + void controlBackgroundColor(QWidget *widget); + +private: + BackgroundColorHelper(); + + void slotPaletteChanged(); + + void updateBackgroundColor(); + +private: + std::vector> m_colorControlledWidgets; + QColor m_backgroundColor; + + static BackgroundColorHelper *s_instance; +}; + +#endif // BACKGROUNDCOLORHELPER_H diff --git a/src/selectionmode/selectionmodebottombar.cpp b/src/selectionmode/selectionmodebottombar.cpp new file mode 100644 index 000000000..5bf19295a --- /dev/null +++ b/src/selectionmode/selectionmodebottombar.cpp @@ -0,0 +1,705 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2022 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "selectionmodebottombar.h" + +#include "backgroundcolorhelper.h" +#include "dolphin_generalsettings.h" +#include "dolphincontextmenu.h" +#include "dolphinmainwindow.h" +#include "dolphinremoveaction.h" +#include "global.h" +#include "kitemviews/kfileitemlisttostring.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +SelectionModeBottomBar::SelectionModeBottomBar(KActionCollection *actionCollection, QWidget *parent) : + QWidget{parent}, + m_actionCollection{actionCollection} +{ + // Showing of this widget is normally animated. We hide it for now and make it small. + hide(); + setMaximumHeight(0); + + setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Fixed); + setMinimumWidth(0); + + auto fillParentLayout = new QGridLayout(this); + fillParentLayout->setContentsMargins(0, 0, 0, 0); + + // Put the contents into a QScrollArea. This prevents increasing the view width + // in case that not enough width for the contents is available. (this trick is also used in dolphinsearchbox.cpp.) + auto scrollArea = new QScrollArea(this); + fillParentLayout->addWidget(scrollArea); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->setWidgetResizable(true); + + auto contentsContainer = new QWidget(scrollArea); + scrollArea->setWidget(contentsContainer); + contentsContainer->installEventFilter(this); // Adjusts the height of this bar to the height of the contentsContainer + + BackgroundColorHelper::instance()->controlBackgroundColor(this); + + // We will mostly interact with m_layout when changing the contents and not care about the other internal hierarchy. + m_layout = new QHBoxLayout(contentsContainer); +} + +void SelectionModeBottomBar::setVisible(bool visible, Animated animated) +{ + Q_ASSERT_X(animated == WithAnimation, "SelectionModeBottomBar::setVisible", "This wasn't implemented."); + + if (!visible && m_contents == PasteContents) { + return; // The bar with PasteContents should not be hidden or users might not know how to paste what they just copied. + // Set m_contents to anything else to circumvent this prevention mechanism. + } + + if (!m_heightAnimation) { + m_heightAnimation = new QPropertyAnimation(this, "maximumHeight"); + } + disconnect(m_heightAnimation, &QAbstractAnimation::finished, + this, nullptr); + m_heightAnimation->setDuration(2 * + style()->styleHint(QStyle::SH_Widget_Animation_Duration, nullptr, this) * + GlobalConfig::animationDurationFactor()); + + if (visible) { + show(); + m_heightAnimation->setStartValue(0); + m_heightAnimation->setEndValue(sizeHint().height()); + m_heightAnimation->setEasingCurve(QEasingCurve::OutCubic); + connect(m_heightAnimation, &QAbstractAnimation::finished, + this, [this](){ setMaximumHeight(sizeHint().height()); }); + } else { + m_heightAnimation->setStartValue(height()); + m_heightAnimation->setEndValue(0); + m_heightAnimation->setEasingCurve(QEasingCurve::OutCubic); + connect(m_heightAnimation, &QAbstractAnimation::finished, + this, &QWidget::hide); + } + + m_heightAnimation->start(); +} + +QSize SelectionModeBottomBar::sizeHint() const +{ + // 1 as width because this widget should never be the reason the DolphinViewContainer is made wider. + return QSize{1, m_layout->parentWidget()->sizeHint().height()}; +} + +void SelectionModeBottomBar::slotSelectionChanged(const KFileItemList &selection, const QUrl &baseUrl) +{ + if (m_contents == GeneralContents) { + auto contextActions = contextActionsFor(selection, baseUrl); + m_generalBarActions.clear(); + for (auto i = contextActions.begin(); i != contextActions.end(); ++i) { + m_generalBarActions.emplace_back(ActionWithWidget{*i}); + } + resetContents(GeneralContents); + } + updateMainActionButton(selection); +} + +void SelectionModeBottomBar::slotSplitTabDisabled() +{ + switch (m_contents) { + case CopyToOtherViewContents: + case MoveToOtherViewContents: + Q_EMIT leaveSelectionModeRequested(); + default: + return; + } +} + +void SelectionModeBottomBar::resetContents(SelectionModeBottomBar::Contents contents) +{ + emptyBarContents(); + + // A label is added in many of the methods below. We only know its size a bit later and if it should be hidden. + QTimer::singleShot(10, this, [this](){ updateExplanatoryLabelVisibility(); }); + + Q_CHECK_PTR(m_actionCollection); + m_contents = contents; + switch (contents) { + case CopyContents: + return addCopyContents(); + case CopyLocationContents: + return addCopyLocationContents(); + case CopyToOtherViewContents: + return addCopyToOtherViewContents(); + case CutContents: + return addCutContents(); + case DeleteContents: + return addDeleteContents(); + case DuplicateContents: + return addDuplicateContents(); + case GeneralContents: + return addGeneralContents(); + case PasteContents: + return addPasteContents(); + case MoveToOtherViewContents: + return addMoveToOtherViewContents(); + case MoveToTrashContents: + return addMoveToTrashContents(); + case RenameContents: + return addRenameContents(); + } +} + +bool SelectionModeBottomBar::eventFilter(QObject *watched, QEvent *event) +{ + Q_ASSERT(qobject_cast(watched)); // This evenfFilter is only implemented for QWidgets. + + switch (event->type()) { + case QEvent::ChildAdded: + case QEvent::ChildRemoved: + QTimer::singleShot(0, this, [this](){ setMaximumHeight(sizeHint().height()); }); + // Fall through. + default: + return false; + } +} + +void SelectionModeBottomBar::resizeEvent(QResizeEvent *resizeEvent) +{ + if (resizeEvent->oldSize().width() == resizeEvent->size().width()) { + // The width() didn't change so our custom override isn't needed. + return QWidget::resizeEvent(resizeEvent); + } + m_layout->parentWidget()->setFixedWidth(resizeEvent->size().width()); + + if (m_contents == GeneralContents) { + Q_ASSERT(m_overflowButton); + if (unusedSpace() < 0) { + // The bottom bar is overflowing! We need to hide some of the widgets. + for (auto i = m_generalBarActions.rbegin(); i != m_generalBarActions.rend(); ++i) { + if (!i->isWidgetVisible()) { + continue; + } + i->widget()->setVisible(false); + + // Add the action to the overflow. + std::cout << "An Action is added to the m_overflowButton because of a resize: " << qPrintable(i->action()->text()) << "\n"; + auto overflowMenu = m_overflowButton->menu(); + if (overflowMenu->actions().isEmpty()) { + overflowMenu->addAction(i->action()); + } else { + overflowMenu->insertAction(overflowMenu->actions().at(0), i->action()); + } + std::cout << "The number of actions in the menu is now " << m_overflowButton->menu()->actions().count() << "\n."; + m_overflowButton->setVisible(true); + if (unusedSpace() >= 0) { + break; // All widgets fit now. + } + } + } else { + // We have some unusedSpace(). Let's check if we can maybe add more of the contextual action's widgets. + for (auto i = m_generalBarActions.begin(); i != m_generalBarActions.end(); ++i) { + if (i->isWidgetVisible()) { + continue; + } + if (!i->widget()) { + i->newWidget(this); + i->widget()->setVisible(false); + m_layout->insertWidget(m_layout->count() - 1, i->widget()); // Insert before m_overflowButton + } + if (unusedSpace() < i->widget()->sizeHint().width()) { + // It doesn't fit. We keep it invisible. + break; + } + i->widget()->setVisible(true); + + // Remove the action from the overflow. + std::cout << "An Action is removed from the m_overflowButton because of a resize: " << qPrintable(i->action()->text()) << "\n"; + auto overflowMenu = m_overflowButton->menu(); + overflowMenu->removeAction(i->action()); + std::cout << "The number of actions in the menu is now " << m_overflowButton->menu()->actions().count() << "\n."; + if (overflowMenu->isEmpty()) { + m_overflowButton->setVisible(false); + } + } + } + } + + // Hide the leading explanation if it doesn't fit. The buttons are labeled clear enough that this shouldn't be a big UX problem. + updateExplanatoryLabelVisibility(); + return QWidget::resizeEvent(resizeEvent); +} + +void SelectionModeBottomBar::addCopyContents() +{ + m_explanatoryLabel = new QLabel(i18nc("@info explaining the next step in a process", "Select the files and folders that should be copied."), this); + m_explanatoryLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + m_explanatoryLabel->setWordWrap(true); + m_layout->addWidget(m_explanatoryLabel); + + // i18n: Aborts the current step-by-step process to copy files by leaving the selection mode. + auto *cancelButton = new QPushButton(i18nc("@action:button", "Abort Copying"), this); + connect(cancelButton, &QAbstractButton::clicked, this, &SelectionModeBottomBar::leaveSelectionModeRequested); + m_layout->addWidget(cancelButton); + + auto *copyButton = new QPushButton(this); + // We claim to have PasteContents already so triggering the copy action next won't instantly hide the bottom bar. + connect(copyButton, &QAbstractButton::clicked, [this]() { + if (GeneralSettings::showPasteBarAfterCopying()) { + m_contents = Contents::PasteContents; + } + }); + // Connect the copy action as a second step. + m_mainAction = ActionWithWidget(m_actionCollection->action(KStandardAction::name(KStandardAction::Copy)), copyButton); + // Finally connect the lambda that actually changes the contents to the PasteContents. + connect(copyButton, &QAbstractButton::clicked, [this]() { + if (GeneralSettings::showPasteBarAfterCopying()) { + resetContents(Contents::PasteContents); // resetContents() needs to be connected last because + // it instantly deletes the button and then the other slots won't be called. + } + Q_EMIT leaveSelectionModeRequested(); + }); + updateMainActionButton(KFileItemList()); + m_layout->addWidget(copyButton); +} + +void SelectionModeBottomBar::addCopyLocationContents() +{ + m_explanatoryLabel = new QLabel(i18nc("@info explaining the next step in a process", "Select one file or folder whose location should be copied."), this); + m_explanatoryLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + m_explanatoryLabel->setWordWrap(true); + m_layout->addWidget(m_explanatoryLabel); + + // i18n: Aborts the current step-by-step process to copy the location of files by leaving the selection mode. + auto *cancelButton = new QPushButton(i18nc("@action:button", "Abort Copying"), this); + connect(cancelButton, &QAbstractButton::clicked, this, &SelectionModeBottomBar::leaveSelectionModeRequested); + m_layout->addWidget(cancelButton); + + auto *copyLocationButton = new QPushButton(this); + m_mainAction = ActionWithWidget(m_actionCollection->action(QStringLiteral("copy_location")), copyLocationButton); + updateMainActionButton(KFileItemList()); + m_layout->addWidget(copyLocationButton); +} + +void SelectionModeBottomBar::addCopyToOtherViewContents() +{ + // i18n: "Copy over" refers to copying to the other split view area that is currently visible to the user. + m_explanatoryLabel = new QLabel(i18nc("@info explaining the next step in a process", "Select the files and folders that should be copied over."), this); + m_explanatoryLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + m_explanatoryLabel->setWordWrap(true); + m_layout->addWidget(m_explanatoryLabel); + + // i18n: Aborts the current step-by-step process to copy the location of files by leaving the selection mode. + auto *cancelButton = new QPushButton(i18nc("@action:button", "Abort Copying"), this); + connect(cancelButton, &QAbstractButton::clicked, this, &SelectionModeBottomBar::leaveSelectionModeRequested); + m_layout->addWidget(cancelButton); + + auto *copyToOtherViewButton = new QPushButton(this); + m_mainAction = ActionWithWidget(m_actionCollection->action(QStringLiteral("copy_to_inactive_split_view")), copyToOtherViewButton); + updateMainActionButton(KFileItemList()); + m_layout->addWidget(copyToOtherViewButton); +} + +void SelectionModeBottomBar::addCutContents() +{ + m_explanatoryLabel = new QLabel(i18nc("@info explaining the next step in a process", "Select the files and folders that should be cut."), this); + m_explanatoryLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + m_explanatoryLabel->setWordWrap(true); + m_layout->addWidget(m_explanatoryLabel); + + // i18n: Aborts the current step-by-step process to cut files by leaving the selection mode. + auto *cancelButton = new QPushButton(i18nc("@action:button", "Abort Cutting"), this); + connect(cancelButton, &QAbstractButton::clicked, this, &SelectionModeBottomBar::leaveSelectionModeRequested); + m_layout->addWidget(cancelButton); + + auto *cutButton = new QPushButton(this); + // We claim to have PasteContents already so triggering the cut action next won't instantly hide the bottom bar. + connect(cutButton, &QAbstractButton::clicked, [this]() { + if (GeneralSettings::showPasteBarAfterCopying()) { + m_contents = Contents::PasteContents; + } + }); + // Connect the cut action as a second step. + m_mainAction = ActionWithWidget(m_actionCollection->action(KStandardAction::name(KStandardAction::Cut)), cutButton); + // Finally connect the lambda that actually changes the contents to the PasteContents. + connect(cutButton, &QAbstractButton::clicked, [this](){ + if (GeneralSettings::showPasteBarAfterCopying()) { + resetContents(Contents::PasteContents); // resetContents() needs to be connected last because + // it instantly deletes the button and then the other slots won't be called. + } + Q_EMIT leaveSelectionModeRequested(); + }); + updateMainActionButton(KFileItemList()); + m_layout->addWidget(cutButton); +} + +void SelectionModeBottomBar::addDeleteContents() +{ + m_explanatoryLabel = new QLabel(i18nc("@info explaining the next step in a process", "Select the files and folders that should be permanently deleted."), this); + m_explanatoryLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + m_explanatoryLabel->setWordWrap(true); + m_layout->addWidget(m_explanatoryLabel); + + // i18n: Aborts the current step-by-step process to delete files by leaving the selection mode. + auto *cancelButton = new QPushButton(i18nc("@action:button", "Abort"), this); + connect(cancelButton, &QAbstractButton::clicked, this, &SelectionModeBottomBar::leaveSelectionModeRequested); + m_layout->addWidget(cancelButton); + + auto *deleteButton = new QPushButton(this); + m_mainAction = ActionWithWidget(m_actionCollection->action(KStandardAction::name(KStandardAction::DeleteFile)), deleteButton); + updateMainActionButton(KFileItemList()); + m_layout->addWidget(deleteButton); +} + +void SelectionModeBottomBar::addDuplicateContents() +{ + m_explanatoryLabel = new QLabel(i18nc("@info explaining the next step in a process", "Select the files and folders that should be duplicated here."), this); + m_explanatoryLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + m_explanatoryLabel->setWordWrap(true); + m_layout->addWidget(m_explanatoryLabel); + + // i18n: Aborts the current step-by-step process to duplicate files by leaving the selection mode. + auto *cancelButton = new QPushButton(i18nc("@action:button", "Abort Duplicating"), this); + connect(cancelButton, &QAbstractButton::clicked, this, &SelectionModeBottomBar::leaveSelectionModeRequested); + m_layout->addWidget(cancelButton); + + auto *duplicateButton = new QPushButton(this); + m_mainAction = ActionWithWidget(m_actionCollection->action(QStringLiteral("duplicate")), duplicateButton); + updateMainActionButton(KFileItemList()); + m_layout->addWidget(duplicateButton); +} + +void SelectionModeBottomBar::addGeneralContents() +{ + if (!m_overflowButton) { + m_overflowButton = new QToolButton{this}; + // i18n: This button appears in a bar if there isn't enough horizontal space to fit all the other buttons. + // The small icon-only button opens a menu that contains the actions that didn't fit on the bar. + // Since this is an icon-only button this text will only appear as a tooltip and as accessibility text. + m_overflowButton->setToolTip(i18nc("@action", "More")); + m_overflowButton->setAccessibleName(m_overflowButton->toolTip()); + m_overflowButton->setIcon(QIcon::fromTheme(QStringLiteral("view-more-horizontal-symbolic"))); + m_overflowButton->setMenu(new QMenu{m_overflowButton}); + m_overflowButton->setPopupMode(QToolButton::ToolButtonPopupMode::InstantPopup); + m_overflowButton->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::MinimumExpanding); // Makes sure it has the same height as the labeled buttons. + m_layout->addWidget(m_overflowButton); + } else { + m_overflowButton->menu()->actions().clear(); + // The overflowButton should be part of the calculation for needed space so we set it visible in regards to unusedSpace(). + m_overflowButton->setVisible(true); + } + + // We first add all the m_generalBarActions to the bar until the bar is full. + auto i = m_generalBarActions.begin(); + for (; i != m_generalBarActions.end(); ++i) { + if (i->action()->isVisible()) { + if (i->widget()) { + i->widget()->setEnabled(i->action()->isEnabled()); + } else { + i->newWidget(this); + i->widget()->setVisible(false); + m_layout->insertWidget(m_layout->count() - 1, i->widget()); // Insert before m_overflowButton + } + if (unusedSpace() < i->widget()->sizeHint().width()) { + std::cout << "The " << unusedSpace() << " is smaller than the button->sizeHint().width() of " << i->widget()->sizeHint().width() << " plus the m_layout->spacing() of " << m_layout->spacing() << " so the action " << qPrintable(i->action()->text()) << " doesn't get its own button.\n"; + break; // The bar is too full already. We keep it invisible. + } else { + std::cout << "The " << unusedSpace() << " is bigger than the button->sizeHint().width() of " << i->widget()->sizeHint().width() << " plus the m_layout->spacing() of " << m_layout->spacing() << " so the action " << qPrintable(i->action()->text()) << " was added as its own button/widget.\n"; + i->widget()->setVisible(true); + } + } + } + // We are done adding widgets to the bar so either we were able to fit all the actions in there + m_overflowButton->setVisible(false); + // …or there are more actions left which need to be put into m_overflowButton. + for (; i != m_generalBarActions.end(); ++i) { + m_overflowButton->menu()->addAction(i->action()); + + // The overflowButton is set visible if there is actually an action in it. + if (!m_overflowButton->isVisible() && i->action()->isVisible() && !i->action()->isSeparator()) { + m_overflowButton->setVisible(true); + } + } +} + +void SelectionModeBottomBar::addMoveToOtherViewContents() +{ + // i18n: "Move over" refers to moving to the other split view area that is currently visible to the user. + m_explanatoryLabel = new QLabel(i18nc("@info explaining the next step in a process", "Select the files and folders that should be moved over."), this); + m_explanatoryLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + m_explanatoryLabel->setWordWrap(true); + m_layout->addWidget(m_explanatoryLabel); + + // i18n: Aborts the current step-by-step process to copy the location of files by leaving the selection mode. + auto *cancelButton = new QPushButton(i18nc("@action:button", "Abort Moving"), this); + connect(cancelButton, &QAbstractButton::clicked, this, &SelectionModeBottomBar::leaveSelectionModeRequested); + m_layout->addWidget(cancelButton); + + auto *moveToOtherViewButton = new QPushButton(this); + m_mainAction = ActionWithWidget(m_actionCollection->action(QStringLiteral("move_to_inactive_split_view")), moveToOtherViewButton); + updateMainActionButton(KFileItemList()); + m_layout->addWidget(moveToOtherViewButton); +} + +void SelectionModeBottomBar::addMoveToTrashContents() +{ + m_explanatoryLabel = new QLabel(i18nc("@info explaining the next step in a process", "Select the files and folders that should be moved to the Trash."), this); + m_explanatoryLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + m_explanatoryLabel->setWordWrap(true); + m_layout->addWidget(m_explanatoryLabel); + + // i18n: Aborts the current step-by-step process of moving files to the trash by leaving the selection mode. + auto *cancelButton = new QPushButton(i18nc("@action:button", "Abort"), this); + connect(cancelButton, &QAbstractButton::clicked, this, &SelectionModeBottomBar::leaveSelectionModeRequested); + m_layout->addWidget(cancelButton); + + auto *moveToTrashButton = new QPushButton(this); + m_mainAction = ActionWithWidget(m_actionCollection->action(KStandardAction::name(KStandardAction::MoveToTrash)), moveToTrashButton); + updateMainActionButton(KFileItemList()); + m_layout->addWidget(moveToTrashButton); +} + +void SelectionModeBottomBar::addPasteContents() +{ + m_explanatoryLabel = new QLabel(xi18n("The selected files and folders were added to the Clipboard. " + "Now the Paste action can be used to transfer them from the Clipboard " + "to any other location. They can even be transferred to other applications by using their " + "respective Paste actions."), this); + m_explanatoryLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + m_explanatoryLabel->setWordWrap(true); + m_layout->addWidget(m_explanatoryLabel); + + auto *vBoxLayout = new QVBoxLayout(this); + m_layout->addLayout(vBoxLayout); + + /** We are in "PasteContents" mode which means hiding the bottom bar is impossible. + * So we first have to claim that we have different contents before requesting to leave selection mode. */ + auto actuallyLeaveSelectionMode = [this]() { + m_contents = Contents::CopyLocationContents; + Q_EMIT leaveSelectionModeRequested(); + }; + + auto *pasteButton = new QPushButton(this); + copyActionDataToButton(pasteButton, m_actionCollection->action(KStandardAction::name(KStandardAction::Paste))); + pasteButton->setText(i18nc("@action A more elaborate and clearly worded version of the Paste action", "Paste from Clipboard")); + connect(pasteButton, &QAbstractButton::clicked, this, actuallyLeaveSelectionMode); + vBoxLayout->addWidget(pasteButton); + + auto *dismissButton = new QToolButton(this); + dismissButton->setText(i18nc("@action Dismisses a bar explaining how to use the Paste action", "Dismiss this Reminder")); + connect(dismissButton, &QAbstractButton::clicked, this, actuallyLeaveSelectionMode); + auto *dontRemindAgainAction = new QAction(i18nc("@action Dismisses an explanatory area and never shows it again", "Don't remind me again"), this); + connect(dontRemindAgainAction, &QAction::triggered, this, []() { + GeneralSettings::setShowPasteBarAfterCopying(false); + }); + connect(dontRemindAgainAction, &QAction::triggered, this, actuallyLeaveSelectionMode); + auto *dismissButtonMenu = new QMenu(dismissButton); + dismissButtonMenu->addAction(dontRemindAgainAction); + dismissButton->setMenu(dismissButtonMenu); + dismissButton->setPopupMode(QToolButton::MenuButtonPopup); + vBoxLayout->addWidget(dismissButton); + + m_explanatoryLabel->setMaximumHeight(pasteButton->sizeHint().height() + dismissButton->sizeHint().height() + m_explanatoryLabel->fontMetrics().height()); +} + +void SelectionModeBottomBar::addRenameContents() +{ + m_explanatoryLabel = new QLabel(i18nc("@info explains the next step in a process", "Select the file or folder that should be renamed.\nBulk renaming is possible when multiple items are selected."), this); + m_explanatoryLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); + m_explanatoryLabel->setWordWrap(true); + m_layout->addWidget(m_explanatoryLabel); + + // i18n: Aborts the current step-by-step process to delete files by leaving the selection mode. + auto *cancelButton = new QPushButton(i18nc("@action:button", "Stop Renaming"), this); + connect(cancelButton, &QAbstractButton::clicked, this, &SelectionModeBottomBar::leaveSelectionModeRequested); + m_layout->addWidget(cancelButton); + + auto *renameButton = new QPushButton(this); + m_mainAction = ActionWithWidget(m_actionCollection->action(KStandardAction::name(KStandardAction::RenameFile)), renameButton); + updateMainActionButton(KFileItemList()); + m_layout->addWidget(renameButton); +} + +void SelectionModeBottomBar::emptyBarContents() +{ + QLayoutItem *child; + while ((child = m_layout->takeAt(0)) != nullptr) { + if (auto *childLayout = child->layout()) { + QLayoutItem *grandChild; + while ((grandChild = childLayout->takeAt(0)) != nullptr) { + delete grandChild->widget(); // delete the widget + delete grandChild; // delete the layout item + } + } + delete child->widget(); // delete the widget + delete child; // delete the layout item + } +} + +std::vector SelectionModeBottomBar::contextActionsFor(const KFileItemList& selectedItems, const QUrl& baseUrl) +{ + std::vector contextActions; + contextActions.emplace_back(m_actionCollection->action(KStandardAction::name(KStandardAction::Copy))); + contextActions.emplace_back(m_actionCollection->action(KStandardAction::name(KStandardAction::Cut))); + contextActions.emplace_back(m_actionCollection->action(KStandardAction::name(KStandardAction::RenameFile))); + contextActions.emplace_back(m_actionCollection->action(KStandardAction::name(KStandardAction::MoveToTrash))); + + if (!selectedItems.isEmpty()) { + // We are going to add the actions from the right-click context menu for the selected items. + auto *dolphinMainWindow = qobject_cast(window()); + Q_CHECK_PTR(dolphinMainWindow); + if (!m_fileItemActions) { + m_fileItemActions = new KFileItemActions(this); + m_fileItemActions->setParentWidget(dolphinMainWindow); + connect(m_fileItemActions, &KFileItemActions::error, this, &SelectionModeBottomBar::error); + } + m_internalContextMenu = std::make_unique(dolphinMainWindow, selectedItems.constFirst(), selectedItems, baseUrl, m_fileItemActions); + auto internalContextMenuActions = m_internalContextMenu->actions(); + + // There are some actions which we wouldn't want to add. We remember them in the actionsThatShouldntBeAdded set. + // We don't want to add the four basic actions again which were already added to the top. + std::unordered_set actionsThatShouldntBeAdded{contextActions.begin(), contextActions.end()}; + // "Delete" isn't really necessary to add because we have "Move to Trash" already. It is also more dangerous so let's exclude it. + actionsThatShouldntBeAdded.insert(m_actionCollection->action(KStandardAction::name(KStandardAction::DeleteFile))); + // "Open Terminal" isn't really context dependent and can therefore be opened from elsewhere instead. + actionsThatShouldntBeAdded.insert(m_actionCollection->action(QStringLiteral("open_terminal"))); + + // KHamburgerMenu would only be visible if there is no menu available anywhere on the user interface. This might be useful for recovery from + // such a situation in theory but a bar with context dependent actions doesn't really seem like the right place for it. + Q_ASSERT(internalContextMenuActions.first()->icon().name() == m_actionCollection->action(KStandardAction::name(KStandardAction::HamburgerMenu))->icon().name()); + internalContextMenuActions.removeFirst(); + + for (auto it = internalContextMenuActions.constBegin(); it != internalContextMenuActions.constEnd(); ++it) { + if (actionsThatShouldntBeAdded.count(*it)) { + continue; // Skip this action. + } + if (!qobject_cast(*it)) { // We already have a "Move to Trash" action so we don't want a DolphinRemoveAction. + // We filter duplicate separators here so we won't have to deal with them later. + if (!contextActions.back()->isSeparator() || !(*it)->isSeparator()) { + contextActions.emplace_back((*it)); + } + } + } + } + return contextActions; +} + +int SelectionModeBottomBar::unusedSpace() const +{ + int sumOfPreferredWidths = m_layout->contentsMargins().left() + m_layout->contentsMargins().right(); + if (m_overflowButton) { + sumOfPreferredWidths += m_overflowButton->sizeHint().width(); + } + std::cout << "These layout items should have sane width: "; + for (int i = 0; i < m_layout->count(); ++i) { + auto widget = m_layout->itemAt(i)->widget(); + if (widget && !widget->isVisibleTo(widget->parentWidget())) { + continue; // We don't count invisible widgets. + } + std::cout << m_layout->itemAt(i)->sizeHint().width() << ", "; + if (m_layout->itemAt(i)->sizeHint().width() == 0) { + // One of the items reports an invalid width. We can't work with this so we report an unused space of 0 which should lead to as few changes to the + // layout as possible until the next resize event happens at a later point in time. + //return 0; + } + sumOfPreferredWidths += m_layout->itemAt(i)->sizeHint().width() + m_layout->spacing(); + } + std::cout << "leads to unusedSpace = " << width() << " - " << sumOfPreferredWidths - 20 << " = " << width() - sumOfPreferredWidths - 20 << "\n"; + return width() - sumOfPreferredWidths - 20; // We consider all space used when there are only 20 pixels left + // so there is some room to breath and not too much wonkyness while resizing. +} + +void SelectionModeBottomBar::updateExplanatoryLabelVisibility() +{ + if (!m_explanatoryLabel) { + return; + } + std::cout << "label minimumSizeHint compared to width() :" << m_explanatoryLabel->sizeHint().width() << "/" << m_explanatoryLabel->width() << "; unusedSpace: " << unusedSpace() << "\n"; + if (m_explanatoryLabel->isVisible()) { + m_explanatoryLabel->setVisible(unusedSpace() > 0); + } else { + // We only want to re-show the label when it fits comfortably so the computation below adds another "+20". + m_explanatoryLabel->setVisible(unusedSpace() > m_explanatoryLabel->sizeHint().width() + 20); + } +} + +void SelectionModeBottomBar::updateMainActionButton(const KFileItemList& selection) +{ + if (!m_mainAction.widget()) { + return; + } + Q_ASSERT(qobject_cast(m_mainAction.widget())); + + // Users are nudged towards selecting items by having the button disabled when nothing is selected. + m_mainAction.widget()->setEnabled(selection.count() > 0 && m_mainAction.action()->isEnabled()); + QFontMetrics fontMetrics = m_mainAction.widget()->fontMetrics(); + + QString buttonText; + switch (m_contents) { + case CopyContents: + buttonText = i18ncp("@action A more elaborate and clearly worded version of the Copy action", + "Copy %2 to the Clipboard", "Copy %2 to the Clipboard", selection.count(), + fileItemListToString(selection, fontMetrics.averageCharWidth() * 20, fontMetrics)); + break; + case CopyLocationContents: + buttonText = i18ncp("@action A more elaborate and clearly worded version of the Copy Location action", + "Copy the Location of %2 to the Clipboard", "Copy the Location of %2 to the Clipboard", selection.count(), + fileItemListToString(selection, fontMetrics.averageCharWidth() * 20, fontMetrics)); + break; + case CutContents: + buttonText = i18ncp("@action A more elaborate and clearly worded version of the Cut action", + "Cut %2 to the Clipboard", "Cut %2 to the Clipboard", selection.count(), + fileItemListToString(selection, fontMetrics.averageCharWidth() * 20, fontMetrics)); + break; + case DeleteContents: + buttonText = i18ncp("@action A more elaborate and clearly worded version of the Delete action", + "Permanently Delete %2", "Permanently Delete %2", selection.count(), + fileItemListToString(selection, fontMetrics.averageCharWidth() * 20, fontMetrics)); + break; + case DuplicateContents: + buttonText = i18ncp("@action A more elaborate and clearly worded version of the Duplicate action", + "Duplicate %2", "Duplicate %2", selection.count(), + fileItemListToString(selection, fontMetrics.averageCharWidth() * 20, fontMetrics)); + break; + case MoveToTrashContents: + buttonText = i18ncp("@action A more elaborate and clearly worded version of the Trash action", + "Move %2 to the Trash", "Move %2 to the Trash", selection.count(), + fileItemListToString(selection, fontMetrics.averageCharWidth() * 20, fontMetrics)); + break; + case RenameContents: + buttonText = i18ncp("@action A more elaborate and clearly worded version of the Rename action", + "Rename %2", "Rename %2", selection.count(), + fileItemListToString(selection, fontMetrics.averageCharWidth() * 20, fontMetrics)); + break; + default: + return; + } + if (buttonText != QStringLiteral("NULL")) { + static_cast(m_mainAction.widget())->setText(buttonText); + + // The width of the button has changed. We might want to hide the label so the full button text fits on the bar. + updateExplanatoryLabelVisibility(); + } +} diff --git a/src/selectionmode/selectionmodebottombar.h b/src/selectionmode/selectionmodebottombar.h new file mode 100644 index 000000000..61cb90334 --- /dev/null +++ b/src/selectionmode/selectionmodebottombar.h @@ -0,0 +1,181 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2022 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef SELECTIONMODEBOTTOMBAR_H +#define SELECTIONMODEBOTTOMBAR_H + +#include "actionwithwidget.h" +#include "global.h" + +#include +#include +#include +#include + +#include + +class DolphinContextMenu; +class KActionCollection; +class KFileItemActions; +class KFileItemList; +class QAbstractButton; +class QAction; +class QFontMetrics; +class QHBoxLayout; +class QLabel; +class QPushButton; +class QResizeEvent; +class QToolButton; +class QUrl; + +/** + * A bar mainly used in selection mode that serves various purposes depending on what the user is currently trying to do. + * + * The Contents enum below gives a rough idea about the different states this bar might have. + * The bar is notified of various changes that make changing or updating the content worthwhile. + */ +class SelectionModeBottomBar : public QWidget +{ + Q_OBJECT + +public: + /** The different contents this bar can have. */ + enum Contents{ + CopyContents, + CopyLocationContents, + CopyToOtherViewContents, + CutContents, + DeleteContents, + DuplicateContents, + GeneralContents, + MoveToOtherViewContents, + MoveToTrashContents, + PasteContents, + RenameContents + }; + + /** + * Default constructor + */ + explicit SelectionModeBottomBar(KActionCollection *actionCollection, QWidget *parent); + + /** + * Plays a show or hide animation while changing visibility. + * Therefore, if this method is used to hide this widget, the actual hiding will be postponed until the animation finished. + * @see QWidget::setVisible() + */ + void setVisible(bool visible, Animated animated); + using QWidget::setVisible; // Makes sure that the setVisible() declaration above doesn't hide the one from QWidget. + + void resetContents(Contents contents); + inline Contents contents() const + { + return m_contents; + }; + + QSize sizeHint() const override; + +public Q_SLOTS: + void slotSelectionChanged(const KFileItemList &selection, const QUrl &baseUrl); + + /** Used to notify the m_selectionModeBottomBar that there is no other ViewContainer in the tab. */ + void slotSplitTabDisabled(); + +Q_SIGNALS: + /** + * Forwards the errors from the KFileItemAction::error() used for contextual actions. + */ + void error(const QString &errorMessage); + + void leaveSelectionModeRequested(); + +protected: + /** Is installed on an internal widget to make sure that the height of the bar is adjusted to its contents. */ + bool eventFilter(QObject *watched, QEvent *event) override; + void resizeEvent(QResizeEvent *resizeEvent) override; + +private: + void addCopyContents(); + void addCopyLocationContents(); + void addCopyToOtherViewContents(); + void addCutContents(); + void addDeleteContents(); + void addDuplicateContents(); + /** + * Adds the actions of m_generalBarActions as buttons to the bar. An overflow menu button is + * created to make sure any amount of actions can be accessed. + */ + void addGeneralContents(); + void addMoveToOtherViewContents(); + void addMoveToTrashContents(); + void addPasteContents(); + void addRenameContents(); + + /** + * Deletes all visible widgets and layouts from the bar. + */ + void emptyBarContents(); + + /** + * @returns A vector containing contextual actions for the given \a selection in the \a baseUrl. + * Cut, Copy, Rename and MoveToTrash are always added. Any further contextual actions depend on + * \a selection and \a baseUrl. \a selection and \a baseUrl can be empty/default constructed if + * no item- or view-specific actions should be added aside from Cut, Copy, Rename, MoveToTrash. + * @param selectedItems The selected items for which contextual actions should be displayed. + * @param baseUrl Base URL of the viewport the contextual actions apply to. + */ + std::vector contextActionsFor(const KFileItemList &selectedItems, const QUrl &baseUrl); + + /** + * @returns the amount of pixels that can be spared to add more widgets. A negative value might + * be returned which signifies that some widgets should be hidden or removed from this bar to + * make sure that this SelectionModeBottomBar won't stretch the width of its parent. + */ + int unusedSpace() const; + + /** + * The label isn't that important. This method hides it if there isn't enough room on the bar or + * shows it if there is. + */ + void updateExplanatoryLabelVisibility(); + + /** + * Changes the text and enabled state of the main action button + * based on the amount of currently selected items and the state of the current m_mainAction. + * The current main action depends on the current barContents. + * @param selection the currently selected fileItems. + */ + void updateMainActionButton(const KFileItemList &selection); + +private: + /// All the actions that should be available from this bar when in general mode. + std::vector m_generalBarActions; + /// The context menu used to retrieve all the actions that are relevant for the current selection. + std::unique_ptr m_internalContextMenu; + /// An object that is necessary to keep around for m_internalContextMenu. + KFileItemActions *m_fileItemActions = nullptr; + + /// @see updateMainActionButtonText + ActionWithWidget m_mainAction = ActionWithWidget(nullptr); + /// The button containing all the actions that don't currently fit into the bar. + QPointer m_overflowButton; + /// The actionCollection from which the actions for this bar are retrieved. + KActionCollection *m_actionCollection; + /// Describes the current contents of the bar. + Contents m_contents; + /** The layout all the buttons and labels are added to. + * Do not confuse this with layout() because we do have a QScrollView in between this widget and m_layout. */ + QHBoxLayout *m_layout; + + /// @see SelectionModeBottomBar::setVisible() + QPointer m_heightAnimation; + + /// The info label used for some of the BarContents. Is hidden for narrow widths. + QPointer m_explanatoryLabel; +}; + +#endif // SELECTIONMODEBOTTOMBAR_H diff --git a/src/selectionmode/selectionmodetopbar.cpp b/src/selectionmode/selectionmodetopbar.cpp new file mode 100644 index 000000000..89a4aa03a --- /dev/null +++ b/src/selectionmode/selectionmodetopbar.cpp @@ -0,0 +1,131 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2022 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#include "selectionmodetopbar.h" + +#include "backgroundcolorhelper.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +SelectionModeTopBar::SelectionModeTopBar(QWidget *parent) : + QWidget{parent} +{ + // Showing of this widget is normally animated. We hide it for now and make it small. + hide(); + setMaximumHeight(0); + + setToolTip(KToolTipHelper::whatsThisHintOnly()); + setWhatsThis(xi18nc("@info:whatsthis", "Selection ModeSelect files or folders to manage or manipulate them." + "Press on a file or folder to select it.Press on an already selected file or folder to deselect it." + "Pressing an empty area does not clear the selection." + "Selection rectangles (created by dragging from an empty area) invert the selection status of items within." + "The available action buttons at the bottom change depending on the current selection.")); + + auto fillParentLayout = new QGridLayout(this); + fillParentLayout->setContentsMargins(0, 0, 0, 0); + + // Put the contents into a QScrollArea. This prevents increasing the view width + // in case that not enough width for the contents is available. (this trick is also used in selectionmodebottombar.cpp.) + auto scrollArea = new QScrollArea(this); + fillParentLayout->addWidget(scrollArea); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->setWidgetResizable(true); + + auto contentsContainer = new QWidget(scrollArea); + scrollArea->setWidget(contentsContainer); + + BackgroundColorHelper::instance()->controlBackgroundColor(this); + + setMinimumWidth(0); + + m_fullLabelString = i18nc("@info label above the view explaining the state", + "Selection Mode: Click on files or folders to select or deselect them."); + m_shortLabelString = i18nc("@info label above the view explaining the state", + "Selection Mode"); + m_label = new QLabel(contentsContainer); + m_label->setMinimumWidth(0); + BackgroundColorHelper::instance()->controlBackgroundColor(m_label); + + m_closeButton = new QPushButton(QIcon::fromTheme(QStringLiteral("window-close-symbolic")), "", contentsContainer); + m_closeButton->setToolTip(i18nc("@action:button", "Exit Selection Mode")); + m_closeButton->setAccessibleName(m_closeButton->toolTip()); + m_closeButton->setFlat(true); + connect(m_closeButton, &QAbstractButton::pressed, + this, &SelectionModeTopBar::leaveSelectionModeRequested); + + QHBoxLayout *layout = new QHBoxLayout(contentsContainer); + auto contentsMargins = layout->contentsMargins(); + m_preferredHeight = contentsMargins.top() + m_label->sizeHint().height() + contentsMargins.bottom(); + scrollArea->setMaximumHeight(m_preferredHeight); + m_closeButton->setFixedHeight(m_preferredHeight); + layout->setContentsMargins(0, 0, 0, 0); + + layout->addStretch(); + layout->addWidget(m_label); + layout->addStretch(); + layout->addWidget(m_closeButton); +} + +void SelectionModeTopBar::setVisible(bool visible, Animated animated) +{ + Q_ASSERT_X(animated == WithAnimation, "SelectionModeTopBar::setVisible", "This wasn't implemented."); + + if (!m_heightAnimation) { + m_heightAnimation = new QPropertyAnimation(this, "maximumHeight"); + } + disconnect(m_heightAnimation, &QAbstractAnimation::finished, + this, &QWidget::hide); + m_heightAnimation->setDuration(2 * + style()->styleHint(QStyle::SH_Widget_Animation_Duration, nullptr, this) * + GlobalConfig::animationDurationFactor()); + + if (visible) { + show(); + m_heightAnimation->setStartValue(0); + m_heightAnimation->setEndValue(m_preferredHeight); + m_heightAnimation->setEasingCurve(QEasingCurve::OutCubic); + } else { + m_heightAnimation->setStartValue(height()); + m_heightAnimation->setEndValue(0); + m_heightAnimation->setEasingCurve(QEasingCurve::OutCubic); + connect(m_heightAnimation, &QAbstractAnimation::finished, + this, &QWidget::hide); + } + + m_heightAnimation->start(); +} + +void SelectionModeTopBar::resizeEvent(QResizeEvent */* resizeEvent */) +{ + updateLabelString(); +} + +void SelectionModeTopBar::updateLabelString() +{ + QFontMetrics fontMetrics = m_label->fontMetrics(); + if (fontMetrics.horizontalAdvance(m_fullLabelString) + m_closeButton->sizeHint().width() + style()->pixelMetric(QStyle::PM_LayoutLeftMargin) * 2 + style()->pixelMetric(QStyle::PM_LayoutRightMargin) * 2 < width()) { + m_label->setText(m_fullLabelString); + } else { + m_label->setText(m_shortLabelString); + } +} diff --git a/src/selectionmode/selectionmodetopbar.h b/src/selectionmode/selectionmodetopbar.h new file mode 100644 index 000000000..fa829aef5 --- /dev/null +++ b/src/selectionmode/selectionmodetopbar.h @@ -0,0 +1,66 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2022 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef SELECTIONMODETOPBAR_H +#define SELECTIONMODETOPBAR_H + +#include "global.h" + +#include +#include +#include +#include + +class QHideEvent; +class QLabel; +class QPushButton; +class QResizeEvent; +class QShowEvent; + +/** + * @todo write docs + */ +class SelectionModeTopBar : public QWidget +{ + Q_OBJECT + +public: + SelectionModeTopBar(QWidget *parent); + + /** + * Plays a show or hide animation while changing visibility. + * Therefore, if this method is used to hide this widget, the actual hiding will be postponed until the animation finished. + * @see QWidget::setVisible() + */ + void setVisible(bool visible, Animated animated); + using QWidget::setVisible; // Makes sure that the setVisible() declaration above doesn't hide the one from QWidget. + +Q_SIGNALS: + void leaveSelectionModeRequested(); + +protected: + void resizeEvent(QResizeEvent */* resizeEvent */) override; + +private: + /** Decides whether the m_fullLabelString or m_shortLabelString should be used based on available width. */ + void updateLabelString(); + +private: + QLabel *m_label; + QPushButton *m_closeButton; + + /** @see updateLabelString() */ + QString m_fullLabelString; + /** @see updateLabelString() */ + QString m_shortLabelString; + + int m_preferredHeight; + + QPointer m_heightAnimation; +}; + +#endif // SELECTIONMODETOPBAR_H diff --git a/src/selectionmode/singleclickselectionproxystyle.h b/src/selectionmode/singleclickselectionproxystyle.h new file mode 100644 index 000000000..9c185a85a --- /dev/null +++ b/src/selectionmode/singleclickselectionproxystyle.h @@ -0,0 +1,29 @@ +/* + This file is part of the KDE project + SPDX-FileCopyrightText: 2020 Felix Ernst + + SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL +*/ + +#ifndef SINGLECLICKSELECTIONPROXYSTYLE_H +#define SINGLECLICKSELECTIONPROXYSTYLE_H + +#include + +/** + * @todo write docs + */ +class SingleClickSelectionProxyStyle : public QProxyStyle +{ +public: + inline int styleHint(StyleHint hint, const QStyleOption *option = nullptr, + const QWidget *widget = nullptr, QStyleHintReturn *returnData = nullptr) const override + { + if (hint == QStyle::SH_ItemView_ActivateItemOnSingleClick) { + return 0; + } + return QProxyStyle::styleHint(hint, option, widget, returnData); + } +}; + +#endif // SINGLECLICKSELECTIONPROXYSTYLE_H -- cgit v1.3