[graphics/koko] /: Add FileDropArea and allow dropping files into gallery or folder delegate

Oliver Beard <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 5e8d15710eee2b21e74f670c39b6c8f41549e092 by Oliver Beard.
Committed on 30/07/2026 at 15:13.
Pushed by olib into branch 'master'.

Add FileDropArea and allow dropping files into gallery or folder delegate

Adds `FileDropArea` to detect and handle dropping files. This is implemented for the `GalleryView` with a background to indicate dropping will drop into the current folder. If the drag is over a folder delegate, then it'll be dropped into the folder.

This allows drag and drop from the gallery into a visible folder, or between Koko instances, or from Dolphin or the desktop, etc.

Non-media files are currently accepted, as with paste.

Dropping is denied (as dragging is) for non-folder views (e.g. collections or 'Open With').

`KGuiAddons` is required to set the window for the job, so that sub-windows i.e. file already exists dialog are positioned correctly. I continue to grumble at the poor `QWidget *window` API in KIO.

M  +1    -0    .kde-ci.yml
M  +1    -1    CMakeLists.txt
M  +2    -0    src/CMakeLists.txt
A  +126  -0    src/filedroparea.cpp     [License: LGPL(v2.1+)]
A  +58   -0    src/filedroparea.h     [License: LGPL(v2.1+)]
M  +85   -38   src/qml/gallery/GalleryPage.qml

https://invent.kde.org/graphics/koko/-/commit/5e8d15710eee2b21e74f670c39b6c8f41549e092

diff --git a/.kde-ci.yml b/.kde-ci.yml
index 92da230e..b27d01ac 100644
--- a/.kde-ci.yml
+++ b/.kde-ci.yml
@@ -17,6 +17,7 @@ Dependencies:
     'frameworks/kfilemetadata': '@latest-kf6'
     'frameworks/kdbusaddons': '@latest-kf6'
     'frameworks/purpose': '@latest-kf6'
+    'frameworks/kguiaddons': '@latest-kf6'
     'libraries/kirigami-addons': '@latest-kf6'
     'libraries/kquickimageeditor': '@latest-kf6'
     'libraries/kirigami-app-components': '@latest-kf6'
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7fb8db7c..66b1b9f5 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -50,7 +50,7 @@ set_package_properties(Qt6 PROPERTIES
     DESCRIPTION "Base components"
 )
 
-find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS I18n Declarative Config ConfigWidgets KIO CoreAddons Crash Notifications FileMetaData DBusAddons Kirigami Purpose)
+find_package(KF6 ${KF_MIN_VERSION} REQUIRED COMPONENTS I18n Declarative Config ConfigWidgets KIO CoreAddons Crash Notifications FileMetaData DBusAddons Kirigami Purpose GuiAddons)
 
 set_package_properties(KF6 PROPERTIES
     TYPE REQUIRED
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a57a3738..fe199d90 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -20,6 +20,7 @@ qt_add_library(koko_static STATIC
     controller.cpp
     kdtree.cpp
     filemenumanager.cpp
+    filedroparea.cpp
     dirmodelutils.cpp
     notificationmanager.cpp
     fileinfo.cpp
@@ -76,6 +77,7 @@ target_link_libraries(koko_static PUBLIC
     KF6::I18nQml
     KF6::Notifications
     KF6::KirigamiActionCollection
+    KF6::GuiAddons
     LibExiv2::LibExiv2
     KQuickImageEditor
 )
diff --git a/src/filedroparea.cpp b/src/filedroparea.cpp
new file mode 100644
index 00000000..e0efd1de
--- /dev/null
+++ b/src/filedroparea.cpp
@@ -0,0 +1,126 @@
+/*
+    SPDX-FileCopyrightText: 2026 Oliver Beard <[email protected]>
+    SPDX-License-Identifier: LGPL-2.1-or-later
+*/
+
+#include <QMimeData>
+
+#include <KIO/DropJob>
+#include <KJobWindows>
+
+#include "filedroparea.h"
+
+FileDropArea::FileDropArea(QQuickItem *parent)
+    : QQuickItem(parent)
+{
+    setFlag(ItemAcceptsDrops, true);
+}
+
+void FileDropArea::setWindow(QQuickWindow *window)
+{
+    if (m_window == window) {
+        return;
+    }
+
+    m_window = window;
+    Q_EMIT windowChanged();
+}
+
+QQuickWindow *FileDropArea::window() const
+{
+    return m_window;
+}
+
+QUrl FileDropArea::rootUrl() const
+{
+    return m_rootUrl;
+}
+
+void FileDropArea::setRootUrl(const QUrl &url)
+{
+    if (m_rootUrl == url) {
+        return;
+    }
+
+    m_rootUrl = url;
+    Q_EMIT rootUrlChanged();
+}
+
+bool FileDropArea::enabled() const
+{
+    return m_enabled;
+}
+
+void FileDropArea::setEnabled(const bool enabled)
+{
+    if (m_enabled == enabled) {
+        return;
+    }
+
+    m_enabled = enabled;
+    Q_EMIT enabledChanged();
+}
+
+bool FileDropArea::containsDrag() const
+{
+    return m_containsDrag;
+}
+
+void FileDropArea::dragEnterEvent(QDragEnterEvent *event)
+{
+    if (m_enabled && event->mimeData()->hasUrls()) {
+        event->acceptProposedAction();
+
+        setContainsDrag(true);
+    }
+}
+
+void FileDropArea::dragMoveEvent(QDragMoveEvent *event)
+{
+    if (m_enabled && event->mimeData()->hasUrls()) {
+        event->acceptProposedAction();
+
+        setContainsDrag(true);
+    }
+}
+
+void FileDropArea::dropEvent(QDropEvent *event)
+{
+    if (m_enabled && event->mimeData()->hasUrls()) {
+        auto droppedUrls = std::make_shared<QList<QUrl>>();
+
+        auto dropJob = KIO::drop(event, m_rootUrl);
+        if (m_window) {
+            KJobWindows::setWindow(dropJob, m_window);
+        }
+        connect(dropJob, &KIO::DropJob::itemCreated, this, [droppedUrls](const QUrl &url) {
+            *droppedUrls << url;
+        });
+        connect(dropJob, &KJob::finished, this, [this, droppedUrls]() {
+            if (!droppedUrls->isEmpty()) {
+                Q_EMIT this->droppedUrls(*droppedUrls);
+            }
+        });
+
+        event->acceptProposedAction();
+    }
+
+    setContainsDrag(false);
+}
+
+void FileDropArea::dragLeaveEvent(QDragLeaveEvent *event)
+{
+    Q_UNUSED(event)
+
+    setContainsDrag(false);
+}
+
+void FileDropArea::setContainsDrag(const bool containsDrag)
+{
+    if (m_containsDrag == containsDrag) {
+        return;
+    }
+
+    m_containsDrag = containsDrag;
+    Q_EMIT containsDragChanged();
+}
diff --git a/src/filedroparea.h b/src/filedroparea.h
new file mode 100644
index 00000000..24c65114
--- /dev/null
+++ b/src/filedroparea.h
@@ -0,0 +1,58 @@
+/*
+    SPDX-FileCopyrightText: 2026 Oliver Beard <[email protected]>
+    SPDX-License-Identifier: LGPL-2.1-or-later
+*/
+
+#pragma once
+
+#include <QQuickItem>
+#include <QQuickWindow>
+#include <qqmlregistration.h>
+
+class FileDropArea : public QQuickItem
+{
+    Q_OBJECT
+    QML_ELEMENT
+
+    Q_PROPERTY(QQuickWindow *window READ window WRITE setWindow NOTIFY windowChanged REQUIRED FINAL)
+    Q_PROPERTY(QUrl rootUrl READ rootUrl WRITE setRootUrl NOTIFY rootUrlChanged REQUIRED FINAL)
+    Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged FINAL)
+
+    Q_PROPERTY(bool containsDrag READ containsDrag NOTIFY containsDragChanged FINAL)
+
+public:
+    explicit FileDropArea(QQuickItem *parent = nullptr);
+
+    [[nodiscard]] QQuickWindow *window() const;
+    void setWindow(QQuickWindow *window);
+
+    [[nodiscard]] QUrl rootUrl() const;
+    void setRootUrl(const QUrl &url);
+
+    [[nodiscard]] bool enabled() const;
+    void setEnabled(const bool enabled);
+
+    [[nodiscard]] bool containsDrag() const;
+
+Q_SIGNALS:
+    void windowChanged();
+    void rootUrlChanged();
+    void enabledChanged();
+    void containsDragChanged();
+
+    void droppedUrls(QList<QUrl> urls);
+
+protected:
+    void dragEnterEvent(QDragEnterEvent *event) override;
+    void dragMoveEvent(QDragMoveEvent *event) override;
+    void dragLeaveEvent(QDragLeaveEvent *event) override;
+    void dropEvent(QDropEvent *event) override;
+
+private:
+    void setContainsDrag(const bool containsDrag);
+
+    QPointer<QQuickWindow> m_window = nullptr;
+    QUrl m_rootUrl;
+    bool m_enabled = true;
+    bool m_containsDrag = false;
+};
diff --git a/src/qml/gallery/GalleryPage.qml b/src/qml/gallery/GalleryPage.qml
index f3848626..e12120ca 100644
--- a/src/qml/gallery/GalleryPage.qml
+++ b/src/qml/gallery/GalleryPage.qml
@@ -42,6 +42,47 @@ Kirigami.ScrollablePage {
     property bool showingCollections: galleryModel.showingCollections
     property bool selectionMode: selectionModel.hasSelection
 
+    // Highlight urls on paste or drop
+    property url lastHighlightedUrl
+    property var highlightedUrls: new Set()
+
+    function highlightUrls(urls: list<url>): void {
+        console.log(urls, typeof urls)
+        urls.forEach(url => page.highlightedUrls.add(url));
+        page.lastHighlightedUrl = urls[urls.length - 1];
+        selectionModel.clearSelection();
+        // Ensure no race when model is already up to date
+        page.checkRowsForHighlight(null, 0, gridView.model.rowCount() - 1);
+    }
+
+    function checkRowsForHighlight(parent: var /*modelIndex*/, first: int, last: int): void {
+        if (page.highlightedUrls.size === 0) {
+            return;
+        }
+
+        for (let row = first; row <= last; ++row) {
+            const index = gridView.model.index(row, 0);
+            const url = gridView.model.data(index, AbstractGalleryModel.UrlRole);
+
+            if (page.highlightedUrls.has(url)) {
+                page.highlightedUrls.delete(url);
+                selectionModel.select(index, ItemSelectionModel.Select);
+
+                if (url === page.lastHighlightedUrl) {
+                    gridView.currentIndex = row;
+                    page.lastHighlightedUrl = "";
+                }
+            }
+        }
+    }
+
+    Connections {
+        target: gridView.model
+        function onRowsInserted(parent: var /*modelIndex*/, first: int, last: int) {
+            page.checkRowsForHighlight(parent, first, last);
+        }
+    }
+
     Component.onCompleted: {
         if (page.canNavigate) {
             page.navigationHistory = [page.galleryModel.path];
@@ -361,47 +402,14 @@ Kirigami.ScrollablePage {
 
     Koko.FileMenuManager {
         id: fileMenuManager
+
         urls: selectionModel.selectedIndexes.map(index => selectionModel.model.data(index, AbstractGalleryModel.UrlRole))
-        rootUrl: page.galleryModel.path
+        rootUrl: page.isFolderView ? page.galleryModel.path : ""
+
         enabled: page.visible && page.enabled
         window: page.Window.window
 
-        // Handle selection of pasted items:
-
-        property url lastPastedUrl
-        property var pastedUrls: new Set()
-
-        onPastedUrls: (urls) => {
-            urls.forEach(url => fileMenuManager.pastedUrls.add(url));
-            fileMenuManager.lastPastedUrl = urls[urls.length - 1];
-            selectionModel.clearSelection();
-
-            // If it turns out we get told about pasted urls after the model includes them:
-            // onRowsInserted(undefined, 0, gridView.model.rowCount() - 1);
-        }
-
-        function onRowsInserted(parent, first, last) {
-            if (pastedUrls.size === 0) {
-                return;
-            }
-
-            for (let row = first; row <= last; ++row) {
-                const index = gridView.model.index(row, 0);
-                const url = gridView.model.data(index, AbstractGalleryModel.UrlRole);
-
-                if (fileMenuManager.pastedUrls.has(url)) {
-                    fileMenuManager.pastedUrls.delete(url);
-                    selectionModel.select(index, ItemSelectionModel.Select);
-
-                    if (url === fileMenuManager.lastPastedUrl) {
-                        gridView.currentIndex = row;
-                        fileMenuManager.lastPastedUrl = undefined;
-                    }
-                }
-            }
-        }
-
-        Component.onCompleted: gridView.model.rowsInserted.connect(onRowsInserted)
+        onPastedUrls: (urls) => page.highlightUrls(urls)
     }
 
     readonly property list<Kirigami.Action> fileMenuActions: [
@@ -706,6 +714,32 @@ Kirigami.ScrollablePage {
             }
         }
 
+        Koko.FileDropArea {
+            id: gridViewDropArea
+            anchors.fill: parent
+
+            z: -1 // Allow delegate drop area to take precedence
+
+            window: Window.window
+            rootUrl: enabled ? page.galleryModel.path : ""
+            enabled: page.isFolderView
+
+            onDroppedUrls: (urls) => page.highlightUrls(urls)
+        }
+
+        Rectangle {
+            anchors.fill: parent
+            color: Kirigami.Theme.hoverColor
+
+            opacity: gridViewDropArea.containsDrag ? 0.2 : 0
+            Behavior on opacity {
+                NumberAnimation {
+                    duration: Kirigami.Units.shortDuration
+                    easing.type: Easing.InOutQuad
+                }
+            }
+        }
+
         delegate: GalleryDelegate {
             id: delegate
 
@@ -714,7 +748,7 @@ Kirigami.ScrollablePage {
             thumbnailPriority: gridView.calculateThumbnailPriority(delegate)
 
             highlighted: gridView.currentIndex == index
-            selected: selectionModel.selectedIndexes.includes(gridView.model.index(index, 0))
+            selected: selectionModel.selectedIndexes.includes(gridView.model.index(index, 0)) || (delegateDropAreaLoader.item?.containsDrag ?? false)
             selectionMode: page.selectionMode
 
             TapHandler {
@@ -744,6 +778,19 @@ Kirigami.ScrollablePage {
                                                   : delegate.showMenu()
             }
 
+            Loader {
+                id: delegateDropAreaLoader
+                anchors.fill: delegate
+
+                active: page.isFolderView && delegate.itemType === Koko.AbstractGalleryModel.Folder
+
+                sourceComponent: Koko.FileDropArea {
+                    anchors.fill: parent
+                    window: Window.window
+                    rootUrl: delegate.url
+                }
+            }
+
             // Keep unselected items out of drag image with multiple selection.
             visible: dragHandler.draggingMultipleSelection && !dragHandler.hasDragImage && !selected ? 0 : 1
             // keep background hidden when generating the drag image for a single unselected item
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.