diff --git a/applets/notifications/package/contents/ui/NotificationDelegate.qml b/applets/notifications/package/contents/ui/NotificationDelegate.qml --- a/applets/notifications/package/contents/ui/NotificationDelegate.qml +++ b/applets/notifications/package/contents/ui/NotificationDelegate.qml @@ -115,6 +115,19 @@ // model.actions JS array is implicitly turned into a ListModel which we can assign directly actions: model.actions created: model.created + urls: { + // QML ListModel tries to be smart and turns our urls Array into a dict with index as key... + var urls = [] + + var modelUrls = model.urls + if (modelUrls) { + for (var key in modelUrls) { + urls.push(modelUrls[key]) + } + } + + return urls + } onClose: { if (notificationsModel.count > 1) { @@ -132,6 +145,7 @@ executeAction(source, actionId) actions.clear() } + onOpenUrl: Qt.openUrlExternally(url) } } //MouseArea diff --git a/applets/notifications/package/contents/ui/NotificationItem.qml b/applets/notifications/package/contents/ui/NotificationItem.qml --- a/applets/notifications/package/contents/ui/NotificationItem.qml +++ b/applets/notifications/package/contents/ui/NotificationItem.qml @@ -39,6 +39,7 @@ signal close signal configure signal action(string actionId) + signal openUrl(url url) property bool compact: false @@ -48,6 +49,7 @@ property alias body: bodyText.text property alias configurable: settingsButton.visible property var created + property var urls: [] property int maximumTextHeight: -1 @@ -61,6 +63,13 @@ } } + if (thumbnailStripLoader.item) { + var item = thumbnailStripLoader.item.pressedAction() + if (item) { + return item + } + } + if (settingsButton.pressed) { return settingsButton } @@ -309,6 +318,13 @@ } } } - } + Loader { + id: thumbnailStripLoader + Layout.fillWidth: true + Layout.preferredHeight: item ? item.implicitHeight : 0 + source: "ThumbnailStrip.qml" + active: notificationItem.urls.length > 0 + } + } } diff --git a/applets/notifications/package/contents/ui/NotificationPopup.qml b/applets/notifications/package/contents/ui/NotificationPopup.qml --- a/applets/notifications/package/contents/ui/NotificationPopup.qml +++ b/applets/notifications/package/contents/ui/NotificationPopup.qml @@ -113,6 +113,7 @@ icon: notificationProperties ? notificationProperties.appIcon : "" image: notificationProperties ? notificationProperties.image : undefined configurable: (notificationProperties ? notificationProperties.configurable : false) && !Settings.isMobile + urls: notificationProperties ? notificationProperties.urls : [] x: units.smallSpacing y: units.smallSpacing @@ -133,6 +134,10 @@ actions.clear() notificationPopup.hide() } + onOpenUrl: { + Qt.openUrlExternally(url) + notificationPopup.hide() + } } } diff --git a/applets/notifications/package/contents/ui/ThumbnailStrip.qml b/applets/notifications/package/contents/ui/ThumbnailStrip.qml new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/ThumbnailStrip.qml @@ -0,0 +1,187 @@ +/* + * Copyright 2016 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU Library General Public License as + * published by the Free Software Foundation; either version 2, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Library General Public License for more details + * + * You should have received a copy of the GNU Library General Public + * License along with this program; if not, write to the + * Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import QtQuick 2.0 +import QtQuick.Layouts 1.1 + +import org.kde.plasma.core 2.0 as PlasmaCore +import org.kde.plasma.components 2.0 as PlasmaComponents +import org.kde.plasma.extras 2.0 as PlasmaExtras + +import org.kde.kquickcontrolsaddons 2.0 + +import org.kde.plasma.private.notifications 1.0 as Notifications + +ListView { + id: previewList + + readonly property int itemSquareSize: units.gridUnit * 4 + + // if it's only one file, show a larger preview + // however if no preview could be generated, which we only know after we tried, + // the delegate will use itemSquareSize instead as there's no point in showing a huge file icon + readonly property int itemWidth: previewList.count === 1 ? width : itemSquareSize + readonly property int itemHeight: previewList.count === 1 ? Math.round(width / 3) : itemSquareSize + + // by the time the "model" is populated, the Layout isn't finished yet, causing ListView to have a 0 width + // hence it's based on the mainLayout.width instead + readonly property int maximumItemCount: Math.floor(mainLayout.width / itemSquareSize) + + model: { + var urls = notificationItem.urls + if (urls.length <= maximumItemCount) { + return urls + } + // if it wouldn't fit, remove one item in favor of the "+n" badge + return urls.slice(0, maximumItemCount - 1) + } + orientation: ListView.Horizontal + spacing: units.smallSpacing + interactive: false + + footer: notificationItem.urls.length > maximumItemCount ? moreBadge : null + + function pressedAction() { + for (var i = 0; i < count; ++i) { + var item = itemAtIndex(i) + if (item.pressed) { + return item + } + } + } + + // HACK ListView only provides itemAt(x,y) methods but since we don't scroll + // we can make assumptions on what our layout looks like... + function itemAtIndex(index) { + return itemAt(index * (itemSquareSize + spacing), 0) + } + + Component { + id: moreBadge + + // if there's more urls than we can display, show a "+n" badge + Item { + width: moreLabel.width + height: previewList.height + + PlasmaExtras.Heading { + id: moreLabel + anchors { + left: parent.left + // ListView doesn't add spacing before the footer + leftMargin: previewList.spacing + top: parent.top + bottom: parent.bottom + } + level: 3 + verticalAlignment: Text.AlignVCenter + text: i18nc("Indicator that there are more urls in the notification than previews shown", "+%1", notificationItem.urls.length - previewList.count) + } + } + } + + delegate: MouseArea { + id: previewDelegate + + // clip is expensive, only clip if the QPixmapItem would leak outside + clip: previewPixmap.height > height + + width: thumbnailer.hasPreview ? previewList.itemWidth : previewList.itemSquareSize + height: thumbnailer.hasPreview ? previewList.itemHeight : previewList.itemSquareSize + + preventStealing: true + cursorShape: Qt.OpenHandCursor + + onClicked: notificationItem.openUrl(modelData) + + // cannot drag itself, hence dragging the Pixmap instead + drag.target: previewPixmap + + Drag.dragType: Drag.Automatic + Drag.active: previewDelegate.drag.active + Drag.mimeData: ({ + "text/uri-list": modelData, + "text/plain": modelData + }) + + // first item determins the ListView height + Binding { + target: previewList + property: "implicitHeight" + value: previewDelegate.height + when: index === 0 + } + + Notifications.Thumbnailer { + id: thumbnailer + + readonly property real ratio: pixmapSize.height ? pixmapSize.width / pixmapSize.height : 1 + + url: modelData + size: Qt.size(previewList.itemWidth, previewList.itemHeight) + } + + QPixmapItem { + id: previewPixmap + + anchors.centerIn: parent + + width: parent.width + height: width / thumbnailer.ratio + pixmap: thumbnailer.pixmap + } + + PlasmaCore.IconItem { + anchors.fill: parent + source: thumbnailer.iconName + usesPlasmaTheme: false + } + + Rectangle { + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + } + color: theme.textColor + opacity: 0.6 + height: fileNameLabel.contentHeight + + PlasmaComponents.Label { + id: fileNameLabel + anchors { + fill: parent + leftMargin: units.smallSpacing + rightMargin: units.smallSpacing + } + wrapMode: Text.NoWrap + height: implicitHeight // unset Label default height + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideMiddle + font.pointSize: theme.smallestFont.pointSize + color: theme.backgroundColor + text: { + var splitUrl = modelData.split("/") + return splitUrl[splitUrl.length - 1] + } + } + } + } +} diff --git a/applets/notifications/plugin/CMakeLists.txt b/applets/notifications/plugin/CMakeLists.txt --- a/applets/notifications/plugin/CMakeLists.txt +++ b/applets/notifications/plugin/CMakeLists.txt @@ -1,13 +1,16 @@ set(notificationshelper_SRCS notificationshelper.cpp notificationshelperplugin.cpp + thumbnailer.cpp ) add_library(notificationshelperplugin SHARED ${notificationshelper_SRCS}) target_link_libraries(notificationshelperplugin Qt5::Core Qt5::Gui Qt5::Qml - Qt5::Quick) + Qt5::Quick + KF5::KIOWidgets # PreviewJob + ) install(TARGETS notificationshelperplugin DESTINATION ${KDE_INSTALL_QMLDIR}/org/kde/plasma/private/notifications) diff --git a/applets/notifications/plugin/notificationshelperplugin.cpp b/applets/notifications/plugin/notificationshelperplugin.cpp --- a/applets/notifications/plugin/notificationshelperplugin.cpp +++ b/applets/notifications/plugin/notificationshelperplugin.cpp @@ -18,6 +18,7 @@ #include "notificationshelperplugin.h" #include "notificationshelper.h" +#include "thumbnailer.h" #include #include @@ -72,6 +73,8 @@ Q_ASSERT(uri == QLatin1String("org.kde.plasma.private.notifications")); qmlRegisterType(uri, 1, 0, "NotificationsHelper"); + qmlRegisterType(uri, 1, 0, "Thumbnailer"); + qmlRegisterSingletonType(uri, 1, 0, "UrlHelper", urlcheck_singletontype_provider); qmlRegisterSingletonType(uri, 1, 0, "ProcessRunner", processrunner_singleton_provider); } diff --git a/applets/notifications/plugin/thumbnailer.h b/applets/notifications/plugin/thumbnailer.h new file mode 100644 --- /dev/null +++ b/applets/notifications/plugin/thumbnailer.h @@ -0,0 +1,79 @@ +/* + Copyright (C) 2016 Kai Uwe Broulik + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#pragma once + +#include +#include + +#include +#include +#include + +class Thumbnailer : public QObject, public QQmlParserStatus +{ + Q_OBJECT + Q_INTERFACES(QQmlParserStatus) + + Q_PROPERTY(QUrl url READ url WRITE setUrl NOTIFY urlChanged) + Q_PROPERTY(QSize size READ size WRITE setSize NOTIFY sizeChanged) + + Q_PROPERTY(bool hasPreview READ hasPreview NOTIFY pixmapChanged) + Q_PROPERTY(QPixmap pixmap READ pixmap NOTIFY pixmapChanged) + Q_PROPERTY(QSize pixmapSize READ pixmapSize NOTIFY pixmapChanged) + + Q_PROPERTY(QString iconName READ iconName NOTIFY iconNameChanged) + +public: + explicit Thumbnailer(QObject *parent = nullptr); + ~Thumbnailer() override; + + QUrl url() const; + void setUrl(const QUrl &url); + + QSize size() const; + void setSize(const QSize &size); + + bool hasPreview() const; + QPixmap pixmap() const; + QSize pixmapSize() const; + + QString iconName() const; + + void classBegin() override; + void componentComplete() override; + +signals: + void urlChanged(); + void sizeChanged(); + void pixmapChanged(); + void iconNameChanged(); + +private: + void generatePreview(); + + bool m_inited = false; + + QUrl m_url; + QSize m_size; + + QPixmap m_pixmap; + + QString m_iconName; + +}; diff --git a/applets/notifications/plugin/thumbnailer.cpp b/applets/notifications/plugin/thumbnailer.cpp new file mode 100644 --- /dev/null +++ b/applets/notifications/plugin/thumbnailer.cpp @@ -0,0 +1,130 @@ +/* + Copyright (C) 2016 Kai Uwe Broulik + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "thumbnailer.h" + +#include + +#include +#include + +Thumbnailer::Thumbnailer(QObject *parent) : QObject(parent) +{ + +} + +Thumbnailer::~Thumbnailer() = default; + +void Thumbnailer::classBegin() +{ + +} + +void Thumbnailer::componentComplete() +{ + m_inited = true; + generatePreview(); +} + +QUrl Thumbnailer::url() const +{ + return m_url; +} + +void Thumbnailer::setUrl(const QUrl &url) +{ + if (m_url != url) { + m_url = url; + emit urlChanged(); + + generatePreview(); + } +} + +QSize Thumbnailer::size() const +{ + return m_size; +} + +void Thumbnailer::setSize(const QSize &size) +{ + if (m_size != size) { + m_size = size; + emit sizeChanged(); + + generatePreview(); + } +} + +bool Thumbnailer::hasPreview() const +{ + return !m_pixmap.isNull(); +} + +QPixmap Thumbnailer::pixmap() const +{ + return m_pixmap; +} + +QSize Thumbnailer::pixmapSize() const +{ + return m_pixmap.size(); +} + +QString Thumbnailer::iconName() const +{ + return m_iconName; +} + +void Thumbnailer::generatePreview() +{ + if (!m_inited) { + return; + } + + if (!m_url.isValid() || !m_url.isLocalFile() || !m_size.isValid()) { + return; + } + + KIO::PreviewJob *job = KIO::filePreview(KFileItemList({KFileItem(m_url)}), m_size); + job->setIgnoreMaximumSize(true); + + connect(job, &KIO::PreviewJob::gotPreview, this, [this](const KFileItem &item, const QPixmap &preview) { + Q_UNUSED(item); + m_pixmap = preview; + emit pixmapChanged(); + + if (!m_iconName.isEmpty()) { + m_iconName.clear(); + emit iconNameChanged(); + } + }); + + connect(job, &KIO::PreviewJob::failed, this, [this](const KFileItem &item) { + m_pixmap = QPixmap(); + emit pixmapChanged(); + + const QString &iconName = item.determineMimeType().iconName(); + if (m_iconName != iconName) { + m_iconName = iconName; + emit iconNameChanged(); + } + }); + + job->start(); +} diff --git a/dataengines/notifications/notificationsengine.cpp b/dataengines/notifications/notificationsengine.cpp --- a/dataengines/notifications/notificationsengine.cpp +++ b/dataengines/notifications/notificationsengine.cpp @@ -190,6 +190,7 @@ const QString appRealName = hints[QStringLiteral("x-kde-appname")].toString(); const QString eventId = hints[QStringLiteral("x-kde-eventId")].toString(); const bool skipGrouping = hints[QStringLiteral("x-kde-skipGrouping")].toBool(); + const QStringList &urls = hints[QStringLiteral("x-kde-urls")].toStringList(); // group notifications that have the same title coming from the same app // or if they are on the "blacklist", honor the skipGrouping hint sent @@ -342,6 +343,8 @@ notificationData.insert(QStringLiteral("urgency"), hints[QStringLiteral("urgency")].toInt()); } + notificationData.insert(QStringLiteral("urls"), urls); + setData(source, notificationData); m_activeNotifications.insert(source, app_name + summary);