diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index e1d6373c..0063f295 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -361,6 +361,21 @@ FocusScope { sourceComponent: OSDTab {} + onActiveChanged: { + if (active && item) + Qt.callLater(() => item.forceActiveFocus()); + } + } + + Loader { + id: defaultAppsLoader + anchors.fill: parent + active: root.currentIndex === 34 + visible: active + focus: active + + sourceComponent: DefaultAppsTab {} + onActiveChanged: { if (active && item) Qt.callLater(() => item.forceActiveFocus()); diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index a091c431..39b911fb 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -159,13 +159,6 @@ Rectangle { "icon": "tune", "tabIndex": 18 }, - { - "id": "running_apps", - "text": I18n.tr("Running Apps"), - "icon": "app_registration", - "tabIndex": 19, - "hyprlandNiriOnly": true - }, { "id": "updater", "text": I18n.tr("System Updater"), @@ -184,7 +177,7 @@ Rectangle { { "id": "dock_launcher", "text": I18n.tr("Dock & Launcher"), - "icon": "apps", + "icon": "shelf_auto_hide", "collapsedByDefault": true, "children": [ { @@ -241,6 +234,28 @@ Rectangle { "tabIndex": 7, "dmsOnly": true }, + { + "id": "applications", + "text": I18n.tr("Applications"), + "icon": "apps", + "collapsedByDefault": true, + "children": [ + { + "id": "default_apps", + "text": I18n.tr("Default Apps"), + "icon": "star", + "tabIndex": 34, + "gioOnly": true + }, + { + "id": "running_apps", + "text": I18n.tr("Running Apps"), + "icon": "app_registration", + "tabIndex": 19, + "hyprlandNiriOnly": true + } + ] + }, { "id": "system", "text": I18n.tr("System"), @@ -349,6 +364,8 @@ Rectangle { return false; if (item.updaterOnly && !SystemUpdateService.sysupdateAvailable) return false; + if (item.gioOnly && !DesktopService.gioAvailable) + return false; return true; } diff --git a/quickshell/Modules/Settings/DefaultAppsTab.qml b/quickshell/Modules/Settings/DefaultAppsTab.qml new file mode 100644 index 00000000..7f619c2d --- /dev/null +++ b/quickshell/Modules/Settings/DefaultAppsTab.qml @@ -0,0 +1,391 @@ +import QtQuick +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 appCategory: ({ + WebBrowser: 0, + FileManager: 1, + TextEditor: 2, + ImageViewer: 3, + VideoPlayer: 4, + MusicPlayer: 5, + PDFReader: 6, + Mail: 7, + Terminal: 8, + Calendar: 9 + }) + + property string currentWebBrowserAppId: "" + property string currentFileManagerAppId: "" + property string currentTextEditorAppId: "" + property string currentImageViewerAppId: "" + property string currentVideoPlayerAppId: "" + property string currentMusicPlayerAppId: "" + property string currentPDFReaderAppId: "" + property string currentMailAppId: "" + property string currentTerminalAppId: "" + property string currentCalendarAppId: "" + + property var categoryModels: ({}) + + // A curated list of MIME types for each category. + // The first one is used for fetching the apps list and current default, + // the rest are for setting the default app. + readonly property var mimeMapping: ({ + [root.appCategory.WebBrowser]: [ + "x-scheme-handler/https", + "x-scheme-handler/http", + "text/html", + "application/xhtml+xml" + ], + [root.appCategory.FileManager]: [ + "inode/directory", + "x-scheme-handler/file" + ], + [root.appCategory.TextEditor]: [ + "text/plain", + "application/x-zerosize", + "text/x-c++src", + "text/x-csrc", + "text/x-python", + "text/x-shellscript", + "application/json" + ], + [root.appCategory.ImageViewer]: [ + "image/png", + "image/jpeg", + "image/gif", + "image/bmp", + "image/webp", + "image/avif", + "image/svg+xml" + ], + [root.appCategory.VideoPlayer]: [ + "video/mp4", + "video/x-matroska", + "video/webm", + "video/avi", + "video/mpeg", + "video/quicktime", + "video/x-msvideo" + ], + [root.appCategory.MusicPlayer]: [ + "audio/mpeg", + "audio/x-flac", + "audio/wav", + "audio/ogg", + "audio/aac", + "audio/webm" + ], + [root.appCategory.PDFReader]: [ + "application/pdf", + "application/x-ext-pdf", + "application/x-bzpdf", + "application/x-gzpdf", + "application/vnd.comicbook-rar", + "application/vnd.comicbook+zip" + ], + [root.appCategory.Mail]: ["x-scheme-handler/mailto"], + [root.appCategory.Calendar]: ["x-scheme-handler/calendar"], + [root.appCategory.Terminal]: ["terminal"] // Special + }) + + function propertyName(type) { + const names = Object.keys(root.appCategory); + return "current" + names[type] + "AppId"; + } + + function loadAppSearchCategory(categoryName) { + const apps = AppSearchService.getVisibleApplications() || []; + return apps.filter(app => { + const categories = app.categories || []; + return categories.includes(categoryName); + }); + } + + function getAppDisplayName(appId) { + let entry = DesktopEntries.heuristicLookup(appId); + if (entry && entry.name) { + return entry.name; + } + // If the appname can't be found, show the appID + const withoutSuffix = appId.replace(/\.desktop$/, ""); + if (withoutSuffix !== appId) { + entry = DesktopEntries.heuristicLookup(withoutSuffix); + if (entry && entry.name) { + return entry.name; + } + } + return appId; + } + + function loadCategoryModel(categoryKey, categorySearchName) { + const apps = loadAppSearchCategory(categorySearchName); + const appIds = apps.map(app => app.id || app.execString || "").filter(id => id); + let models = Object.assign({}, root.categoryModels); + models[categoryKey] = appIds.map(id => ({ + text: root.getAppDisplayName(id), + value: id + })); + root.categoryModels = models; + } + + Component.onCompleted: { + const categories = Object.values(root.appCategory); + + categories.forEach(category => { + switch (category) { + case root.appCategory.Terminal: + // Terminals don't have a MIME type + loadCategoryModel(root.appCategory.Terminal, "TerminalEmulator"); + getDefaultTerminal(); + break; + case root.appCategory.WebBrowser: + // When using the MIME type, stuff like dms-run shows up. + // It's probably better to use the category. + loadCategoryModel(root.appCategory.WebBrowser, "WebBrowser"); + DesktopService.getDefaultApp(mimeMapping[category][0], category.toString()); + break; + case root.appCategory.FileManager: + // Use categories for file managers instead, + // you don't want Kate as your file manager just because it can open folders + loadCategoryModel(root.appCategory.FileManager, "FileManager"); + DesktopService.getDefaultApp(mimeMapping[category][0], category.toString()); + break; + default: + const mimeType = mimeMapping[category][0]; + DesktopService.getDefaultApp(mimeType, category.toString()); + DesktopService.getAppsForMimeType(mimeType, category.toString()); + break; + } + }); + } + + function getDefaultTerminal() { + // Run xdg-terminal-exec to get the default terminal + const proc = xdgGetDefaultTerminal.createObject(root, { + running: true + }); + } + + function setDefaultTerminal(terminalId) { + // Write to xdg-terminals.list + const proc = xdgSetDefaultTerminal.createObject(root, { + terminalId: terminalId, + running: true + }); + } + + Component { + id: xdgSetDefaultTerminal + Process { + property string terminalId: "" + property string configPath: Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config") + command: ["sh", "-c", `echo "${terminalId}.desktop" > "${configPath}/xdg-terminals.list"`] + onExited: (exitCode, exitStatus) => { + if (exitCode != 0) { + log.error("Failed to write xdg-terminals.list, exit code:", exitCode); + } + destroy(); + } + } + } + + Component { + id: xdgGetDefaultTerminal + Process { + property string configPath: Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config") + + command: ["sh", "-c", `cat '${configPath}/xdg-terminals.list'`] + stdout: StdioCollector { + onStreamFinished: { + const defaultTerminal = text.trim(); + if (defaultTerminal) { + root.currentTerminalAppId = defaultTerminal; + } else { + log.warn("No default terminal found"); + } + } + } + stderr: StdioCollector { + onStreamFinished: { + if (text.trim().length > 0) { + log.error("Error getting default terminal:", text); + } + } + } + onExited: (exitCode, exitStatus) => { + destroy(); + } + } + } + + Connections { + target: DesktopService + + function onGetAppsForMimeResult(mimeType, appIds, callbackId) { + if (!appIds || appIds.length === 0) { + log.info("No apps found for MIME type:", mimeType); + return; + } + + let categoryIndex = parseInt(callbackId); + let models = Object.assign({}, root.categoryModels); + + models[categoryIndex] = appIds.map(id => { + return { + text: root.getAppDisplayName(id), + value: id + }; + }); + + root.categoryModels = models; + } + + function onGetDefaultAppResult(mimeType, desktopFileId, callbackId) { + if (!desktopFileId) { + log.info("No default app found for MIME type:", mimeType); + return + } + root[propertyName(parseInt(callbackId))] = desktopFileId; + } + } + + component AppSelector: SettingsDropdownRow { + property int category: -1 + options: (root.categoryModels[category] || []).map(opt => opt.text) + enabled: options.length > 0 + emptyText: options.length > 0 ? I18n.tr("Unset", "Unset") : "" + opacity: options.length > 0 ? 1 : 0.5 + currentValue: { + let id = root[propertyName(category)]; + if (!id || id.length === 0) { + return "" + } + return root.getAppDisplayName(id); + } + onValueChanged: val => { + let model = root.categoryModels[category] || []; + let found = model.find(opt => opt.text === val); + if (found) { + if (category === root.appCategory.Terminal) { + root.setDefaultTerminal(found.value); + } else { + // Set the default app for all MIME types in the category + // If the app doesn't support a MIME type, it will be ignored + root.mimeMapping[category].forEach(mimeType => { + DesktopService.setDefaultApp(mimeType, found.value, category.toString()); + }); + } + } + } + } + + // Dropdowns + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + topPadding: 4 + width: Math.min(550, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXL + + SettingsCard { + title: I18n.tr("Internet", "Internet") + iconName: "public" + + AppSelector { + text: I18n.tr("Web Browser", "Web Browser") + tags: ["web", "browser", "internet"] + category: root.appCategory.WebBrowser + description: I18n.tr("Handles links and opens HTML files", "Handles links and opens HTML files") + } + + AppSelector { + text: I18n.tr("Mail", "Mail") + category: root.appCategory.Mail + tags: ["mail", "email"] + description: I18n.tr("Handles mailto links", "Handles mailto links") + } + } + + SettingsCard { + title: I18n.tr("Utilities", "Utilities") + iconName: "terminal" + + AppSelector { + text: I18n.tr("File Manager", "File Manager") + tags: ["file", "manager"] + category: root.appCategory.FileManager + description: I18n.tr("Manages files and directories", "Manages files and directories") + } + AppSelector { + text: I18n.tr("Terminal", "Terminal") + category: root.appCategory.Terminal + tags: ["terminal", "console"] + description: I18n.tr("Used for xdg-terminal-exec", "Used for xdg-terminal-exec") + } + AppSelector { + text: I18n.tr("Calendar", "Calendar") + category: root.appCategory.Calendar + tags: ["calendar", "events"] + description: I18n.tr("Manages calendar events", "Manages calendar events") + } + } + + SettingsCard { + title: I18n.tr("Documents", "Documents") + iconName: "edit_document" + + AppSelector { + text: I18n.tr("Text Editor", "Text Editor") + category: root.appCategory.TextEditor + tags: ["text", "editor"] + description: I18n.tr("For editing plain text files", "For editing plain text files") + } + AppSelector { + text: I18n.tr("PDF Reader", "PDF Reader") + category: root.appCategory.PDFReader + tags: ["pdf", "reader"] + description: I18n.tr("For reading PDF files", "For reading PDF files") + } + } + + SettingsCard { + title: I18n.tr("Multimedia", "Multimedia") + iconName: "movie" + AppSelector { + text: I18n.tr("Image Viewer", "Image Viewer") + category: root.appCategory.ImageViewer + tags: ["image", "viewer"] + description: I18n.tr("Opens image files", "Opens image files") + } + AppSelector { + text: I18n.tr("Video Player", "Video Player") + category: root.appCategory.VideoPlayer + tags: ["video", "player"] + description: I18n.tr("Plays video files", "Plays video files") + } + AppSelector { + text: I18n.tr("Music Player", "Music Player") + category: root.appCategory.MusicPlayer + tags: ["music", "player"] + description: I18n.tr("Plays audio files", "Plays audio files") + } + } + } + } +} diff --git a/quickshell/Services/DesktopService.qml b/quickshell/Services/DesktopService.qml index c0117218..bebcc520 100644 --- a/quickshell/Services/DesktopService.qml +++ b/quickshell/Services/DesktopService.qml @@ -1,5 +1,6 @@ pragma Singleton pragma ComponentBehavior: Bound +import Quickshell.Io import QtQuick import Quickshell @@ -8,6 +9,27 @@ Singleton { id: root property var _cache: ({}) + property bool gioAvailable: false; + // For the queue that setDefaultApp uses + property var _setDefaultAppQueue: [] + property bool _isProcessingQueue: false + + Component.onCompleted: { + checkGioAndXdgMime.running = true; + } + + Process { + id: checkGioAndXdgMime + command: ["sh", "-c", "which gio && which xdg-mime"] + running: false + onExited: (exitCode) => { + if (exitCode === 0) { + root.gioAvailable = true; + } else { + root.gioAvailable = false; + } + } + } function resolveIconPath(moddedAppId) { if (!moddedAppId) @@ -89,4 +111,181 @@ Singleton { _cache[moddedAppId] = result; return result; } + + + // Set default app for a MIME type + Component { + id: gioSetDefaultApp + + Process { + property string targetMimeType: "" + property string targetDesktopFileId: "" + property string callbackId: "" + + // Check if the app actually supports the MIME type before setting it as default + // This uses a shell script + command: ["sh", "-c", ` + apps=$(gio mime "${targetMimeType}" 2>/dev/null | grep -v "^Default" | awk '{print $1}') + if echo "$apps" | grep -Fxq "${targetDesktopFileId}"; then + xdg-mime default "${targetDesktopFileId}" "${targetMimeType}" + gio mime "${targetMimeType}" "${targetDesktopFileId}" + fi + `] + + onExited: (exitCode, exitStatus) => { + const success = (exitCode === 0) + if (!success) { + log.error("DesktopService: failed to set default app for", targetMimeType, "to", targetDesktopFileId, "(exit code:", exitCode + ")") + } + root._processDefaultAppQueue() + destroy() + } + } + } + + function setDefaultApp(mimeType, desktopFileId, callbackId = "") { + // Add .desktop in case it's missing, xdg-mime needs it + if (!desktopFileId.endsWith(".desktop")) { + desktopFileId += ".desktop"; + } + + // Queue the request to avoid race conditions + _setDefaultAppQueue.push({ + mimeType: mimeType, + desktopFileId: desktopFileId, + callbackId: callbackId + }) + + // Start processing the queue if not already running + if (!_isProcessingQueue) { + _processDefaultAppQueue() + } + } + + function _processDefaultAppQueue() { + if (_setDefaultAppQueue.length === 0) { + _isProcessingQueue = false; + return; + } + + _isProcessingQueue = true; + const request = _setDefaultAppQueue.shift(); + + const proc = gioSetDefaultApp.createObject(root, { + targetMimeType: request.mimeType, + targetDesktopFileId: request.desktopFileId, + callbackId: request.callbackId, + running: true + }) + + if (!proc) { + log.warn("DesktopService: couldn't create process for", request.mimeType, request.desktopFileId) + _processDefaultAppQueue() + } + } + + + + // Get default app for a MIME type + Component { + id: xdgGetDefaultApp + + Process { + property string targetMimeType: "" + property string callbackId: "" + + stdout: StdioCollector { + onStreamFinished: { + const desktopFileId = text.trim(); + root.getDefaultAppResult(targetMimeType, desktopFileId, callbackId); + } + } + + stderr: StdioCollector { + onStreamFinished: { + if (text.trim().length > 0) { + log.error("DesktopService: xdg-mime query error:", text, "mime:", targetMimeType) + } + } + } + + onExited: (exitCode, exitStatus) => { destroy() } + } + } + + function getDefaultApp(mimeType, callbackId = "") { + const proc = xdgGetDefaultApp.createObject(root, { + targetMimeType: mimeType, + callbackId: callbackId, + command: ["xdg-mime", "query", "default", mimeType], + running: true + }) + + if (!proc) { + log.warn("DesktopService: couldn't create process for", mimeType) + } + } + + signal getDefaultAppResult(string mimeType, string desktopFileId, string callbackId) + + + + // Get apps that support a MIME type + Component { + id: gioGetAppsForMime + + Process { + property string targetMimeType: "" + property string callbackId: "" + + stdout: StdioCollector { + onStreamFinished: { + const lines = text.split("\n"); + let appIds = []; + let seen = {}; + + for (let line of lines) { + const trimmed = line.trim(); + if ( + trimmed && + trimmed.endsWith(".desktop") && + !trimmed.startsWith("Default") && + !trimmed.startsWith("default=") + ) { + if (!seen[trimmed]) { + seen[trimmed] = true; + appIds.push(trimmed); + } + } + } + root.getAppsForMimeResult(targetMimeType, appIds, callbackId); + } + } + + stderr: StdioCollector { + onStreamFinished: { + if (text.trim().length > 0) { + log.error("DesktopService: gio mime query error:", text, "command:", command, "mime:", targetMimeType); + } + } + } + + onExited: (exitCode, exitStatus) => { destroy() } + } + } + + function getAppsForMimeType(mimeType, callbackId = "") { + const proc = gioGetAppsForMime.createObject(root, { + targetMimeType: mimeType, + callbackId: callbackId, + command: ["gio", "mime", mimeType], + running: true + }); + + if (!proc) { + log.warn("DesktopService: couldn't create process for", mimeType) + } + } + + signal getAppsForMimeResult(string mimeType, var appIds, string callbackId) }