From d2905072c0bfaa0d9c47f399cd50ef7793fcd293 Mon Sep 17 00:00:00 2001 From: arfan Date: Wed, 3 Jun 2026 09:52:58 +0700 Subject: [PATCH] feat(settings): Added Settings Tab Autostart App (XDG Autostart) (#2535) * feat(Autostart): add Autostart tab and application selection popup * fix(AutoStartTab): update systemdUserDir property to use XDG_CONFIG_HOME * fix(AutoStartTab): update autostartDir and systemdUserDir to use StandardPaths for config home * refactor(AutoStartTab): use FileView & FolderListModel * refactor(AutoStartTab): implement systemd override generation for autostart applications using FileView * feat(AutoStartTab): add systemd check to determine environment and update tray icon visibility * feat(SettingsSidebar, AutoStartTab, DesktopService): add autostart functionality and systemd checks * feat(AutoStartTab): add hidden property support for desktop entries and toggle functionality * feat(AutoStartTab): add initialize autostart directory and add toast if writer failed * add(AutoStartTab): logging for scoped log tracking --------- --- .../Modals/Settings/SettingsContent.qml | 17 + .../Modals/Settings/SettingsSidebar.qml | 9 + quickshell/Modules/Settings/AutoStartTab.qml | 751 ++++++++++++++++++ .../Settings/Widgets/AppBrowserPopup.qml | 332 ++++++++ quickshell/Services/DesktopService.qml | 35 + 5 files changed, 1144 insertions(+) create mode 100644 quickshell/Modules/Settings/AutoStartTab.qml create mode 100644 quickshell/Modules/Settings/Widgets/AppBrowserPopup.qml diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 9b65cd2f..5a038716 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -570,5 +570,22 @@ FocusScope { Qt.callLater(() => item.forceActiveFocus()); } } + + Loader { + id: autoStartLoader + anchors.fill: parent + active: root.currentIndex === 36 + visible: active + focus: active + + sourceComponent: AutoStartTab { + parentModal: root.parentModal + } + + onActiveChanged: { + if (active && item) + Qt.callLater(() => item.forceActiveFocus()); + } + } } } diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 21b5ecad..b4668246 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -245,6 +245,13 @@ Rectangle { "icon": "app_registration", "tabIndex": 19, "hyprlandNiriOnly": true + }, + { + "id": "autostart", + "text": I18n.tr("Autostart Apps"), + "icon": "line_start", + "tabIndex": 36, + "autostartOnly": true } ] }, @@ -369,6 +376,8 @@ Rectangle { return false; if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable) return false; + if (item.autostartOnly && !DesktopService.autostartAvailable) + return false; return true; } diff --git a/quickshell/Modules/Settings/AutoStartTab.qml b/quickshell/Modules/Settings/AutoStartTab.qml new file mode 100644 index 00000000..ec3135b3 --- /dev/null +++ b/quickshell/Modules/Settings/AutoStartTab.qml @@ -0,0 +1,751 @@ +import QtCore +import QtQuick +import Qt.labs.folderlistmodel +import Quickshell +import Quickshell.Io +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Settings.Widgets + +Item { + id: root + + readonly property var log: Log.scoped("AutoStartTab") + property var parentModal: null + property var entries: [] + property var desktopApps: [] + property string newEntryType: "desktop" + property string newEntryName: "" + property string newEntryExec: "" + property string newEntryDesktopId: "" + property string newEntryCommandWrapper: "%command%" + + readonly property string autostartDir: { + const configHome = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)); + return configHome + "/autostart"; + } + + function lookupDesktopIcon(name, exec, fileName) { + const appId = fileName ? fileName.replace(/\.desktop$/, "") : ""; + let entry = appId ? DesktopEntries.heuristicLookup(appId) : null; + if (entry && entry.icon) return entry.icon; + if (exec) { + const cmdBase = exec.split(" ")[0].split("/").pop(); + for (let i = 0; i < root.desktopApps.length; i++) { + const app = root.desktopApps[i]; + if (app.icon) { + const appExec = (app.exec || app.execString || "").split(" ")[0].split("/").pop(); + if (appExec === cmdBase) return app.icon; + } + } + } + return ""; + } + + function parseDesktopFile(content, filePath) { + if (!content || content.length === 0) return null; + const lines = content.split("\n"); + let name = ""; + let execCmd = ""; + let icon = ""; + let hidden = false; + let isDesktopEntry = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "[Desktop Entry]") { + isDesktopEntry = true; + } else if (isDesktopEntry) { + if (line.startsWith("[")) break; + const nameMatch = line.match(/^Name=(.+)$/); + if (nameMatch) name = nameMatch[1]; + const execMatch = line.match(/^Exec=(.+)$/); + if (execMatch) execCmd = execMatch[1]; + const iconMatch = line.match(/^Icon=(.+)$/); + if (iconMatch) icon = iconMatch[1]; + const hiddenMatch = line.match(/^Hidden=(true|false)$/); + if (hiddenMatch) hidden = hiddenMatch[1] === "true"; + } + } + if (!isDesktopEntry || !name || !execCmd) return null; + const fileName = filePath.split("/").pop(); + if (!icon) icon = root.lookupDesktopIcon(name, execCmd, fileName); + return { name: name, exec: execCmd, icon: icon, hidden: hidden, filePath: filePath, fileName: fileName, content: content }; + } + + function addEntry() { + if (newEntryType === "desktop") { + if (!newEntryDesktopId) return; + const app = desktopApps.find(a => (a.id || a.execString) === newEntryDesktopId); + if (!app) return; + const entryName = app.name || newEntryDesktopId; + const appExec = app.exec || app.execString || ""; + const execCmd = root.newEntryCommandWrapper.replace("%command%", appExec); + const appIcon = app.icon || ""; + const fileName = entryName.toLowerCase().replace(/[^a-z0-9]/g, "-") + ".desktop"; + writeDesktopFile(fileName, entryName, execCmd, appIcon); + } else { + if (!newEntryName || !newEntryExec) return; + const fileName = newEntryName.toLowerCase().replace(/[^a-z0-9]/g, "-") + ".desktop"; + writeDesktopFile(fileName, newEntryName, newEntryExec, ""); + } + } + + function writeDesktopFile(fileName, name, execCmd, icon) { + let content = "[Desktop Entry]\nType=Application\nName=" + name + "\nExec=" + execCmd + "\n"; + if (icon) content += "Icon=" + icon + "\n"; + writerFileView.path = root.autostartDir + "/" + fileName; + writerFileView.setText(content); + root.resetNewEntry(); + } + + function setHidden(entry, hidden) { + if (!entry || !entry.content) return; + const lines = entry.content.split("\n"); + const hiddenValue = hidden ? "true" : "false"; + let found = false; + const merged = lines.map(line => { + const m = line.match(/^Hidden=(true|false)\s*$/); + if (m) { + found = true; + return "Hidden=" + hiddenValue; + } + return line; + }); + if (!found) { + const idx = merged.findIndex(l => l.trim() === "[Desktop Entry]"); + if (idx >= 0) + merged.splice(idx + 1, 0, "Hidden=" + hiddenValue); + else + merged.unshift("Hidden=" + hiddenValue); + } + writerFileView.path = entry.filePath; + writerFileView.setText(merged.join("\n")); + } + + function removeEntry(filePath) { + const proc = removeFileComponent.createObject(root, { + targetPath: filePath, + running: true + }); + } + + function resetNewEntry() { + newEntryType = "desktop"; + newEntryName = ""; + newEntryExec = ""; + newEntryDesktopId = ""; + newEntryCommandWrapper = "%command%"; + } + + function addOrUpdateEntry(entry) { + var list = root.entries.slice(); + for (var i = 0; i < list.length; i++) { + if (list[i].filePath === entry.filePath) { + list[i] = entry; + root.entries = list; + return; + } + } + list.push(entry); + list.sort((a, b) => a.fileName.localeCompare(b.fileName)); + root.entries = list; + } + + function removeEntryByPath(filePath) { + var list = root.entries.filter(e => e.filePath !== filePath); + root.entries = list; + } + + FileView { + id: writerFileView + blockLoading: true + atomicWrites: true + onSaveFailed: error => { + ToastService.showError(I18n.tr("Failed to write autostart entry")) + log.warn("Failed to write autostart entry to " + writerFileView.path + ": " + error); + } + } + + FolderListModel { + id: folderModel + nameFilters: ["*.desktop"] + showDirs: false + showDotAndDotDot: false + showHidden: false + sortField: FolderListModel.Name + + onStatusChanged: { + if (status !== FolderListModel.Ready) return; + // rebuild entries + const validPaths = new Set(); + for (let i = 0; i < folderModel.count; i++) { + const fp = folderModel.get(i, "filePath") || ""; + validPaths.add(fp.startsWith("file://") ? fp.substring(7) : fp); + } + const filtered = root.entries.filter(e => validPaths.has(e.filePath)); + if (filtered.length !== root.entries.length) { + root.entries = filtered; + } + } + + onCountChanged: { + fileReaderRepeater.model = count; + } + } + + Repeater { + id: fileReaderRepeater + model: 0 + + Item { + required property int index + + readonly property string filePath: { + const fp = folderModel.get(index, "filePath") || ""; + return fp.startsWith("file://") ? fp.substring(7) : fp; + } + + FileView { + id: fileView + path: filePath ? "file://" + filePath : "" + watchChanges: true + + onLoaded: { + const entry = root.parseDesktopFile(fileView.text(), filePath); + if (entry) { + root.addOrUpdateEntry(entry); + } else { + root.removeEntryByPath(filePath); + } + } + + onFileChanged: reload() + + onLoadFailed: { + root.removeEntryByPath(filePath); + } + } + } + } + + Component { + id: removeFileComponent + Process { + property string targetPath: "" + command: ["rm", "-f", targetPath] + onExited: (exitCode, exitStatus) => { + root.removeEntryByPath(targetPath); + destroy(); + } + } + } + + function generateTrayIconFixSystemdOverride() { + const configHome = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)); + const dir = configHome + "/systemd/user/app-@autostart.service.d"; + const proc = systemdOverrideMkDirComp.createObject(root, { targetPath: dir, running: true }); + } + + FileView { + id: systemdOverrideWriter + atomicWrites: true + + // make sure we don't overwrite an existing override with a default one, in case the user has already customized it + function buildOverrideContent(existing) { + if (!existing) return "[Unit]\nAfter=dms.service\n"; + const lines = existing.split("\n"); + const hasAfter = lines.some(l => l.trim() === "After=dms.service"); + if (hasAfter) return existing; + const unitIdx = lines.findIndex(l => l.trim() === "[Unit]"); + if (unitIdx >= 0) { + lines.splice(unitIdx + 1, 0, "After=dms.service"); + } else { + lines.push("[Unit]", "After=dms.service"); + } + return lines.join("\n"); + } + + onLoaded: { + const merged = buildOverrideContent(text()); + if (merged !== text()) setText(merged); + ToastService.showInfo(I18n.tr("Systemd Override generated")); + } + + onLoadFailed: { + setText("[Unit]\nAfter=dms.service\n"); + ToastService.showInfo(I18n.tr("Systemd Override generated")); + } + + onSaveFailed: error => { + ToastService.showError(I18n.tr("Failed to generate systemd override")); + log.warn("Failed to write systemd override to " + systemdOverrideWriter.path + ": " + error); + } + } + + Component { + id: systemdOverrideMkDirComp + Process { + property string targetPath: "" + command: ["mkdir", "-p", targetPath] + onExited: (exitCode) => { + if (exitCode === 0) { + systemdOverrideWriter.path = targetPath + "/override.conf"; + } else { + ToastService.showError(I18n.tr("Failed to generate systemd override")); + } + destroy(); + } + } + } + + Component { + id: autostartInitMkDirComp + Process { + command: ["mkdir", "-p", root.autostartDir] + onExited: (exitCode) => { + if (exitCode === 0) { + folderModel.folder = "file://" + root.autostartDir; + } + destroy(); + } + } + } + + Component.onCompleted: { + desktopApps = AppSearchService.getVisibleApplications() || []; + autostartInitMkDirComp.createObject(root, { running: true }); + } + + Component.onDestruction: { + desktopApps = []; + } + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + AppBrowserPopup { + id: appBrowserPopup + appsModel: root.desktopApps + parentModal: root.parentModal + onAppSelected: appId => root.newEntryDesktopId = appId + } + + Column { + id: mainColumn + topPadding: 4 + width: Math.min(550, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXL + visible: DesktopService.autostartAvailable + + SettingsCard { + width: parent.width + iconName: "add_circle" + title: I18n.tr("Add Entry") + + SettingsDropdownRow { + width: parent.width + text: I18n.tr("Entry Type") + description: I18n.tr("Choose whether to launch a desktop app or a command") + currentValue: root.newEntryType === "desktop" ? I18n.tr("Desktop Application") : I18n.tr("Command Line") + options: [I18n.tr("Desktop Application"), I18n.tr("Command Line")] + onValueChanged: val => { + root.newEntryType = val === I18n.tr("Desktop Application") ? "desktop" : "command"; + } + } + + Column { + width: parent.width + visible: root.newEntryType === "desktop" + spacing: Theme.spacingM + + Item { + width: parent.width + height: appLabelColumn.height + + Column { + id: appLabelColumn + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Application") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + text: I18n.tr("Select a desktop application") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + } + + Row { + width: parent.width + spacing: Theme.spacingM + + StyledRect { + height: 40 + radius: Theme.cornerRadius + color: root.newEntryDesktopId ? Theme.surfaceContainerHigh : Theme.withAlpha(Theme.surfaceContainerHigh, 0.5) + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + readonly property string selectedName: { + if (!root.newEntryDesktopId) return ""; + const app = root.desktopApps.find(a => (a.id || a.execString) === root.newEntryDesktopId); + return app ? (app.name || app.id || "") : root.newEntryDesktopId; + } + + width: parent.width - browseButton.width - Theme.spacingM + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + visible: root.newEntryDesktopId !== "" + + Image { + width: 24 + height: 24 + source: { + const app = root.desktopApps.find(a => (a.id || a.execString) === root.newEntryDesktopId); + return Paths.resolveIconUrl(app?.icon || "application-x-executable"); + } + sourceSize.width: 24 + sourceSize.height: 24 + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + onStatusChanged: { + if (status === Image.Error) + source = "image://icon/application-x-executable"; + } + } + + StyledText { + text: parent.parent.selectedName + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + StyledText { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("No application selected") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + visible: root.newEntryDesktopId === "" + } + } + + DankButton { + id: browseButton + text: I18n.tr("Browse") + iconName: "search" + onClicked: appBrowserPopup.show() + } + } + + Item { + width: parent.width + height: wrapperLabelColumn.height + + Column { + id: wrapperLabelColumn + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Command") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + text: I18n.tr("Wrap the app command. %command% is replaced with the actual executable") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + } + + DankTextField { + width: parent.width + placeholderText: I18n.tr("%command%") + text: root.newEntryCommandWrapper + onTextChanged: root.newEntryCommandWrapper = text + } + } + + Column { + width: parent.width + visible: root.newEntryType === "command" + spacing: Theme.spacingM + + Item { + width: parent.width + height: labelColumn.height + + Column { + id: labelColumn + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Name") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + text: I18n.tr("Display name for this entry") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + } + + DankTextField { + width: parent.width + placeholderText: I18n.tr("e.g. My Script") + text: root.newEntryName + onTextChanged: root.newEntryName = text + } + + Item { + width: parent.width + height: labelColumn2.height + + Column { + id: labelColumn2 + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Command") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + text: I18n.tr("Full command to execute") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + } + + DankTextField { + width: parent.width + placeholderText: I18n.tr("e.g. /usr/bin/my-script --flag") + text: root.newEntryExec + onTextChanged: root.newEntryExec = text + } + } + + StyledText { + width: parent.width + text: I18n.tr("These add entries to the XDG autostart directory (~/.config/autostart/*.desktop)") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + + Item { + width: parent.width + height: Theme.spacingM + } + + DankButton { + anchors.horizontalCenter: parent.horizontalCenter + text: I18n.tr("Add to Autostart") + iconName: "add" + enabled: { + if (root.newEntryType === "desktop") return root.newEntryDesktopId !== ""; + return root.newEntryName !== "" && root.newEntryExec !== ""; + } + onClicked: root.addEntry() + } + } + + SettingsCard { + id: entriesCard + width: parent.width + iconName: "line_start" + title: I18n.tr("Autostart Entries") + settingKey: "autostartEntries" + collapsible: true + expanded: true + + Row { + width: parent.width + spacing: Theme.spacingM + + StyledText { + width: parent.width - clearAllButton.width - Theme.spacingM + text: I18n.tr("Applications and commands to start automatically when you log in") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + anchors.verticalCenter: parent.verticalCenter + } + + DankActionButton { + id: clearAllButton + iconName: "delete_sweep" + iconSize: Theme.iconSize - 2 + iconColor: Theme.error + anchors.verticalCenter: parent.verticalCenter + onClicked: { + for (let i = 0; i < root.entries.length; i++) { + root.removeEntry(root.entries[i].filePath); + } + } + } + } + + Column { + id: entriesList + width: parent.width + spacing: Theme.spacingS + + Repeater { + model: root.entries + + delegate: Rectangle { + width: entriesList.width + height: 48 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.3) + border.width: 0 + + Row { + width: parent.width + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + StyledText { + text: (index + 1).toString() + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.primary + width: 20 + anchors.verticalCenter: parent.verticalCenter + } + + Image { + width: 24 + height: 24 + source: Paths.resolveIconUrl(modelData.icon || "application-x-executable") + sourceSize.width: 24 + sourceSize.height: 24 + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + onStatusChanged: { + if (status === Image.Error) + source = "image://icon/application-x-executable"; + } + } + + Column { + width: parent.width - 20 - Theme.spacingM - 24 - Theme.spacingM - Theme.spacingM - 60 - Theme.spacingM - 32 - Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + StyledText { + width: parent.width + text: modelData.name + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: modelData.hidden ? Theme.surfaceVariantText : Theme.surfaceText + elide: Text.ElideRight + opacity: modelData.hidden ? 0.6 : 1.0 + } + + StyledText { + width: parent.width + text: modelData.hidden ? I18n.tr("Disabled") : modelData.exec + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + } + } + + DankToggle { + anchors.verticalCenter: parent.verticalCenter + checked: !modelData.hidden + onToggled: checked => root.setHidden(modelData, !checked) + } + } + + DankActionButton { + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + iconName: "close" + iconSize: 16 + buttonSize: 32 + circular: true + iconColor: Theme.error + onClicked: root.removeEntry(modelData.filePath) + } + } + } + + StyledText { + width: parent.width + text: I18n.tr("No autostart entries") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + visible: root.entries.length === 0 + } + } + } + + SettingsCard { + width: parent.width + iconName: "system_tray" + title: I18n.tr("Tray Icon Fix") + visible: DesktopService.isSystemd + + Column { + width: parent.width + spacing: Theme.spacingM + + StyledText { + width: parent.width + text: I18n.tr("If autostart app icons don't appear in the system tray, generate a systemd override to ensure DMS starts before autostart apps") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + DankButton { + anchors.horizontalCenter: parent.horizontalCenter + text: I18n.tr("Generate Override") + iconName: "build" + onClicked: root.generateTrayIconFixSystemdOverride() + } + } + } + } + } +} diff --git a/quickshell/Modules/Settings/Widgets/AppBrowserPopup.qml b/quickshell/Modules/Settings/Widgets/AppBrowserPopup.qml new file mode 100644 index 00000000..ed52c0bb --- /dev/null +++ b/quickshell/Modules/Settings/Widgets/AppBrowserPopup.qml @@ -0,0 +1,332 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +FloatingWindow { + id: root + + property bool disablePopupTransparency: true + property string searchQuery: "" + property var filteredApps: [] + property int selectedIndex: -1 + property bool keyboardNavigationActive: false + property var appsModel: [] + property var parentModal: null + parentWindow: parentModal + + signal appSelected(string appId) + + objectName: "appBrowserPopup" + title: I18n.tr("Select Application") + minimumSize: Qt.size(400, 350) + implicitWidth: 500 + implicitHeight: 550 + color: "transparent" + visible: false + + WindowBlur { + targetWindow: root + blurX: 0 + blurY: 0 + blurWidth: root.visible ? root.width : 0 + blurHeight: root.visible ? root.height : 0 + blurRadius: Theme.cornerRadius + } + + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainer, 0.95) + border.color: Theme.outlineMedium + border.width: Theme.layerOutlineWidth + } + + FocusScope { + anchors.fill: parent + focus: true + + Keys.onPressed: event => { + switch (event.key) { + case Qt.Key_Escape: + root.hide(); + event.accepted = true; + return; + case Qt.Key_Down: + root.selectNext(); + event.accepted = true; + return; + case Qt.Key_Up: + root.selectPrevious(); + event.accepted = true; + return; + case Qt.Key_Return: + case Qt.Key_Enter: + if (root.keyboardNavigationActive) { + root.selectApp(); + } else if (root.filteredApps.length > 0) { + root.selectAppByIndex(0); + } + event.accepted = true; + return; + } + } + + Column { + anchors.fill: parent + spacing: 0 + + Item { + width: parent.width + height: 48 + + MouseArea { + anchors.fill: parent + onPressed: windowControls.tryStartMove() + } + + Rectangle { + anchors.fill: parent + color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5) + } + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingL + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "add_circle" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Select Application") + font.pixelSize: Theme.fontSizeXLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + DankActionButton { + iconName: "close" + iconSize: Theme.iconSize - 4 + iconColor: Theme.surfaceText + onClicked: root.hide() + } + } + } + + Item { + width: parent.width + height: parent.height - 48 + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + DankTextField { + id: searchField + width: parent.width + height: 48 + cornerRadius: Theme.cornerRadius + backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, 0.8) + normalBorderColor: Theme.outlineMedium + focusedBorderColor: Theme.primary + leftIconName: "search" + leftIconSize: Theme.iconSize + leftIconColor: Theme.surfaceVariantText + leftIconFocusedColor: Theme.primary + showClearButton: true + textColor: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + placeholderText: I18n.tr("Search applications...") + text: root.searchQuery + onTextEdited: { + root.searchQuery = text; + root.updateFilteredApps(); + } + } + + DankListView { + id: appList + width: parent.width + height: parent.height - searchField.height - Theme.spacingM + spacing: Theme.spacingS + model: root.filteredApps + clip: true + + delegate: Rectangle { + width: appList.width + height: 60 + radius: Theme.cornerRadius + required property int index + required property var modelData + + readonly property bool isSelected: root.keyboardNavigationActive && index === root.selectedIndex + + color: isSelected ? Theme.withAlpha(Theme.primary, 0.16) : appArea.containsMouse ? Theme.withAlpha(Theme.primary, 0.08) : Theme.withAlpha(Theme.surfaceVariant, 0.3) + border.color: isSelected ? Theme.primary : Theme.outlineMedium + border.width: isSelected ? 2 : Theme.layerOutlineWidth + + Row { + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingM + + Image { + width: 28 + height: 28 + source: Paths.resolveIconUrl(modelData.icon || "application-x-executable") + sourceSize.width: 28 + sourceSize.height: 28 + fillMode: Image.PreserveAspectFit + anchors.verticalCenter: parent.verticalCenter + onStatusChanged: { + if (status === Image.Error) + source = "image://icon/application-x-executable"; + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + width: parent.width - 28 - Theme.spacingM * 3 - 24 + + StyledText { + text: modelData.name || modelData.id || "" + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + width: parent.width + } + + StyledText { + text: modelData.comment || modelData.genericName || "" + font.pixelSize: Theme.fontSizeSmall + color: Theme.outline + elide: Text.ElideRight + width: parent.width + } + } + + DankIcon { + name: "add" + size: Theme.iconSize - 4 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: appArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + const appId = modelData.id || modelData.execString || ""; + root.appSelected(appId); + root.hide(); + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + } + } + } + } + + FloatingWindowControls { + id: windowControls + targetWindow: root + } + } + + function updateFilteredApps() { + const allApps = root.appsModel || []; + var filtered = []; + if (!searchQuery || searchQuery.length === 0) { + filtered = allApps.slice(); + } else { + var query = searchQuery.toLowerCase(); + for (var i = 0; i < allApps.length; i++) { + var app = allApps[i]; + var name = (app.name || "").toLowerCase(); + var id = (app.id || "").toLowerCase(); + var comment = (app.comment || app.genericName || "").toLowerCase(); + if (name.indexOf(query) !== -1 || id.indexOf(query) !== -1 || comment.indexOf(query) !== -1) + filtered.push(app); + } + } + filteredApps = filtered; + selectedIndex = -1; + keyboardNavigationActive = false; + } + + function selectNext() { + if (filteredApps.length === 0) return; + keyboardNavigationActive = true; + selectedIndex = Math.min(selectedIndex + 1, filteredApps.length - 1); + } + + function selectPrevious() { + if (filteredApps.length === 0) return; + keyboardNavigationActive = true; + selectedIndex = Math.max(selectedIndex - 1, -1); + if (selectedIndex === -1) keyboardNavigationActive = false; + } + + function selectApp() { + if (selectedIndex < 0 || selectedIndex >= filteredApps.length) return; + selectAppByIndex(selectedIndex); + } + + function selectAppByIndex(idx) { + const app = filteredApps[idx]; + if (!app) return; + root.appSelected(app.id || app.execString || ""); + hide(); + } + + function show() { + updateFilteredApps(); + visible = true; + Qt.callLater(() => searchField.forceActiveFocus()); + } + + function hide() { + visible = false; + searchQuery = ""; + filteredApps = []; + selectedIndex = -1; + keyboardNavigationActive = false; + } + + onVisibleChanged: { + if (!visible) { + searchQuery = ""; + filteredApps = []; + selectedIndex = -1; + keyboardNavigationActive = false; + } + } +} diff --git a/quickshell/Services/DesktopService.qml b/quickshell/Services/DesktopService.qml index 34bcae77..d54d0309 100644 --- a/quickshell/Services/DesktopService.qml +++ b/quickshell/Services/DesktopService.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell +import Quickshell.Io import qs.Common import qs.Services @@ -12,6 +13,40 @@ Singleton { readonly property var log: Log.scoped("DesktopService") property var _cache: ({}) + property bool isSystemd: false + property bool systemdAutostartTargetActive: false + property bool systemdAutostartTargetChecked: false + readonly property bool autostartAvailable: root.systemdAutostartTargetChecked && (!root.isSystemd || root.systemdAutostartTargetActive) + + Component.onCompleted: initSystemCheckProcess.running = true + + Process { + id: initSystemCheckProcess + command: ["sh", "-c", "cat /proc/1/comm 2>/dev/null | tr -d '\\n'"] + running: false + stdout: StdioCollector { + onStreamFinished: { + root.isSystemd = (text || "").trim() === "systemd"; + if (!root.isSystemd) + root.systemdAutostartTargetChecked = true; + else + systemdAutostartTargetCheck.running = true; + } + } + } + + Process { + id: systemdAutostartTargetCheck + command: ["systemctl", "--user", "is-active", "xdg-desktop-autostart.target"] + running: false + stdout: StdioCollector { + onStreamFinished: { + root.systemdAutostartTargetActive = (text || "").trim() === "active"; + root.systemdAutostartTargetChecked = true; + } + } + } + function resolveIconPath(moddedAppId) { if (!moddedAppId) return "";