diff --git a/applets/notifications/CMakeLists.txt b/applets/notifications/CMakeLists.txt --- a/applets/notifications/CMakeLists.txt +++ b/applets/notifications/CMakeLists.txt @@ -1,4 +1,23 @@ -add_subdirectory(lib) -add_subdirectory(plugin) +add_definitions(-DTRANSLATION_DOMAIN=\"plasma_applet_org.kde.plasma.notifications\") + +set(notificationapplet_SRCS + notificationapplet.cpp + filemenu.cpp + thumbnailer.cpp +) + +add_library(plasma_applet_notifications MODULE ${notificationapplet_SRCS}) + +kcoreaddons_desktop_to_json(plasma_applet_notifications package/metadata.desktop) + +target_link_libraries(plasma_applet_notifications + Qt5::Gui + Qt5::Quick # for QQmlParserStatus + KF5::I18n + KF5::Plasma + KF5::KIOWidgets # for PreviewJob + ) + +install(TARGETS plasma_applet_notifications DESTINATION ${KDE_INSTALL_PLUGINDIR}/plasma/applets) plasma_install_package(package org.kde.plasma.notifications) diff --git a/applets/notifications/Messages.sh b/applets/notifications/Messages.sh new file mode 100755 --- /dev/null +++ b/applets/notifications/Messages.sh @@ -0,0 +1,2 @@ +#! /usr/bin/env bash +$XGETTEXT `find . -name \*.js -o -name \*.qml -o -name \*.cpp` -o $podir/plasma_applet_org.kde.plasma.notifications.pot diff --git a/applets/notifications/filemenu.h b/applets/notifications/filemenu.h new file mode 100644 --- /dev/null +++ b/applets/notifications/filemenu.h @@ -0,0 +1,62 @@ +/* + Copyright (C) 2016,2019 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 + +class QAction; +class QQuickItem; + +class FileMenu : public QObject +{ + Q_OBJECT + Q_PROPERTY(QUrl url READ url WRITE setUrl NOTIFY urlChanged) + Q_PROPERTY(QQuickItem *visualParent READ visualParent WRITE setVisualParent NOTIFY visualParentChanged) + Q_PROPERTY(bool visible READ visible WRITE setVisible NOTIFY visibleChanged) + +public: + explicit FileMenu(QObject *parent = nullptr); + ~FileMenu() override; + + QUrl url() const; + void setUrl(const QUrl &url); + + QQuickItem *visualParent() const; + void setVisualParent(QQuickItem *visualParent); + + bool visible() const; + void setVisible(bool visible); + + Q_INVOKABLE void open(int x, int y); + +signals: + void actionTriggered(QAction *action); + + void urlChanged(); + void visualParentChanged(); + void visibleChanged(); + +private: + QUrl m_url; + QPointer m_visualParent; + bool m_visible = false; + +}; diff --git a/applets/notifications/plugin/thumbnailer.cpp b/applets/notifications/filemenu.cpp rename from applets/notifications/plugin/thumbnailer.cpp rename to applets/notifications/filemenu.cpp --- a/applets/notifications/plugin/thumbnailer.cpp +++ b/applets/notifications/filemenu.cpp @@ -1,5 +1,5 @@ /* - Copyright (C) 2016 Kai Uwe Broulik + Copyright (C) 2016,2019 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 @@ -16,9 +16,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ -#include "thumbnailer.h" - -#include +#include "filemenu.h" #include #include @@ -38,104 +36,90 @@ #include -Thumbnailer::Thumbnailer(QObject *parent) : QObject(parent) +FileMenu::FileMenu(QObject *parent) : QObject(parent) { } -Thumbnailer::~Thumbnailer() = default; - -void Thumbnailer::classBegin() -{ - -} +FileMenu::~FileMenu() = default; -void Thumbnailer::componentComplete() -{ - m_inited = true; - generatePreview(); -} - -QUrl Thumbnailer::url() const +QUrl FileMenu::url() const { return m_url; } -void Thumbnailer::setUrl(const QUrl &url) +void FileMenu::setUrl(const QUrl &url) { if (m_url != url) { m_url = url; emit urlChanged(); - - generatePreview(); } } -QSize Thumbnailer::size() const +QQuickItem *FileMenu::visualParent() const { - return m_size; + return m_visualParent.data(); } -void Thumbnailer::setSize(const QSize &size) +void FileMenu::setVisualParent(QQuickItem *visualParent) { - if (m_size != size) { - m_size = size; - emit sizeChanged(); - - generatePreview(); + if (m_visualParent.data() == visualParent) { + return; } -} -bool Thumbnailer::hasPreview() const -{ - return !m_pixmap.isNull(); -} - -QPixmap Thumbnailer::pixmap() const -{ - return m_pixmap; + if (m_visualParent) { + disconnect(m_visualParent.data(), nullptr, this, nullptr); + } + m_visualParent = visualParent; + if (m_visualParent) { + connect(m_visualParent.data(), &QObject::destroyed, this, &FileMenu::visualParentChanged); + } + emit visualParentChanged(); } -QSize Thumbnailer::pixmapSize() const +bool FileMenu::visible() const { - return m_pixmap.size(); + return m_visible; } -QString Thumbnailer::iconName() const +void FileMenu::setVisible(bool visible) { - return m_iconName; -} + if (m_visible == visible) { + return; + } -bool Thumbnailer::menuVisible() const -{ - return m_menuVisible; + if (visible) { + open(0, 0); + } else { + // TODO warning or close? + } } -void Thumbnailer::showContextMenu(int x, int y, const QString &path, QQuickItem *ctx) +void FileMenu::open(int x, int y) { - if (!ctx || !ctx->window()) { + if (!m_visualParent || !m_visualParent->window()) { return; } - const QUrl url(path); - if (!url.isValid()) { + if (!m_url.isValid()) { return; } - KFileItem fileItem(url); + KFileItem fileItem(m_url); QMenu *menu = new QMenu(); menu->setAttribute(Qt::WA_DeleteOnClose, true); + connect(menu, &QMenu::triggered, this, &FileMenu::actionTriggered); connect(menu, &QMenu::aboutToHide, this, [this] { - m_menuVisible = false; - emit menuVisibleChanged(); + m_visible = false; + emit visibleChanged(); }); - if (KProtocolManager::supportsListing(url)) { + if (KProtocolManager::supportsListing(m_url)) { QAction *openContainingFolderAction = menu->addAction(QIcon::fromTheme(QStringLiteral("folder-open")), i18n("Open Containing Folder")); - connect(openContainingFolderAction, &QAction::triggered, [url] { - KIO::highlightInFileManager({url}); + connect(openContainingFolderAction, &QAction::triggered, [this] { + KIO::highlightInFileManager({m_url}); }); } @@ -171,71 +155,31 @@ // this causes the next click to go missing //by releasing manually we avoid that situation - auto ungrabMouseHack = [ctx]() { - if (ctx->window()->mouseGrabberItem()) { - ctx->window()->mouseGrabberItem()->ungrabMouse(); + auto ungrabMouseHack = [this]() { + if (m_visualParent && m_visualParent->window() && m_visualParent->window()->mouseGrabberItem()) { + m_visualParent->window()->mouseGrabberItem()->ungrabMouse(); } }; - QTimer::singleShot(0, ctx, ungrabMouseHack); + QTimer::singleShot(0, m_visualParent, ungrabMouseHack); //end workaround QPoint pos; - if (x == -1 && y == -1) { // align "bottom left of ctx" + if (x == -1 && y == -1) { // align "bottom left of visualParent" menu->adjustSize(); - pos = ctx->mapToGlobal(QPointF(0, ctx->height())).toPoint(); + pos = m_visualParent->mapToGlobal(QPointF(0, m_visualParent->height())).toPoint(); if (!qApp->isRightToLeft()) { - pos.rx() += ctx->width(); + pos.rx() += m_visualParent->width(); pos.rx() -= menu->width(); } } else { - pos = ctx->mapToGlobal(QPointF(x, y)).toPoint(); + pos = m_visualParent->mapToGlobal(QPointF(x, y)).toPoint(); } menu->popup(pos); - m_menuVisible = true; - emit menuVisibleChanged(); -} - -void Thumbnailer::generatePreview() -{ - if (!m_inited) { - return; - } - - if (!m_url.isValid() || !m_url.isLocalFile() || !m_size.isValid()) { - return; - } - - auto maxSize = qMax(m_size.width(), m_size.height()); - KIO::PreviewJob *job = KIO::filePreview(KFileItemList({KFileItem(m_url)}), QSize(maxSize,maxSize)); - job->setScaleType(KIO::PreviewJob::Scaled); - 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(); + m_visible = true; + emit visibleChanged(); } diff --git a/applets/notifications/lib/CMakeLists.txt b/applets/notifications/lib/CMakeLists.txt deleted file mode 100644 --- a/applets/notifications/lib/CMakeLists.txt +++ /dev/null @@ -1,14 +0,0 @@ -set(notificationsapplet_SRCS - notificationsapplet.cpp -) - -add_library(plasma_applet_notifications MODULE ${notificationsapplet_SRCS}) - -kcoreaddons_desktop_to_json(plasma_applet_notifications ../package/metadata.desktop) - -target_link_libraries(plasma_applet_notifications - KF5::WindowSystem - KF5::Plasma - KF5::ConfigCore) - -install(TARGETS plasma_applet_notifications DESTINATION ${KDE_INSTALL_PLUGINDIR}/plasma/applets) diff --git a/applets/notifications/lib/notificationsapplet.h b/applets/notifications/lib/notificationsapplet.h deleted file mode 100644 --- a/applets/notifications/lib/notificationsapplet.h +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2014 (c) Martin Klapetek - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License as - * published by the Free Software Foundation; either version 2 of - * the License or (at your option) version 3 or any later version - * accepted by the membership of KDE e.V. (or its successor approved - * by the membership of KDE e.V.), which shall act as a proxy - * defined in Section 14 of version 3 of the license. - * - * 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -#ifndef NOTIFICATIONS_APPLET_H -#define NOTIFICATIONS_APPLET_H - -#include - -#include "../plugin/notificationshelper.h" - -class NotificationsApplet : public Plasma::Applet -{ - Q_OBJECT - Q_PROPERTY(uint screenPosition READ screenPosition WRITE onScreenPositionChanged NOTIFY screenPositionChanged) - Q_PROPERTY(QRect availableScreenRect READ availableScreenRect NOTIFY availableScreenRectChanged) - -public: - NotificationsApplet(QObject *parent, const QVariantList &data); - ~NotificationsApplet() override; - - uint screenPosition() const; - - // This is the screen position that is stored - // in the config file, used to initialize the - // applet settings dialog - Q_INVOKABLE uint configScreenPosition() const; - - QRect availableScreenRect() const; - -public Q_SLOTS: - void init() override; - void onScreenPositionChanged(uint position); - void onAppletLocationChanged(); - -Q_SIGNALS: - void screenPositionChanged(uint position); - void availableScreenRectChanged(const QRect &availableScreenRect); - -private: - void setScreenPositionFromAppletLocation(); - void onScreenChanges(); - - NotificationsHelper::PositionOnScreen m_popupPosition; - QRect m_availableScreenRect; -}; - - -#endif // NOTIFICATIONS_APPLET diff --git a/applets/notifications/lib/notificationsapplet.cpp b/applets/notifications/lib/notificationsapplet.cpp deleted file mode 100644 --- a/applets/notifications/lib/notificationsapplet.cpp +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright 2014 (c) Martin Klapetek - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License as - * published by the Free Software Foundation; either version 2 of - * the License or (at your option) version 3 or any later version - * accepted by the membership of KDE e.V. (or its successor approved - * by the membership of KDE e.V.), which shall act as a proxy - * defined in Section 14 of version 3 of the license. - * - * 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -#include "notificationsapplet.h" - -#include - -#include -#include - - -NotificationsApplet::NotificationsApplet(QObject *parent, const QVariantList &data) - : Plasma::Applet(parent, data), - m_availableScreenRect(0,0,0,0) -{ -} - -NotificationsApplet::~NotificationsApplet() -{ -} - -void NotificationsApplet::init() -{ - m_popupPosition = (NotificationsHelper::PositionOnScreen)configScreenPosition(); - - connect(this, &Plasma::Applet::locationChanged, - this, &NotificationsApplet::onAppletLocationChanged); - - connect(containment(), &Plasma::Containment::screenChanged, - this, &NotificationsApplet::onScreenChanges); - - Q_ASSERT(containment()); - Q_ASSERT(containment()->corona()); - connect(containment()->corona(), &Plasma::Corona::availableScreenRectChanged, this, &NotificationsApplet::onScreenChanges); - - Plasma::Applet::init(); - - onScreenChanges(); - onAppletLocationChanged(); -} - -void NotificationsApplet::onScreenChanges() -{ - // when removing the panel the applet is in, the containment is being destroyed but its corona is still - // there, rightfully emitting availableScreenRectChanged and then we blow up if we try to access it. - if (!containment() || !containment()->corona()) { - return; - } - - auto newAvailableScreenRect = containment()->corona()->availableScreenRect(containment()->screen()); - if (newAvailableScreenRect != m_availableScreenRect) { - m_availableScreenRect = newAvailableScreenRect; - Q_EMIT availableScreenRectChanged(m_availableScreenRect); - } -} - -QRect NotificationsApplet::availableScreenRect() const -{ - return m_availableScreenRect; -} - -void NotificationsApplet::onAppletLocationChanged() -{ - if (configScreenPosition() == 0) { - // If the screenPosition is set to default, - // just follow the panel - setScreenPositionFromAppletLocation(); - } -} - -uint NotificationsApplet::screenPosition() const -{ - return m_popupPosition; -} - -void NotificationsApplet::onScreenPositionChanged(uint position) -{ - KConfigGroup globalGroup = globalConfig(); - globalGroup.writeEntry("popupPosition", position); - globalGroup.sync(); - - // If the position is set to default, let the setScreenPositionFromAppletLocation() - // figure out the effective position, otherwise just set it to m_popupPosition - // and emit the change - if (position == NotificationsHelper::Default) { - setScreenPositionFromAppletLocation(); - } else if (m_popupPosition != position) { - m_popupPosition = (NotificationsHelper::PositionOnScreen)position; - Q_EMIT screenPositionChanged(m_popupPosition); - } -} - -uint NotificationsApplet::configScreenPosition() const -{ - KConfigGroup globalGroup = globalConfig(); - return globalGroup.readEntry("popupPosition", 0); //0 is default -} - -void NotificationsApplet::setScreenPositionFromAppletLocation() -{ - NotificationsHelper::PositionOnScreen newPopupPosition = m_popupPosition; - if (location() == Plasma::Types::TopEdge) { - if (QGuiApplication::isRightToLeft()) { - newPopupPosition = NotificationsHelper::TopLeft; - } else { - newPopupPosition = NotificationsHelper::TopRight; - } - } else { - if (QGuiApplication::isRightToLeft()) { - newPopupPosition = NotificationsHelper::BottomLeft; - } else { - newPopupPosition = NotificationsHelper::BottomRight; - } - } - - if (newPopupPosition != m_popupPosition) { - m_popupPosition = newPopupPosition; - Q_EMIT screenPositionChanged(m_popupPosition); - } -} - -K_EXPORT_PLASMA_APPLET_WITH_JSON(notifications, NotificationsApplet, "metadata.json") - -#include "notificationsapplet.moc" diff --git a/applets/notifications/notificationapplet.h b/applets/notifications/notificationapplet.h new file mode 100644 --- /dev/null +++ b/applets/notifications/notificationapplet.h @@ -0,0 +1,60 @@ +/* + * Copyright 2018 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#pragma once + +#include + +class QQuickItem; +class QString; +class QRect; + +class NotificationApplet : public Plasma::Applet +{ + Q_OBJECT + + Q_PROPERTY(bool dragActive READ dragActive NOTIFY dragActiveChanged) + +public: + explicit NotificationApplet(QObject *parent, const QVariantList &data); + ~NotificationApplet() override; + + void init() override; + void configChanged() override; + + bool dragActive() const; + Q_INVOKABLE bool isDrag(int oldX, int oldY, int newX, int newY) const; + Q_INVOKABLE void startDrag(QQuickItem *item, const QUrl &url, const QPixmap &pixmap); + + Q_INVOKABLE void setSelectionClipboardText(const QString &text); + + Q_INVOKABLE bool isPrimaryScreen(const QRect &rect) const; + +signals: + void dragActiveChanged(); + +private slots: + void doDrag(QQuickItem *item, const QUrl &url, const QPixmap &pixmap); + +private: + bool m_dragActive = false; + +}; diff --git a/applets/notifications/notificationapplet.cpp b/applets/notifications/notificationapplet.cpp new file mode 100644 --- /dev/null +++ b/applets/notifications/notificationapplet.cpp @@ -0,0 +1,128 @@ +/* + * Copyright 2018 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "notificationapplet.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "filemenu.h" +#include "thumbnailer.h" + +NotificationApplet::NotificationApplet(QObject *parent, const QVariantList &data) + : Plasma::Applet(parent, data) +{ + static bool s_typesRegistered = false; + if (!s_typesRegistered) { + const char uri[] = "org.kde.plasma.private.notifications"; + qmlRegisterType(uri, 2, 0, "FileMenu"); + qmlRegisterType(uri, 2, 0, "Thumbnailer"); + qmlProtectModule(uri, 2); + s_typesRegistered = true; + } +} + +NotificationApplet::~NotificationApplet() = default; + +void NotificationApplet::init() +{ + +} + +void NotificationApplet::configChanged() +{ + +} + +bool NotificationApplet::dragActive() const +{ + return m_dragActive; +} + +bool NotificationApplet::isDrag(int oldX, int oldY, int newX, int newY) const +{ + return ((QPoint(oldX, oldY) - QPoint(newX, newY)).manhattanLength() >= qApp->styleHints()->startDragDistance()); +} + +void NotificationApplet::startDrag(QQuickItem *item, const QUrl &url, const QPixmap &pixmap) +{ + // This allows the caller to return, making sure we don't crash if + // the caller is destroyed mid-drag + + QMetaObject::invokeMethod(this, "doDrag", Qt::QueuedConnection, + Q_ARG(QQuickItem*, item), Q_ARG(QUrl, url), Q_ARG(QPixmap, pixmap)); +} + +void NotificationApplet::doDrag(QQuickItem *item, const QUrl &url, const QPixmap &pixmap) +{ + if (item && item->window() && item->window()->mouseGrabberItem()) { + item->window()->mouseGrabberItem()->ungrabMouse(); + } + + QDrag *drag = new QDrag(item); + + QMimeData *mimeData = new QMimeData(); + + if (!url.isEmpty()) { + mimeData->setUrls(QList() << url); + } + + drag->setMimeData(mimeData); + + if (!pixmap.isNull()) { + drag->setPixmap(pixmap); + } + + m_dragActive = true; + emit dragActiveChanged(); + + drag->exec(); + + m_dragActive = false; + emit dragActiveChanged(); +} + +void NotificationApplet::setSelectionClipboardText(const QString &text) +{ + // FIXME KDeclarative Clipboard item uses QClipboard::Mode for "mode" + // which is an enum inaccessible from QML + QGuiApplication::clipboard()->setText(text, QClipboard::Selection); +} + +bool NotificationApplet::isPrimaryScreen(const QRect &rect) const +{ + QScreen *screen = QGuiApplication::primaryScreen(); + if (!screen) { + return false; + } + + // HACK + return rect == screen->geometry(); +} + +K_EXPORT_PLASMA_APPLET_WITH_JSON(icon, NotificationApplet, "metadata.json") + +#include "notificationapplet.moc" diff --git a/applets/notifications/package/Messages.sh b/applets/notifications/package/Messages.sh deleted file mode 100644 --- a/applets/notifications/package/Messages.sh +++ /dev/null @@ -1,5 +0,0 @@ -#! /usr/bin/env bash -$EXTRACTRC `find . -name \*.rc -o -name \*.ui -o -name \*.kcfg` >> rc.cpp -$XGETTEXT `find . -name \*.qml` -L Java -o $podir/plasma_applet_org.kde.plasma.notifications.pot -$XGETTEXT rc.cpp -jo $podir/plasma_applet_org.kde.plasma.notifications.pot -rm -f rc.cpp diff --git a/applets/notifications/package/contents/config/main.xml b/applets/notifications/package/contents/config/main.xml deleted file mode 100644 --- a/applets/notifications/package/contents/config/main.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - true - - - - true - - - - true - - - - diff --git a/applets/notifications/package/contents/tests/test.qml b/applets/notifications/package/contents/tests/test.qml deleted file mode 100644 --- a/applets/notifications/package/contents/tests/test.qml +++ /dev/null @@ -1,74 +0,0 @@ -import QtQuick 2.0 -import org.kde.plasma.core 2.0 as PlasmaCore -import org.kde.plasma.plasmoid 2.0 - -Item { - id: root - property QtObject plasmoidItem: null - onPlasmoidItemChanged: { - if (plasmoidItem && !root.plasmoidItem.rootItem) { - discardTimer.running = true - return - } - - showNotification("testing test") - console.log("sent notification", root.plasmoidItem, root.plasmoidItem.rootItem) - } - Timer { - id: discardTimer - interval: 0 - running: false - onTriggered: { - notificationShown = notificationClosed = true - root.done(); - } - } - - PlasmaCore.DataSource { - id: notificationSource - engine: "notifications" - connectedSources: "org.freedesktop.Notifications" - } - - function showNotification(summary, icon, appname, body, timeout) { - if(!icon) icon = "debug-run" - if(!appname) appname="test" - if(!body) body="" - if(!timeout) timeout=2000 - - var service = notificationSource.serviceForSource("notification"); - var operation = service.operationDescription("createNotification"); - operation["appName"] = appname; - operation["appIcon"] = icon; - operation["summary"] = summary; - operation["body"] = body; - operation["timeout"] = timeout; - - service.startOperationCall(operation); - } - - Connections { - target: root.plasmoidItem.rootItem ? root.plasmoidItem.rootItem.notifications : null - onPopupShown: { - root.notificationShown = true - popupConnections.target = popup - } - } - - Connections { - id: popupConnections - onVisibleChanged: { - if (target.visible) { - popupConnections.target.mainItem.close() - } else { - root.notificationClosed = true - root.done() - } - } - } - - property bool notificationShown: false - property bool notificationClosed: false - signal done() - readonly property bool failed: !notificationShown || !notificationClosed -} diff --git a/applets/notifications/package/contents/ui/CompactRepresentation.qml b/applets/notifications/package/contents/ui/CompactRepresentation.qml new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/CompactRepresentation.qml @@ -0,0 +1,201 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import QtQuick 2.8 +import QtQuick.Layouts 1.1 + +import org.kde.plasma.core 2.0 as PlasmaCore +import org.kde.plasma.components 2.0 as PlasmaComponents + +MouseArea { + id: compactRoot + + // FIXME figure out a way how to let the compact icon not grow beond iconSizeHints + // but still let it expand eventually for a sidebar + + /*readonly property bool inPanel: (plasmoid.location === PlasmaCore.Types.TopEdge + || plasmoid.location === PlasmaCore.Types.RightEdge + || plasmoid.location === PlasmaCore.Types.BottomEdge + || plasmoid.location === PlasmaCore.Types.LeftEdge) + + Layout.minimumWidth: plasmoid.formFactor === PlasmaCore.Types.Horizontal ? height : units.iconSizes.small + Layout.minimumHeight: plasmoid.formFactor === PlasmaCore.Types.Vertical ? width : (units.iconSizes.small + 2 * theme.mSize(theme.defaultFont).height) + + Layout.maximumWidth: -1//inPanel ? units.iconSizeHints.panel : -1 + Layout.maximumHeight: inPanel ? units.iconSizeHints.panel : -1*/ + + property int activeCount: 0 + property int unreadCount: 0 + + property int jobsCount: 0 + property int jobsPercentage: 0 + + property bool inhibited: false + + property bool wasExpanded: false + onPressed: wasExpanded = plasmoid.expanded + onClicked: plasmoid.expanded = !wasExpanded + + PlasmaCore.Svg { + id: notificationSvg + imagePath: "icons/notification" + colorGroup: PlasmaCore.ColorScope.colorGroup + } + + PlasmaCore.SvgItem { + id: notificationIcon + anchors.centerIn: parent + width: units.roundToIconSize(Math.min(parent.width, parent.height)) + height: width + svg: notificationSvg + visible: opacity > 0 + + elementId: "notification-disabled" + + Item { + id: jobProgressItem + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + width: notificationIcon.width * (jobsPercentage / 100) + + clip: true + visible: false + + PlasmaCore.SvgItem { + anchors { + left: parent.left + top: parent.top + bottom: parent.bottom + } + width: notificationIcon.width + + svg: notificationSvg + elementId: "notification-progress-active" + } + } + + TightLabel { + id: countLabel + anchors.centerIn: parent + font.pointSize: -1 + // FIXME fontSizeMode is awful but FontMetrics also doesn't cut it + font.pixelSize: Math.round(parent.height * (0.3 + 0.3 / text.length)) + // TODO add animation when it changes? + text: compactRoot.unreadCount || "" + } + + PlasmaComponents.BusyIndicator { + id: busyIndicator + anchors.fill: parent + visible: false + running: visible + } + + PlasmaCore.SvgItem { + id: notificationActiveItem + anchors.fill: parent + + svg: notificationSvg + elementId: "notification-active" + opacity: 0 + scale: 2 + visible: opacity > 0 + } + } + + PlasmaCore.IconItem { + id: dndIcon + anchors.fill: parent + source: "notifications-disabled" + opacity: 0 + scale: 2 + visible: opacity > 0 + } + + states: [ + State { // active process + when: compactRoot.jobsCount > 0 + PropertyChanges { + target: notificationIcon + elementId: "notification-progress-inactive" + } + PropertyChanges { + target: countLabel + text: compactRoot.jobsCount + } + PropertyChanges { + target: busyIndicator + visible: true + } + PropertyChanges { + target: jobProgressItem + visible: true + } + }, + State { // active notification + when: compactRoot.activeCount > 0 + PropertyChanges { + target: notificationActiveItem + scale: 1 + opacity: 1 + } + }, + State { // do not disturb + when: compactRoot.inhibited + PropertyChanges { + target: dndIcon + scale: 1 + opacity: 1 + } + PropertyChanges { + target: notificationIcon + scale: 0 + opacity: 0 + } + }, + State { // unread notifications + when: compactRoot.unreadCount > 0 + PropertyChanges { + target: notificationIcon + elementId: "notification-empty" + } + PropertyChanges { + target: countLabel + text: compactRoot.unreadCount + } + } + ] + + transitions: [ + Transition { + to: "*" // any state + NumberAnimation { + targets: [notificationIcon, notificationActiveItem, dndIcon] + properties: "opacity,scale" + duration: units.longDuration + easing.type: Easing.InOutQuad + } + } + ] + +} diff --git a/applets/notifications/package/contents/ui/EditContextMenu.qml b/applets/notifications/package/contents/ui/EditContextMenu.qml new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/EditContextMenu.qml @@ -0,0 +1,78 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import QtQuick 2.8 + +import org.kde.plasma.core 2.0 as PlasmaCore +import org.kde.plasma.components 2.0 as PlasmaComponents + +import org.kde.kquickcontrolsaddons 2.0 as KQCAddons + +PlasmaComponents.ContextMenu { + id: contextMenu + + signal closed + + property QtObject __clipboard: KQCAddons.Clipboard { } + + // can be a Text or TextEdit + property Item target + + property string link + + onStatusChanged: { + if (status === PlasmaComponents.DialogStatus.Closed) { + closed(); + } + } + + PlasmaComponents.MenuItem { + text: i18n("Copy Link Address") + onClicked: __clipboard.content = contextMenu.link + visible: contextMenu.link !== "" + } + + PlasmaComponents.MenuItem { + separator: true + visible: contextMenu.link !== "" + } + + PlasmaComponents.MenuItem { + text: i18n("Copy") + icon: "edit-copy" + enabled: typeof target.selectionStart !== "undefined" + ? target.selectionStart !== target.selectionEnd + : (target.text || "").length > 0 + onClicked: { + if (typeof target.copy === "function") { + target.copy(); + } else { + __clipboard.content = target.text; + } + } + } + + PlasmaComponents.MenuItem { + id: selectAllAction + text: i18n("Select All") + onClicked: target.selectAll() + visible: typeof target.selectAll === "function" + } +} diff --git a/applets/notifications/package/contents/ui/FullRepresentation.qml b/applets/notifications/package/contents/ui/FullRepresentation.qml new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/FullRepresentation.qml @@ -0,0 +1,464 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import QtQuick 2.8 +import QtQuick.Layouts 1.1 + +import org.kde.plasma.plasmoid 2.0 +import org.kde.plasma.core 2.0 as PlasmaCore +import org.kde.plasma.components 2.0 as PlasmaComponents +import org.kde.plasma.components 3.0 as PlasmaComponents3 +import org.kde.plasma.extras 2.0 as PlasmaExtras + +import org.kde.kcoreaddons 1.0 as KCoreAddons + +import org.kde.notificationmanager 1.0 as NotificationManager + +import "global" + +ColumnLayout { + Layout.preferredWidth: units.gridUnit * 18 + Layout.preferredHeight: units.gridUnit * 24 + Layout.fillHeight: plasmoid.formFactor === PlasmaCore.Types.Vertical + spacing: units.smallSpacing + + // TODO these should be configurable in the future + readonly property int dndMorningHour: 6 + readonly property int dndEveningHour: 20 + + Connections { + target: plasmoid + onExpandedChanged: { + if (plasmoid.expanded) { + list.positionViewAtBeginning(); + } + } + } + + // header + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + RowLayout { + Layout.fillWidth: true + + RowLayout { + id: dndRow + spacing: units.smallSpacing + + PlasmaComponents3.CheckBox { + id: dndCheck + text: i18n("Do not disturb") + spacing: units.smallSpacing + checkable: true + checked: Globals.inhibited + + // Let the menu open on press + onPressed: { + if (!Globals.inhibited) { + dndMenu.date = new Date(); + // shows ontop of CheckBox to hide the fact that it's unchecked + // until you actually select something :) + dndMenu.open(0, 0); + } + } + // but disable only on click + onClicked: { + if (Globals.inhibited) { + notificationSettings.notificationsInhibitedUntil = undefined; + notificationSettings.revokeApplicationInhibitions(); + + notificationSettings.save(); + } + } + + contentItem: RowLayout { + spacing: dndCheck.spacing + + PlasmaCore.IconItem { + Layout.leftMargin: dndCheck.mirrored ? 0 : dndCheck.indicator.width + dndCheck.spacing + Layout.rightMargin: dndCheck.mirrored ? dndCheck.indicator.width + dndCheck.spacing : 0 + source: "notifications-disabled" + Layout.preferredWidth: units.iconSizes.smallMedium + Layout.preferredHeight: units.iconSizes.smallMedium + } + + PlasmaComponents.Label { + text: i18n("Do not disturb") + } + } + + PlasmaComponents.ModelContextMenu { + id: dndMenu + property date date + visualParent: dndCheck + + onClicked: { + notificationSettings.notificationsInhibitedUntil = model.date; + notificationSettings.save(); + } + + model: { + var model = []; + + // For 1 hour + var d = dndMenu.date; + d.setHours(d.getHours() + 1); + d.setSeconds(0); + model.push({date: d, text: i18n("For 1 hour")}); + + d = dndMenu.date; + d.setHours(d.getHours() + 4); + d.setSeconds(0); + model.push({date: d, text: i18n("For 4 hours")}); + + // Until this evening + d = dndMenu.date; + // TODO make the user's preferred time schedule configurable + d.setHours(dndEveningHour); + d.setMinutes(0); + d.setSeconds(0); + model.push({date: d, text: i18n("Until this evening"), visible: dndMenu.date.getHours() < dndEveningHour}); + + // Until next morning + d = dndMenu.date; + d.setDate(d.getDate() + 1); + d.setHours(dndMorningHour); + d.setMinutes(0); + d.setSeconds(0); + model.push({date: d, text: i18n("Until tomorrow morning"), visible: dndMenu.date.getHours() > dndMorningHour}); + + // Until Monday + var d = dndMenu.date; + d.setHours(dndMorningHour); + // wraps around if neccessary + d.setDate(d.getDate() + (7 - d.getDay() + 1)); + d.setMinutes(0); + d.setSeconds(0); + // show Friday and Saturday, Sunday is "0" but for that you can use "until tomorrow morning" + model.push({date: d, text: i18n("Until Monday"), visible: dndMenu.date.getDay() >= 5}); + + // Until "turned off" + var d = dndMenu.date; + // Just set it to one year in the future so we don't need yet another "do not disturb enabled" property + d.setFullYear(d.getFullYear() + 1); + model.push({date: d, text: i18n("Until turned off")}); + + return model; + } + } + } + } + + Item { + Layout.fillWidth: true + } + + PlasmaComponents.ToolButton { + iconName: "configure" + tooltip: plasmoid.action("openKcm").text + visible: plasmoid.action("openKcm").enabled + onClicked: plasmoid.action("openKcm").trigger() + } + } + + PlasmaExtras.DescriptiveLabel { + Layout.leftMargin: dndCheck.mirrored ? 0 : dndCheck.indicator.width + 2 * dndCheck.spacing + units.iconSizes.smallMedium + Layout.rightMargin: dndCheck.mirrored ? dndCheck.indicator.width + 2 * dndCheck.spacing + units.iconSizes.smallMedium : 0 + Layout.fillWidth: true + wrapMode: Text.WordWrap + textFormat: Text.PlainText + text: { + if (!Globals.inhibited) { + return ""; + } + + var inhibitedUntil = notificationSettings.notificationsInhibitedUntil; + var inhibitedByApp = notificationSettings.notificationsInhibitedByApplication; + + var sections = []; + + // Show until time if valid but not if too far int he future + if (!isNaN(inhibitedUntil.getTime()) && inhibitedUntil.getTime() - new Date().getTime() < 365 * 24 * 60 * 60 * 1000 /* 1 year*/) { + sections.push(i18nc("Do not disturb until date", "Until %1", + KCoreAddons.Format.formatRelativeDateTime(inhibitedUntil, Locale.ShortFormat))); + } + + if (inhibitedByApp) { + var inhibitionAppNames = notificationSettings.notificationInhibitionApplications; + var inhibitionAppReasons = notificationSettings.notificationInhibitionReasons; + + for (var i = 0, length = inhibitionAppNames.length; i < length; ++i) { + var name = inhibitionAppNames[i]; + var reason = inhibitionAppReasons[i]; + + if (reason) { + sections.push(i18nc("Do not disturb until app has finished (reason)", "While %1 is active (%2)", name, reason)); + } else { + sections.push(i18nc("Do not disturb until app has finished", "While %1 is active", name)); + } + } + } + + return sections.join(" ยท "); + } + visible: text !== "" + } + } + + PlasmaCore.SvgItem { + elementId: "horizontal-line" + Layout.fillWidth: true + // why is this needed here but not in the delegate? + Layout.preferredHeight: naturalSize.height + svg: PlasmaCore.Svg { + id: lineSvg + imagePath: "widgets/line" + } + } + + RowLayout { + Layout.fillWidth: true + + PlasmaExtras.Heading { + Layout.fillWidth: true + level: 3 + opacity: 0.6 + text: list.count === 0 ? i18n("No unread notifications.") : i18n("Notifications") + } + + PlasmaComponents.ToolButton { + iconName: "edit-clear-history" + tooltip: i18n("Clear History") + visible: plasmoid.action("clearHistory").visible + onClicked: action_clearHistory() + } + } + + // actual notifications + PlasmaExtras.ScrollArea { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.preferredWidth: units.gridUnit * 18 + Layout.preferredHeight: units.gridUnit * 24 + + ListView { + id: list + model: historyModel + + add: Transition { + SequentialAnimation { + PauseAnimation { duration: units.longDuration } + ParallelAnimation { + NumberAnimation { property: "opacity"; from: 0; duration: units.longDuration } + NumberAnimation { property: "height"; from: 0; duration: units.longDuration } + } + } + } + addDisplaced: Transition { + NumberAnimation { properties: "y"; duration: units.longDuration } + } + + remove: Transition { + ParallelAnimation { + NumberAnimation { property: "opacity"; to: 0; duration: units.longDuration } + NumberAnimation { property: "x"; to: list.width; duration: units.longDuration } + } + } + removeDisplaced: Transition { + SequentialAnimation { + PauseAnimation { duration: units.longDuration } + NumberAnimation { properties: "y"; duration: units.longDuration } + } + } + + // This is so the delegates can detect the change in "isInGroup" and show a separator + section { + property: "isInGroup" + criteria: ViewSection.FullString + } + + delegate: Loader { + id: delegateLoader + width: list.width + sourceComponent: model.isGroup ? groupDelegate : notificationDelegate + + Component { + id: groupDelegate + NotificationHeader { + applicationName: model.applicationName + applicationIconSource: model.applicationIconName + + // don't show timestamp for group + + configurable: model.configurable + closable: model.closable + closeButtonTooltip: i18n("Close Group") + + onCloseClicked: historyModel.close(historyModel.index(index, 0)) + + onConfigureClicked: historyModel.configure(historyModel.index(index, 0)) + } + } + + Component { + id: notificationDelegate + ColumnLayout { + spacing: units.smallSpacing + + RowLayout { + Item { + id: groupLineContainer + Layout.fillHeight: true + Layout.topMargin: units.smallSpacing + width: units.iconSizes.small + visible: model.isInGroup + + PlasmaCore.SvgItem { + elementId: "vertical-line" + svg: lineSvg + anchors.horizontalCenter: parent.horizontalCenter + width: units.iconSizes.small + height: parent.height + } + } + + NotificationItem { + Layout.fillWidth: true + + notificationType: model.type + + inGroup: model.isInGroup + + applicationName: model.applicationName + applicationIconSource: model.applicationIconName + deviceName: model.deviceName || "" + + time: model.updated || model.created + + // configure button on every single notifications is bit overwhelming + configurable: !inGroup && model.configurable + + dismissable: model.type === NotificationManager.Notifications.JobType + && model.jobState !== NotificationManager.Notifications.JobStateStopped + && model.dismissed + // TODO would be nice to be able to undismiss jobs even when they autohide + && notificationSettings.permanentJobPopups + dismissed: model.dismissed || false + closable: model.closable + + summary: model.summary + body: model.body || "" + icon: model.image || model.iconName + + urls: model.urls || [] + + jobState: model.jobState || 0 + percentage: model.percentage || 0 + jobError: model.jobError || 0 + suspendable: !!model.suspendable + killable: !!model.killable + jobDetails: model.jobDetails || null + + configureActionLabel: model.configureActionLabel || "" + // In the popup the default action is triggered by clicking on the popup + // however in the list this is undesirable, so instead show a clickable button + // in case you have a non-expired notification in history (do not disturb mode) + // unless it has the same label as an action + readonly property bool addDefaultAction: (model.hasDefaultAction + && model.defaultActionLabel + && (model.actionLabels || []).indexOf(model.defaultActionLabel) === -1) ? true : false + actionNames: { + var actions = (model.actionNames || []); + if (addDefaultAction) { + actions.unshift("default"); // prepend + } + return actions; + } + actionLabels: { + var labels = (model.actionLabels || []); + if (addDefaultAction) { + labels.unshift(model.defaultActionLabel); + } + return labels; + } + + onCloseClicked: historyModel.close(historyModel.index(index, 0)) + onDismissClicked: model.dismissed = false + onConfigureClicked: historyModel.configure(historyModel.index(index, 0)) + + onActionInvoked: { + if (actionName === "default") { + historyModel.invokeDefaultAction(historyModel.index(index, 0)); + } else { + historyModel.invokeAction(historyModel.index(index, 0), actionName); + } + // Keep it in the history + historyModel.expire(historyModel.index(index, 0)); + } + onOpenUrl: { + Qt.openUrlExternally(url); + historyModel.expire(historyModel.index(index, 0)); + } + onFileActionInvoked: historyModel.expire(historyModel.index(index, 0)) + + onSuspendJobClicked: historyModel.suspendJob(historyModel.index(index, 0)) + onResumeJobClicked: historyModel.resumeJob(historyModel.index(index, 0)) + onKillJobClicked: historyModel.killJob(historyModel.index(index, 0)) + } + } + + PlasmaComponents.ToolButton { + Layout.preferredWidth: minimumWidth + iconName: model.isGroupExpanded ? "arrow-up" : "arrow-down" + text: model.isGroupExpanded ? i18n("Show Fewer") + : i18nc("Expand to show n more notifications", + "Show %1 More", (model.groupChildrenCount - model.expandedGroupChildrenCount)) + visible: (model.groupChildrenCount > model.expandedGroupChildrenCount || model.isGroupExpanded) + && delegateLoader.ListView.nextSection !== delegateLoader.ListView.section + onClicked: { + // Scroll to the group top if groups are collsped + if (model.isGroupExpanded) { + var persistentGroupIdx = historyModel.makePersistentModelIndex(historyModel.groupIndex(historyModel.index(model.index, 0))); + model.isGroupExpanded = false; + list.positionViewAtIndex(persistentGroupIdx.row, ListView.Contain); + } else { + model.isGroupExpanded = true; + } + } + } + + PlasmaCore.SvgItem { + Layout.fillWidth: true + Layout.bottomMargin: units.smallSpacing + elementId: "horizontal-line" + svg: lineSvg + + // property is only atached to the delegate itself (the Loader in our case) + visible: (!model.isInGroup || delegateLoader.ListView.nextSection !== delegateLoader.ListView.section) + && delegateLoader.ListView.nextSection !== "" // don't show after last item + } + } + } + } + } + } +} diff --git a/applets/notifications/package/contents/ui/JobDelegate.qml b/applets/notifications/package/contents/ui/JobDelegate.qml deleted file mode 100644 --- a/applets/notifications/package/contents/ui/JobDelegate.qml +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2011 Marco Martin - * Copyright 2014 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.components 2.0 as PlasmaComponents -import org.kde.plasma.extras 2.0 as PlasmaExtras -import org.kde.kquickcontrolsaddons 2.0 - -Column { - id: jobItem - - width: parent.width - spacing: jobItem.layoutSpacing - - readonly property int layoutSpacing: units.largeSpacing / 4 - readonly property int animationDuration: units.shortDuration * 2 - - readonly property string infoMessage: getData(jobsSource.data, "infoMessage", '') - readonly property string labelName0: getData(jobsSource.data, "labelName0", '') - readonly property string labelName1: getData(jobsSource.data, "labelName1", '') - readonly property string labelFileName0: getData(jobsSource.data, "labelFileName0", '') - readonly property string labelFileName1: getData(jobsSource.data, "labelFileName1", '') - readonly property string label0: getData(jobsSource.data, "label0", '') - readonly property string label1: getData(jobsSource.data, "label1", '') - readonly property bool isSuspended: getData(jobsSource.data, "state", '') === "suspended" - - property alias infoMessageVisible: infoLabel.visible - - function getData(data, name, defaultValue) { - var source = model.name - return data[source] ? (data[source][name] ? data[source][name] : defaultValue) : defaultValue; - } - - PlasmaExtras.Heading { - id: infoLabel - width: parent.width - opacity: 0.6 - level: 3 - text: jobItem.isSuspended ? i18nc("Placeholder is job name, eg. 'Copying'", "%1 (Paused)", infoMessage) : infoMessage - textFormat: Text.PlainText - } - - RowLayout { - width: parent.width - - PlasmaComponents.Label { - id: summary - Layout.fillWidth: true - elide: Text.ElideMiddle - text: { - var label = labelFileName1 || labelFileName0; - var lastSlashIdx = label.lastIndexOf("/"); - return label.slice(lastSlashIdx + 1); - } - textFormat: Text.PlainText - } - - PlasmaComponents.ToolButton { - id: expandButton - iconSource: checked ? "arrow-down" : (LayoutMirroring.enabled ? "arrow-left" : "arrow-right") - tooltip: checked ? i18nc("A button tooltip; hides item details", "Hide Details") - : i18nc("A button tooltip; expands the item to show details", "Show Details") - checkable: true - onCheckedChanged: { - if (checked) { - // Need to force the Loader active here, otherwise the transition - // doesn't fire because the height is still 0 without a loaded item - detailsLoader.active = true - } - } - } - } - - Loader { - id: detailsLoader - width: parent.width - height: 0 - //visible: false // this breaks the opening transition but given the loaded item is released anyway... - source: "JobDetailsItem.qml" - active: false - opacity: state === "expanded" ? 0.6 : 0 - Behavior on opacity { NumberAnimation { duration: jobItem.animationDuration } } - - states: [ - State { - name: "expanded" - when: expandButton.checked && detailsLoader.status === Loader.Ready - PropertyChanges { - target: detailsLoader - height: detailsLoader.item.implicitHeight - } - } - ] - transitions : [ - Transition { - from: "" // default state - collapsed - to: "expanded" - SequentialAnimation { - ScriptAction { - script: detailsLoader.clip = true - } - NumberAnimation { - duration: jobItem.animationDuration - properties: "height" - easing.type: Easing.InOutQuad - } - ScriptAction { script: detailsLoader.clip = false } - } - }, - Transition { - from: "expanded" - to: "" // default state - collapsed - SequentialAnimation { - ScriptAction { script: detailsLoader.clip = true } - NumberAnimation { - duration: jobItem.animationDuration - properties: "height" - easing.type: Easing.InOutQuad - } - ScriptAction { - script: { - detailsLoader.clip = false - detailsLoader.active = false - } - } - } - } - ] - } - - RowLayout { - width: parent.width - height: pauseButton.height - spacing: jobItem.layoutSpacing - - PlasmaComponents.ProgressBar { - id: progressBar - Layout.fillWidth: true - //height: units.gridUnit - minimumValue: 0 - maximumValue: 100 - //percentage doesn't always exist, so doesn't get in the model - value: getData(jobsSource.data, "percentage", 0) - indeterminate: plasmoid.expanded && jobsSource.data[model.name] - && typeof jobsSource.data[model.name]["percentage"] === "undefined" - && !jobItem.isSuspended - } - - PlasmaComponents.ToolButton { - id: pauseButton - iconSource: jobItem.isSuspended ? "media-playback-start" : "media-playback-pause" - visible: getData(jobsSource.data, "suspendable", 0) - - onClicked: { - var operationName = "suspend" - if (jobItem.isSuspended) { - operationName = "resume" - } - var service = jobsSource.serviceForSource(model.name) - var operation = service.operationDescription(operationName) - service.startOperationCall(operation) - } - } - - PlasmaComponents.ToolButton { - id: stopButton - iconSource: "media-playback-stop" - visible: getData(jobsSource.data, "killable", 0) - - onClicked: { - var service = jobsSource.serviceForSource(model.name) - var operation = service.operationDescription("stop") - service.startOperationCall(operation) - } - } - } - -} diff --git a/applets/notifications/package/contents/ui/JobDetails.qml b/applets/notifications/package/contents/ui/JobDetails.qml new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/JobDetails.qml @@ -0,0 +1,153 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import QtQuick 2.8 +import QtQuick.Layouts 1.1 + +import org.kde.plasma.core 2.0 as PlasmaCore +import org.kde.plasma.extras 2.0 as PlasmaExtras + +import org.kde.kcoreaddons 1.0 as KCoreAddons + +import org.kde.notificationmanager 1.0 as NotificationManager + +GridLayout { + id: detailsGrid + + property QtObject jobDetails + + columns: 2 + rowSpacing: Math.round(units.smallSpacing / 2) + columnSpacing: units.smallSpacing + + // once you use Layout.column/Layout.row *all* of the items in the Layout have to use them + Repeater { + model: [1, 2] + + PlasmaExtras.DescriptiveLabel { + Layout.column: 0 + Layout.row: index + Layout.alignment: Qt.AlignTop | Qt.AlignRight + text: jobDetails["descriptionLabel" + modelData] && jobDetails["descriptionValue" + modelData] + ? i18nc("Row description, e.g. Source", "%1:", jobDetails["descriptionLabel" + modelData]) : "" + font: theme.smallestFont + textFormat: Text.PlainText + visible: text !== "" + } + } + + Repeater { + model: [1, 2] + + PlasmaExtras.DescriptiveLabel { + id: descriptionValueLabel + Layout.column: 1 + Layout.row: index + Layout.fillWidth: true + font: theme.smallestFont + elide: Text.ElideMiddle + textFormat: Text.PlainText + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + maximumLineCount: 5 + visible: text !== "" + + Component.onCompleted: bindText() + function bindText() { + text = Qt.binding(function() { + return jobDetails["descriptionLabel" + modelData] && jobDetails["descriptionValue" + modelData] + ? jobDetails["descriptionValue" + modelData] : ""; + }); + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onPressed: { + // break binding so it doesn't update while the menu is opened + descriptionValueLabel.text = descriptionValueLabel.text; + descriptionValueMenu.open(mouse.x, mouse.y) + } + } + + EditContextMenu { + id: descriptionValueMenu + target: descriptionValueLabel + // defer re-binding until after the "Copy" action in the menu has triggered + onClosed: Qt.callLater(descriptionValueLabel.bindText) + } + } + } + + Repeater { + model: ["Bytes", "Files", "Directories"] + + PlasmaExtras.DescriptiveLabel { + Layout.column: 1 + Layout.row: 2 + index + Layout.fillWidth: true + text: { + var processed = jobDetails["processed" + modelData]; + var total = jobDetails["total" + modelData]; + + if (processed > 0 || total > 1) { + if (processed > 0 && total > 0 && processed <= total) { + switch(modelData) { + case "Bytes": + return i18nc("How many bytes have been copied", "%2 of %1", + KCoreAddons.Format.formatByteSize(total), + KCoreAddons.Format.formatByteSize(processed)) + case "Files": + return i18ncp("How many files have been copied", "%2 of %1 file", "%2 of %1 files", + total, processed); + case "Directories": + return i18ncp("How many dirs have been copied", "%2 of %1 folder", "%2 of %1 folders", + total, processed); + } + } else { + switch(modelData) { + case "Bytes": + return KCoreAddons.Format.formatByteSize(processed || total) + case "Files": + return i18np("%1 file", "%1 files", (processed || total)); + case "Directories": + return i18np("%1 folder", "%1 folders", (processed || total)); + } + } + } + + return ""; + } + font: theme.smallestFont + textFormat: Text.PlainText + visible: text !== "" + } + } + + PlasmaExtras.DescriptiveLabel { + Layout.column: 1 + Layout.row: 2 + 3 + Layout.fillWidth: true + text: jobDetails.speed > 0 ? i18nc("Bytes per second", "%1/s", + KCoreAddons.Format.formatByteSize(jobDetails.speed)) : "" + font: theme.smallestFont + textFormat: Text.PlainText + visible: text !== "" + } +} diff --git a/applets/notifications/package/contents/ui/JobDetailsItem.qml b/applets/notifications/package/contents/ui/JobDetailsItem.qml deleted file mode 100644 --- a/applets/notifications/package/contents/ui/JobDetailsItem.qml +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright 2011 Marco Martin - * Copyright 2014 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.kcoreaddons 1.0 as KCoreAddons -import org.kde.kquickcontrolsaddons 2.0 - -// Unfortunately ColumnLayout was a pain to use, so it's a Column with Rows inside -Column { - id: detailsItem - - spacing: jobItem.layoutSpacing - - readonly property int eta: jobItem.getData(jobsSource.data, "eta", 0) - readonly property string speed: jobItem.getData(jobsSource.data, "speed", '') - - property int leftColumnWidth - - function localizeProcessedAmount(id) { - var data = jobsSource.data[modelData] - - if (!data) { - return "" - } - - var unit = data["processedUnit" + id] - var processed = data["processedAmount" + id] - var total = data["totalAmount" + id] - - //if bytes localise the unit - if (unit === "bytes") { - return i18nc("How much many bytes (or whether unit in the locale has been copied over total", "%1 of %2", - KCoreAddons.Format.formatByteSize(processed), - KCoreAddons.Format.formatByteSize(total)) - //else print something only if is interesting data (ie more than one file/directory etc to copy - } else if (total > 1) { - // HACK Actually the owner of the job is responsible for sending the unit in a user-displayable - // way but this has been broken for years and every other unit (other than files and dirs) is correct - if (unit === "files") { - return i18ncp("Either just 1 file or m of n files are being processed", "1 file", "%2 of %1 files", total, processed) - } else if (unit === "dirs") { - return i18ncp("Either just 1 dir or m of n dirs are being processed", "1 dir", "%2 of %1 dirs", total, processed) - } - - return i18n("%1 of %2 %3", processed, total, unit) - } else { - return "" - } - } - - // The 2 main labels (eg. Source and Destination) - Repeater { - model: 2 - - RowLayout { - width: parent.width - spacing: jobItem.layoutSpacing - visible: labelNameText.text !== "" || labelText.text !== "" - - PlasmaComponents.Label { - id: labelNameText - Layout.minimumWidth: leftColumnWidth - Layout.maximumWidth: leftColumnWidth - height: paintedHeight - onPaintedWidthChanged: { - if (paintedWidth > leftColumnWidth) { - leftColumnWidth = paintedWidth - } - } - - font: theme.smallestFont - text: jobItem["labelName" + index] ? i18nc("placeholder is row description, such as Source or Destination", "%1:", jobItem["labelName" + index]) : "" - horizontalAlignment: Text.AlignRight - textFormat: Text.PlainText - } - - PlasmaComponents.Label { - id: labelText - Layout.fillWidth: true - height: paintedHeight - - font: theme.smallestFont - text: jobItem["label" + index] || "" - textFormat: Text.PlainText - elide: Text.ElideMiddle - - PlasmaCore.ToolTipArea { - anchors.fill: parent - subText: labelText.truncated ? labelText.text : "" - textFormat: Text.PlainText - } - } - } - } - - // The three details rows (eg. how many files and folders have been copied and the total amount etc) - Repeater { - model: 3 - - PlasmaComponents.Label { - id: detailsLabel - anchors { - left: parent.left - leftMargin: leftColumnWidth + jobItem.layoutSpacing - right: parent.right - } - height: paintedHeight - - text: localizeProcessedAmount(index) - textFormat: Text.PlainText - font: theme.smallestFont - visible: text !== "" - } - } - - PlasmaComponents.Label { - id: speedLabel - anchors { - left: parent.left - leftMargin: leftColumnWidth + jobItem.layoutSpacing - right: parent.right - } - height: paintedHeight - - font: theme.smallestFont - text: eta > 0 ? i18nc("Speed and estimated time to completion", "%1 (%2 remaining)", speed, KCoreAddons.Format.formatSpelloutDuration(eta)) : speed - textFormat: Text.PlainText - visible: eta > 0 || parseInt(speed) > 0 - } - -} diff --git a/applets/notifications/package/contents/ui/JobItem.qml b/applets/notifications/package/contents/ui/JobItem.qml new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/JobItem.qml @@ -0,0 +1,197 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import QtQuick 2.8 +import QtQuick.Window 2.2 +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.notificationmanager 1.0 as NotificationManager + +import org.kde.plasma.private.notifications 2.0 as Notifications + +ColumnLayout { + id: jobItem + + property int jobState + property int jobError + + property alias percentage: progressBar.value + property alias suspendable: suspendButton.visible + property alias killable: killButton.visible + + property bool hovered + property QtObject jobDetails + // TOOD make an alias on visible if we're not doing an animation + property bool showDetails + + readonly property alias menuOpen: otherFileActionsMenu.visible + + signal suspendJobClicked + signal resumeJobClicked + signal killJobClicked + + signal openUrl(string url) + signal fileActionInvoked + + spacing: 0 + + RowLayout { + id: progressRow + Layout.fillWidth: true + spacing: units.smallSpacing + + PlasmaComponents.ProgressBar { + id: progressBar + Layout.fillWidth: true + minimumValue: 0 + maximumValue: 100 + // TODO do we actually need the window visible check? perhaps I do because it can be in popup or expanded plasmoid + indeterminate: visible && Window.window && Window.window.visible && percentage < 1 + && jobItem.jobState === NotificationManager.Notifications.JobStateRunning + // is this too annoying? + && (jobItem.jobDetails.processedBytes === 0 || jobItem.jobDetails.totalBytes === 0) + && jobItem.jobDetails.processedFiles === 0 + //&& jobItem.jobDetails.processedDirectories === 0 + } + + RowLayout { + spacing: 0 + + PlasmaComponents.ToolButton { + id: suspendButton + tooltip: i18nc("Pause running job", "Pause") + iconSource: "media-playback-pause" + onClicked: jobItem.jobState === NotificationManager.Notifications.JobStateSuspended ? jobItem.resumeJobClicked() + : jobItem.suspendJobClicked() + } + + PlasmaComponents.ToolButton { + id: killButton + tooltip: i18nc("Cancel running job", "Cancel") + iconSource: "media-playback-stop" + onClicked: jobItem.killJobClicked() + } + + PlasmaComponents.ToolButton { + id: expandButton + Layout.leftMargin: units.smallSpacing + iconSource: checked ? "arrow-down" : (LayoutMirroring.enabled ? "arrow-left" : "arrow-right") + tooltip: checked ? i18nc("A button tooltip; hides item details", "Hide Details") + : i18nc("A button tooltip; expands the item to show details", "Show Details") + checkable: true + enabled: jobItem.jobDetails && jobItem.jobDetails.hasDetails + } + } + } + + Loader { + Layout.fillWidth: true + active: expandButton.checked + // Loader doesn't reset its height when unloaded, just hide it altogether + visible: active + sourceComponent: JobDetails { + jobDetails: jobItem.jobDetails + } + } + + Flow { // it's a Flow so it can wrap if too long + id: jobDoneActions + Layout.fillWidth: true + spacing: units.smallSpacing + // We want the actions to be right-aligned but Flow also reverses + // the order of items, so we put them in reverse order + layoutDirection: Qt.RightToLeft + visible: url && url.toString() !== "" + + property var url: { + if (jobItem.jobState !== NotificationManager.Notifications.JobStateStopped + || jobItem.jobError + || !jobItem.jobDetails + || jobItem.jobDetails.totalFiles <= 0) { + return null; + } + + // For a single file show actions for it + if (jobItem.jobDetails.totalFiles === 1) { + return jobItem.jobDetails.descriptionUrl; + } else { + return jobItem.jobDetails.destUrl; + } + } + + PlasmaComponents.Button { + id: otherFileActionsButton + iconName: "application-menu" + tooltip: i18n("More Options...") + checkable: true + onPressedChanged: { + if (pressed) { + checked = Qt.binding(function() { + return otherFileActionsMenu.visible; + }); + otherFileActionsMenu.open(-1, -1); + } + } + + Notifications.FileMenu { + id: otherFileActionsMenu + url: jobDoneActions.url || "" + visualParent: otherFileActionsButton + onActionTriggered: jobItem.fileActionInvoked() + } + } + + PlasmaComponents.Button { + // would be nice to have the file icon here? + text: jobItem.jobDetails && jobItem.jobDetails.totalFiles > 1 ? i18n("Open Containing Folder") : i18n("Open") + onClicked: jobItem.openUrl(jobDoneActions.url) + width: minimumWidth + } + } + + states: [ + State { + when: jobItem.jobState === NotificationManager.Notifications.JobStateSuspended + PropertyChanges { + target: suspendButton + tooltip: i18nc("Resume paused job", "Resume") + iconSource: "media-playback-start" + } + PropertyChanges { + target: progressBar + enabled: false + } + }, + State { + when: jobItem.jobState === NotificationManager.Notifications.JobStateStopped + PropertyChanges { + target: progressRow + visible: false + } + PropertyChanges { + target: expandButton + checked: false + } + } + ] +} diff --git a/applets/notifications/package/contents/ui/Jobs.qml b/applets/notifications/package/contents/ui/Jobs.qml deleted file mode 100644 --- a/applets/notifications/package/contents/ui/Jobs.qml +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright 2012 Marco Martin - * - * 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 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.plasma.private.notifications 1.0 - -Column { - id: jobsRoot - width: parent.width - - property alias count: jobs.count - - ListModel { - id: jobs - } - - PlasmaCore.DataSource { - id: jobsSource - - property var runningJobs: ({}) - - engine: "applicationjobs" - interval: 0 - - onSourceAdded: { - connectSource(source) - jobs.append({name: source}) - } - - onSourceRemoved: { - // remove source from jobs model - for (var i = 0, len = jobs.count; i < len; ++i) { - if (jobs.get(i).name === source) { - jobs.remove(i) - break - } - } - - if (!notifications) { - delete runningJobs[source] - return - } - - var error = runningJobs[source]["error"] - var errorText = runningJobs[source]["errorText"] - - // 1 = ERR_USER_CANCELED - don't show any notification at all - if (error === 1) { - delete runningJobs[source] - return - } - - var message = runningJobs[source]["label1"] ? runningJobs[source]["label1"] : runningJobs[source]["label0"] - var infoMessage = runningJobs[source]["infoMessage"] - if (!message && !infoMessage) { - delete runningJobs[source] - return - } - - var summary = infoMessage ? i18nc("the job, which can be anything, has finished", "%1: Finished", infoMessage) : i18n("Job Finished") - - if (error) { - summary = infoMessage ? i18nc("the job, which can be anything, failed to complete", "%1: Failed", infoMessage) : i18n("Job Failed") - } - - // notification body interprets HTML, so we need to manually escape the name - var body = (errorText || message || "").replace(/[&<>]/g, function (tag) { - return { - '&': '&', - '<': '<', - '>': '>' - }[tag] || tag - }); - - var op = { - appIcon: runningJobs[source].appIconName, - appName: runningJobs[source].appName, - summary: summary, - body: body, - isPersistent: !!error, // we'll assume success to be the note-unworthy default, only be persistent in error case - urgency: 0, - configurable: false, - skipGrouping: true, // Bug 360156 - actions: !error && UrlHelper.isUrlValid(message) ? ["jobUrl#" + message, i18n("Open...")] : [] - }; // If the actionId contains "jobUrl#", it tries to open the "id" value (which is "message") - - notifications.createNotification(op); - - delete runningJobs[source] - } - - onNewData: { - runningJobs[sourceName] = data - } - - onDataChanged: { - var total = 0 - for (var i = 0; i < sources.length; ++i) { - if (jobsSource.data[sources[i]] && jobsSource.data[sources[i]]["percentage"]) { - total += jobsSource.data[sources[i]]["percentage"] - } - } - - total /= sources.length - notificationsApplet.globalProgress = total/100 - } - - Component.onCompleted: { - connectedSources = sources - } - } - - Item { - visible: jobs.count > 3 - - PlasmaComponents.ProgressBar { - anchors { - verticalCenter: parent.verticalCenter - left: parent.left - right: parent.right - } - - minimumValue: 0 - maximumValue: 100 - value: notificationsApplet.globalProgress * 100 - } - } - - Repeater { - id: jobsRepeater - model: jobs - delegate: JobDelegate { - infoMessageVisible: { - if (!infoMessage) { - return false; - } - - // hide info message if it's the same as the previous job, while we don't - // actively group those jobs, it still improves the situation where you - // started copying a couple of different things simultaneously - var previousItem = jobsRepeater.itemAt(index - 1); - if (!previousItem) { - return true; - } - - return previousItem.infoMessage !== infoMessage; - } - } - } -} diff --git a/applets/notifications/package/contents/ui/NotificationDelegate.qml b/applets/notifications/package/contents/ui/NotificationDelegate.qml deleted file mode 100644 --- a/applets/notifications/package/contents/ui/NotificationDelegate.qml +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright 2011 Marco Martin - * - * 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.Controls.Private 1.0 -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 - -PlasmaComponents.ListItem { - id: notificationItem - width: historyList.width - - property ListModel listModel; - - opacity: 1-Math.abs(x)/width - - enabled: model.hasDefaultAction - checked: notificationItem.containsMouse - - Timer { - interval: 10*60*1000 - repeat: false - running: !idleTimeSource.idle - onTriggered: { - if (!listModel.inserting) - listModel.remove(index) - } - } - - MouseArea { - width: parent.width - height: childrenRect.height - acceptedButtons: Qt.NoButton - - drag { - target: notificationItem - axis: Drag.XAxis - //kind of an hack over Column being too smart - minimumX: -parent.width + 1 - maximumX: parent.width - 1 - } - onReleased: { - if (notificationItem.x < -notificationItem.width/2) { - removeAnimation.exitFromRight = false - removeAnimation.running = true - } else if (notificationItem.x > notificationItem.width/2 ) { - removeAnimation.exitFromRight = true - removeAnimation.running = true - } else { - resetAnimation.running = true - } - } - - SequentialAnimation { - id: removeAnimation - property bool exitFromRight: true - NumberAnimation { - target: notificationItem - properties: "x" - to: removeAnimation.exitFromRight ? notificationItem.width-1 : 1-notificationItem.width - duration: units.longDuration - easing.type: Easing.InOutQuad - } - NumberAnimation { - target: notificationItem - properties: "height" - to: 0 - duration: units.longDuration - easing.type: Easing.InOutQuad - } - ScriptAction { - script: { - closeNotification(model.source); - listModel.remove(index); - } - } - } - - SequentialAnimation { - id: resetAnimation - NumberAnimation { - target: notificationItem - properties: "x" - to: 0 - duration: units.longDuration - easing.type: Easing.InOutQuad - } - } - - NotificationItem { - id: notification - width: parent.width - - compact: true - icon: model.appIcon - image: model.image - summary: model.summary - body: model.body - configurable: model.configurable && !Settings.isMobile - // model.actions JS array is implicitly turned into a ListModel which we can assign directly - actions: model.actions - created: model.created - hasDefaultAction: model.hasDefaultAction - hasConfigureAction: model.hasConfigureAction - 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 (listModel.count > 1) { - removeAnimation.running = true - } else { - closeNotification(model.source) - listModel.remove(index) - } - } - onConfigure: { - plasmoid.expanded = false - configureNotification(model.appRealName, model.eventId) - } - onAction: { - executeAction(model.source, actionId) - actions.clear() - } - onOpenUrl: { - plasmoid.expanded = false - Qt.openUrlExternally(url) - } - } - - } //MouseArea - - Component.onCompleted: { - mainScrollArea.height = mainScrollArea.implicitHeight - } - Component.onDestruction: { - mainScrollArea.height = mainScrollArea.implicitHeight - } -} diff --git a/applets/notifications/package/contents/ui/NotificationHeader.qml b/applets/notifications/package/contents/ui/NotificationHeader.qml new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/NotificationHeader.qml @@ -0,0 +1,217 @@ +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import QtQuick 2.8 +import QtQuick.Layouts 1.1 +import QtQuick.Window 2.2 + +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.notificationmanager 1.0 as NotificationManager + +import org.kde.kcoreaddons 1.0 as KCoreAddons + +import "global" + +RowLayout { + id: notificationHeading + property bool inGroup + property int notificationType + + property var applicationIconSource + property string applicationName + property string deviceName + + property string configureActionLabel + + property alias configurable: configureButton.visible + property alias dismissable: dismissButton.visible + property bool dismissed + property alias closeButtonTooltip: closeButton.tooltip + property alias closable: closeButton.visible + + property var time + + property int jobState + property QtObject jobDetails + + signal configureClicked + signal dismissClicked + signal closeClicked + + // notification created/updated time changed + onTimeChanged: updateAgoText() + + function updateAgoText() { + ageLabel.agoText = ageLabel.generateAgoText(); + } + + spacing: units.smallSpacing + Layout.preferredHeight: Math.max(applicationNameLabel.implicitHeight, units.iconSizes.small) + + Connections { + target: Globals + // clock time changed + onTimeChanged: notificationHeading.updateAgoText() + } + + PlasmaCore.IconItem { + id: applicationIconItem + Layout.preferredWidth: units.iconSizes.small + Layout.preferredHeight: units.iconSizes.small + source: notificationHeading.applicationIconSource + usesPlasmaTheme: false + visible: valid + } + + PlasmaExtras.DescriptiveLabel { + id: applicationNameLabel + Layout.fillWidth: true + textFormat: Text.PlainText + elide: Text.ElideRight + text: notificationHeading.applicationName + (notificationHeading.deviceName ? " ยท " + notificationHeading.deviceName : "") + } + + PlasmaExtras.DescriptiveLabel { + id: ageLabel + + // the "n minutes ago" text, for jobs we show remaining time instead + // updated periodically by a Timer hence this property with generate() function + property string agoText: "" + visible: text !== "" + text: generateRemainingText() || agoText + Layout.rightMargin: 0 // the ToolButton's margins are enough + + function generateAgoText() { + if (!time || isNaN(time.getTime()) || notificationHeading.jobState === NotificationManager.Notifications.JobStateRunning) { + return ""; + } + + var now = new Date(); + var deltaMinutes = Math.floor((now.getTime() - time.getTime()) / 1000 / 60); + if (deltaMinutes < 1) { + return ""; + } + + // Received less than an hour ago, show relative minutes + if (deltaMinutes < 60) { + return i18ncp("Notification was added minutes ago, keep short", "%1 min ago", "%1 min ago", deltaMinutes); + } + // Received less than a day ago, show time, 23 hours so the time isn't ambiguous between today and yesterday + if (deltaMinutes < 60 * 23) { + return Qt.formatTime(time, Qt.locale().timeFormat(Locale.ShortFormat).replace(/.ss?/i, "")); + } + + // Otherwise show relative date (Yesterday, "Last Sunday", or just date if too far in the past) + return KCoreAddons.Format.formatRelativeDate(time, Locale.ShortFormat); + } + + function generateRemainingText() { + if (notificationHeading.notificationType !== NotificationManager.Notifications.JobType + || notificationHeading.jobState === NotificationManager.Notifications.JobStateStopped) { + return ""; + } + + var details = notificationHeading.jobDetails; + if (!details || !details.speed) { + return ""; + } + + var remaining = details.totalBytes - details.processedBytes; + if (remaining <= 0) { + return ""; + } + + var eta = remaining / details.speed; + if (!eta) { + return ""; + } + + if (eta < 60) { // 1 minute + return i18ncp("seconds remaining, keep short", + "%1s remaining", "%1s remaining", Math.round(eta)); + } + if (eta < 60 * 60) {// 1 hour + return i18ncp("minutes remaining, keep short", + "%1min remaining", "%1min remaining", + Math.round(eta / 60)); + } + if (eta < 60 * 60 * 5) { // 5 hours max, if it takes even longer there's no real point in shoing that + return i18ncp("hours remaining, keep short", + "%1h remaining", "%1h remaining", + Math.round(eta / 60 / 60)); + } + + return ""; + } + + PlasmaCore.ToolTipArea { + anchors.fill: parent + active: ageLabel.agoText !== "" + subText: notificationHeading.time ? notificationHeading.time.toLocaleString(Qt.locale(), Locale.LongFormat) : "" + } + } + + RowLayout { + id: headerButtonsRow + spacing: 0 + + PlasmaComponents.ToolButton { + id: configureButton + tooltip: notificationHeading.configureActionLabel || i18n("Configure") + iconSource: "configure" + visible: false + onClicked: notificationHeading.configureClicked() + } + + PlasmaComponents.ToolButton { + id: dismissButton + tooltip: notificationHeading.dismissed ? i18nc("Opposite of minimize", "Restore") : i18n("Minimize") + iconSource: notificationHeading.dismissed ? "window-restore" : "window-minimize" + visible: false + onClicked: notificationHeading.dismissClicked() + } + + PlasmaComponents.ToolButton { + id: closeButton + tooltip: i18n("Close") + iconSource: "window-close" + visible: false + onClicked: notificationHeading.closeClicked() + } + } + + states: [ + State { + when: notificationHeading.inGroup + PropertyChanges { + target: applicationIconItem + source: "" + } + PropertyChanges { + target: applicationNameLabel + visible: false + } + } + + ] +} diff --git a/applets/notifications/package/contents/ui/NotificationIcon.qml b/applets/notifications/package/contents/ui/NotificationIcon.qml deleted file mode 100644 --- a/applets/notifications/package/contents/ui/NotificationIcon.qml +++ /dev/null @@ -1,165 +0,0 @@ -/*************************************************************************** - * Copyright 2011 Davide Bettio * - * Copyright 2011 Marco Martin * - * * - * 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 of the License, 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 org.kde.plasma.core 2.0 as PlasmaCore -import org.kde.plasma.components 2.0 as PlasmaComponents -import org.kde.kquickcontrolsaddons 2.0 - -Item { - PlasmaCore.SvgItem { - id: notificationSvgItem - anchors.centerIn: parent - width: units.roundToIconSize(Math.min(parent.width, parent.height)) - height: width - - svg: notificationSvg - - elementId: { - if (activeItemsCount > 0) { - if (jobs && jobs.count > 0) { - return "notification-progress-inactive" - } else { - return "notification-empty" - } - } - return "notification-disabled" - } - - state: notificationsApplet.state - - PlasmaCore.Svg { - id: notificationSvg - imagePath: "icons/notification" - colorGroup: PlasmaCore.ColorScope.colorGroup - } - - Item { - id: jobProgressItem - anchors { - left: parent.left - top: parent.top - bottom: parent.bottom - } - width: notificationSvgItem.width * globalProgress - - clip: true - visible: jobs.count > 0 - - PlasmaCore.SvgItem { - anchors { - left: parent.left - top: parent.top - bottom: parent.bottom - } - width: notificationSvgItem.width - - svg: notificationSvg - elementId: "notification-progress-active" - } - } - - PlasmaComponents.BusyIndicator { - anchors.fill: parent - - visible: jobs ? jobs.count > 0 : false - running: visible - } - - PlasmaComponents.Label { - id: notificationCountLabel - property int oldActiveItemsCount: 0 - - // anchors.fill: parent breaks at small sizes for some reason - anchors.centerIn: parent - width: parent.width - (units.smallSpacing * 2.5 * text.length) - height: width - - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - text: notificationsApplet.activeItemsCount - font.pointSize: 100 - fontSizeMode: Text.Fit - minimumPointSize: theme.smallestFont.pointSize - visible: notificationsApplet.activeItemsCount > 0 - - Connections { - target: notificationsApplet - onActiveItemsCountChanged: { - if (notificationsApplet.activeItemsCount > notificationCountLabel.oldActiveItemsCount) { - notificationAnimation.running = true - } - notificationCountLabel.oldActiveItemsCount = notificationsApplet.activeItemsCount - } - - } - } - - PlasmaCore.SvgItem { - id: notificationAnimatedItem - anchors.fill: parent - - svg: notificationSvg - elementId: "notification-active" - opacity: 0 - scale: 2 - - SequentialAnimation { - id: notificationAnimation - - NumberAnimation { - target: notificationAnimatedItem - duration: units.longDuration - properties: "opacity, scale" - to: 1 - easing.type: Easing.InOutQuad - } - - PauseAnimation { duration: units.longDuration * 2 } - - ParallelAnimation { - NumberAnimation { - target: notificationAnimatedItem - duration: units.longDuration - properties: "opacity" - to: 0 - easing.type: Easing.InOutQuad - } - - NumberAnimation { - target: notificationAnimatedItem - duration: units.longDuration - properties: "scale" - to: 2 - easing.type: Easing.InOutQuad - } - } - } - } - - MouseArea { - anchors.fill: parent - property bool wasExpanded: false - - onPressed: wasExpanded = plasmoid.expanded - onClicked: plasmoid.expanded = !wasExpanded - } - } -} 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 @@ -1,430 +1,350 @@ /* - * Copyright 2011 Marco Martin - * Copyright 2014 Kai Uwe Broulik + * Copyright 2018-2019 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 free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. * - * 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 + * 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 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. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see */ -import QtQuick 2.5 +import QtQuick 2.8 import QtQuick.Layouts 1.1 -import QtQuick.Controls.Private 1.0 +import QtQuick.Window 2.2 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 -MouseArea { - id: notificationItem - width: parent.width - implicitHeight: { - if (bodyText.lineCount > 1) { - return mainLayout.height + 0.5 * units.smallSpacing // close button height = about 1 unit - } - if (appIconItem.valid || imageItem.nativeWidth > 0) { - return Math.max((mainLayout.height + 1.5 * units.smallSpacing),(units.iconSizes.large + 2 * units.smallSpacing)) - } - if (bottomPart.height != 0) { - return mainLayout.height + (mainLayout.height > units.iconSizes.large ? 1.5 : 2) * units.smallSpacing - } else { - return mainLayout.height + units.smallSpacing // close button again - } - } - - // We need to clip here because we support displaying images through - // and if we don't clip, they will be painted over the borders of the dialog/item - clip: true - - signal close - signal configure - signal action(string actionId) - signal openUrl(url url) - - property bool compact: false - - property alias icon: appIconItem.source - property alias image: imageItem.image - property alias summary: summaryLabel.text - property alias body: bodyText.text - property alias configurable: settingsButton.visible - property var created - property var urls: [] +import org.kde.kquickcontrolsaddons 2.0 as KQCAddons - property int maximumTextHeight: -1 +import org.kde.notificationmanager 1.0 as NotificationManager - property ListModel actions: ListModel { } +ColumnLayout { + id: notificationItem - property bool hasDefaultAction: false - property bool hasConfigureAction: false + property bool hovered: false + property int maximumLineCount: 0 + property alias bodyCursorShape: bodyLabel.cursorShape - readonly property bool dragging: thumbnailStripLoader.item ? thumbnailStripLoader.item.dragging : false + property int notificationType - onClicked: { - // the MEL would close the notification before the action button - // onClicked handler would fire effectively breaking notification actions - if (pressedAction()) { - return - } + property bool inGroup: false - if (hasDefaultAction) { - // the notification was clicked, trigger the default action if set - action("default") - } - } + property alias applicationIconSource: notificationHeading.applicationIconSource + property alias applicationName: notificationHeading.applicationName + property alias deviceName: notificationHeading.deviceName - function pressedAction() { - for (var i = 0, count = actionRepeater.count; i < count; ++i) { - var item = actionRepeater.itemAt(i) - if (item.pressed) { - return item - } - } + property string summary + property alias time: notificationHeading.time - if (thumbnailStripLoader.item) { - var item = thumbnailStripLoader.item.pressedAction() - if (item) { - return item - } - } + property alias configurable: notificationHeading.configurable + property alias dismissable: notificationHeading.dismissable + property alias dismissed: notificationHeading.dismissed + property alias closable: notificationHeading.closable - if (settingsButton.pressed) { - return settingsButton - } - - if (closeButton.pressed) { - return closeButton - } - - return null - } + // This isn't an alias because TextEdit RichText adds some HTML tags to it + property string body + property var icon + property var urls: [] - function updateTimeLabel() { - if (!created || created.getTime() <= 0) { - timeLabel.text = "" - return - } - var currentTime = new Date().getTime() - var createdTime = created.getTime() - var d = (currentTime - createdTime) / 1000 - if (d < 10) { - timeLabel.text = i18nc("notification was just added, keep short", "Just now") - } else if (d < 20) { - timeLabel.text = i18nc("10 seconds ago, keep short", "10 s ago"); - } else if (d < 40) { - timeLabel.text = i18nc("30 seconds ago, keep short", "30 s ago"); - } else if (d < 60 * 60) { - timeLabel.text = i18ncp("minutes ago, keep short", "%1 min ago", "%1 min ago", Math.round(d / 60)) - } else if (d <= 60 * 60 * 23) { - timeLabel.text = Qt.formatTime(created, Qt.locale().timeFormat(Locale.ShortFormat).replace(/.ss?/i, "")) - } else { - var yesterday = new Date() - yesterday.setDate(yesterday.getDate() - 1) // this will wrap - yesterday.setHours(0) - yesterday.setMinutes(0) - yesterday.setSeconds(0) - - if (createdTime > yesterday.getTime()) { - timeLabel.text = i18nc("notification was added yesterday, keep short", "Yesterday"); - } else { - timeLabel.text = i18ncp("notification was added n days ago, keep short", - "%1 day ago", "%1 days ago", - Math.round((currentTime - yesterday.getTime()) / 1000 / 3600 / 24)); - } - } + property int jobState + property int percentage + property int jobError: 0 + property bool suspendable + property bool killable + + property QtObject jobDetails + property bool showDetails + + property alias configureActionLabel: notificationHeading.configureActionLabel + property var actionNames: [] + property var actionLabels: [] + + property int headingLeftPadding: 0 + property int headingRightPadding: 0 + + property int thumbnailLeftPadding: 0 + property int thumbnailRightPadding: 0 + property int thumbnailTopPadding: 0 + property int thumbnailBottomPadding: 0 + + readonly property bool menuOpen: bodyLabel.contextMenu !== null + || (thumbnailStripLoader.item && thumbnailStripLoader.item.menuOpen) + || (jobLoader.item && jobLoader.item.menuOpen) + readonly property bool dragging: thumbnailStripLoader.item && thumbnailStripLoader.item.dragging + + signal bodyClicked(var mouse) + signal closeClicked + signal configureClicked + signal dismissClicked + signal actionInvoked(string actionName) + signal openUrl(string url) + signal fileActionInvoked + + signal suspendJobClicked + signal resumeJobClicked + signal killJobClicked + + spacing: units.smallSpacing + + NotificationHeader { + id: notificationHeading + Layout.fillWidth: true + Layout.leftMargin: notificationItem.headingLeftPadding + Layout.rightMargin: notificationItem.headingRightPadding + + inGroup: notificationItem.inGroup + + notificationType: notificationItem.notificationType + jobState: notificationItem.jobState + jobDetails: notificationItem.jobDetails + + onConfigureClicked: notificationItem.configureClicked() + onDismissClicked: notificationItem.dismissClicked() + onCloseClicked: notificationItem.closeClicked() } - Timer { - interval: 15000 - running: plasmoid.expanded - repeat: true - triggeredOnStart: true - onTriggered: updateTimeLabel() + RowLayout { + id: defaultHeaderContainer + Layout.fillWidth: true } - PlasmaCore.IconItem { - id: appIconItem - - width: units.iconSizes.large - height: units.iconSizes.large - - anchors { - top: parent.top - left: parent.left - leftMargin: units.smallSpacing - topMargin: units.smallSpacing - } + // Notification body + RowLayout { + id: bodyRow + Layout.fillWidth: true + spacing: units.smallSpacing - visible: imageItem.nativeWidth === 0 && valid - animated: false - } - - QImageItem { - id: imageItem - anchors.fill: appIconItem - - smooth: true - visible: nativeWidth > 0 - } - - ColumnLayout { - id: mainLayout + ColumnLayout { + Layout.fillWidth: true + spacing: 0 - anchors { - top: parent.top - topMargin: bodyText.lineCount > 1 ? 0 : Math.round((mainLayout.height > units.iconSizes.large ? 0.5 : 1) * units.smallSpacing) // lift up heading if bodyText is too long/tall - left: appIconItem.valid || imageItem.nativeWidth > 0 ? appIconItem.right : parent.left - right: parent.right - leftMargin: units.smallSpacing * 2 - rightMargin: units.smallSpacing // Equal padding on either side (notification icon margin) - } + RowLayout { + id: summaryRow + Layout.fillWidth: true + visible: summaryLabel.text !== "" + + PlasmaExtras.Heading { + id: summaryLabel + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + textFormat: Text.PlainText + maximumLineCount: 3 + wrapMode: Text.WordWrap + elide: Text.ElideRight + level: 4 + text: { + if (notificationItem.notificationType === NotificationManager.Notifications.JobType) { + if (notificationItem.jobState === NotificationManager.Notifications.JobStateSuspended) { + return i18nc("Job name, e.g. Copying is paused", "%1 (Paused)", notificationItem.summary); + } else if (notificationItem.jobState === NotificationManager.Notifications.JobStateStopped) { + if (notificationItem.error) { + if (notificationItem.summary) { + return i18nc("Job name, e.g. Copying has failed", "%1 (Failed)", notificationItem.summary); + } else { + return i18n("Job Failed"); + } + } else { + if (notificationItem.summary) { + return i18nc("Job name, e.g. Copying has finished", "%1 (Finished)", notificationItem.summary); + } else { + return i18n("Job Finished"); + } + } + } + } + // some apps use their app name as summary, avoid showing the same text twice + // try very hard to match the two + if (notificationItem.summary && notificationItem.summary.toLocaleLowerCase().trim() != notificationItem.applicationName.toLocaleLowerCase().trim()) { + return notificationItem.summary; + } + return ""; + } + visible: text !== "" + } - spacing: Math.round(units.smallSpacing / 2) + // inGroup headerItem is reparented here + } - RowLayout { - id: titleBar - spacing: units.smallSpacing + RowLayout { + id: bodyTextRow - PlasmaExtras.Heading { - id: summaryLabel Layout.fillWidth: true - Layout.fillHeight: true - height: undefined - verticalAlignment: Text.AlignVCenter - level: 4 - elide: Text.ElideRight - wrapMode: Text.NoWrap - textFormat: Text.PlainText - } - - PlasmaExtras.Heading { - id: timeLabel - Layout.fillHeight: true - level: 5 - visible: text !== "" - verticalAlignment: Text.AlignVCenter + spacing: units.smallSpacing - PlasmaCore.ToolTipArea { - anchors.fill: parent - subText: Qt.formatDateTime(created, Qt.DefaultLocaleLongDate) + SelectableLabel { + id: bodyLabel + // FIXME how to assign this via State? target: bodyLabel.Layout doesn't work and just assigning the property doesn't either + Layout.alignment: notificationItem.inGroup ? Qt.AlignTop : Qt.AlignVCenter + Layout.fillWidth: true + + Layout.maximumHeight: notificationItem.maximumLineCount > 0 + ? (theme.mSize(font).height * notificationItem.maximumLineCount) : -1 + text: notificationItem.body + // Cannot do text !== "" because RichText adds some HTML tags even when empty + visible: notificationItem.body !== "" + onClicked: notificationItem.bodyClicked(mouse) + onLinkActivated: Qt.openUrlExternally(link) } - } - PlasmaComponents.ToolButton { - id: settingsButton - width: units.iconSizes.smallMedium - height: width - visible: false - - iconSource: "configure" - - onClicked: { - if (notificationItem.hasConfigureAction) { - notificationItem.action("settings"); - } else { - configure() - } - } + // inGroup iconContainer is reparented here } + } - PlasmaComponents.ToolButton { - id: closeButton + Item { + id: iconContainer - width: units.iconSizes.smallMedium - height: width - flat: compact + Layout.preferredWidth: units.iconSizes.large + Layout.preferredHeight: units.iconSizes.large - iconSource: "window-close" + visible: iconItem.active || imageItem.active - onClicked: close() + PlasmaCore.IconItem { + id: iconItem + // don't show two identical icons + readonly property bool active: valid && source != notificationItem.applicationIconSource + anchors.fill: parent + usesPlasmaTheme: false + smooth: true + source: typeof notificationItem.icon === "string" ? notificationItem.icon : "" + visible: active } + KQCAddons.QImageItem { + id: imageItem + readonly property bool active: !null && nativeWidth > 0 + anchors.fill: parent + smooth: true + fillMode: KQCAddons.QImageItem.PreserveAspectFit + visible: active + image: typeof notificationItem.icon === "object" ? notificationItem.icon : undefined + } } + } - RowLayout { - id: bottomPart - Layout.alignment: Qt.AlignTop - spacing: units.smallSpacing - - // Force the whole thing to collapse if the children are invisible - // If there is a big notification followed by a small one, the height - // of the popup does not always shrink back, so this forces it to - // height=0 when those are invisible. -1 means "default to implicitHeight" - Layout.maximumHeight: bodyText.length > 0 || notificationItem.actions.count > 0 ? -1 : 0 + // Job progress reporting + Loader { + id: jobLoader + Layout.fillWidth: true + active: notificationItem.notificationType === NotificationManager.Notifications.JobType + sourceComponent: JobItem { + jobState: notificationItem.jobState + jobError: notificationItem.jobError + percentage: notificationItem.percentage + suspendable: notificationItem.suspendable + killable: notificationItem.killable + + jobDetails: notificationItem.jobDetails + showDetails: notificationItem.showDetails + + onSuspendJobClicked: notificationItem.suspendJobClicked() + onResumeJobClicked: notificationItem.resumeJobClicked() + onKillJobClicked: notificationItem.killJobClicked() + + onOpenUrl: notificationItem.openUrl(url) + onFileActionInvoked: notificationItem.fileActionInvoked() + + hovered: notificationItem.hovered + } + } - PlasmaExtras.ScrollArea { - id: bodyTextScrollArea - Layout.alignment: Qt.AlignTop - Layout.fillWidth: true + RowLayout { + Layout.fillWidth: true + visible: actionRepeater.count > 0 - implicitHeight: maximumTextHeight > 0 ? Math.min(maximumTextHeight, bodyText.paintedHeight) : bodyText.paintedHeight - visible: bodyText.length > 0 - - flickableItem.boundsBehavior: Flickable.StopAtBounds - flickableItem.flickableDirection: Flickable.VerticalFlick - horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff - - TextEdit { - id: bodyText - width: bodyTextScrollArea.width - enabled: !Settings.isMobile - - color: PlasmaCore.ColorScope.textColor - selectedTextColor: theme.viewBackgroundColor - selectionColor: theme.viewFocusColor - font.capitalization: theme.defaultFont.capitalization - font.family: theme.defaultFont.family - font.italic: theme.defaultFont.italic - font.letterSpacing: theme.defaultFont.letterSpacing - font.pointSize: theme.defaultFont.pointSize - font.strikeout: theme.defaultFont.strikeout - font.underline: theme.defaultFont.underline - font.weight: theme.defaultFont.weight - font.wordSpacing: theme.defaultFont.wordSpacing - renderType: Text.NativeRendering - selectByMouse: true - readOnly: true - wrapMode: Text.Wrap - textFormat: TextEdit.RichText - - // ensure selecting text scrolls the view as needed... - onCursorRectangleChanged: { - var flick = bodyTextScrollArea.flickableItem - if (flick.contentY >= cursorRectangle.y) { - flick.contentY = cursorRectangle.y - } else if (flick.contentY + flick.height <= cursorRectangle.y + cursorRectangle.height) { - flick.contentY = cursorRectangle.y + cursorRectangle.height - flick.height - } + // Notification actions + Flow { // it's a Flow so it can wrap if too long + Layout.fillWidth: true + visible: actionRepeater.count > 0 + spacing: units.smallSpacing + layoutDirection: Qt.RightToLeft + + Repeater { + id: actionRepeater + + model: { + var buttons = []; + // HACK We want the actions to be right-aligned but Flow also reverses + var actionNames = (notificationItem.actionNames || []).reverse(); + var actionLabels = (notificationItem.actionLabels || []).reverse(); + for (var i = 0; i < actionNames.length; ++i) { + buttons.push({ + actionName: actionNames[i], + label: actionLabels[i] + }); } - MouseArea { - property int selectionStart - property point mouseDownPos: Qt.point(-999, -999); - - anchors.fill: parent - acceptedButtons: Qt.RightButton | Qt.LeftButton - cursorShape: bodyText.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor - preventStealing: true // don't let us accidentally drag the Flickable - - onPressed: { - if (mouse.button === Qt.RightButton) { - contextMenu.link = bodyText.linkAt(mouse.x, mouse.y); - contextMenu.open(mouse.x, mouse.y); - return; - } - - mouseDownPos = Qt.point(mouse.x, mouse.y); - selectionStart = bodyText.positionAt(mouse.x, mouse.y); - var pos = bodyText.positionAt(mouse.x, mouse.y); - // deselect() would scroll to the end which we don't want - bodyText.select(pos, pos); - } - - onReleased: { - // emulate "onClicked" - var manhattanLength = Math.abs(mouseDownPos.x - mouse.x) + Math.abs(mouseDownPos.y - mouse.y); - if (manhattanLength <= Qt.styleHints.startDragDistance) { - var link = bodyText.linkAt(mouse.x, mouse.y); - if (link) { - Qt.openUrlExternally(link); - } else { - notificationItem.clicked(null/*mouse*/); - } - } - mouseDownPos = Qt.point(-999, -999); - } - - // HACK to be able to select text whilst still getting all mouse events to the MouseArea - onPositionChanged: { - if (pressed) { - var pos = bodyText.positionAt(mouseX, mouseY); - if (selectionStart < pos) { - bodyText.select(selectionStart, pos); - } else { - bodyText.select(pos, selectionStart); - } - } - } - - Clipboard { - id: clipboard - } - - PlasmaComponents.ContextMenu { - id: contextMenu - property string link + return buttons; + } - PlasmaComponents.MenuItem { - text: i18n("Copy Link Address") - onClicked: clipboard.content = contextMenu.link - visible: contextMenu.link !== "" - } + PlasmaComponents.ToolButton { + flat: false + // why does it spit "cannot assign undefined to string" when a notification becomes expired? + text: modelData.label || "" + Layout.preferredWidth: minimumWidth + onClicked: notificationItem.actionInvoked(modelData.actionName) + } + } + } + } - PlasmaComponents.MenuItem { - separator: true - visible: contextMenu.link !== "" - } + // thumbnails + Loader { + id: thumbnailStripLoader + Layout.leftMargin: notificationItem.thumbnailLeftPadding + Layout.rightMargin: notificationItem.thumbnailRightPadding + Layout.topMargin: notificationItem.thumbnailTopPadding + Layout.bottomMargin: notificationItem.thumbnailBottomPadding + Layout.fillWidth: true + active: notificationItem.urls.length > 0 + visible: active + sourceComponent: ThumbnailStrip { + leftPadding: -thumbnailStripLoader.Layout.leftMargin + rightPadding: -thumbnailStripLoader.Layout.rightMargin + topPadding: -thumbnailStripLoader.Layout.topMargin + bottomPadding: -thumbnailStripLoader.Layout.bottomMargin + urls: notificationItem.urls + onOpenUrl: notificationItem.openUrl(url) + onFileActionInvoked: notificationItem.fileActionInvoked() + } + } - PlasmaComponents.MenuItem { - text: i18n("Copy") - icon: "edit-copy" - enabled: bodyText.selectionStart !== bodyText.selectionEnd - onClicked: bodyText.copy() - } + states: [ + State { + when: notificationItem.inGroup + PropertyChanges { + target: notificationHeading + parent: summaryRow + } - PlasmaComponents.MenuItem { - text: i18n("Select All") - onClicked: bodyText.selectAll() - } - } - } - } + PropertyChanges { + target: summaryRow + visible: true + } + PropertyChanges { + target: summaryLabel + visible: true } - ColumnLayout { - id: actionsColumn - Layout.alignment: Qt.AlignTop - Layout.maximumWidth: theme.mSize(theme.defaultFont).width * (compact ? 10 : 16) - // this is so it never collapses but always follows what the Buttons below want - // but also don't let the buttons get too narrow (e.g. "View" or "Open" button) - Layout.minimumWidth: Math.max(units.gridUnit * 4, implicitWidth) + /*PropertyChanges { + target: bodyLabel.Label + alignment: Qt.AlignTop + }*/ - spacing: units.smallSpacing - visible: notificationItem.actions && notificationItem.actions.count > 0 - - Repeater { - id: actionRepeater - model: notificationItem.actions - - PlasmaComponents.Button { - Layout.fillWidth: true - Layout.preferredWidth: minimumWidth - Layout.maximumWidth: actionsColumn.Layout.maximumWidth - text: model.text - tooltip: width < minimumWidth ? text : "" - onClicked: notificationItem.action(model.id) - } - } + PropertyChanges { + target: iconContainer + parent: bodyTextRow } } - - 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 @@ -1,142 +1,215 @@ /* - * Copyright 2014 Martin Klapetek + * Copyright 2019 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 free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. * - * 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 + * 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 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. + * You should have received a copy of the GNU General Public License + * along with this program. If not, see */ -import QtQuick 2.0 -import QtQuick.Controls.Private 1.0 +import QtQuick 2.8 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.components 2.0 as Components -import org.kde.kquickcontrolsaddons 2.0 as KQuickControlsAddons +import org.kde.notificationmanager 1.0 as NotificationManager + +import ".." PlasmaCore.Dialog { id: notificationPopup - location: PlasmaCore.Types.Floating - type: PlasmaCore.Dialog.Notification - flags: Qt.WindowDoesNotAcceptFocus + property int popupWidth - property var notificationProperties: ({}) - signal notificationTimeout() + property alias notificationType: notificationItem.notificationType - onVisibleChanged: { - if (!visible) { - notificationTimer.stop(); - } - } + property alias applicationName: notificationItem.applicationName + property alias applicationIconSource: notificationItem.applicationIconSource + property alias deviceName: notificationItem.deviceName + + property alias time: notificationItem.time + + property alias summary: notificationItem.summary + property alias body: notificationItem.body + property alias icon: notificationItem.icon + property alias urls: notificationItem.urls + + property int urgency + property int timeout + property int dismissTimeout + + property alias jobState: notificationItem.jobState + property alias percentage: notificationItem.percentage + property alias jobError: notificationItem.jobError + property alias suspendable: notificationItem.suspendable + property alias killable: notificationItem.killable + property alias jobDetails: notificationItem.jobDetails + + property alias configureActionLabel: notificationItem.configureActionLabel + property alias configurable: notificationItem.configurable + property alias dismissable: notificationItem.dismissable + property alias closable: notificationItem.closable - onYChanged: { - if (visible && !notificationItem.dragging) { - notificationTimer.restart(); + property bool hasDefaultAction + property alias actionNames: notificationItem.actionNames + property alias actionLabels: notificationItem.actionLabels + + signal configureClicked + signal dismissClicked + signal closeClicked + + signal defaultActionInvoked + signal actionInvoked(string actionName) + signal openUrl(string url) + signal fileActionInvoked + + signal expired + + signal suspendJobClicked + signal resumeJobClicked + signal killJobClicked + + property int defaultTimeout: 5000 + readonly property int effectiveTimeout: { + if (timeout === -1) { + return defaultTimeout; } + if (dismissTimeout) { + return dismissTimeout; + } + return timeout; } - function populatePopup(notification) { - notificationProperties = notification - notificationTimer.interval = notification.expireTimeout - notificationTimer.restart(); - //temporarly disable height binding, avoids an useless window resize when removing the old actions - heightBinding.when = false; - // notification.actions is a JS array, but we can easily append that to our model - notificationItem.actions.clear(); - // Workaround a crash in Qt when appending an empty list (https://codereview.qt-project.org/#/c/223985/) - if (notificationProperties.actions.length > 0) { - notificationItem.actions.append(notificationProperties.actions); + location: PlasmaCore.Types.Floating + + type: PlasmaCore.Dialog.Notification + flags: { + var flags = Qt.WindowDoesNotAcceptFocus; + // FIXME this needs support in KWin somehow... + if (urgency === NotificationManager.Notifications.CriticalUrgency) { + flags |= Qt.WindowStaysOnTopHint; } - //enable height binding again, finally do the resize - heightBinding.when = true; + return flags; } - function clearPopup() { - notificationProperties = {} - notificationItem.actions.clear() + visible: false + + // When notification is updated, restart hide timer + onTimeChanged: { + if (timer.running) { + timer.restart(); + } } - mainItem: NotificationItem { - id: notificationItem + mainItem: MouseArea { + id: area + width: notificationPopup.popupWidth + height: notificationItem.implicitHeight + notificationItem.y hoverEnabled: true + cursorShape: hasDefaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: hasDefaultAction ? Qt.LeftButton : Qt.NoButton + + onClicked: notificationPopup.defaultActionInvoked() + LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft LayoutMirroring.childrenInherit: true - //the binding needs to be disabled when re-populating actions, to minimize resizes - Binding on height { - id: heightBinding - value: notificationItem.implicitHeight - when: true - } - Timer { - id: notificationTimer + id: timer + interval: notificationPopup.effectiveTimeout + running: notificationPopup.visible && !area.containsMouse && interval > 0 + && !notificationItem.dragging && !notificationItem.menuOpen onTriggered: { - if (!notificationProperties.isPersistent) { - expireNotification(notificationProperties.source) + if (notificationPopup.dismissTimeout) { + notificationPopup.dismissClicked(); + } else { + notificationPopup.expired(); } - notificationPopup.notificationTimeout(); } } - onContainsMouseChanged: { - if (containsMouse) { - notificationTimer.stop() - } else if (!containsMouse && !dragging && visible) { - notificationTimer.restart() - } + + Timer { + id: timeoutIndicatorDelayTimer + // only show indicator for the last ten seconds of timeout + readonly property int remainingTimeout: 10000 + interval: Math.max(0, timer.interval - remainingTimeout) + running: interval > 0 && timer.running } - onDraggingChanged: { - if (dragging) { - notificationTimer.stop() - } else if (!containsMouse && !dragging && visible) { - notificationTimer.restart() + + Rectangle { + id: timeoutIndicatorRect + anchors { + right: parent.right + rightMargin: -notificationPopup.margins.right + bottom: parent.bottom + bottomMargin: -notificationPopup.margins.bottom + } + width: units.devicePixelRatio * 3 + radius: width + color: theme.highlightColor + opacity: timeoutIndicatorAnimation.running ? 0.6 : 0 + visible: units.longDuration > 1 + Behavior on opacity { + NumberAnimation { + duration: units.longDuration + } } - } - summary: notificationProperties.summary || "" - body: notificationProperties.body || "" - icon: notificationProperties.appIcon || "" - image: notificationProperties.image - // explicit true/false or else it complains about assigning undefined to bool - configurable: notificationProperties.configurable && !Settings.isMobile ? true : false - urls: notificationProperties.urls || [] - hasDefaultAction: notificationProperties.hasDefaultAction || false - hasConfigureAction: notificationProperties.hasConfigureAction || false - - width: Math.round(23 * units.gridUnit) - maximumTextHeight: theme.mSize(theme.defaultFont).height * 10 - - onClose: { - closeNotification(notificationProperties.source) - // the popup will be closed in response to sourceRemoved - } - onConfigure: { - configureNotification(notificationProperties.appRealName, notificationProperties.eventId) - notificationPositioner.closePopup(notificationProperties.source); - } - onAction: { - executeAction(notificationProperties.source, actionId) - actions.clear() + NumberAnimation { + id: timeoutIndicatorAnimation + target: timeoutIndicatorRect + property: "height" + from: area.height + notificationPopup.margins.top + notificationPopup.margins.bottom + to: 0 + duration: Math.min(timer.interval, timeoutIndicatorDelayTimer.remainingTimeout) + running: timer.running && !timeoutIndicatorDelayTimer.running && units.longDuration > 1 + } } - onOpenUrl: { - Qt.openUrlExternally(url) - notificationPositioner.closePopup(notificationProperties.source); + + NotificationItem { + id: notificationItem + // let the item bleed into the dialog margins so the close button margins cancel out + y: closable || dismissable || configurable ? -notificationPopup.margins.top : 0 + headingRightPadding: -notificationPopup.margins.right + width: parent.width + hovered: area.containsMouse + maximumLineCount: 8 + bodyCursorShape: notificationPopup.hasDefaultAction ? Qt.PointingHandCursor : 0 + + thumbnailLeftPadding: -notificationPopup.margins.left + thumbnailRightPadding: -notificationPopup.margins.right + thumbnailTopPadding: -notificationPopup.margins.top + thumbnailBottomPadding: -notificationPopup.margins.bottom + + closable: true + onBodyClicked: { + if (area.acceptedButtons & mouse.button) { + area.clicked(null /*mouse*/); + } + } + onCloseClicked: notificationPopup.closeClicked() + onDismissClicked: notificationPopup.dismissClicked() + onConfigureClicked: notificationPopup.configureClicked() + onActionInvoked: notificationPopup.actionInvoked(actionName) + onOpenUrl: notificationPopup.openUrl(url) + onFileActionInvoked: notificationPopup.fileActionInvoked() + + onSuspendJobClicked: notificationPopup.suspendJobClicked() + onResumeJobClicked: notificationPopup.resumeJobClicked() + onKillJobClicked: notificationPopup.killJobClicked() } } } diff --git a/applets/notifications/package/contents/ui/Notifications.qml b/applets/notifications/package/contents/ui/Notifications.qml deleted file mode 100644 --- a/applets/notifications/package/contents/ui/Notifications.qml +++ /dev/null @@ -1,328 +0,0 @@ -/* - * Copyright 2012 Marco Martin - * - * 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.2 -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.plasma.private.notifications 1.0 - - -Column { - id: notificationsRoot - anchors { - left: parent.left - right: parent.right - } - - property alias count: notificationsRepeater.count - readonly property int historyCount: historyList.count - - property bool showHistory: plasmoid.configuration.showHistory - - signal popupShown(var popup) - - onShowHistoryChanged: { - if(!showHistory) - clearHistory() - } - - Component.onCompleted: { - // Create the popup components and pass them to the C++ plugin - for (var i = 0; i < 3; i++) { - var popup = notificationPopupComponent.createObject(); - notificationPositioner.addNotificationPopup(popup); - } - } - - function addNotification(notification) { - // Do not show duplicated notifications - for (var i = 0; i < notificationsModel.count; ++i) { - if (notificationsModel.get(i).source === notification.source && - notificationsModel.get(i).appName === notification.appName && - notificationsModel.get(i).summary === notification.summary && - notificationsModel.get(i).body === notification.body) { - return - } - } - - for (var i = 0; i < notificationsModel.count; ++i) { - if (notificationsModel.get(i).source === notification.source || - (notificationsModel.get(i).appName === notification.appName && - notificationsModel.get(i).summary === notification.summary && - notificationsModel.get(i).body === notification.body)) { - - notificationsModel.remove(i) - break - } - } - if (notificationsModel.count > 20) { - notificationsModel.remove(notificationsModel.count-1) - } - - if (notification.isPersistent) { - notification.created = new Date(); - - notificationsModel.inserting = true; - notificationsModel.insert(0, notification); - notificationsModel.inserting = false; - } - else if (showHistory) { - - notificationsHistoryModel.inserting = true; - - //Disable actions in this copy as they will stop working once the original notification is closed. - //Only the jobUrl (which is a URL to open) can continue working as we'll handle this internally. - var actions = notification.actions.filter(function (item) { - return item.id.indexOf("jobUrl#") === 0; - }); - - //create a copy of the notification. - //Disable actions in this copy as they will stop working once the original notification is closed. - notificationsHistoryModel.insert(0, { - "compact" : notification.compact, - "icon" : notification.icon, - "image" : notification.image, - "summary" : notification.summary, - "body" : notification.body, - "configurable" : false, - "created" : new Date(), - "urls" : notification.urls, - "maximumTextHeight" : notification.maximumTextHeight, - "actions" : actions, - "hasDefaultAction" : false, - "hasConfigureAction" : false, - }); - notificationsHistoryModel.inserting = false; - } - - notificationPositioner.displayNotification(notification); - } - - function executeAction(source, id) { - //try to use the service - if (id.indexOf("jobUrl#") === -1) { - var service = notificationsSource.serviceForSource(source) - var op = service.operationDescription("invokeAction") - op["actionId"] = id - - service.startOperationCall(op) - //try to open the id as url - } else if (id.indexOf("jobUrl#") !== -1) { - Qt.openUrlExternally(id.slice(7)); - } - - notificationPositioner.closePopup(source); - } - - function configureNotification(appRealName, eventId) { - var service = notificationsSource.serviceForSource("notification") - var op = service.operationDescription("configureNotification") - op.appRealName = appRealName - op.eventId = eventId - service.startOperationCall(op) - } - function createNotification(data) { - var service = notificationsSource.serviceForSource("notification"); - var op = service.operationDescription("createNotification"); - // add everything from "data" to "op" - for (var attrname in data) { op[attrname] = data[attrname]; } - service.startOperationCall(op); - } - - function closeNotification(source) { - var service = notificationsSource.serviceForSource(source) - var op = service.operationDescription("userClosed") - service.startOperationCall(op) - } - - function expireNotification(source) { - var service = notificationsSource.serviceForSource(source) - var op = service.operationDescription("expireNotification") - service.startOperationCall(op) - } - - function clearNotifications() { - for (var i = 0, length = notificationsSource.sources.length; i < length; ++i) { - var source = notificationsSource.sources[i]; - closeNotification(source) - notificationPositioner.closePopup(source); - } - - notificationsModel.clear() - clearHistory() - } - - function clearHistory() { - notificationsHistoryModel.clear() - } - - Component { - id: notificationPopupComponent - NotificationPopup { } - } - - ListModel { - id: notificationsModel - property bool inserting: false - } - - ListModel { - id: notificationsHistoryModel - property bool inserting: false - } - - PlasmaCore.DataSource { - id: idleTimeSource - - property bool idle: data["UserActivity"]["IdleTime"] > 300000 - - engine: "powermanagement" - interval: 30000 - connectedSources: ["UserActivity"] - //Idle with more than 5 minutes of user inactivity - } - - PlasmaCore.DataSource { - id: notificationsSource - - engine: "notifications" - interval: 0 - - onSourceAdded: { - connectSource(source); - } - - onSourceRemoved: { - notificationPositioner.closePopup(source); - - for (var i = 0; i < notificationsModel.count; ++i) { - if (notificationsModel.get(i).source === source) { - notificationsModel.remove(i) - break - } - } - } - - onNewData: { - var _data = data; // Temp copy to avoid lots of context switching - var actions = [] - _data["hasDefaultAction"] = false - _data["hasConfigureAction"] = false; - if (data["actions"] && data["actions"].length % 2 == 0) { - for (var i = 0; i < data["actions"].length; i += 2) { - var action = data["actions"][i] - if (action === "default") { // The default action is not shown, but we want to know it's there - _data["hasDefaultAction"] = true - } else if (action === "settings") { // configure icon in the notification for custom notification settings - _data["hasConfigureAction"] = true; - _data["configurable"] = true; - } else { - actions.push({ - id: data["actions"][i], - text: data["actions"][i+1] - }) - } - } - } - _data["source"] = sourceName - _data["actions"] = actions - notificationsRoot.addNotification(_data) - } - - } - - Connections { - target: plasmoid.nativeInterface - onAvailableScreenRectChanged: { - notificationPositioner.setPlasmoidScreenGeometry(availableScreenRect); - } - } - - NotificationsHelper { - id: notificationPositioner - popupLocation: plasmoid.nativeInterface.screenPosition - - Component.onCompleted: { - notificationPositioner.setPlasmoidScreenGeometry(plasmoid.nativeInterface.availableScreenRect); - } - onPopupShown: notificationsRoot.popupShown(popup) - } - - Repeater { - id: notificationsRepeater - model: notificationsModel - delegate: NotificationDelegate { listModel: notificationsModel } - } - - RowLayout { - Layout.fillWidth: true - spacing: units.smallSpacing - visible: historyCount > 0 - width: parent.width - - PlasmaExtras.Heading { - Layout.fillWidth: true - level: 3 - opacity: 0.6 - text: i18n("History") - } - - PlasmaComponents.ToolButton { - Layout.rightMargin: spacerSvgFrame.margins.right - iconSource: "edit-clear-history" - tooltip: i18n("Clear History") - onClicked: clearHistory() - } - } - - // This hack is unfortunately needed to have the buttons align, - // the ones in the list contain have a margin due to a frame for being a list item. - PlasmaCore.FrameSvgItem { - id : spacerSvgFrame - imagePath: "widgets/listitem" - prefix: "normal" - visible: false - } - - // History stuff - // The history is shown outside in a ListView - Binding { - target: historyList - property: "model" - value: notificationsHistoryModel - when: showHistory - } - - Binding { - target: historyList - property: "delegate" - value: notificationsHistoryDelegate - when: showHistory - } - - Component { - id: notificationsHistoryDelegate - NotificationDelegate { - listModel: notificationsHistoryModel - } - } -} diff --git a/applets/notifications/package/contents/ui/ScreenPositionSelector.qml b/applets/notifications/package/contents/ui/ScreenPositionSelector.qml deleted file mode 100644 --- a/applets/notifications/package/contents/ui/ScreenPositionSelector.qml +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Copyright 2015 (C) Martin Klapetek - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License as - * published by the Free Software Foundation; either version 2 of - * the License or (at your option) version 3 or any later version - * accepted by the membership of KDE e.V. (or its successor approved - * by the membership of KDE e.V.), which shall act as a proxy - * defined in 14 of version 3 of the license. - * - * 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -import QtQuick 2.0 -import QtQuick.Controls 1.0 as QtControls -import QtQuick.Window 2.2 -import org.kde.plasma.core 2.0 as PlasmaCore - -import org.kde.plasma.private.notifications 1.0 - -QtControls.GroupBox { - id: monitorPanel - - width: units.gridUnit * 13 + units.gridUnit * 2 - height: (screenRatio * units.gridUnit * 13) + (units.gridUnit * 2) + basePart.height - - flat: true - - property int selectedPosition - property var disabledPositions: [] - property real screenRatio: Screen.height / Screen.width - - onEnabledChanged: { - if (!enabled) { - positionRadios.current = null - } - - selectedPosition = NotificationsHelper.Default - } - - PlasmaCore.Svg { - id: monitorSvg - imagePath: "widgets/monitor" - } - - PlasmaCore.SvgItem { - id: topleftPart - anchors { - left: parent.left - top: parent.top - } - svg: monitorSvg - elementId: "topleft" - width: units.gridUnit - height: units.gridUnit - } - - PlasmaCore.SvgItem { - id: topPart - anchors { - top: parent.top - left: topleftPart.right - right: toprightPart.left - } - svg: monitorSvg - elementId: "top" - height: units.gridUnit - } - - PlasmaCore.SvgItem { - id: toprightPart - anchors { - right: parent.right - top: parent.top - } - svg: monitorSvg - elementId: "topright" - width: units.gridUnit - height: units.gridUnit - } - - PlasmaCore.SvgItem { - id: leftPart - anchors { - left: parent.left - top: topleftPart.bottom - bottom: bottomleftPart.top - } - svg: monitorSvg - elementId: "left" - width: units.gridUnit - } - - PlasmaCore.SvgItem { - id: rightPart - anchors { - right: parent.right - top: toprightPart.bottom - bottom: bottomrightPart.top - } - svg: monitorSvg - elementId: "right" - width: units.gridUnit - } - - PlasmaCore.SvgItem { - id: bottomleftPart - anchors { - left: parent.left - bottom: basePart.top - } - svg: monitorSvg - elementId: "bottomleft" - width: units.gridUnit - height: units.gridUnit - } - - PlasmaCore.SvgItem { - id: bottomPart - anchors { - bottom: basePart.top - left: bottomleftPart.right - right: bottomrightPart.left - } - svg: monitorSvg - elementId: "bottom" - height: units.gridUnit - } - - PlasmaCore.SvgItem { - id: bottomrightPart - anchors { - right: parent.right - bottom: basePart.top - } - svg: monitorSvg - elementId: "bottomright" - width: units.gridUnit - height: units.gridUnit - } - - PlasmaCore.SvgItem { - id: basePart - anchors { - bottom: parent.bottom - horizontalCenter: parent.horizontalCenter - } - width: 120 - height: 60 - svg: monitorSvg - elementId: "base" - } - - QtControls.ExclusiveGroup { - id: positionRadios - - onCurrentChanged: { - monitorPanel.selectedPosition = current.position; - } - - } - - QtControls.RadioButton { - anchors { - top: topPart.bottom - left: leftPart.right - margins: units.smallSpacing - } - readonly property int position: NotificationsHelper.TopLeft - checked: monitorPanel.selectedPosition == position - visible: monitorPanel.disabledPositions.indexOf(position) == -1 - exclusiveGroup: positionRadios - } - QtControls.RadioButton { - anchors { - top: topPart.bottom - horizontalCenter: topPart.horizontalCenter - margins: units.smallSpacing - } - readonly property int position: NotificationsHelper.TopCenter - checked: monitorPanel.selectedPosition == position - visible: monitorPanel.disabledPositions.indexOf(position) == -1 - exclusiveGroup: positionRadios - } - QtControls.RadioButton { - anchors { - top: topPart.bottom - right: rightPart.left - margins: units.smallSpacing - } - readonly property int position: NotificationsHelper.TopRight - checked: monitorPanel.selectedPosition == position - visible: monitorPanel.disabledPositions.indexOf(position) == -1 - exclusiveGroup: positionRadios - } - QtControls.RadioButton { - anchors { - left: leftPart.right - verticalCenter: leftPart.verticalCenter - margins: units.smallSpacing - } - readonly property int position: NotificationsHelper.Left - checked: monitorPanel.selectedPosition == position - visible: monitorPanel.disabledPositions.indexOf(position) == -1 - exclusiveGroup: positionRadios - } - QtControls.RadioButton { - anchors { - horizontalCenter: topPart.horizontalCenter - verticalCenter: leftPart.verticalCenter - margins: units.smallSpacing - } - readonly property int position: NotificationsHelper.Center - checked: monitorPanel.selectedPosition == position - visible: monitorPanel.disabledPositions.indexOf(position) == -1 - exclusiveGroup: positionRadios - } - QtControls.RadioButton { - anchors { - right: rightPart.left - verticalCenter: rightPart.verticalCenter - margins: units.smallSpacing - } - readonly property int position: NotificationsHelper.Right - checked: monitorPanel.selectedPosition == position - visible: monitorPanel.disabledPositions.indexOf(position) == -1 - exclusiveGroup: positionRadios - } - QtControls.RadioButton { - anchors { - bottom: bottomPart.top - left: leftPart.right - margins: units.smallSpacing - } - readonly property int position: NotificationsHelper.BottomLeft - checked: monitorPanel.selectedPosition == position - visible: monitorPanel.disabledPositions.indexOf(position) == -1 - exclusiveGroup: positionRadios - } - QtControls.RadioButton { - anchors { - bottom: bottomPart.top - horizontalCenter: bottomPart.horizontalCenter - margins: units.smallSpacing - } - readonly property int position: NotificationsHelper.BottomCenter - checked: monitorPanel.selectedPosition == position - visible: monitorPanel.disabledPositions.indexOf(position) == -1 - exclusiveGroup: positionRadios - } - QtControls.RadioButton { - anchors { - bottom: bottomPart.top - right: rightPart.left - margins: units.smallSpacing - } - readonly property int position: NotificationsHelper.BottomRight - checked: monitorPanel.selectedPosition == position - visible: monitorPanel.disabledPositions.indexOf(position) == -1 - exclusiveGroup: positionRadios - } -} diff --git a/applets/notifications/package/contents/ui/SelectableLabel.qml b/applets/notifications/package/contents/ui/SelectableLabel.qml new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/SelectableLabel.qml @@ -0,0 +1,172 @@ +/* + * Copyright 2011 Marco Martin + * Copyright 2014, 2019 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import QtQuick 2.8 +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 + +// NOTE This wrapper item is needed for QQC ScrollView to work +// In NotificationItem we just do SelectableLabel {} and then it gets confused +// as to which is the "contentItem" +Item { + id: bodyTextContainer + + property alias text: bodyText.text + property alias font: bodyText.font + + property int cursorShape + + property QtObject contextMenu: null + + signal clicked(var mouse) + signal linkActivated(string link) + + implicitWidth: bodyText.paintedWidth + implicitHeight: bodyText.paintedHeight + + PlasmaExtras.ScrollArea { + id: bodyTextScrollArea + + anchors.fill: parent + + flickableItem.boundsBehavior: Flickable.StopAtBounds + flickableItem.flickableDirection: Flickable.VerticalFlick + horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff + + TextEdit { + id: bodyText + width: bodyTextScrollArea.width + // TODO check that this doesn't causes infinite loops when it starts adding and removing the scrollbar + //width: bodyTextScrollArea.viewport.width + //enabled: !Settings.isMobile + + color: PlasmaCore.ColorScope.textColor + selectedTextColor: theme.viewBackgroundColor + selectionColor: theme.viewFocusColor + font.capitalization: theme.defaultFont.capitalization + font.family: theme.defaultFont.family + font.italic: theme.defaultFont.italic + font.letterSpacing: theme.defaultFont.letterSpacing + font.pointSize: theme.defaultFont.pointSize + font.strikeout: theme.defaultFont.strikeout + font.underline: theme.defaultFont.underline + font.weight: theme.defaultFont.weight + font.wordSpacing: theme.defaultFont.wordSpacing + renderType: Text.NativeRendering + selectByMouse: true + readOnly: true + wrapMode: Text.Wrap + textFormat: TextEdit.RichText + + onLinkActivated: bodyTextContainer.linkActivated(link) + + // ensure selecting text scrolls the view as needed... + onCursorRectangleChanged: { + var flick = bodyTextScrollArea.flickableItem + if (flick.contentY >= cursorRectangle.y) { + flick.contentY = cursorRectangle.y + } else if (flick.contentY + flick.height <= cursorRectangle.y + cursorRectangle.height) { + flick.contentY = cursorRectangle.y + cursorRectangle.height - flick.height + } + } + MouseArea { + property int selectionStart + property point mouseDownPos: Qt.point(-999, -999); + + anchors.fill: parent + acceptedButtons: Qt.RightButton | Qt.LeftButton + cursorShape: { + if (bodyText.hoveredLink) { + return Qt.PointingHandCursor; + } else if (bodyText.selectionStart !== bodyText.selectionEnd) { + return Qt.IBeamCursor; + } else { + return bodyTextContainer.cursorShape || Qt.IBeamCursor; + } + } + preventStealing: true // don't let us accidentally drag the Flickable + + onPressed: { + if (mouse.button === Qt.RightButton) { + contextMenu = contextMenuComponent.createObject(bodyText); + contextMenu.link = bodyText.linkAt(mouse.x, mouse.y); + + contextMenu.closed.connect(function() { + contextMenu.destroy(); + contextMenu = null; + }); + contextMenu.open(mouse.x, mouse.y); + return; + } + + mouseDownPos = Qt.point(mouse.x, mouse.y); + selectionStart = bodyText.positionAt(mouse.x, mouse.y); + var pos = bodyText.positionAt(mouse.x, mouse.y); + // deselect() would scroll to the end which we don't want + bodyText.select(pos, pos); + } + + onReleased: { + // emulate "onClicked" + var manhattanLength = Math.abs(mouseDownPos.x - mouse.x) + Math.abs(mouseDownPos.y - mouse.y); + if (manhattanLength <= Qt.styleHints.startDragDistance) { + var link = bodyText.linkAt(mouse.x, mouse.y); + if (link) { + Qt.openUrlExternally(link); + } else { + bodyTextContainer.clicked(mouse); + } + } + + // emulate selection clipboard + if (bodyText.selectedText) { + plasmoid.nativeInterface.setSelectionClipboardText(bodyText.selectedText); + } + + mouseDownPos = Qt.point(-999, -999); + } + + // HACK to be able to select text whilst still getting all mouse events to the MouseArea + onPositionChanged: { + if (pressed) { + var pos = bodyText.positionAt(mouseX, mouseY); + if (selectionStart < pos) { + bodyText.select(selectionStart, pos); + } else { + bodyText.select(pos, selectionStart); + } + } + } + } + } + } + + Component { + id: contextMenuComponent + + EditContextMenu { + target: bodyText + } + } +} diff --git a/applets/notifications/package/contents/ui/ThumbnailStrip.qml b/applets/notifications/package/contents/ui/ThumbnailStrip.qml --- a/applets/notifications/package/contents/ui/ThumbnailStrip.qml +++ b/applets/notifications/package/contents/ui/ThumbnailStrip.qml @@ -19,201 +19,143 @@ import QtQuick 2.0 import QtQuick.Layouts 1.1 +import QtGraphicalEffects 1.0 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.kquickcontrolsaddons 2.0 as KQCAddons -import org.kde.plasma.private.notifications 1.0 as Notifications +import org.kde.plasma.private.notifications 2.0 as Notifications -ListView { - id: previewList +MouseArea { + id: thumbnailArea - readonly property int itemSquareSize: units.gridUnit * 4 + // The protocol supports multiple URLs but so far it's only used to show + // a single preview image, so this code is simplified a lot to accomodate + // this usecase and drops everything else (fallback to app icon or ListView + // for multiple files) + property var urls - // 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 + readonly property bool dragging: plasmoid.nativeInterface.dragActive + readonly property alias menuOpen: fileMenu.visible - // 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) + property int _pressX: -1 + property int _pressY: -1 - // whether we're currently dragging, this way we can keep the popup around during the entire - // drag operation even if the mouse leaves the popup - property bool dragging: Notifications.DragHelper.dragActive + property int leftPadding: 0 + property int rightPadding: 0 + property int topPadding: 0 + property int bottomPadding: 0 - model: { - var urls = notificationItem.urls - if (urls.length <= maximumItemCount) { - return urls + signal openUrl(string url) + signal fileActionInvoked + + implicitHeight: Math.max(menuButton.height + 2 * menuButton.anchors.topMargin, + Math.round(Math.min(width / 3, width / thumbnailer.ratio))) + + topPadding + bottomPadding + + preventStealing: true + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + + onClicked: { + if (mouse.button === Qt.LeftButton) { + thumbnailArea.openUrl(thumbnailer.url) } - // 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 + onPressed: { + if (mouse.button === Qt.LeftButton) { + _pressX = mouse.x; + _pressY = mouse.y; + } else if (mouse.button === Qt.RightButton) { + // avoid menu button glowing if we didn't actually press it + menuButton.checked = false; - function pressedAction() { - for (var i = 0; i < count; ++i) { - var item = itemAtIndex(i) - if (item.pressed) { - return item - } + fileMenu.visualParent = this; + fileMenu.open(mouse.x, mouse.y); } } - - // 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) + onPositionChanged: { + if (_pressX !== -1 && _pressY !== -1 && plasmoid.nativeInterface.isDrag(_pressX, _pressY, mouse.x, mouse.y)) { + plasmoid.nativeInterface.startDrag(previewPixmap, thumbnailer.url, thumbnailer.pixmap); + _pressX = -1; + _pressY = -1; + } } - - 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) - } + onReleased: { + _pressX = -1; + _pressY = -1; + } + onContainsMouseChanged: { + if (!containsMouse) { + _pressX = -1; + _pressY = -1; } } - delegate: MouseArea { - id: previewDelegate - - property int pressX: -1 - property int pressY: -1 - - // 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 - acceptedButtons: Qt.LeftButton | Qt.RightButton + Notifications.FileMenu { + id: fileMenu + url: thumbnailer.url + visualParent: menuButton + onActionTriggered: thumbnailArea.fileActionInvoked() + } - onClicked: { - if (mouse.button === Qt.LeftButton) { - notificationItem.openUrl(modelData); - } - } + Notifications.Thumbnailer { + id: thumbnailer - onPressed: { - if (mouse.button === Qt.LeftButton) { - pressX = mouse.x; - pressY = mouse.y; - } else if (mouse.button === Qt.RightButton) { - // avoid menu button glowing if we didn't actually press it - menuButton.checked = false; + readonly property real ratio: pixmapSize.height ? pixmapSize.width / pixmapSize.height : 1 - thumbnailer.showContextMenu(mouse.x, mouse.y, modelData, this); - } - } - onPositionChanged: { - if (pressX !== -1 && pressY !== -1 && Notifications.DragHelper.isDrag(pressX, pressY, mouse.x, mouse.y)) { - Notifications.DragHelper.startDrag(previewDelegate, modelData /*url*/, thumbnailer.pixmap); - pressX = -1; - pressY = -1; - } - } - onReleased: { - pressX = -1; - pressY = -1; - } - onContainsMouseChanged: { - if (!containsMouse) { - pressX = -1; - pressY = -1; - } - } + url: urls[0] + // height is dynamic, so request a "square" size and then show it fitting to aspect ratio + size: Qt.size(thumbnailArea.width, thumbnailArea.width) + } - // first item determines the ListView height - Binding { - target: previewList - property: "implicitHeight" - value: previewDelegate.height - when: index === 0 + KQCAddons.QPixmapItem { + id: previewBackground + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + layer.enabled: true + opacity: 0.25 + pixmap: thumbnailer.pixmap + layer.effect: FastBlur { + source: previewBackground + anchors.fill: parent + radius: 30 } + } - Notifications.Thumbnailer { - id: thumbnailer - - readonly property real ratio: pixmapSize.height ? pixmapSize.width / pixmapSize.height : 1 - - url: modelData - size: Qt.size(previewList.itemWidth, previewList.itemHeight) + Item { + anchors { + fill: parent + leftMargin: thumbnailArea.leftPadding + rightMargin: thumbnailArea.rightPadding + topMargin: thumbnailArea.topPadding + bottomMargin: thumbnailArea.bottomPadding } - QPixmapItem { + KQCAddons.QPixmapItem { id: previewPixmap - - anchors.centerIn: parent - - width: parent.width - height: width / thumbnailer.ratio + anchors.fill: parent pixmap: thumbnailer.pixmap smooth: true + fillMode: Image.PreserveAspectFit } PlasmaCore.IconItem { - anchors.fill: parent - source: thumbnailer.iconName + anchors.centerIn: parent + width: height + height: units.roundToIconSize(parent.height) usesPlasmaTheme: false + source: !thumbnailer.busy && !thumbnailer.hasPreview ? thumbnailer.iconName : "" } - 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] - } - } + PlasmaComponents.BusyIndicator { + anchors.centerIn: parent + running: thumbnailer.busy + visible: thumbnailer.busy } PlasmaComponents.Button { @@ -223,31 +165,22 @@ right: parent.right margins: units.smallSpacing } - width: Math.ceil(units.iconSizes.small + 2 * units.smallSpacing) - height: width tooltip: i18n("More Options...") Accessible.name: tooltip + iconName: "application-menu" checkable: true - // -1 tells it to "align bottom left of item (this)" - onClicked: { - checked = Qt.binding(function() { - return thumbnailer.menuVisible; - }); + onPressedChanged: { + if (pressed) { + // fake "pressed" while menu is open + checked = Qt.binding(function() { + return fileMenu.visible; + }); - thumbnailer.showContextMenu(-1, -1, modelData, this) - } - - PlasmaCore.IconItem { - anchors { - fill: parent - margins: units.smallSpacing + fileMenu.visualParent = this; + // -1 tells it to "align bottom left of visualParent (this)" + fileMenu.open(-1, -1); } - source: "application-menu" - - // From Plasma's ToolButtonStyle: - active: parent.hovered - colorGroup: parent.hovered ? PlasmaCore.Theme.ButtonColorGroup : PlasmaCore.ColorScope.colorGroup } } } diff --git a/applets/notifications/package/contents/ui/TightLabel.qml b/applets/notifications/package/contents/ui/TightLabel.qml new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/TightLabel.qml @@ -0,0 +1,53 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import QtQuick 2.8 + +import org.kde.plasma.components 2.0 as PlasmaComponents + +Item { + id: labelContainer + + property alias font: label.font + property alias color: label.color + property alias text: label.text + + implicitWidth: metrics.tightBoundingRect.width + implicitHeight: metrics.tightBoundingRect.height + + TextMetrics { + id: metrics + font: label.font + text: label.text + elide: Qt.ElideNone + } + + PlasmaComponents.Label { + id: label + //x: -metrics.tightBoundingRect.x + // FIXME why is this completely off?! + //y: -metrics.tightBoundingRect.y + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + + width: metrics.tightBoundingRect.width + height: metrics.tightBoundingRect.height + } +} diff --git a/applets/notifications/package/contents/ui/configNotifications.qml b/applets/notifications/package/contents/ui/configNotifications.qml deleted file mode 100644 --- a/applets/notifications/package/contents/ui/configNotifications.qml +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2014 Kai Uwe Broulik - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License as - * published by the Free Software Foundation; either version 2 of - * the License or (at your option) version 3 or any later version - * accepted by the membership of KDE e.V. (or its successor approved - * by the membership of KDE e.V.), which shall act as a proxy - * defined in Section 14 of version 3 of the license. - * - * 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 General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see - */ - -import QtQuick 2.0 -import QtQuick.Controls 1.0 as QtControls -import QtQuick.Layouts 1.1 as QtLayouts -import QtQuick.Window 2.2 -import org.kde.plasma.core 2.0 as PlasmaCore - -import org.kde.plasma.private.notifications 1.0 - -Item { - id: appearancePage - width: childrenRect.width - height: childrenRect.height - - signal configurationChanged - - property alias cfg_showNotifications: showNotificationsCheckBox.checked - property alias cfg_showJobs: showJobsCheckBox.checked - property alias cfg_showHistory: showHistoryCheckBox.checked - - QtLayouts.ColumnLayout { - anchors.left: parent.left - QtControls.CheckBox { - id: showNotificationsCheckBox - text: i18n("Show application and system notifications") - } - - QtControls.CheckBox { - id: showHistoryCheckBox - text: i18n("Show a history of notifications") - enabled: showNotificationsCheckBox.checked - } - - QtControls.CheckBox { - id: showJobsCheckBox - text: i18n("Track file transfers and other jobs") - } - - QtControls.CheckBox { - id: useCustomPopupPositionCheckBox - text: i18n("Use custom position for the notification popup") - checked: plasmoid.nativeInterface.configScreenPosition() !== NotificationsHelper.Default - } - - ScreenPositionSelector { - id: screenPositionSelector - enabled: useCustomPopupPositionCheckBox.checked - selectedPosition: plasmoid.nativeInterface.screenPosition - disabledPositions: [NotificationsHelper.Left, NotificationsHelper.Center, NotificationsHelper.Right] - } - } - - Component.onCompleted: { - plasmoid.nativeInterface.screenPosition = Qt.binding(function() {configurationChanged(); return screenPositionSelector.selectedPosition; }); - } -} diff --git a/applets/notifications/package/contents/ui/global/Globals.qml b/applets/notifications/package/contents/ui/global/Globals.qml new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/global/Globals.qml @@ -0,0 +1,424 @@ +/* + * Copyright 2019 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +pragma Singleton +import QtQuick 2.8 +import QtQuick.Layouts 1.1 + +import org.kde.plasma.plasmoid 2.0 +import org.kde.plasma.core 2.0 as PlasmaCore +import org.kde.plasma.components 2.0 as Components +import org.kde.kquickcontrolsaddons 2.0 + +import org.kde.notificationmanager 1.0 as NotificationManager + +import ".." + +// This singleton object contains stuff shared between all notification plasmoids, namely: +// - Popup creation and placement +// - Do not disturb mode +QtObject { + id: globals + + // Listened to by "ago" label in NotificationHeader to update all of them in unison + signal timeChanged + + property bool inhibited: false + + onInhibitedChanged: { + var pa = pulseAudio.item; + if (!pa) { + return; + } + + var stream = pa.notificationStream; + if (!stream) { + return; + } + + if (inhibited) { + // Only remember that we muted if previously not muted. + if (!stream.muted) { + notificationSettings.notificationSoundsInhibited = true; + stream.mute(); + } + } else { + // Only unmute if we previously muted it. + if (notificationSettings.notificationSoundsInhibited) { + stream.unmute(); + } + notificationSettings.notificationSoundsInhibited = false; + } + notificationSettings.save(); + } + + // Some parts of the code rely on plasmoid.nativeInterface and since we're in a singleton here + // this is named "plasmoid" + property QtObject plasmoid: plasmoids[0] + + // HACK When a plasmoid is destroyed, QML sets its value to "null" in the Array + // so we then remove it so we have a working "plasmoid" again + onPlasmoidChanged: { + if (!plasmoid) { + // this doesn't emit a change, only in ratePlasmoids() it will detect the change + plasmoids.splice(0, 1); // remove first + ratePlasmoids(); + } + } + + // all notification plasmoids + property var plasmoids: [] + + property int popupLocation: { + switch (notificationSettings.popupPosition) { + // Auto-determine location based on plasmoid location + case NotificationManager.Settings.CloseToWidget: + if (!plasmoid) { + return Qt.AlignBottom | Qt.AlignRight; // just in case + } + + var alignment = 0; + if (plasmoid.location === PlasmaCore.Types.LeftEdge) { + alignment |= Qt.AlignLeft; + } else if (plasmoid.location === PlasmaCore.Types.RightEdge) { + alignment |= Qt.AlignRight; + } else { + // would be nice to do plasmoid.compactRepresentationItem.mapToItem(null) and then + // position the popups depending on the relative position within the panel + alignment |= Qt.application.layoutDirection === Qt.RightToLeft ? Qt.AlignLeft : Qt.AlignRight; + } + if (plasmoid.location === PlasmaCore.Types.TopEdge) { + alignment |= Qt.AlignTop; + } else { + alignment |= Qt.AlignBottom; + } + return alignment; + + case NotificationManager.Settings.TopLeft: + return Qt.AlignTop | Qt.AlignLeft; + case NotificationManager.Settings.TopCenter: + return Qt.AlignTop | Qt.AlignHCenter; + case NotificationManager.Settings.TopRight: + return Qt.AlignTop | Qt.AlignRight; + case NotificationManager.Settings.BottomLeft: + return Qt.AlignBottom | Qt.AlignLeft; + case NotificationManager.Settings.BottomCenter: + return Qt.AlignBottom | Qt.AlignHCenter; + case NotificationManager.Settings.BottomRight: + return Qt.AlignBottom | Qt.AlignRight; + } + } + + // The raw width of the popup's content item, the Dialog itself adds some margins + property int popupWidth: units.gridUnit * 18 + property int popupEdgeDistance: units.largeSpacing + property int popupSpacing: units.largeSpacing + + // How much vertical screen real estate the notification popups may consume + readonly property real popupMaximumScreenFill: 0.75 + + property var screenRect: plasmoid ? plasmoid.availableScreenRect : undefined + + onPopupLocationChanged: Qt.callLater(positionPopups) + onScreenRectChanged: Qt.callLater(positionPopups) + + Component.onCompleted: checkInhibition() + + function adopt(plasmoid) { + // this doesn't emit a change, only in ratePlasmoids() it will detect the change + globals.plasmoids.push(plasmoid); + ratePlasmoids(); + } + + // Sorts plasmoids based on a heuristic to find a suitable plasmoid to follow when placing popups + function ratePlasmoids() { + var plasmoidScore = function(plasmoid) { + if (!plasmoid) { + return 0; + } + + var score = 0; + + // Prefer plasmoids in a panel, prefer horizontal panels over vertical ones + if (plasmoid.location === PlasmaCore.Types.LeftEdge + || plasmoid.location === PlasmaCore.Types.RightEdge) { + score += 1; + } else if (plasmoid.location === PlasmaCore.Types.TopEdge + || plasmoid.location === PlasmaCore.Types.BottomEdge) { + score += 2; + } + + // Prefer iconified plasmoids + if (!plasmoid.expanded) { + ++score; + } + + // Prefer plasmoids on primary screen + if (plasmoid.nativeInterface && plasmoid.nativeInterface.isPrimaryScreen(plasmoid.screenGeometry)) { + ++score; + } + + return score; + } + + var newPlasmoids = plasmoids; + newPlasmoids.sort(function (a, b) { + var scoreA = plasmoidScore(a); + var scoreB = plasmoidScore(b); + // Sort descending by score + if (scoreA < scoreB) { + return 1; + } else if (scoreA > scoreB) { + return -1; + } else { + return 0; + } + }); + globals.plasmoids = newPlasmoids; + } + + function checkInhibition() { + globals.inhibited = Qt.binding(function() { + var inhibited = false; + + var inhibitedUntil = notificationSettings.notificationsInhibitedUntil; + if (!isNaN(inhibitedUntil.getTime())) { + inhibited |= (new Date().getTime() < inhibitedUntil.getTime()); + } + + if (notificationSettings.notificationsInhibitedByApplication) { + inhibited |= true; + } + + return inhibited; + }); + } + + function positionPopups() { + var rect = screenRect; + if (!rect || rect.width <= 0 || rect.height <= 0) { + return; + } + + var y = screenRect.y; + if (popupLocation & Qt.AlignBottom) { + y += screenRect.height; + } else { + y += popupEdgeDistance; + } + + var x = screenRect.x; + if (popupLocation & Qt.AlignLeft) { + x += popupEdgeDistance; + } + + for (var i = 0; i < popupInstantiator.count; ++i) { + var popup = popupInstantiator.objectAt(i); + + if (popupLocation & Qt.AlignHCenter) { + popup.x = x + (screenRect.width - popup.width) / 2; + } else if (popupLocation & Qt.AlignRight) { + popup.x = screenRect.width - popupEdgeDistance - popup.width; + } else { + popup.x = x; + } + + var delta = popupSpacing + popup.height; + + if (popupLocation & Qt.AlignTop) { + popup.y = y; + y += delta; + } else { + y -= delta; + popup.y = y; + } + + // don't let notifications take more than popupMaximumScreenFill of the screen + var visible = true; + if (i > 0) { // however always show at least one popup + if (popupLocation & Qt.AlignTop) { + visible = (popup.y + popup.height < screenRect.y + (screenRect.height * popupMaximumScreenFill)); + } else { + visible = (popup.y > screenRect.y + (screenRect.height * (1 - popupMaximumScreenFill))); + } + } + + // TODO would be nice to hide popups when systray or panel controller is open + popup.visible = visible; + } + } + + property QtObject popupNotificationsModel: NotificationManager.Notifications { + limit: globals.screenRect ? (Math.ceil(globals.screenRect.height / (theme.mSize(theme.defaultFont).height * 4))) : 0 + showExpired: false + showDismissed: false + blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications + blacklistedNotifyRcNames: notificationSettings.popupBlacklistedServices + whitelistedDesktopEntries: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedApplications : [] + whitelistedNotifyRcNames: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedServices : [] + showJobs: notificationSettings.jobsInNotifications + sortMode: NotificationManager.Notifications.SortByTypeAndUrgency + groupMode: NotificationManager.Notifications.GroupDisabled + urgencies: { + var urgencies = 0; + + // Critical always except in do not disturb mode when disabled in settings + if (!globals.inhibited || notificationSettings.criticalPopupsInDoNotDisturbMode) { + urgencies |= NotificationManager.Notifications.CriticalUrgency; + } + + // Normal only when not in do not disturb mode + if (!globals.inhibited) { + urgencies |= NotificationManager.Notifications.NormalUrgency; + } + + // Low only when enabled in settings and not in do not disturb mode + if (!globals.inhibited && notificationSettings.lowPriorityPopups) { + urgencies |=NotificationManager.Notifications.LowUrgency; + } + + return urgencies; + } + } + + property QtObject notificationSettings: NotificationManager.Settings { + onNotificationsInhibitedUntilChanged: globals.checkInhibition() + } + + // This periodically checks whether do not disturb mode timed out and updates the "minutes ago" labels + property QtObject timeSource: PlasmaCore.DataSource { + engine: "time" + connectedSources: ["Local"] + interval: 60000 // 1 min + intervalAlignment: PlasmaCore.Types.AlignToMinute + onDataChanged: { + checkInhibition(); + globals.timeChanged(); + } + } + + property Instantiator popupInstantiator: Instantiator { + model: popupNotificationsModel + delegate: NotificationPopup { + // so Instantiator can access that after the model row is gone + readonly property var notificationId: model.notificationId + + popupWidth: globals.popupWidth + + notificationType: model.type + + applicationName: model.applicationName + applicationIconSource: model.applicationIconName + deviceName: model.deviceName || "" + + time: model.updated || model.created + + configurable: model.configurable + // For running jobs instead of offering a "close" button that might lead the user to + // think that will cancel the job, we offer a "dismiss" button that hides it in the history + dismissable: model.type === NotificationManager.Notifications.JobType + && model.jobState !== NotificationManager.Notifications.JobStateStopped + // TODO would be nice to be able to "pin" jobs when they autohide + && notificationSettings.permanentJobPopups + closable: model.closable + + summary: model.summary + body: model.body || "" + icon: model.image || model.iconName + hasDefaultAction: model.hasDefaultAction || false + timeout: model.timeout + defaultTimeout: notificationSettings.popupTimeout + // When configured to not keep jobs open permanently, we autodismiss them after the standard timeout + dismissTimeout: !notificationSettings.permanentJobPopups + && model.type === NotificationManager.Notifications.JobType + && model.jobState !== NotificationManager.Notifications.JobStateStopped + ? defaultTimeout : 0 + + urls: model.urls || [] + urgency: model.urgency || NotificationManager.Notifications.NormalUrgency + + jobState: model.jobState || 0 + percentage: model.percentage || 0 + jobError: model.jobError || 0 + suspendable: !!model.suspendable + killable: !!model.killable + jobDetails: model.jobDetails || null + + configureActionLabel: model.configureActionLabel || "" + actionNames: model.actionNames + actionLabels: model.actionLabels + + onExpired: popupNotificationsModel.expire(popupNotificationsModel.index(index, 0)) + onCloseClicked: popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) + onDismissClicked: model.dismissed = true + onConfigureClicked: popupNotificationsModel.configure(popupNotificationsModel.index(index, 0)) + onDefaultActionInvoked: { + popupNotificationsModel.invokeDefaultAction(popupNotificationsModel.index(index, 0)) + popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) + } + onActionInvoked: { + popupNotificationsModel.invokeAction(popupNotificationsModel.index(index, 0), actionName) + popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) + } + onOpenUrl: { + Qt.openUrlExternally(url); + popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) + } + onFileActionInvoked: popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) + + onSuspendJobClicked: popupNotificationsModel.suspendJob(popupNotificationsModel.index(index, 0)) + onResumeJobClicked: popupNotificationsModel.resumeJob(popupNotificationsModel.index(index, 0)) + onKillJobClicked: popupNotificationsModel.killJob(popupNotificationsModel.index(index, 0)) + + onHeightChanged: Qt.callLater(positionPopups) + onWidthChanged: Qt.callLater(positionPopups) + + Component.onCompleted: { + // Register apps that were seen spawning a popup so they can be configured later + // Apps with notifyrc can already be configured anyway + if (model.desktopEntry && !model.notifyRcName) { + notificationSettings.registerKnownApplication(model.desktopEntry); + notificationSettings.save(); + } + + // Tell the model that we're handling the timeout now + popupNotificationsModel.stopTimeout(popupNotificationsModel.index(index, 0)); + } + } + onObjectAdded: { + // also needed for it to correctly layout its contents + object.visible = true; + Qt.callLater(positionPopups); + } + onObjectRemoved: { + var notificationId = object.notificationId + // Popup might have been destroyed because of a filter change, tell the model to do the timeout work for us again + // cannot use QModelIndex here as the model row is already gone + popupNotificationsModel.startTimeout(notificationId); + + Qt.callLater(positionPopups); + } + } + + // TODO use pulseaudio-qt for this once it becomes a framework + property QtObject pulseAudio: Loader { + source: "PulseAudio.qml" + } +} diff --git a/applets/notifications/package/contents/config/config.qml b/applets/notifications/package/contents/ui/global/PulseAudio.qml rename from applets/notifications/package/contents/config/config.qml rename to applets/notifications/package/contents/ui/global/PulseAudio.qml --- a/applets/notifications/package/contents/config/config.qml +++ b/applets/notifications/package/contents/ui/global/PulseAudio.qml @@ -1,5 +1,5 @@ /* - * Copyright 2014 Kai Uwe Broulik + * Copyright 2017, 2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as @@ -18,14 +18,36 @@ * along with this program. If not, see */ -import QtQuick 2.0 +import QtQuick 2.2 -import org.kde.plasma.configuration 2.0 +import org.kde.plasma.private.volume 0.1 -ConfigModel { - ConfigCategory { - name: i18n("Information") - icon: "preferences-desktop-notification-bell" - source: "configNotifications.qml" +QtObject { + id: pulseAudio + + readonly property string notificationStreamId: "sink-input-by-media-role:event" + + property QtObject notificationStream + + property QtObject instantiator: Instantiator { + model: StreamRestoreModel {} + + delegate: QtObject { + readonly property string name: Name + readonly property bool muted: Muted + + function mute() { + Muted = true + } + function unmute() { + Muted = false + } + } + + onObjectAdded: { + if (object.name === notificationStreamId) { + notificationStream = object; + } + } } } diff --git a/applets/notifications/package/contents/ui/global/qmldir b/applets/notifications/package/contents/ui/global/qmldir new file mode 100644 --- /dev/null +++ b/applets/notifications/package/contents/ui/global/qmldir @@ -0,0 +1 @@ +singleton Globals 1.0 Globals.qml diff --git a/applets/notifications/package/contents/ui/main.qml b/applets/notifications/package/contents/ui/main.qml --- a/applets/notifications/package/contents/ui/main.qml +++ b/applets/notifications/package/contents/ui/main.qml @@ -1,160 +1,148 @@ -/*************************************************************************** - * Copyright 2011 Davide Bettio * - * Copyright 2011 Marco Martin * - * * - * 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 of the License, 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 +/* + * Copyright 2018-2019 Kai Uwe Broulik + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * 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 General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import QtQuick 2.8 + import org.kde.plasma.plasmoid 2.0 import org.kde.plasma.core 2.0 as PlasmaCore -import org.kde.plasma.components 2.0 as PlasmaComponents import org.kde.kquickcontrolsaddons 2.0 -import org.kde.plasma.extras 2.0 as PlasmaExtras - -import org.kde.plasma.private.notifications 1.0 -import "uiproperties.js" as UiProperties +import org.kde.kcoreaddons 1.0 as KCoreAddons +import org.kde.kquickcontrolsaddons 2.0 as KQCAddons -MouseEventListener { - id: notificationsApplet - //width: units.gridUnit.width * 10 - //height: units.gridUnit.width * 15 +import org.kde.notificationmanager 1.0 as NotificationManager - //Layout.minimumWidth: mainScrollArea.implicitWidth - //Layout.minimumHeight: mainScrollArea.implicitHeight - Layout.minimumWidth: 256 // FIXME: use above - Layout.minimumHeight: 256 +import "global" - LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft - LayoutMirroring.childrenInherit: true +Item { + id: root - property int layoutSpacing: UiProperties.layoutSpacing + Plasmoid.status: historyModel.activeJobsCount > 0 + || historyModel.unreadNotificationsCount > 0 + || Globals.inhibited ? PlasmaCore.Types.ActiveStatus + : PlasmaCore.Types.PassiveStatus - property real globalProgress: 0 + Plasmoid.toolTipSubText: { + var lines = []; - property Item notifications: historyList.headerItem ? historyList.headerItem.notifications : null - property Item jobs: historyList.headerItem ? historyList.headerItem.jobs : null - - //notifications + jobs - property int activeItemsCount: (notifications ? notifications.count : 0) + (jobs ? jobs.count : 0) - property int totalCount: activeItemsCount + (notifications ? notifications.historyCount : 0) + if (historyModel.activeJobsCount > 0) { + lines.push(i18np("%1 running job", "%1 running jobs", historyModel.activeJobsCount)); + } - Plasmoid.switchWidth: units.gridUnit * 20 - Plasmoid.switchHeight: units.gridUnit * 30 + // Any notification that is newer than "lastRead" is "unread" + // since it doesn't know the popup is on screen which makes the user see it + var actualUnread = historyModel.unreadNotificationsCount - Globals.popupNotificationsModel.activeNotificationsCount; + if (actualUnread > 0) { + lines.push(i18np("%1 unread notification", "%1 unread notifications", actualUnread)); + } - Plasmoid.status: activeItemsCount > 0 ? PlasmaCore.Types.ActiveStatus : PlasmaCore.Types.PassiveStatus + if (Globals.inhibited) { + var inhibitedUntil = notificationSettings.notificationsInhibitedUntil + var inhibitedUntilValid = !isNaN(inhibitedUntil.getTime()); - Plasmoid.toolTipSubText: { - if (activeItemsCount == 0) { - return i18n("No notifications or jobs") - } else if (!notifications || !notifications.count) { - return i18np("%1 running job", "%1 running jobs", jobs.count) - } else if (!jobs || !jobs.count) { - return i18np("%1 notification", "%1 notifications", notifications.count) - } else { - return i18np("%1 running job", "%1 running jobs", jobs.count) + "\n" + i18np("%1 notification", "%1 notifications", notifications.count) + // TODO check app inhibition, too + if (inhibitedUntilValid) { + lines.push(i18n("Do not disturb until %1", + KCoreAddons.Format.formatRelativeDateTime(inhibitedUntil, Locale.ShortFormat))); + } + } else if (lines.length === 0) { + lines.push("No unread notificatons"); } + + return lines.join("\n"); } - Plasmoid.compactRepresentation: NotificationIcon { } + Plasmoid.switchWidth: units.gridUnit * 14 + Plasmoid.switchHeight: units.gridUnit * 10 - // Always scroll to the top when opening as that's where the important stuff goes on Plasmoid.onExpandedChanged: { - if (Plasmoid.expanded) { - // contentY doesn't really work with ListView (creates and destroys delegates on demand and positions them randomly) - // so first use its "move to the top" method and then move it further up to reveal all of its "header" contents - historyList.positionViewAtBeginning(); - historyList.contentY = historyList.originY; + if (!plasmoid.expanded) { + // FIXME Qt.callLater because system tray gets confused when an applet becomes passive when clicking to hide it + Qt.callLater(function() { + historyModel.lastRead = undefined; // reset to now + historyModel.collapseAllGroups(); + }); } } - hoverEnabled: !UiProperties.touchInput + Plasmoid.compactRepresentation: CompactRepresentation { + activeCount: Globals.popupNotificationsModel.activeNotificationsCount + unreadCount: Math.min(99, historyModel.unreadNotificationsCount) + + jobsCount: historyModel.activeJobsCount + jobsPercentage: historyModel.jobsPercentage + + inhibited: Globals.inhibited + } + + Plasmoid.fullRepresentation: FullRepresentation { - onActiveItemsCountChanged: { - if (!activeItemsCount) { - plasmoid.expanded = false; - } } - PlasmaExtras.Heading { - width: parent.width - level: 3 - opacity: 0.6 - visible: notificationsApplet.totalCount == 0 - text: i18n("No new notifications.") + NotificationManager.Settings { + id: notificationSettings } - PlasmaExtras.ScrollArea { - id: mainScrollArea - anchors.fill: parent - - // HACK The history of notifications can become quite large. In order to avoid a memory leak - // show them in a ListView which creates delegate instances only on demand. - // The ListView's header functionality is abused to provide the jobs and regular notifications - // which are few and might store some state inside the delegate (e.g. expanded state) and - // thus are created all at once by a Repeater. - ListView { - id: historyList - - // The history stuff is quite entangled with regular notifications, so - // model and delegate are set by Bindings {} inside Notifications.qml - - header: Column { - property alias jobs: jobsLoader.item - property alias notifications: notificationsLoader.item - - width: historyList.width - - Loader { - id: jobsLoader - width: parent.width - source: "Jobs.qml" - active: plasmoid.configuration.showJobs - } - - Loader { - id: notificationsLoader - width: parent.width - source: "Notifications.qml" - active: plasmoid.configuration.showNotifications - } + NotificationManager.Notifications { + id: historyModel + showExpired: true + showDismissed: true + showJobs: notificationSettings.jobsInNotifications + sortMode: NotificationManager.Notifications.SortByTypeAndUrgency + groupMode: NotificationManager.Notifications.GroupApplicationsFlat + groupLimit: 2 + expandUnread: true + blacklistedDesktopEntries: notificationSettings.historyBlacklistedApplications + blacklistedNotifyRcNames: notificationSettings.historyBlacklistedServices + urgencies: { + var urgencies = NotificationManager.Notifications.CriticalUrgency + | NotificationManager.Notifications.NormalUrgency; + if (notificationSettings.lowPriorityHistory) { + urgencies |= NotificationManager.Notifications.LowUrgency; } + return urgencies; } } - function action_clearNotifications() { - notifications.clearNotifications(); - notifications.clearHistory(); + function action_clearHistory() { + historyModel.clear(NotificationManager.Notifications.ClearExpired); + if (historyModel.count === 0) { + plasmoid.expanded = false; + } } - function action_notificationskcm() { - KCMShell.open("kcmnotify"); + function action_openKcm() { + KQCAddons.KCMShell.open("kcm_notifications"); } Component.onCompleted: { - plasmoid.setAction("clearNotifications", i18n("Clear Notifications"), "edit-clear-history") - var clearAction = plasmoid.action("clearNotifications"); + Globals.adopt(plasmoid); + + plasmoid.setAction("clearHistory", i18n("Clear History"), "edit-clear-history"); + var clearAction = plasmoid.action("clearHistory"); clearAction.visible = Qt.binding(function() { - return notificationsApplet.notifications && (notificationsApplet.notifications.count > 0 || notificationsApplet.notifications.historyCount > 0); - }) + return historyModel.expiredNotificationsCount > 0; + }); - if (KCMShell.authorize("kcmnotify.desktop").length > 0) { - plasmoid.setAction("notificationskcm", i18n("&Configure Event Notifications and Actions..."), "preferences-desktop-notification-bell") - } + // FIXME only while Multi-page KCMs are broken when embedded in plasmoid config + plasmoid.setAction("openKcm", i18n("&Configure Event Notifications and Actions..."), "preferences-desktop-notification-bell"); + plasmoid.action("openKcm").visible = (KQCAddons.KCMShell.authorize("kcm_notifications.desktop").length > 0); } } diff --git a/applets/notifications/package/contents/ui/uiproperties.js b/applets/notifications/package/contents/ui/uiproperties.js deleted file mode 100644 --- a/applets/notifications/package/contents/ui/uiproperties.js +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2012 Marco Martin - * - * 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. - */ - - -var toolIconSize = units.iconSizes.smallMedium -var layoutSpacing = 4 -var touchInput = false diff --git a/applets/notifications/package/metadata.desktop b/applets/notifications/package/metadata.desktop --- a/applets/notifications/package/metadata.desktop +++ b/applets/notifications/package/metadata.desktop @@ -149,20 +149,21 @@ Type=Service Icon=preferences-desktop-notification-bell X-KDE-ParentApp= -X-KDE-PluginInfo-Author=Martin Klapetek +X-KDE-PluginInfo-Author=Kai Uwe Broulik X-KDE-PluginInfo-Category=Tasks -X-KDE-PluginInfo-Email=mklapetek@kde.org +X-KDE-PluginInfo-Email=kde@privat.broulik.de X-KDE-PluginInfo-License=GPL-2.0+ X-KDE-PluginInfo-Name=org.kde.plasma.notifications -X-KDE-PluginInfo-Version=3.0 -X-KDE-PluginInfo-Website=https://www.kde.org/plasma-desktop +X-KDE-PluginInfo-Version=4.0 +X-KDE-PluginInfo-Website=https://plasma.kde.org/ X-KDE-ServiceTypes=Plasma/Applet X-Plasma-API=declarativeappletscript X-Plasma-Provides=org.kde.plasma.notifications X-KDE-PluginInfo-EnabledByDefault=true X-KDE-Library=plasma_applet_notifications X-Plasma-MainScript=ui/main.qml -X-Plasma-RequiredExtensions=LaunchApp X-Plasma-NotificationArea=true X-Plasma-NotificationAreaCategory=ApplicationStatus +# FIXME doesn't work with multi-page KCMs +#X-Plasma-ConfigPlugins=kcm_notifications diff --git a/applets/notifications/plugin/CMakeLists.txt b/applets/notifications/plugin/CMakeLists.txt deleted file mode 100644 --- a/applets/notifications/plugin/CMakeLists.txt +++ /dev/null @@ -1,19 +0,0 @@ -set(notificationshelper_SRCS - notificationshelper.cpp - notificationshelperplugin.cpp - thumbnailer.cpp - draghelper.cpp - ) - -add_library(notificationshelperplugin SHARED ${notificationshelper_SRCS}) -target_link_libraries(notificationshelperplugin Qt5::Core - Qt5::Gui - Qt5::Qml - Qt5::Quick - KF5::KIOWidgets - KF5::I18n - ) - -install(TARGETS notificationshelperplugin DESTINATION ${KDE_INSTALL_QMLDIR}/org/kde/plasma/private/notifications) - -install(FILES qmldir DESTINATION ${KDE_INSTALL_QMLDIR}/org/kde/plasma/private/notifications) diff --git a/applets/notifications/plugin/draghelper.h b/applets/notifications/plugin/draghelper.h deleted file mode 100644 --- a/applets/notifications/plugin/draghelper.h +++ /dev/null @@ -1,53 +0,0 @@ -/*************************************************************************** - * Copyright (C) 2013 by Eike Hein * - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, 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 General Public License for more details. * - * * - * You should have received a copy of the GNU 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 . * - ***************************************************************************/ - -#ifndef DRAGHELPER_H -#define DRAGHELPER_H - -#include -#include -#include - -class QQuickItem; - -class DragHelper : public QObject -{ -Q_OBJECT - -Q_PROPERTY(bool dragActive READ dragActive NOTIFY dragActiveChanged) - -public: - explicit DragHelper(QObject *parent = nullptr); - ~DragHelper() override; - - bool dragActive() const; - - Q_INVOKABLE bool isDrag(int oldX, int oldY, int newX, int newY) const; - Q_INVOKABLE void startDrag(QQuickItem* item, const QUrl &url = QUrl(), const QPixmap &pixmap = QPixmap()); - -Q_SIGNALS: - void dragActiveChanged(); - -private: - Q_INVOKABLE void doDrag(QQuickItem* item, const QUrl &url = QUrl(), const QPixmap &pixmap = QPixmap()); - - bool m_dragActive = false; -}; - -#endif diff --git a/applets/notifications/plugin/draghelper.cpp b/applets/notifications/plugin/draghelper.cpp deleted file mode 100644 --- a/applets/notifications/plugin/draghelper.cpp +++ /dev/null @@ -1,84 +0,0 @@ -/*************************************************************************** - * Copyright (C) 2013 by Eike Hein * - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, 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 General Public License for more details. * - * * - * You should have received a copy of the GNU 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 . * - ***************************************************************************/ - -#include "draghelper.h" - -#include -#include -#include -#include -#include -#include - -DragHelper::DragHelper(QObject* parent) : QObject(parent) -{ -} - -DragHelper::~DragHelper() -{ -} - -bool DragHelper::dragActive() const -{ - return m_dragActive; -} - -bool DragHelper::isDrag(int oldX, int oldY, int newX, int newY) const -{ - return ((QPoint(oldX, oldY) - QPoint(newX, newY)).manhattanLength() >= qApp->styleHints()->startDragDistance()); -} - -void DragHelper::startDrag(QQuickItem *item, const QUrl &url, const QPixmap &pixmap) -{ - // This allows the caller to return, making sure we don't crash if - // the caller is destroyed mid-drag - - QMetaObject::invokeMethod(this, "doDrag", Qt::QueuedConnection, - Q_ARG(QQuickItem*, item), Q_ARG(QUrl, url), Q_ARG(QPixmap, pixmap)); -} - -void DragHelper::doDrag(QQuickItem *item, const QUrl &url, const QPixmap &pixmap) -{ - if (item && item->window() && item->window()->mouseGrabberItem()) { - item->window()->mouseGrabberItem()->ungrabMouse(); - } - - QDrag *drag = new QDrag(item); - - QMimeData *mimeData = new QMimeData(); - - if (!url.isEmpty()) { - mimeData->setUrls(QList() << url); - } - - drag->setMimeData(mimeData); - - if (!pixmap.isNull()) { - drag->setPixmap(pixmap); - } - - m_dragActive = true; - emit dragActiveChanged(); - - drag->exec(); - - m_dragActive = false; - emit dragActiveChanged(); -} - diff --git a/applets/notifications/plugin/notificationshelper.h b/applets/notifications/plugin/notificationshelper.h deleted file mode 100644 --- a/applets/notifications/plugin/notificationshelper.h +++ /dev/null @@ -1,95 +0,0 @@ -/* - Copyright (C) 2014 Martin Klapetek - - 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 -*/ - -#ifndef NOTIFICATIONSHELPER_H -#define NOTIFICATIONSHELPER_H - -#include -#include -#include -#include - -class QQuickWindow; -class QTimer; -class QReadWriteLock; - -class NotificationsHelper : public QObject -{ - Q_OBJECT - Q_PROPERTY(PositionOnScreen popupLocation MEMBER m_popupLocation WRITE setPopupLocation NOTIFY popupLocationChanged) - -public: - enum PositionOnScreen { - Default, // Follows the panel - TopLeft, - TopCenter, - TopRight, - Left, - Center, - Right, - BottomLeft, - BottomCenter, - BottomRight - }; - Q_ENUM(PositionOnScreen) - - explicit NotificationsHelper(QObject *parent = nullptr); - ~NotificationsHelper() override; - Q_INVOKABLE void addNotificationPopup(QObject *win); - Q_INVOKABLE void closePopup(const QString &sourceName); - - Q_INVOKABLE void setPlasmoidScreenGeometry(const QRect &geometry); - - void setPopupLocation(PositionOnScreen popupLocation); - - /** - * Fills the popup with data from notificationData - * and puts the popup on proper place on screen. - * If there's no space on screen for the notification, - * it's queued and displayed as soon as there's space for it - */ - Q_INVOKABLE void displayNotification(const QVariantMap ¬ificationData); - -Q_SIGNALS: - void popupLocationChanged(); - void popupShown(QQuickWindow* popup); -// void plasmoidScreenChanged(); - -private Q_SLOTS: - void onPopupClosed(); - void processQueues(); - void processShow(); - void processHide(); - -private: - void repositionPopups(); - - QList m_popupsOnScreen; - QList m_availablePopups; - QHash m_sourceMap; - QRect m_plasmoidScreen; - PositionOnScreen m_popupLocation; - int m_offset; - bool m_busy; - QList m_hideQueue; - QList m_showQueue; - QReadWriteLock *m_mutex; - QTimer *m_dispatchTimer; -}; - -#endif // NOTIFICATIONSHELPER_H diff --git a/applets/notifications/plugin/notificationshelper.cpp b/applets/notifications/plugin/notificationshelper.cpp deleted file mode 100644 --- a/applets/notifications/plugin/notificationshelper.cpp +++ /dev/null @@ -1,328 +0,0 @@ -/* - Copyright (C) 2014 Martin Klapetek - - 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 "notificationshelper.h" - -#include -#include -#include -#include -#include -#include -#include - -NotificationsHelper::NotificationsHelper(QObject *parent) - : QObject(parent), - m_popupLocation(NotificationsHelper::BottomRight), - m_busy(false) -{ - m_mutex = new QReadWriteLock(QReadWriteLock::Recursive); - m_offset = QFontMetrics(QGuiApplication::font()).boundingRect(QStringLiteral("M")).height(); - - m_dispatchTimer = new QTimer(this); - m_dispatchTimer->setInterval(500); - m_dispatchTimer->setSingleShot(true); - connect(m_dispatchTimer, &QTimer::timeout, [this](){m_busy = false; processQueues();}); -} - -NotificationsHelper::~NotificationsHelper() -{ - qDeleteAll(m_availablePopups); - qDeleteAll(m_popupsOnScreen); - delete m_mutex; -} - -void NotificationsHelper::setPopupLocation(PositionOnScreen popupLocation) -{ - if (m_popupLocation != popupLocation) { - m_popupLocation = popupLocation; - emit popupLocationChanged(); - - repositionPopups(); - } -} - -void NotificationsHelper::setPlasmoidScreenGeometry(const QRect &plasmoidScreenGeometry) -{ - m_plasmoidScreen = plasmoidScreenGeometry; - repositionPopups(); -} - -void NotificationsHelper::addNotificationPopup(QObject *win) -{ - QQuickWindow *popup = qobject_cast(win); - m_availablePopups.append(popup); - - // Don't let QML ever delete this component - QQmlEngine::setObjectOwnership(win, QQmlEngine::CppOwnership); - - connect(win, SIGNAL(notificationTimeout()), - this, SLOT(onPopupClosed())); - - connect(popup, &QWindow::heightChanged, this, &NotificationsHelper::repositionPopups, Qt::UniqueConnection); - - //We are sure that after visibleChanged the size is final - //and the first expose event didn't arrive yet - connect(popup, &QQuickWindow::visibleChanged, this, &NotificationsHelper::repositionPopups); -} - -void NotificationsHelper::processQueues() -{ - if (m_busy) { - return; - } - - m_mutex->lockForRead(); - bool shouldProcessShow = !m_showQueue.isEmpty() && !m_availablePopups.isEmpty(); - m_mutex->unlock(); - - if (shouldProcessShow) { - m_busy = true; - processShow(); - // Return here, makes the movement more clear and easier to follow - return; - } - - m_mutex->lockForRead(); - bool shouldProcessHide = !m_hideQueue.isEmpty(); - m_mutex->unlock(); - - if (shouldProcessHide) { - m_busy = true; - processHide(); - } -} - -void NotificationsHelper::processShow() -{ - m_mutex->lockForWrite(); - const QVariantMap notificationData = m_showQueue.takeFirst(); - m_mutex->unlock(); - - QString sourceName = notificationData.value(QStringLiteral("source")).toString(); - - // Try getting existing popup for the given source - // (case of notification being just updated) - QQuickWindow *popup = m_sourceMap.value(sourceName); - - if (!popup) { - // No existing notification for the given source, - // take one from the available popups - m_mutex->lockForWrite(); - popup = m_availablePopups.takeFirst(); - m_popupsOnScreen << popup; - m_sourceMap.insert(sourceName, popup); - m_mutex->unlock(); - // Set the source name directly on the popup object too - // to avoid looking up the notificationProperties map as above - popup->setProperty("sourceName", sourceName); - } - - // Populate the popup with data, this is the component's own QML method - QMetaObject::invokeMethod(popup, "populatePopup", Qt::DirectConnection, Q_ARG(QVariant, notificationData)); - Q_EMIT popupShown(popup); - - //use setproperty so the Dialog reimplementation will be used - popup->setProperty("visible", true); - - if (!m_dispatchTimer->isActive()) { - m_dispatchTimer->start(); - } -} - -void NotificationsHelper::processHide() -{ - m_mutex->lockForWrite(); - QQuickWindow *popup = m_hideQueue.takeFirst(); - m_mutex->unlock(); - - if (popup) { - m_mutex->lockForWrite(); - // Remove the popup from the active list and return it into the available list - m_popupsOnScreen.removeAll(popup); - m_sourceMap.remove(popup->property("sourceName").toString()); - if (!m_availablePopups.contains(popup)) { - // make extra sure that pointers in here aren't doubled - m_availablePopups.append(popup); - } - m_mutex->unlock(); - - popup->hide(); - - QMetaObject::invokeMethod(popup, "clearPopup", Qt::DirectConnection); - } - - m_mutex->lockForRead(); - bool shouldReposition = !m_popupsOnScreen.isEmpty();// && m_showQueue.isEmpty(); - m_mutex->unlock(); - - if (shouldReposition) { - repositionPopups(); - } - - if (!m_dispatchTimer->isActive()) { - m_dispatchTimer->start(); - } -} - -void NotificationsHelper::displayNotification(const QVariantMap ¬ificationData) -{ - if (notificationData.isEmpty()) { - return; - } - - QVariant sourceName = notificationData.value(QStringLiteral("source")); - - // first check if we don't already have data for the same source - // which would mean that the notification was just updated - // so remove the old one and append the newest data only - QMutableListIterator i(m_showQueue); - while (i.hasNext()) { - if (i.next().value(QStringLiteral("source")) == sourceName) { - m_mutex->lockForWrite(); - i.remove(); - m_mutex->unlock(); - } - } - - // ...also look into the hide queue, if it's already queued - // for hiding, we need to remove it from there otherwise - // it will get closed too soon - QMutableListIterator j(m_hideQueue); - while (j.hasNext()) { - if (j.next()->property("sourceName") == sourceName) { - m_mutex->lockForWrite(); - j.remove(); - m_mutex->unlock(); - } - } - - m_mutex->lockForWrite(); - m_showQueue.append(notificationData); - m_mutex->unlock(); - - if (!m_dispatchTimer->isActive()) { - // If the dispatch timer is not already running, process - // the queues directly, that should cut the time between - // notification emitting the event and popup displaying - processQueues(); - } -} - -void NotificationsHelper::closePopup(const QString &sourceName) -{ - QQuickWindow *popup = m_sourceMap.value(sourceName); - - m_mutex->lockForRead(); - bool shouldQueue = popup && !m_hideQueue.contains(popup); - m_mutex->unlock(); - - // Make sure the notification that was closed (programatically) - // is not in the show queue. This is important otherwise that - // notification will be shown and then never closed (because - // the close event arrives here, before it's even shown) - QMutableListIterator i(m_showQueue); - while (i.hasNext()) { - if (i.next().value(QStringLiteral("source")) == sourceName) { - m_mutex->lockForWrite(); - i.remove(); - m_mutex->unlock(); - } - } - - if (shouldQueue) { - m_mutex->lockForWrite(); - m_hideQueue.append(popup); - m_mutex->unlock(); - - if (!m_dispatchTimer->isActive()) { - processQueues(); - } - } -} - -void NotificationsHelper::onPopupClosed() -{ - QQuickWindow *popup = qobject_cast(sender()); - - m_mutex->lockForRead(); - bool shouldQueue = popup && !m_hideQueue.contains(popup); - m_mutex->unlock(); - - if (shouldQueue) { - m_mutex->lockForWrite(); - m_hideQueue << popup; - m_mutex->unlock(); - - if (!m_dispatchTimer->isActive()) { - processQueues(); - } - } -} - -void NotificationsHelper::repositionPopups() -{ - int cumulativeHeight = m_offset; - - m_mutex->lockForWrite(); - - QPoint pos; - - for (int i = 0; i < m_popupsOnScreen.size(); ++i) { - if (m_popupLocation == NotificationsHelper::TopLeft - || m_popupLocation == NotificationsHelper::TopCenter - || m_popupLocation == NotificationsHelper::TopRight) { - - pos.setY(m_plasmoidScreen.top() + cumulativeHeight); - - } else { - pos.setY(m_plasmoidScreen.bottom() - cumulativeHeight - m_popupsOnScreen[i]->height()); - } - - switch (m_popupLocation) { - case Default: - //This should not happen as the defualt handling is in NotificationApplet::onScreenPositionChanged - Q_ASSERT(false); - qWarning("Notication popupLocation is still \"default\". This should not happen"); - //fall through to top right - case TopRight: - case BottomRight: - pos.setX(m_plasmoidScreen.right() - m_popupsOnScreen[i]->width() - m_offset); - break; - case TopCenter: - case BottomCenter: - pos.setX(m_plasmoidScreen.x() + (m_plasmoidScreen.width() / 2) - (m_popupsOnScreen[i]->width() / 2)); - break; - case TopLeft: - case BottomLeft: - pos.setX(m_plasmoidScreen.left() + m_offset); - break; - case Left: - case Center: - case Right: - // Fall-through to make the compiler happy - break; - } - m_popupsOnScreen[i]->setPosition(pos); - cumulativeHeight += (m_popupsOnScreen[i]->height() + m_offset); - } - - m_mutex->unlock(); -} - - diff --git a/applets/notifications/plugin/notificationshelperplugin.h b/applets/notifications/plugin/notificationshelperplugin.h deleted file mode 100644 --- a/applets/notifications/plugin/notificationshelperplugin.h +++ /dev/null @@ -1,35 +0,0 @@ -/* - Copyright (C) 2014 Martin Klapetek - - 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 -*/ - - -#ifndef NOTIFICATIONSHELPERPLUGIN_H -#define NOTIFICATIONSHELPERPLUGIN_H - -#include - -class NotificationsHelperPlugin : public QQmlExtensionPlugin -{ - Q_OBJECT - Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QQmlExtensionInterface") - -public: - void registerTypes(const char *uri) override; - -}; - -#endif // NOTIFICATIONSHELPERPLUGIN_H diff --git a/applets/notifications/plugin/notificationshelperplugin.cpp b/applets/notifications/plugin/notificationshelperplugin.cpp deleted file mode 100644 --- a/applets/notifications/plugin/notificationshelperplugin.cpp +++ /dev/null @@ -1,62 +0,0 @@ -/* - Copyright (C) 2014 Martin Klapetek - - 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 "notificationshelperplugin.h" -#include "notificationshelper.h" -#include "thumbnailer.h" -#include "draghelper.h" - -#include -#include - -class UrlHelper : public QObject { - Q_OBJECT - public: - Q_INVOKABLE bool isUrlValid(const QString &url) const { - return QUrl::fromUserInput(url).isValid(); - } -}; - -static QObject *urlcheck_singletontype_provider(QQmlEngine *engine, QJSEngine *scriptEngine) -{ - Q_UNUSED(engine) - Q_UNUSED(scriptEngine) - - return new UrlHelper(); -} - -static QObject *draghelper_singletontype_provider(QQmlEngine *engine, QJSEngine *scriptEngine) -{ - Q_UNUSED(engine) - Q_UNUSED(scriptEngine) - - return new DragHelper(); -} - -void NotificationsHelperPlugin::registerTypes(const char *uri) -{ - 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, "DragHelper", draghelper_singletontype_provider); -} - -#include "notificationshelperplugin.moc" diff --git a/applets/notifications/plugin/qmldir b/applets/notifications/plugin/qmldir deleted file mode 100644 --- a/applets/notifications/plugin/qmldir +++ /dev/null @@ -1,2 +0,0 @@ -module org.kde.plasma.private.notifications -plugin notificationshelperplugin diff --git a/applets/notifications/plugin/thumbnailer.h b/applets/notifications/thumbnailer.h rename from applets/notifications/plugin/thumbnailer.h rename to applets/notifications/thumbnailer.h --- a/applets/notifications/plugin/thumbnailer.h +++ b/applets/notifications/thumbnailer.h @@ -35,6 +35,7 @@ Q_PROPERTY(QUrl url READ url WRITE setUrl NOTIFY urlChanged) Q_PROPERTY(QSize size READ size WRITE setSize NOTIFY sizeChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) Q_PROPERTY(bool hasPreview READ hasPreview NOTIFY pixmapChanged) Q_PROPERTY(QPixmap pixmap READ pixmap NOTIFY pixmapChanged) Q_PROPERTY(QSize pixmapSize READ pixmapSize NOTIFY pixmapChanged) @@ -53,24 +54,24 @@ QSize size() const; void setSize(const QSize &size); + bool busy() const; bool hasPreview() const; QPixmap pixmap() const; QSize pixmapSize() const; QString iconName() const; bool menuVisible() const; - Q_INVOKABLE void showContextMenu(int x, int y, const QString &path, QQuickItem *ctx); - void classBegin() override; void componentComplete() override; signals: void menuVisibleChanged(); void urlChanged(); void sizeChanged(); + void busyChanged(); void pixmapChanged(); void iconNameChanged(); @@ -84,6 +85,8 @@ QUrl m_url; QSize m_size; + bool m_busy = false; + QPixmap m_pixmap; QString m_iconName; diff --git a/applets/notifications/thumbnailer.cpp b/applets/notifications/thumbnailer.cpp new file mode 100644 --- /dev/null +++ b/applets/notifications/thumbnailer.cpp @@ -0,0 +1,165 @@ +/* + 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 +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#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::busy() const +{ + return m_busy; +} + +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; +} + +bool Thumbnailer::menuVisible() const +{ + return m_menuVisible; +} + +void Thumbnailer::generatePreview() +{ + if (!m_inited) { + return; + } + + if (!m_url.isValid() || !m_url.isLocalFile() || !m_size.isValid()) { + return; + } + + auto maxSize = qMax(m_size.width(), m_size.height()); + KIO::PreviewJob *job = KIO::filePreview(KFileItemList({KFileItem(m_url)}), QSize(maxSize,maxSize)); + job->setScaleType(KIO::PreviewJob::Scaled); + 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(); + } + }); + + connect(job, &KJob::result, this, [this] { + m_busy = false; + emit busyChanged(); + }); + + m_busy = true; + emit busyChanged(); + + job->start(); +}