diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index c65f8d42..6fe0cb52 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -545,6 +545,9 @@ Singleton { property int dockMaxVisibleApps: 0 property int dockMaxVisibleRunningApps: 0 property bool dockShowOverflowBadge: true + property bool dockShowTrash: false + property string dockTrashFileManager: "nautilus" + property string dockTrashCustomCommand: "" property bool notificationOverlayEnabled: false property bool notificationPopupShadowEnabled: true diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index b4a4ca8a..b7d81ef0 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -350,6 +350,9 @@ var SPEC = { dockMaxVisibleApps: { def: 0 }, dockMaxVisibleRunningApps: { def: 0 }, dockShowOverflowBadge: { def: true }, + dockShowTrash: { def: false }, + dockTrashFileManager: { def: "nautilus" }, + dockTrashCustomCommand: { def: "" }, notificationOverlayEnabled: { def: false }, notificationPopupShadowEnabled: { def: true }, diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index dcca3c07..8815e0af 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -4,6 +4,7 @@ import qs.Common import qs.Modals import qs.Modals.Changelog import qs.Modals.Clipboard +import qs.Modals.Common import qs.Modals.Greeter import qs.Modals.Settings import qs.Modals.DankLauncherV2 @@ -284,11 +285,15 @@ Item { sourceComponent: Dock { contextMenu: dockContextMenuLoader.item ? dockContextMenuLoader.item : null + trashContextMenu: dockTrashContextMenuLoader.item ? dockTrashContextMenuLoader.item : null } onLoaded: { if (item) { dockContextMenuLoader.active = true; + if (SettingsData.dockShowTrash) { + dockTrashContextMenuLoader.active = true; + } } } @@ -340,6 +345,43 @@ Item { } } + LazyLoader { + id: dockTrashContextMenuLoader + + active: false + + DockTrashContextMenu { + id: dockTrashContextMenu + } + } + + Connections { + target: SettingsData + function onDockShowTrashChanged() { + if (SettingsData.dockShowTrash) { + dockTrashContextMenuLoader.active = true; + } + } + } + + ConfirmModal { + id: emptyTrashConfirm + } + + Connections { + target: TrashService + function onEmptyTrashConfirmRequested(itemCount) { + emptyTrashConfirm.showWithOptions({ + title: I18n.tr("Empty Trash?"), + message: I18n.tr("Permanently delete %1 item(s)? This cannot be undone.").arg(itemCount), + confirmText: I18n.tr("Empty"), + cancelText: I18n.tr("Cancel"), + confirmColor: Theme.error, + onConfirm: () => TrashService.emptyTrash() + }); + } + } + LazyLoader { id: notificationCenterLoader diff --git a/quickshell/Modules/Dock/Dock.qml b/quickshell/Modules/Dock/Dock.qml index 136f23ad..4b20e8d7 100644 --- a/quickshell/Modules/Dock/Dock.qml +++ b/quickshell/Modules/Dock/Dock.qml @@ -13,6 +13,7 @@ Variants { model: SettingsData.getFilteredScreens("dock") property var contextMenu + property var trashContextMenu delegate: PanelWindow { id: dock @@ -120,7 +121,7 @@ Variants { return Math.round(v * _dpr) / _dpr; } - property bool contextMenuOpen: (dockVariants.contextMenu && dockVariants.contextMenu.visible && dockVariants.contextMenu.screen === modelData) + property bool contextMenuOpen: (dockVariants.contextMenu && dockVariants.contextMenu.visible && dockVariants.contextMenu.screen === modelData) || (dockVariants.trashContextMenu && dockVariants.trashContextMenu.visible && dockVariants.trashContextMenu.screen === modelData) property bool revealSticky: false readonly property bool shouldHideForWindows: { @@ -659,6 +660,7 @@ Variants { anchors.rightMargin: dock.isVertical ? SettingsData.dockSpacing : 0 contextMenu: dockVariants.contextMenu + trashContextMenu: dockVariants.trashContextMenu groupByApp: dock.groupByApp isVertical: dock.isVertical dockScreen: dock.screen diff --git a/quickshell/Modules/Dock/DockApps.qml b/quickshell/Modules/Dock/DockApps.qml index dbdc5175..eb152848 100644 --- a/quickshell/Modules/Dock/DockApps.qml +++ b/quickshell/Modules/Dock/DockApps.qml @@ -8,6 +8,7 @@ Item { id: root property var contextMenu: null + property var trashContextMenu: null property bool requestDockShow: false property int pinnedAppCount: 0 property bool groupByApp: false @@ -460,19 +461,32 @@ Item { function updateModel() { const baseResult = buildBaseItems(); - dockItems = applyOverflow(baseResult); + let finalItems = applyOverflow(baseResult); + if (SettingsData.dockShowTrash) { + finalItems.push({ + uniqueKey: "trash_button", + type: "trash", + appId: "__TRASH__", + toplevel: null, + isPinned: false, + isRunning: false, + isInOverflow: false + }); + } + dockItems = finalItems; } delegate: Item { id: delegateItem - property var dockButton: itemData.type === "launcher" ? launcherButton : button + property var dockButton: itemData.type === "launcher" ? launcherButton : (itemData.type === "trash" ? trashButton : button) property var itemData: modelData readonly property bool isOverflowToggle: itemData.type === "overflow-toggle" + readonly property bool isTrash: itemData.type === "trash" readonly property bool isInOverflow: itemData.isInOverflow === true clip: false - z: (itemData.type === "launcher" ? launcherButton.dragging : button.dragging) ? 100 : 0 + z: (itemData.type === "launcher" ? launcherButton.dragging : (itemData.type === "trash" ? false : button.dragging)) ? 100 : 0 visible: !isInOverflow || root.overflowExpanded opacity: (isInOverflow && !root.overflowExpanded) ? 0 : 1 scale: (isInOverflow && !root.overflowExpanded) ? 0.8 : 1 @@ -568,9 +582,21 @@ Item { index: model.index } + DockTrashButton { + id: trashButton + visible: itemData.type === "trash" + anchors.centerIn: parent + width: delegateItem.width + height: delegateItem.height + actualIconSize: root.iconSize + dockApps: root + contextMenu: root.trashContextMenu + parentDockScreen: root.dockScreen + } + DockAppButton { id: button - visible: !isOverflowToggle && itemData.type !== "separator" && itemData.type !== "launcher" + visible: !isOverflowToggle && itemData.type !== "separator" && itemData.type !== "launcher" && itemData.type !== "trash" anchors.centerIn: parent width: delegateItem.width height: delegateItem.height @@ -640,6 +666,9 @@ Item { function onDockMaxVisibleRunningAppsChanged() { repeater.updateModel(); } + function onDockShowTrashChanged() { + repeater.updateModel(); + } } onGroupByAppChanged: repeater.updateModel() diff --git a/quickshell/Modules/Dock/DockTrashButton.qml b/quickshell/Modules/Dock/DockTrashButton.qml new file mode 100644 index 00000000..df99f1b8 --- /dev/null +++ b/quickshell/Modules/Dock/DockTrashButton.qml @@ -0,0 +1,137 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + clip: false + + property var dockApps: null + property var contextMenu: null + property var parentDockScreen: null + property real actualIconSize: 40 + property real hoverAnimOffset: 0 + + property bool isHovered: mouseArea.containsMouse + property bool showTooltip: mouseArea.containsMouse + readonly property string tooltipText: TrashService.isEmpty ? I18n.tr("Trash") : (I18n.tr("Trash") + " (" + TrashService.count + ")") + + readonly property bool isVertical: SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right + readonly property real animationDistance: actualIconSize + readonly property real animationDirection: { + if (SettingsData.dockPosition === SettingsData.Position.Bottom) + return -1; + if (SettingsData.dockPosition === SettingsData.Position.Top) + return 1; + if (SettingsData.dockPosition === SettingsData.Position.Right) + return -1; + if (SettingsData.dockPosition === SettingsData.Position.Left) + return 1; + return -1; + } + + onIsHoveredChanged: { + if (mouseArea.pressed) + return; + if (isHovered) { + exitAnimation.stop(); + if (!bounceAnimation.running) + bounceAnimation.restart(); + } else { + bounceAnimation.stop(); + exitAnimation.restart(); + } + } + + SequentialAnimation { + id: bounceAnimation + + running: false + + NumberAnimation { + target: root + property: "hoverAnimOffset" + to: animationDirection * animationDistance * 0.25 + duration: Anims.durShort + easing.type: Easing.BezierSpline + easing.bezierCurve: Anims.emphasizedAccel + } + + NumberAnimation { + target: root + property: "hoverAnimOffset" + to: animationDirection * animationDistance * 0.2 + duration: Anims.durShort + easing.type: Easing.BezierSpline + easing.bezierCurve: Anims.emphasizedDecel + } + } + + NumberAnimation { + id: exitAnimation + + running: false + target: root + property: "hoverAnimOffset" + to: 0 + duration: Anims.durShort + easing.type: Easing.BezierSpline + easing.bezierCurve: Anims.emphasizedDecel + } + + MouseArea { + id: mouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + TrashService.openTrash(); + } else if (mouse.button === Qt.RightButton) { + if (contextMenu) { + contextMenu.showForButton(root, root.height, parentDockScreen, dockApps); + } + } + } + } + + Item { + id: visualContent + anchors.fill: parent + + transform: Translate { + x: !isVertical ? 0 : hoverAnimOffset + y: !isVertical ? hoverAnimOffset : 0 + } + + Item { + anchors.centerIn: parent + width: actualIconSize + height: actualIconSize + + IconImage { + id: trashIcon + anchors.centerIn: parent + width: actualIconSize - 4 + height: actualIconSize - 4 + smooth: true + asynchronous: true + source: Quickshell.iconPath(TrashService.isEmpty ? "user-trash" : "user-trash-full", "user-trash") + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Easing.OutCubic + } + } + } + } + } +} diff --git a/quickshell/Modules/Dock/DockTrashContextMenu.qml b/quickshell/Modules/Dock/DockTrashContextMenu.qml new file mode 100644 index 00000000..056ece36 --- /dev/null +++ b/quickshell/Modules/Dock/DockTrashContextMenu.qml @@ -0,0 +1,377 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Widgets +import qs.Common +import qs.Services +import qs.Widgets + +PanelWindow { + id: root + + WindowBlur { + targetWindow: root + blurX: menuContainer.x + blurY: menuContainer.y + blurWidth: root.visible ? menuContainer.width : 0 + blurHeight: root.visible ? menuContainer.height : 0 + blurRadius: Theme.cornerRadius + } + + WlrLayershell.namespace: "dms:dock-trash-context-menu" + + property var anchorItem: null + property real dockVisibleHeight: 40 + property int margin: 10 + property var dockApps: null + + function showForButton(button, dockHeight, dockScreen, parentDockApps) { + if (dockScreen) { + root.screen = dockScreen; + } + + anchorItem = button; + dockVisibleHeight = dockHeight || 40; + dockApps = parentDockApps || null; + + visible = true; + } + function close() { + visible = false; + } + + screen: null + visible: false + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + color: "transparent" + anchors { + top: true + left: true + right: true + bottom: true + } + + property point anchorPos: Qt.point(screen ? screen.width / 2 : 0, screen ? screen.height - 100 : 0) + + onAnchorItemChanged: updatePosition() + onVisibleChanged: { + if (visible) { + updatePosition(); + } + } + + function updatePosition() { + if (!anchorItem || !screen) { + anchorPos = Qt.point(screen ? screen.width / 2 : 0, screen ? screen.height - 100 : 0); + return; + } + + const dockWindow = anchorItem.Window.window; + if (!dockWindow) { + anchorPos = Qt.point(screen.width / 2, screen.height - 100); + return; + } + + const buttonPosInDock = anchorItem.mapToItem(dockWindow.contentItem, 0, 0); + let actualDockHeight = root.dockVisibleHeight; + + function findDockBackground(item) { + if (item.objectName === "dockBackground") { + return item; + } + for (var i = 0; i < item.children.length; i++) { + const found = findDockBackground(item.children[i]); + if (found) { + return found; + } + } + return null; + } + + const dockBackground = findDockBackground(dockWindow.contentItem); + let actualDockWidth = dockWindow.width; + if (dockBackground) { + actualDockHeight = dockBackground.height; + actualDockWidth = dockBackground.width; + } + + const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right; + const dockMargin = SettingsData.dockMargin + 16; + let buttonScreenX, buttonScreenY; + + if (isVertical) { + const dockContentHeight = dockWindow.height; + const screenHeight = root.screen.height; + const dockTopMargin = Math.round((screenHeight - dockContentHeight) / 2); + buttonScreenY = dockTopMargin + buttonPosInDock.y + anchorItem.height / 2; + + if (SettingsData.dockPosition === SettingsData.Position.Right) { + buttonScreenX = root.screen.width - actualDockWidth - dockMargin - 20; + } else { + buttonScreenX = actualDockWidth + dockMargin + 20; + } + } else { + const isDockAtBottom = SettingsData.dockPosition === SettingsData.Position.Bottom; + + if (isDockAtBottom) { + buttonScreenY = root.screen.height - actualDockHeight - dockMargin - 20; + } else { + buttonScreenY = actualDockHeight + dockMargin + 20; + } + + const dockContentWidth = dockWindow.width; + const screenWidth = root.screen.width; + const dockLeftMargin = Math.round((screenWidth - dockContentWidth) / 2); + buttonScreenX = dockLeftMargin + buttonPosInDock.x + anchorItem.width / 2; + } + + anchorPos = Qt.point(buttonScreenX, buttonScreenY); + } + + Rectangle { + id: menuContainer + + x: { + const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right; + if (isVertical) { + const isDockAtRight = SettingsData.dockPosition === SettingsData.Position.Right; + if (isDockAtRight) { + return Math.max(10, root.anchorPos.x - width + 30); + } else { + return Math.min(root.width - width - 10, root.anchorPos.x - 30); + } + } else { + const left = 10; + const right = root.width - width - 10; + const want = root.anchorPos.x - width / 2; + return Math.max(left, Math.min(right, want)); + } + } + y: { + const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right; + if (isVertical) { + const top = 10; + const bottom = root.height - height - 10; + const want = root.anchorPos.y - height / 2; + return Math.max(top, Math.min(bottom, want)); + } else { + const isDockAtBottom = SettingsData.dockPosition === SettingsData.Position.Bottom; + if (isDockAtBottom) { + return Math.max(10, root.anchorPos.y - height + 30); + } else { + return Math.min(root.height - height - 10, root.anchorPos.y - 30); + } + } + } + + width: Math.min(400, Math.max(180, menuColumn.implicitWidth + Theme.spacingS * 2)) + height: menuColumn.implicitHeight + Theme.spacingS * 2 + color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + radius: Theme.cornerRadius + border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) + border.width: BlurService.enabled ? BlurService.borderWidth : 1 + + opacity: root.visible ? 1 : 0 + visible: opacity > 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.emphasizedEasing + } + } + + Rectangle { + anchors.fill: parent + anchors.topMargin: 4 + anchors.leftMargin: 2 + anchors.rightMargin: -2 + anchors.bottomMargin: -4 + radius: parent.radius + color: Qt.rgba(0, 0, 0, 0.15) + z: -1 + } + + Column { + id: menuColumn + width: parent.width - Theme.spacingS * 2 + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Theme.spacingS + spacing: 1 + + Rectangle { + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: openArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "folder_open" + size: 14 + color: Theme.surfaceText + opacity: 0.7 + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Open Trash") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + DankRipple { + id: openRipple + rippleColor: Theme.surfaceText + cornerRadius: Theme.cornerRadius + } + + MouseArea { + id: openArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => openRipple.trigger(mouse.x, mouse.y) + onClicked: { + TrashService.openTrash(); + root.close(); + } + } + } + + Rectangle { + width: parent.width + height: 28 + radius: Theme.cornerRadius + enabled: !TrashService.isEmpty + opacity: enabled ? 1 : 0.4 + color: emptyArea.containsMouse && enabled ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent" + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "delete_forever" + size: 14 + color: emptyArea.containsMouse && parent.parent.enabled ? Theme.error : Theme.surfaceText + opacity: 0.7 + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: TrashService.isEmpty ? I18n.tr("Empty Trash") : I18n.tr("Empty Trash (%1)").arg(TrashService.count) + font.pixelSize: Theme.fontSizeSmall + color: emptyArea.containsMouse && parent.parent.enabled ? Theme.error : Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + DankRipple { + id: emptyRipple + rippleColor: Theme.error + cornerRadius: Theme.cornerRadius + } + + MouseArea { + id: emptyArea + anchors.fill: parent + hoverEnabled: true + enabled: parent.enabled + cursorShape: Qt.PointingHandCursor + onPressed: mouse => emptyRipple.trigger(mouse.x, mouse.y) + onClicked: { + TrashService.requestEmptyTrash(); + root.close(); + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + } + + Rectangle { + width: parent.width + height: 28 + radius: Theme.cornerRadius + color: settingsArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent" + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "settings" + size: 14 + color: Theme.surfaceText + opacity: 0.7 + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Settings") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Normal + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + } + + DankRipple { + id: settingsRipple + rippleColor: Theme.surfaceText + cornerRadius: Theme.cornerRadius + } + + MouseArea { + id: settingsArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: mouse => settingsRipple.trigger(mouse.x, mouse.y) + onClicked: { + PopoutService.focusOrToggleSettingsWithTab("dock"); + root.close(); + } + } + } + } + } + + MouseArea { + anchors.fill: parent + z: -1 + onClicked: root.close() + } +} diff --git a/quickshell/Modules/Settings/DockTab.qml b/quickshell/Modules/Settings/DockTab.qml index 9b281a37..78a765b6 100644 --- a/quickshell/Modules/Settings/DockTab.qml +++ b/quickshell/Modules/Settings/DockTab.qml @@ -506,6 +506,79 @@ Item { } } + SettingsCard { + width: parent.width + iconName: "delete" + title: I18n.tr("Trash") + settingKey: "dockTrash" + + SettingsToggleRow { + settingKey: "dockShowTrash" + tags: ["dock", "trash", "bin", "recycle"] + text: I18n.tr("Show Trash in Dock") + description: I18n.tr("Place a trash bin at the end of the dock") + checked: SettingsData.dockShowTrash + onToggled: checked => SettingsData.set("dockShowTrash", checked) + } + + SettingsDropdownRow { + id: trashFmDropdown + settingKey: "dockTrashFileManager" + tags: ["dock", "trash", "file", "manager", "nautilus", "thunar", "dolphin", "custom"] + text: I18n.tr("Open Trash With") + description: I18n.tr("File manager used to open the trash. Pick \"custom\" to enter your own command.") + visible: SettingsData.dockShowTrash + currentValue: SettingsData.dockTrashFileManager + options: TrashService.availableFileManagers || [] + onValueChanged: value => SettingsData.set("dockTrashFileManager", value) + } + + FocusScope { + width: parent.width - Theme.spacingM * 2 + height: visible ? trashCustomCommandColumn.implicitHeight : 0 + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + visible: SettingsData.dockShowTrash && SettingsData.dockTrashFileManager === "custom" + + Column { + id: trashCustomCommandColumn + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Custom open-trash command") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + id: trashCustomCommandField + width: parent.width + placeholderText: "pcmanfm trash:///" + backgroundColor: Theme.surfaceContainerHighest + normalBorderColor: Theme.outlineMedium + focusedBorderColor: Theme.primary + + Component.onCompleted: { + if (SettingsData.dockTrashCustomCommand) { + text = SettingsData.dockTrashCustomCommand; + } + } + + onTextEdited: SettingsData.set("dockTrashCustomCommand", text.trim()) + + MouseArea { + anchors.fill: parent + onPressed: mouse => { + trashCustomCommandField.forceActiveFocus(); + mouse.accepted = false; + } + } + } + } + } + } + SettingsCard { width: parent.width iconName: "photo_size_select_large" diff --git a/quickshell/Services/TrashService.qml b/quickshell/Services/TrashService.qml new file mode 100644 index 00000000..1d866655 --- /dev/null +++ b/quickshell/Services/TrashService.qml @@ -0,0 +1,92 @@ +pragma Singleton + +import QtQuick +import Qt.labs.folderlistmodel +import Quickshell +import Quickshell.Io +import qs.Common + +Singleton { + id: root + + readonly property string _homeDir: Quickshell.env("HOME") || "" + readonly property string _xdgDataHome: Quickshell.env("XDG_DATA_HOME") || (_homeDir + "/.local/share") + readonly property string trashFilesDir: _xdgDataHome + "/Trash/files" + + readonly property int count: trashModel.count + readonly property bool isEmpty: count === 0 + + property var availableFileManagers: [] + + signal emptyTrashConfirmRequested(int itemCount) + + FolderListModel { + id: trashModel + folder: "file://" + root.trashFilesDir + showDirs: true + showFiles: true + showHidden: true + showDotAndDotDot: false + sortField: FolderListModel.Name + nameFilters: ["*"] + } + + Process { + id: detectProc + running: false + command: ["sh", "-c", "for fm in nautilus thunar dolphin; do command -v $fm >/dev/null 2>&1 && echo $fm; done"] + stdout: StdioCollector { + onStreamFinished: { + const detected = (text || "").split("\n").map(s => s.trim()).filter(s => s.length > 0); + detected.push("custom"); + root.availableFileManagers = detected; + } + } + } + + Component.onCompleted: { + detectProc.running = true; + } + + function openTrash() { + const choice = SettingsData.dockTrashFileManager || "nautilus"; + if (choice === "custom") { + const cmd = (SettingsData.dockTrashCustomCommand || "").trim(); + if (!cmd) { + ToastService.showInfo(I18n.tr("Cannot open trash: no custom command set"), I18n.tr("Configure one in Settings → Dock → Trash.")); + return; + } + Proc.runCommand(null, ["sh", "-c", cmd], (output, exitCode) => { + if (exitCode !== 0) { + ToastService.showError(I18n.tr("Trash command failed (exit %1)").arg(exitCode), I18n.tr("Check your custom command in Settings → Dock → Trash.")); + } + }, 0, Proc.noTimeout); + return; + } + if (availableFileManagers.indexOf(choice) < 0) { + ToastService.showInfo(I18n.tr("Cannot open trash: '%1' is not installed").arg(choice), I18n.tr("Pick a different file manager in Settings → Dock → Trash.")); + return; + } + switch (choice) { + case "nautilus": + Quickshell.execDetached(["nautilus", "trash:///"]); + break; + case "thunar": + Quickshell.execDetached(["thunar", "trash:///"]); + break; + case "dolphin": + Quickshell.execDetached(["dolphin", "trash:///"]); + break; + } + } + + function requestEmptyTrash() { + if (isEmpty) + return; + emptyTrashConfirmRequested(count); + } + + function emptyTrash() { + Quickshell.execDetached(["gio", "trash", "--empty"]); + } +}