From 851b78f0ffa481ffe52fb16397e69f6cf8ecb7e0 Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Thu, 12 Mar 2026 10:04:27 +0100 Subject: [PATCH] kek test --- Helpers/DesktopUtils.qml | 201 +++++++++++++ Helpers/FileUtils.qml | 28 ++ .../DesktopIcons/BackgroundContextMenu.qml | 150 ++++++++++ .../DesktopIcons/DesktopIconContextMenu.qml | 193 +++++++++++++ Modules/DesktopIcons/DesktopIconDelegate.qml | 273 ++++++++++++++++++ Modules/DesktopIcons/DesktopIcons.qml | 162 +++++++++++ Modules/MediaWidget.qml | 2 +- Modules/Resource.qml | 80 ++--- Modules/UpdatesWidget.qml | 2 +- Modules/Wallpaper/Wallpaper.qml | 4 + Paths/Paths.qml | 1 + Plugins/ZShell/CMakeLists.txt | 1 + Plugins/ZShell/Services/CMakeLists.txt | 4 + Plugins/ZShell/Services/desktopmodel.cpp | 186 ++++++++++++ Plugins/ZShell/Services/desktopmodel.hpp | 46 +++ .../ZShell/Services/desktopstatemanager.cpp | 54 ++++ .../ZShell/Services/desktopstatemanager.hpp | 24 ++ 17 files changed, 1347 insertions(+), 64 deletions(-) create mode 100644 Helpers/DesktopUtils.qml create mode 100644 Helpers/FileUtils.qml create mode 100644 Modules/DesktopIcons/BackgroundContextMenu.qml create mode 100644 Modules/DesktopIcons/DesktopIconContextMenu.qml create mode 100644 Modules/DesktopIcons/DesktopIconDelegate.qml create mode 100644 Modules/DesktopIcons/DesktopIcons.qml create mode 100644 Plugins/ZShell/Services/desktopmodel.cpp create mode 100644 Plugins/ZShell/Services/desktopmodel.hpp create mode 100644 Plugins/ZShell/Services/desktopstatemanager.cpp create mode 100644 Plugins/ZShell/Services/desktopstatemanager.hpp diff --git a/Helpers/DesktopUtils.qml b/Helpers/DesktopUtils.qml new file mode 100644 index 0000000..657eb73 --- /dev/null +++ b/Helpers/DesktopUtils.qml @@ -0,0 +1,201 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + function getAppId(fileName) { + return fileName.endsWith(".desktop") ? fileName.replace(".desktop", "") : null; + } + + function getFileType(fileName, isDir) { + if (isDir) + return "directory"; + let ext = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : ""; + if (ext === "desktop") + return "desktop"; + + const map = { + "image": ["png", "jpg", "jpeg", "svg", "gif", "bmp", "webp", "ico", "tiff", "tif", "heic", "heif", "raw", "psd", "ai", "xcf"], + "video": ["mp4", "mkv", "webm", "avi", "mov", "flv", "wmv", "m4v", "mpg", "mpeg", "3gp", "vob", "ogv", "ts"], + "audio": ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "alac", "mid", "midi", "amr"], + "archive": ["zip", "tar", "gz", "rar", "7z", "xz", "bz2", "tgz", "iso", "img", "dmg", "deb", "rpm", "apk"], + "document": ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "rtf", "epub", "mobi", "djvu"], + "text": ["txt", "md", "rst", "tex", "log", "json", "xml", "yaml", "yml", "toml", "ini", "conf", "cfg", "env", "csv", "tsv"], + "code": ["qml", "cpp", "c", "h", "hpp", "py", "js", "ts", "jsx", "tsx", "java", "rs", "go", "rb", "php", "cs", "swift", "kt", "sh", "bash", "zsh", "fish", "html", "htm", "css", "scss", "sass", "less", "vue", "svelte", "sql", "graphql", "lua", "pl", "dart", "r", "dockerfile", "make"], + "executable": ["exe", "msi", "bat", "cmd", "appimage", "run", "bin", "out", "so", "dll"], + "font": ["ttf", "otf", "woff", "woff2"] + }; + + for (const [type, extensions] of Object.entries(map)) { + if (extensions.includes(ext)) + return type; + } + return "unknown"; + } + + function getIconName(fileName, isDir) { + if (isDir) + return "folder"; + let ext = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : ""; + + const map = { + // Images + "png": "image-x-generic", + "jpg": "image-x-generic", + "jpeg": "image-x-generic", + "svg": "image-svg+xml", + "gif": "image-x-generic", + "bmp": "image-x-generic", + "webp": "image-x-generic", + "ico": "image-x-generic", + "tiff": "image-x-generic", + "tif": "image-x-generic", + "heic": "image-x-generic", + "heif": "image-x-generic", + "raw": "image-x-generic", + "psd": "image-vnd.adobe.photoshop", + "ai": "application-illustrator", + "xcf": "image-x-xcf", + + // Vidéos + "mp4": "video-x-generic", + "mkv": "video-x-generic", + "webm": "video-x-generic", + "avi": "video-x-generic", + "mov": "video-x-generic", + "flv": "video-x-generic", + "wmv": "video-x-generic", + "m4v": "video-x-generic", + "mpg": "video-x-generic", + "mpeg": "video-x-generic", + "3gp": "video-x-generic", + "vob": "video-x-generic", + "ogv": "video-x-generic", + "ts": "video-x-generic", + + // Audio + "mp3": "audio-x-generic", + "wav": "audio-x-generic", + "flac": "audio-x-generic", + "aac": "audio-x-generic", + "ogg": "audio-x-generic", + "m4a": "audio-x-generic", + "wma": "audio-x-generic", + "opus": "audio-x-generic", + "alac": "audio-x-generic", + "mid": "audio-midi", + "midi": "audio-midi", + "amr": "audio-x-generic", + + // Archives & Images + "zip": "application-zip", + "tar": "application-x-tar", + "gz": "application-gzip", + "rar": "application-vnd.rar", + "7z": "application-x-7z-compressed", + "xz": "application-x-xz", + "bz2": "application-x-bzip2", + "tgz": "application-x-compressed-tar", + "iso": "application-x-cd-image", + "img": "application-x-cd-image", + "dmg": "application-x-apple-diskimage", + "deb": "application-vnd.debian.binary-package", + "rpm": "application-x-rpm", + "apk": "application-vnd.android.package-archive", + + // Documents + "pdf": "application-pdf", + "doc": "application-msword", + "docx": "application-vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls": "application-vnd.ms-excel", + "xlsx": "application-vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt": "application-vnd.ms-powerpoint", + "pptx": "application-vnd.openxmlformats-officedocument.presentationml.presentation", + "odt": "application-vnd.oasis.opendocument.text", + "ods": "application-vnd.oasis.opendocument.spreadsheet", + "odp": "application-vnd.oasis.opendocument.presentation", + "rtf": "application-rtf", + "epub": "application-epub+zip", + "mobi": "application-x-mobipocket-ebook", + "djvu": "image-vnd.djvu", + "csv": "text-csv", + "tsv": "text-tab-separated-values", + + // Data & Config + "txt": "text-x-generic", + "md": "text-markdown", + "rst": "text-x-rst", + "tex": "text-x-tex", + "log": "text-x-log", + "json": "application-json", + "xml": "text-xml", + "yaml": "text-x-yaml", + "yml": "text-x-yaml", + "toml": "text-x-toml", + "ini": "text-x-generic", + "conf": "text-x-generic", + "cfg": "text-x-generic", + "env": "text-x-generic", + + // Code + "qml": "text-x-qml", + "cpp": "text-x-c++src", + "c": "text-x-csrc", + "h": "text-x-chdr", + "hpp": "text-x-c++hdr", + "py": "text-x-python", + "js": "text-x-javascript", + "ts": "text-x-typescript", + "jsx": "text-x-javascript", + "tsx": "text-x-typescript", + "java": "text-x-java", + "rs": "text-x-rust", + "go": "text-x-go", + "rb": "text-x-ruby", + "php": "application-x-php", + "cs": "text-x-csharp", + "swift": "text-x-swift", + "kt": "text-x-kotlin", + "sh": "application-x-shellscript", + "bash": "application-x-shellscript", + "zsh": "application-x-shellscript", + "fish": "application-x-shellscript", + "html": "text-html", + "htm": "text-html", + "css": "text-css", + "scss": "text-x-scss", + "sass": "text-x-sass", + "less": "text-x-less", + "vue": "text-html", + "svelte": "text-html", + "sql": "application-x-sql", + "graphql": "text-x-generic", + "lua": "text-x-lua", + "pl": "text-x-perl", + "dart": "text-x-dart", + "r": "text-x-r", + "dockerfile": "text-x-generic", + "make": "text-x-makefile", + + // Executables + "exe": "application-x-executable", + "msi": "application-x-msi", + "bat": "application-x-ms-dos-executable", + "cmd": "application-x-ms-dos-executable", + "appimage": "application-x-executable", + "run": "application-x-executable", + "bin": "application-x-executable", + "out": "application-x-executable", + "so": "application-x-sharedlib", + "dll": "application-x-sharedlib", + + // Fonts + "ttf": "font-x-generic", + "otf": "font-x-generic", + "woff": "font-x-generic", + "woff2": "font-x-generic" + }; + return map[ext] || "text-x-generic"; + } +} diff --git a/Helpers/FileUtils.qml b/Helpers/FileUtils.qml new file mode 100644 index 0000000..9690a6c --- /dev/null +++ b/Helpers/FileUtils.qml @@ -0,0 +1,28 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + function fileNameForPath(str) { + if (typeof str !== "string") + return ""; + const trimmed = trimFileProtocol(str); + return trimmed.split(/[\\/]/).pop(); + } + + function trimFileExt(str) { + if (typeof str !== "string") + return ""; + const trimmed = trimFileProtocol(str); + const lastDot = trimmed.lastIndexOf("."); + if (lastDot > -1 && lastDot > trimmed.lastIndexOf("/")) { + return trimmed.slice(0, lastDot); + } + return trimmed; + } + + function trimFileProtocol(str) { + return str.startsWith("file://") ? str.slice(7) : str; + } +} diff --git a/Modules/DesktopIcons/BackgroundContextMenu.qml b/Modules/DesktopIcons/BackgroundContextMenu.qml new file mode 100644 index 0000000..0b96270 --- /dev/null +++ b/Modules/DesktopIcons/BackgroundContextMenu.qml @@ -0,0 +1,150 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Hyprland +import qs.Components +import qs.Config +import qs.Paths + +Item { + id: root + + anchors.fill: parent + z: 998 + visible: false + + property real menuX: 0 + property real menuY: 0 + + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + + CustomClippingRect { + id: popupBackground + readonly property real padding: 4 + + x: root.menuX + y: root.menuY + + color: DynamicColors.tPalette.m3surface + radius: Appearance.rounding.normal + + implicitWidth: menuLayout.implicitWidth + padding * 2 + implicitHeight: menuLayout.implicitHeight + padding * 2 + + Behavior on opacity { Anim {} } + opacity: root.visible ? 1 : 0 + + ColumnLayout { + id: menuLayout + anchors.fill: parent + anchors.margins: popupBackground.padding + spacing: 0 + + StateLayer { + Layout.fillWidth: true + + contentItem: RowLayout { + spacing: 8 + anchors.fill: parent + anchors.margins: 12 + MaterialIcon { text: "terminal"; font.pointSize: 20 } + CustomText { text: "Open terminal"; Layout.fillWidth: true } + } + + onClicked: { + Quickshell.execDetached([Config.general.apps.terminal, "--working-directory", FileUtils.trimFileProtocol(Paths.desktop)]) + root.close() + } + } + + CustomRect { + Layout.fillWidth: true + implicitHeight: 1 + color: DynamicColors.palette.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } + + StateLayer { + Layout.fillWidth: true + + contentItem: RowLayout { + spacing: 8 + anchors.fill: parent + anchors.margins: 12 + MaterialIcon { text: "settings"; font.pointSize: 20 } + CustomText { text: "Sleex settings"; Layout.fillWidth: true } + } + + onClicked: { + Quickshell.execDetached(["qs", "-p", "/usr/share/sleex/settings.qml"]) + root.close() + } + } + + CustomRect { + Layout.fillWidth: true + implicitHeight: 1 + color: Appearance.m3colors.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } + + StateLayer { + Layout.fillWidth: true + + contentItem: RowLayout { + spacing: 8 + anchors.fill: parent + anchors.margins: 12 + MaterialIcon { text: "logout"; font.pointSize: 20 } + CustomText { text: "Logout"; Layout.fillWidth: true } + } + + onClicked: { + Hyprland.dispatch("global quickshell:sessionOpen") + root.close() + } + } + + CustomRect { + Layout.fillWidth: true + implicitHeight: 1 + color: Appearance.m3colors.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } + + StateLayer { + Layout.fillWidth: true + + contentItem: RowLayout { + spacing: 8 + anchors.fill: parent + anchors.margins: 12 + MaterialIcon { text: Config.options.background.showDesktopIcons ? "visibility_off" : "visibility"; font.pointSize: 20 } + CustomText { text: Config.options.background.showDesktopIcons ? "Hide icons" : "Show icons"; Layout.fillWidth: true } + } + + onClicked: { + Config.options.background.showDesktopIcons = !Config.options.background.showDesktopIcons + root.close() + } + } + } + } + + function openAt(mouseX, mouseY, parentW, parentH) { + menuX = Math.min(mouseX, parentW - popupBackground.implicitWidth) + menuY = Math.min(mouseY, parentH - popupBackground.implicitHeight) + visible = true + } + + function close() { + visible = false + } +} diff --git a/Modules/DesktopIcons/DesktopIconContextMenu.qml b/Modules/DesktopIcons/DesktopIconContextMenu.qml new file mode 100644 index 0000000..238246f --- /dev/null +++ b/Modules/DesktopIcons/DesktopIconContextMenu.qml @@ -0,0 +1,193 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import qs.Components +import qs.Config + +Item { + id: contextMenu + + anchors.fill: parent + z: 999 + visible: false + + property string targetFilePath: "" + property bool targetIsDir: false + property var targetAppEntry: null + + property var targetPaths: [] + + signal openFileRequested(string path, bool isDir) + signal renameRequested(string path) + + property real menuX: 0 + property real menuY: 0 + + MouseArea { + anchors.fill: parent + onClicked: contextMenu.close() + } + + CustomClippingRect { + id: popupBackground + readonly property real padding: 4 + + x: contextMenu.menuX + y: contextMenu.menuY + + color: DynamicColors.tPalette.m3surface + radius: Appearance.rounding.normal + + implicitWidth: menuLayout.implicitWidth + padding * 2 + implicitHeight: menuLayout.implicitHeight + padding * 2 + + Behavior on opacity { Anim {} } + opacity: contextMenu.visible ? 1 : 0 + + ColumnLayout { + id: menuLayout + anchors.fill: parent + anchors.margins: popupBackground.padding + spacing: 0 + + StateLayer { + Layout.fillWidth: true + + contentItem: RowLayout { + spacing: 8 + anchors.fill: parent + anchors.margins: 12 + MaterialIcon { text: "open_in_new"; font.pointSize: 20 } + CustomText { text: "Open"; Layout.fillWidth: true } + } + + onClicked: { + for (let i = 0; i < contextMenu.targetPaths.length; i++) { + let p = contextMenu.targetPaths[i]; + if (p === contextMenu.targetFilePath) { + if (p.endsWith(".desktop") && contextMenu.targetAppEntry) contextMenu.targetAppEntry.execute() + else contextMenu.openFileRequested(p, contextMenu.targetIsDir) + } else { + Quickshell.execDetached(["xdg-open", p]) + } + } + contextMenu.close() + } + } + + StateLayer { + Layout.fillWidth: true + + contentItem: RowLayout { + spacing: 8 + anchors.fill: parent + anchors.margins: 12 + MaterialIcon { text: contextMenu.targetIsDir ? "terminal" : "apps"; font.pointSize: 20 } + CustomText { text: contextMenu.targetIsDir ? "Open in terminal" : "Open with..."; Layout.fillWidth: true } + } + + onClicked: { + Quickshell.execDetached(["xdg-open", contextMenu.targetFilePath]) + contextMenu.close() + } + } + + CustomRect { + Layout.fillWidth: true + implicitHeight: 1 + color: DynamicColors.palette.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } + + StateLayer { + Layout.fillWidth: true + + contentItem: RowLayout { + spacing: 8 + anchors.fill: parent + anchors.margins: 12 + MaterialIcon { text: "content_copy"; font.pointSize: 20 } + CustomText { text: "Copy path"; Layout.fillWidth: true } + } + + onClicked: { + Quickshell.execDetached(["wl-copy", contextMenu.targetPaths.join("\n")]) + contextMenu.close() + } + } + + StateLayer { + Layout.fillWidth: true + visible: contextMenu.targetPaths.length === 1 + + contentItem: RowLayout { + spacing: 8 + anchors.fill: parent + anchors.margins: 12 + MaterialIcon { text: "edit"; font.pointSize: 20 } + CustomText { text: "Rename"; Layout.fillWidth: true } + } + + onClicked: { + contextMenu.renameRequested(contextMenu.targetFilePath) + contextMenu.close() + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: Appearance.m3colors.m3outlineVariant + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } + + StateLayer { + id: deleteButton + Layout.fillWidth: true + colBackgroundHover: Appearance.colors.colError + + contentItem: RowLayout { + spacing: 8 + anchors.fill: parent + anchors.margins: 12 + MaterialIcon { + text: "delete"; + font.pointSize: 20; + color: deleteButton.hovered ? Appearance.colors.colOnError : Appearance.colors.colError + } + CustomText { + text: "Move to trash"; + Layout.fillWidth: true; + color: deleteButton.hovered ? Appearance.colors.colOnError : Appearance.colors.colError + } + } + + onClicked: { + let cmd = ["gio", "trash"].concat(contextMenu.targetPaths) + Quickshell.execDetached(cmd) + contextMenu.close() + } + } + } + } + + function openAt(mouseX, mouseY, path, isDir, appEnt, parentW, parentH, selectionArray) { + targetFilePath = path + targetIsDir = isDir + targetAppEntry = appEnt + + targetPaths = (selectionArray && selectionArray.length > 0) ? selectionArray : [path] + + menuX = Math.min(mouseX, parentW - popupBackground.implicitWidth) + menuY = Math.min(mouseY, parentH - popupBackground.implicitHeight) + + visible = true + } + + function close() { + visible = false + } +} diff --git a/Modules/DesktopIcons/DesktopIconDelegate.qml b/Modules/DesktopIcons/DesktopIconDelegate.qml new file mode 100644 index 0000000..2ec681c --- /dev/null +++ b/Modules/DesktopIcons/DesktopIconDelegate.qml @@ -0,0 +1,273 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import qs.Config +import qs.Components +import qs.Helpers + +Item { + id: delegateRoot + + property var appEntry: fileName.endsWith(".desktop") ? DesktopEntries.byId(DesktopUtils.getAppId(fileName)) : null + property bool fileIsDir: model.isDir + property string fileName: model.fileName + property string filePath: model.filePath + property int gridX: model.gridX + property int gridY: model.gridY + property bool isSnapping: snapAnimX.running || snapAnimY.running + property string resolvedIcon: { + if (fileName.endsWith(".desktop")) { + if (appEntry && appEntry.icon && appEntry.icon !== "") + return appEntry.icon; + return AppSearch.guessIcon(DesktopUtils.getAppId(fileName)); + } else if (DesktopUtils.getFileType(fileName, fileIsDir) === "image") { + return "file://" + filePath; + } else { + return DesktopUtils.getIconName(fileName, fileIsDir); + } + } + + function compensateAndSnap(absVisX, absVisY) { + dragContainer.x = absVisX - delegateRoot.x; + dragContainer.y = absVisY - delegateRoot.y; + snapAnimX.start(); + snapAnimY.start(); + } + + function getDragX() { + return dragContainer.x; + } + + function getDragY() { + return dragContainer.y; + } + + height: root.cellHeight + width: root.cellWidth + x: gridX * root.cellWidth + y: gridY * root.cellHeight + + Behavior on x { + enabled: !mouseArea.drag.active && !isSnapping && !root.selectedIcons.includes(filePath) + + Anim { + } + } + Behavior on y { + enabled: !mouseArea.drag.active && !isSnapping && !root.selectedIcons.includes(filePath) + + Anim { + } + } + + Item { + id: dragContainer + + height: parent.height + width: parent.width + + states: State { + when: mouseArea.drag.active + + PropertyChanges { + opacity: 0.8 + scale: 1.1 + target: dragContainer + z: 100 + } + } + transform: Translate { + x: (root.selectedIcons.includes(filePath) && root.dragLeader !== "" && root.dragLeader !== filePath) ? root.groupDragX : 0 + y: (root.selectedIcons.includes(filePath) && root.dragLeader !== "" && root.dragLeader !== filePath) ? root.groupDragY : 0 + } + transitions: Transition { + Anim { + } + } + + onXChanged: { + if (mouseArea.drag.active) { + root.dragLeader = filePath; + root.groupDragX = x; + } + } + onYChanged: { + if (mouseArea.drag.active) { + root.dragLeader = filePath; + root.groupDragY = y; + } + } + + PropertyAnimation { + id: snapAnimX + + duration: 250 + easing.type: Easing.OutCubic + property: "x" + target: dragContainer + to: 0 + } + + PropertyAnimation { + id: snapAnimY + + duration: 250 + easing.type: Easing.OutCubic + property: "y" + target: dragContainer + to: 0 + } + + Column { + anchors.centerIn: parent + spacing: 6 + + IconImage { + anchors.horizontalCenter: parent.horizontalCenter + implicitSize: 48 + source: { + if (delegateRoot.resolvedIcon.startsWith("file://") || delegateRoot.resolvedIcon.startsWith("/")) { + return delegateRoot.resolvedIcon; + } else { + return Quickshell.iconPath(delegateRoot.resolvedIcon, fileIsDir ? "folder" : "text-x-generic"); + } + } + } + + Item { + height: 40 + width: 88 + + CustomText { + anchors.fill: parent + color: "white" + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + maximumLineCount: 2 + style: Text.Outline + styleColor: "black" + text: (appEntry && appEntry.name !== "") ? appEntry.name : fileName + visible: !renameLoader.active + wrapMode: Text.Wrap + } + + Loader { + id: renameLoader + + active: root.editingFilePath === filePath + anchors.centerIn: parent + height: 24 + width: 110 + + sourceComponent: CustomTextInput { + anchors.fill: parent + anchors.margins: 2 + color: "white" + horizontalAlignment: Text.AlignHCenter + text: fileName + wrapMode: Text.Wrap + + Component.onCompleted: { + forceActiveFocus(); + selectAll(); + } + Keys.onPressed: function (event) { + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (text.trim() !== "" && text !== fileName) { + let newName = text.trim(); + let newPath = filePath.substring(0, filePath.lastIndexOf('/') + 1) + newName; + + Quickshell.execDetached(["mv", filePath, newPath]); + } + root.editingFilePath = ""; + event.accepted = true; + } else if (event.key === Qt.Key_Escape) { + root.editingFilePath = ""; + event.accepted = true; + } + } + onActiveFocusChanged: { + if (!activeFocus && root.editingFilePath === filePath) { + root.editingFilePath = ""; + } + } + } + } + } + } + + CustomRect { + anchors.fill: parent + anchors.margins: 4 + color: "white" + opacity: root.selectedIcons.includes(filePath) ? 0.2 : 0.0 + radius: 8 + + Behavior on opacity { + Anim { + } + } + } + + MouseArea { + id: mouseArea + + acceptedButtons: Qt.LeftButton | Qt.RightButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + drag.target: dragContainer + hoverEnabled: true + + onClicked: mouse => { + root.forceActiveFocus(); + + if (mouse.button === Qt.RightButton) { + if (!root.selectedIcons.includes(filePath)) { + root.selectedIcons = [filePath]; + } + let pos = mapToItem(root, mouse.x, mouse.y); + root.contextMenu.openAt(pos.x, pos.y, filePath, fileIsDir, appEntry, root.width, root.height, root.selectedIcons); + } else { + root.selectedIcons = [filePath]; + root.contextMenu.close(); + } + } + onDoubleClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + if (filePath.endsWith(".desktop") && appEntry) + appEntry.execute(); + else + root.exec(filePath, fileIsDir); + } + } + onPressed: mouse => { + if (mouse.button === Qt.LeftButton && !root.selectedIcons.includes(filePath)) { + root.selectedIcons = [filePath]; + } + } + onReleased: { + if (drag.active) { + let absoluteX = delegateRoot.x + dragContainer.x; + let absoluteY = delegateRoot.y + dragContainer.y; + let snapX = Math.max(0, Math.round(absoluteX / root.cellWidth)); + let snapY = Math.max(0, Math.round(absoluteY / root.cellHeight)); + + root.performMassDrop(filePath, snapX, snapY); + } + } + + CustomRect { + anchors.fill: parent + anchors.margins: 4 + color: "white" + opacity: parent.containsMouse ? 0.1 : 0.0 + radius: 8 + + Behavior on opacity { + Anim { + } + } + } + } + } +} diff --git a/Modules/DesktopIcons/DesktopIcons.qml b/Modules/DesktopIcons/DesktopIcons.qml new file mode 100644 index 0000000..03774b9 --- /dev/null +++ b/Modules/DesktopIcons/DesktopIcons.qml @@ -0,0 +1,162 @@ +import QtQuick +import Quickshell +import qs.Modules +import qs.Helpers +import qs.Paths +import ZShell.Services + +Item { + id: root + + property int cellHeight: 110 + property int cellWidth: 100 + property var contextMenu: desktopMenu + property string dragLeader: "" + property string editingFilePath: "" + property real groupDragX: 0 + property real groupDragY: 0 + property var selectedIcons: [] + property real startX: 0 + property real startY: 0 + + function exec(filePath, isDir) { + const cmd = ["xdg-open", filePath]; + Quickshell.execDetached(cmd); + } + + function performMassDrop(leaderPath, targetX, targetY) { + let maxCol = Math.max(0, Math.floor(gridArea.width / cellWidth) - 1); + let maxRow = Math.max(0, Math.floor(gridArea.height / cellHeight) - 1); + + let visuals = []; + for (let i = 0; i < gridArea.children.length; i++) { + let child = gridArea.children[i]; + if (child.filePath && root.selectedIcons.includes(child.filePath)) { + let isLeader = (root.dragLeader === child.filePath); + let offsetX = isLeader ? child.getDragX() : root.groupDragX; + let offsetY = isLeader ? child.getDragY() : root.groupDragY; + visuals.push({ + childRef: child, + absX: child.x + offsetX, + absY: child.y + offsetY + }); + } + } + + desktopModel.massMove(root.selectedIcons, leaderPath, targetX, targetY, maxCol, maxRow); + + for (let i = 0; i < visuals.length; i++) { + visuals[i].childRef.compensateAndSnap(visuals[i].absX, visuals[i].absY); + } + + root.dragLeader = ""; + root.groupDragX = 0; + root.groupDragY = 0; + } + + anchors.fill: parent + focus: true + + Keys.onPressed: event => { + if (event.key === Qt.Key_F2 && selectedIcons.length > 0) + editingFilePath = selectedIcons[0]; + } + + DesktopModel { + id: desktopModel + + Component.onCompleted: loadDirectory(FileUtils.trimFileProtocol(Paths.desktop)) + } + + Rectangle { + id: lasso + + border.color: Appearance.colors.colPrimary + border.width: 1 + color: DynamicColors.tPalette.m3primary + radius: Appearance.rounding.small + visible: false + z: 99 + } + + MouseArea { + acceptedButtons: Qt.LeftButton | Qt.RightButton + anchors.fill: parent + + onPositionChanged: mouse => { + if (lasso.visible) { + lasso.x = Math.min(mouse.x, root.startX); + lasso.y = Math.min(mouse.y, root.startY); + lasso.width = Math.abs(mouse.x - root.startX); + lasso.height = Math.abs(mouse.y - root.startY); + + let minCol = Math.floor((lasso.x - gridArea.x) / cellWidth); + let maxCol = Math.floor((lasso.x + lasso.width - gridArea.x) / cellWidth); + let minRow = Math.floor((lasso.y - gridArea.y) / cellHeight); + let maxRow = Math.floor((lasso.y + lasso.height - gridArea.y) / cellHeight); + + let newSelection = []; + for (let i = 0; i < gridArea.children.length; i++) { + let child = gridArea.children[i]; + if (child.filePath !== undefined && child.gridX >= minCol && child.gridX <= maxCol && child.gridY >= minRow && child.gridY <= maxRow) { + newSelection.push(child.filePath); + } + } + root.selectedIcons = newSelection; + } + } + onPressed: mouse => { + root.editingFilePath = ""; + desktopMenu.close(); + + if (mouse.button === Qt.RightButton) { + root.selectedIcons = []; + bgContextMenu.openAt(mouse.x, mouse.y, root.width, root.height); + } else { + bgContextMenu.close(); + root.selectedIcons = []; + root.startX = mouse.x; + root.startY = mouse.y; + lasso.x = mouse.x; + lasso.y = mouse.y; + lasso.width = 0; + lasso.height = 0; + lasso.visible = true; + } + } + onReleased: { + lasso.visible = false; + } + } + + Item { + id: gridArea + + anchors.fill: parent + anchors.margins: 20 + anchors.topMargin: 40 + visible: true + + Repeater { + model: desktopModel + + delegate: DesktopIconDelegate { + property int itemIndex: index + } + } + } + + DesktopIconContextMenu { + id: desktopMenu + + onOpenFileRequested: (path, isDir) => root.exec(path, isDir) + onRenameRequested: path => { + root.editingFilePath = path; + } + } + + BackgroundContextMenu { + id: bgContextMenu + + } +} diff --git a/Modules/MediaWidget.qml b/Modules/MediaWidget.qml index 6979b43..1b7c821 100644 --- a/Modules/MediaWidget.qml +++ b/Modules/MediaWidget.qml @@ -41,7 +41,7 @@ CustomRect { MaterialIcon { animate: true color: Players.active?.isPlaying ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface - font.pointSize: Appearance.font.size.normal + font.pointSize: Appearance.font.size.larger text: Players.active?.isPlaying ? "music_note" : "music_off" } diff --git a/Modules/Resource.qml b/Modules/Resource.qml index 89b16a4..43c4561 100644 --- a/Modules/Resource.qml +++ b/Modules/Resource.qml @@ -19,91 +19,47 @@ RowLayout { property bool warning: percentage * 100 >= warningThreshold property int warningThreshold: 80 - clip: true percentage: 0 Behavior on animatedPercentage { Anim { - duration: Appearance.anim.durations.large } } Component.onCompleted: animatedPercentage = percentage - onPercentageChanged: animatedPercentage = percentage + onPercentageChanged: { + const next = percentage; - // Canvas { - // id: gaugeCanvas - // - // anchors.centerIn: parent - // height: width - // width: Math.min(parent.width, parent.height) - // - // Component.onCompleted: requestPaint() - // onPaint: { - // const ctx = getContext("2d"); - // ctx.reset(); - // const cx = width / 2; - // const cy = (height / 2) + 1; - // const radius = (Math.min(width, height) - 12) / 2; - // const lineWidth = 3; - // ctx.beginPath(); - // ctx.arc(cx, cy, radius, root.arcStartAngle, root.arcStartAngle + root.arcSweep); - // ctx.lineWidth = lineWidth; - // ctx.lineCap = "round"; - // ctx.strokeStyle = DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2); - // ctx.stroke(); - // if (root.animatedPercentage > 0) { - // ctx.beginPath(); - // ctx.arc(cx, cy, radius, root.arcStartAngle, root.arcStartAngle + root.arcSweep * root.animatedPercentage); - // ctx.lineWidth = lineWidth; - // ctx.lineCap = "round"; - // ctx.strokeStyle = root.accentColor; - // ctx.stroke(); - // } - // } - // - // Connections { - // function onAnimatedPercentageChanged() { - // gaugeCanvas.requestPaint(); - // } - // - // target: root - // } - // - // Connections { - // function onPaletteChanged() { - // gaugeCanvas.requestPaint(); - // } - // - // target: DynamicColors - // } - // } + if (Math.abs(next - animatedPercentage) >= 0.05) + animatedPercentage = next; + } MaterialIcon { id: icon color: DynamicColors.palette.m3onSurface - font.pointSize: 12 + font.pointSize: Appearance.font.size.larger text: root.icon } CustomClippingRect { - Layout.preferredHeight: root.height - Layout.preferredWidth: 5 + Layout.preferredHeight: root.height - Appearance.padding.small + Layout.preferredWidth: 4 color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) radius: Appearance.rounding.full CustomRect { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - color: root.mainColor - implicitHeight: root.percentage * parent.height - radius: implicitHeight / 2 + id: fill - Behavior on implicitHeight { - Anim { - } + anchors.fill: parent + antialiasing: false + color: root.mainColor + implicitHeight: Math.ceil(root.percentage * parent.height) + radius: Appearance.rounding.full + + transform: Scale { + origin.y: fill.height + yScale: Math.max(0.001, root.animatedPercentage) } } } diff --git a/Modules/UpdatesWidget.qml b/Modules/UpdatesWidget.qml index e6166b7..36cdc67 100644 --- a/Modules/UpdatesWidget.qml +++ b/Modules/UpdatesWidget.qml @@ -22,7 +22,7 @@ CustomRect { spacing: Appearance.spacing.small MaterialIcon { - font.pointSize: Appearance.font.size.normal + font.pointSize: Appearance.font.size.larger text: "package_2" } diff --git a/Modules/Wallpaper/Wallpaper.qml b/Modules/Wallpaper/Wallpaper.qml index fa77580..06f940d 100644 --- a/Modules/Wallpaper/Wallpaper.qml +++ b/Modules/Wallpaper/Wallpaper.qml @@ -2,6 +2,7 @@ import Quickshell import QtQuick import Quickshell.Wayland import qs.Config +import qs.Modules.DesktopIcons Loader { active: Config.background.enabled @@ -30,6 +31,9 @@ Loader { WallBackground { } + + DesktopIcons { + } } } } diff --git a/Paths/Paths.qml b/Paths/Paths.qml index fe33f82..eacf7a7 100644 --- a/Paths/Paths.qml +++ b/Paths/Paths.qml @@ -10,6 +10,7 @@ Singleton { readonly property string cache: `${Quickshell.env("XDG_CACHE_HOME") || `${home}/.cache`}/zshell` readonly property string config: `${Quickshell.env("XDG_CONFIG_HOME") || `${home}/.config`}/zshell` readonly property string data: `${Quickshell.env("XDG_DATA_HOME") || `${home}/.local/share`}/zshell` + readonly property string desktop: `${Quickshell.env("XDG_DATA_HOME") || `${home}/Desktop`}` readonly property string home: Quickshell.env("HOME") readonly property string imagecache: `${cache}/imagecache` readonly property string libdir: Quickshell.env("ZSHELL_LIB_DIR") || "/usr/lib/zshell" diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 9b0b751..fda2330 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -4,6 +4,7 @@ pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET) +pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0) if(NOT Cava_FOUND) pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED) endif() diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 39ecda2..bc4274f 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -7,7 +7,11 @@ qml_module(ZShell-services audiocollector.hpp audiocollector.cpp audioprovider.hpp audioprovider.cpp cavaprovider.hpp cavaprovider.cpp + desktopmodel.hpp desktopmodel.cpp + desktopstatemanager.hpp desktopstatemanager.cpp LIBRARIES + Qt6::Core + Qt6::Qml PkgConfig::Pipewire PkgConfig::Aubio PkgConfig::Cava diff --git a/Plugins/ZShell/Services/desktopmodel.cpp b/Plugins/ZShell/Services/desktopmodel.cpp new file mode 100644 index 0000000..a75cd98 --- /dev/null +++ b/Plugins/ZShell/Services/desktopmodel.cpp @@ -0,0 +1,186 @@ +#include "desktopmodel.hpp" +#include "desktopstatemanager.hpp" +#include +#include + +namespace ZShell::services { + +DesktopModel::DesktopModel(QObject *parent) : QAbstractListModel(parent) { +} + +int DesktopModel::rowCount(const QModelIndex &parent) const { + if (parent.isValid()) return 0; + return m_items.count(); +} + +QVariant DesktopModel::data(const QModelIndex &index, int role) const { + if (!index.isValid() || index.row() >= m_items.size()) return QVariant(); + + const DesktopItem &item = m_items[index.row()]; + switch (role) { + case FileNameRole: return item.fileName; + case FilePathRole: return item.filePath; + case IsDirRole: return item.isDir; + case GridXRole: return item.gridX; + case GridYRole: return item.gridY; + default: return QVariant(); + } +} + +QHash DesktopModel::roleNames() const { + QHash roles; + roles[FileNameRole] = "fileName"; + roles[FilePathRole] = "filePath"; + roles[IsDirRole] = "isDir"; + roles[GridXRole] = "gridX"; + roles[GridYRole] = "gridY"; + return roles; +} + +void DesktopModel::loadDirectory(const QString &path) { + beginResetModel(); + m_items.clear(); + + QDir dir(path); + dir.setFilter(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + QFileInfoList list = dir.entryInfoList(); + + DesktopStateManager sm; + QVariantMap savedLayout = sm.getLayout(); + + for (const QFileInfo &fileInfo : list) { + DesktopItem item; + item.fileName = fileInfo.fileName(); + item.filePath = fileInfo.absoluteFilePath(); + item.isDir = fileInfo.isDir(); + + if (savedLayout.contains(item.fileName)) { + QVariantMap pos = savedLayout[item.fileName].toMap(); + item.gridX = pos["x"].toInt(); + item.gridY = pos["y"].toInt(); + } else { + // TODO: make getEmptySpot in C++ and call it here to get the initial position for new icons + item.gridX = 0; + item.gridY = 0; + } + m_items.append(item); + } + endResetModel(); +} + +void DesktopModel::moveIcon(int index, int newX, int newY) { + if (index < 0 || index >= m_items.size()) return; + + m_items[index].gridX = newX; + m_items[index].gridY = newY; + + QModelIndex modelIndex = createIndex(index, 0); + emit dataChanged(modelIndex, modelIndex, {GridXRole, GridYRole}); + + saveCurrentLayout(); +} + +void DesktopModel::saveCurrentLayout() { + QVariantMap layout; + for (const auto& item : m_items) { + QVariantMap pos; + pos["x"] = item.gridX; + pos["y"] = item.gridY; + layout[item.fileName] = pos; + } + + DesktopStateManager sm; + sm.saveLayout(layout); +} + +void DesktopModel::massMove(const QVariantList& selectedPathsList, const QString& leaderPath, int targetX, int targetY, int maxCol, int maxRow) { + QStringList selectedPaths; + for (const QVariant& v : selectedPathsList) { + selectedPaths << v.toString(); + } + + if (selectedPaths.isEmpty()) return; + + int oldX = 0, oldY = 0; + for (const auto& item : m_items) { + if (item.filePath == leaderPath) { + oldX = item.gridX; + oldY = item.gridY; + break; + } + } + + int deltaX = targetX - oldX; + int deltaY = targetY - oldY; + + if (deltaX == 0 && deltaY == 0) return; + + if (selectedPaths.size() == 1 && targetX >= 0 && targetX <= maxCol && targetY >= 0 && targetY <= maxRow) { + QString movingPath = selectedPaths.first(); + int movingIndex = -1; + int targetIndex = -1; + + for (int i = 0; i < m_items.size(); ++i) { + if (m_items[i].filePath == movingPath) { + movingIndex = i; + } else if (m_items[i].gridX == targetX && m_items[i].gridY == targetY) { + targetIndex = i; + } + } + + if (targetIndex != -1 && movingIndex != -1) { + m_items[targetIndex].gridX = oldX; + m_items[targetIndex].gridY = oldY; + m_items[movingIndex].gridX = targetX; + m_items[movingIndex].gridY = targetY; + + emit dataChanged(index(0, 0), index(m_items.size() - 1, 0), {GridXRole, GridYRole}); + saveCurrentLayout(); + return; + } + } + + QList movingItems; + QSet occupied; + + for (int i = 0; i < m_items.size(); ++i) { + if (selectedPaths.contains(m_items[i].filePath)) { + movingItems.append(&m_items[i]); + } else { + occupied.insert(QString::number(m_items[i].gridX) + "," + QString::number(m_items[i].gridY)); + } + } + + for (auto* item : movingItems) { + int newX = item->gridX + deltaX; + int newY = item->gridY + deltaY; + + bool outOfBounds = newX < 0 || newX > maxCol || newY < 0 || newY > maxRow; + bool collision = occupied.contains(QString::number(newX) + "," + QString::number(newY)); + + if (outOfBounds || collision) { + bool found = false; + for (int x = 0; x <= maxCol && !found; ++x) { + for (int y = 0; y <= maxRow && !found; ++y) { + QString key = QString::number(x) + "," + QString::number(y); + if (!occupied.contains(key)) { + newX = x; + newY = y; + occupied.insert(key); + found = true; + } + } + } + } else { + occupied.insert(QString::number(newX) + "," + QString::number(newY)); + } + + item->gridX = newX; + item->gridY = newY; + } + + emit dataChanged(index(0, 0), index(m_items.size() - 1, 0), {GridXRole, GridYRole}); + saveCurrentLayout(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/desktopmodel.hpp b/Plugins/ZShell/Services/desktopmodel.hpp new file mode 100644 index 0000000..d04dcd2 --- /dev/null +++ b/Plugins/ZShell/Services/desktopmodel.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::services { + +struct DesktopItem { + QString fileName; + QString filePath; + bool isDir; + int gridX; + int gridY; +}; + +class DesktopModel : public QAbstractListModel { +Q_OBJECT +QML_ELEMENT + +public: +enum DesktopRoles { + FileNameRole = Qt::UserRole + 1, + FilePathRole, + IsDirRole, + GridXRole, + GridYRole +}; + +explicit DesktopModel(QObject *parent = nullptr); + +int rowCount(const QModelIndex &parent = QModelIndex()) const override; +QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; +QHash roleNames() const override; + +Q_INVOKABLE void loadDirectory(const QString &path); +Q_INVOKABLE void moveIcon(int index, int newX, int newY); +Q_INVOKABLE void massMove(const QVariantList &selectedPathsList, const QString &leaderPath, int targetX, int targetY, int maxCol, int maxRow); + +private: +QList m_items; +void saveCurrentLayout(); +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/desktopstatemanager.cpp b/Plugins/ZShell/Services/desktopstatemanager.cpp new file mode 100644 index 0000000..55f9c70 --- /dev/null +++ b/Plugins/ZShell/Services/desktopstatemanager.cpp @@ -0,0 +1,54 @@ +#include "desktopstatemanager.hpp" +#include +#include +#include +#include +#include +#include + +namespace ZShell::services { + +DesktopStateManager::DesktopStateManager(QObject *parent) : QObject(parent) { +} + +QString DesktopStateManager::getConfigFilePath() const { + QString configDir = QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + "/sleex"; + QDir dir(configDir); + if (!dir.exists()) { + dir.mkpath("."); + } + return configDir + "/desktop_layout.json"; +} + +void DesktopStateManager::saveLayout(const QVariantMap& layout) { + QJsonObject jsonObj = QJsonObject::fromVariantMap(layout); + QJsonDocument doc(jsonObj); + QFile file(getConfigFilePath()); + + if (file.open(QIODevice::WriteOnly)) { + file.write(doc.toJson(QJsonDocument::Indented)); + file.close(); + } else { + qWarning() << "Sleex: Impossible de sauvegarder le layout du bureau dans" << getConfigFilePath(); + } +} + +QVariantMap DesktopStateManager::getLayout() { + QFile file(getConfigFilePath()); + + if (!file.open(QIODevice::ReadOnly)) { + return QVariantMap(); + } + + QByteArray data = file.readAll(); + file.close(); + + QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isObject()) { + return doc.object().toVariantMap(); + } + + return QVariantMap(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/desktopstatemanager.hpp b/Plugins/ZShell/Services/desktopstatemanager.hpp new file mode 100644 index 0000000..004b7bb --- /dev/null +++ b/Plugins/ZShell/Services/desktopstatemanager.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::services { + +class DesktopStateManager : public QObject { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +public: +explicit DesktopStateManager(QObject *parent = nullptr); + +Q_INVOKABLE void saveLayout(const QVariantMap& layout); +Q_INVOKABLE QVariantMap getLayout(); + +private: +QString getConfigFilePath() const; +}; + +} // namespace ZShell::services