[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