// Applications and session startup. import Quickshell import QtQuick import qs.services SettingsPage { id: root objectName: "applications" title: "Applications" lede: "Choose what opens your files and links, and what starts with your session." property string expandedRole: "" property bool addingAutostart: false // Which entry has been asked to be removed. Removal deletes a file, so // it never happens on a first press. property string confirmingAutostartRemoval: "" readonly property var applications: DesktopEntries.applications.values // Each role governs a whole family of types, not one representative: setting // "Images" writes PNG, JPEG, WebP and the rest together, so a file manager // can never open one image in a viewer and its neighbour in an editor. // The detail line names the family the way someone would describe it. readonly property var roles: [ { key: "browser", label: "Browser", detail: "Web links and HTML pages", categorySets: [["webbrowser"]], terms: ["web browser", "browser"] }, { key: "mail", label: "Mail", detail: "Email links", categorySets: [["email"]], terms: ["mail client", "email client"] }, { key: "files", label: "Files", detail: "Folders and file locations", categorySets: [["filemanager"]], terms: ["file manager"] }, { key: "terminal", label: "Terminal", detail: "Terminal links and command-line handoffs", categorySets: [["terminalemulator"]], terms: ["terminal emulator", "terminal"] }, { key: "images", label: "Images", detail: "PNG, JPEG, GIF, WebP, SVG and other pictures", categorySets: [], terms: ["image viewer", "image editor", "photo viewer", "photo editor", "picture viewer"] }, { key: "music", label: "Music", detail: "MP3, FLAC, Ogg and other audio", categorySets: [["music"], ["audio", "player"]], terms: ["music player", "audio player"] }, { key: "video", label: "Video", detail: "MP4, MKV, WebM and other video", categorySets: [["video"]], terms: ["video player", "movie player"] }, { key: "documents", label: "Documents", detail: "PDF and EPUB documents", categorySets: [["office", "viewer"]], terms: ["document viewer", "pdf viewer", "ebook", "e-book"] }, { key: "text", label: "Text", detail: "Plain text, Markdown, and source files", categorySets: [["texteditor"]], terms: ["text editor", "code editor"] }, { key: "archives", label: "Archives", detail: "Zip, tar, and other archives", categorySets: [["archiving"], ["filemanager"]], terms: ["archive manager", "file roller", "file manager"] } ] function desktopId(entry: var): string { const entryId = String(entry?.id ?? ""); return entryId.endsWith(".desktop") ? entryId : entryId + ".desktop"; } function displayName(entry: var): string { return String(entry?.name || entry?.genericName || root.desktopId(entry)); } function currentHandler(role: string): string { return String(DefaultApps.handlers[role] ?? ""); } function currentEntry(role: string): var { const handler = root.currentHandler(role); return root.applications.find(entry => root.desktopId(entry) === handler) ?? null; } function matchesRole(entry: var, role: var): bool { // DesktopEntries hands back a QML list, not a JavaScript array, so // Array.isArray is false for it. The old code took that as "this is a // string", stringified the list into "Network,WebBrowser" and then split // on ";" only -- producing the single token "network,webbrowser", which // matches no category at all. // // Nothing failed loudly. Browsers still appeared because their generic // name contains "web browser", so the terms fallback carried the role // by itself. Archives matched NOTHING, which meant that row could only // ever offer the application it already had. // // Joining first and splitting on both separators handles the list form // and a plain string equally. const raw = entry.categories; const joined = Array.isArray(raw) ? raw.join(";") : String(raw ?? ""); const categories = []; for (const value of joined.split(/[;,]/)) { const category = value.trim().toLowerCase(); if (category !== "") categories.push(category); } const metadata = [entry.name, entry.genericName] .map(value => String(value ?? "").toLowerCase()) .join(" "); return role.categorySets.some(set => set.every(category => categories.includes(category))) || role.terms.some(term => metadata.includes(term)); } function choicesForRole(role: var): var { const choices = root.applications.filter(entry => root.matchesRole(entry, role)); const currentEntry = root.currentEntry(role.key); if (currentEntry && !choices.some(entry => root.desktopId(entry) === root.desktopId(currentEntry))) choices.push(currentEntry); return choices.sort((left, right) => root.displayName(left).localeCompare(root.displayName(right))); } TextRow { visible: DefaultApps.lastError !== "" label: "Application settings need attention" detail: DefaultApps.lastError value: "" divider: false } SettingsCard { title: "Default applications" subtitle: "Open a row to choose from applications that advertise the matching role." Repeater { model: root.roles delegate: Column { id: roleBlock required property var modelData required property int index readonly property var choices: root.choicesForRole(roleBlock.modelData) readonly property var selectedEntry: root.currentEntry(roleBlock.modelData.key) width: parent.width SettingRow { id: roleRow readonly property bool open: root.expandedRole === roleBlock.modelData.key label: roleBlock.modelData.label detail: roleBlock.modelData.detail activatable: roleBlock.choices.length > 0 && !DefaultApps.busy controlWidth: 210 // Drawn rather than left to SettingRow's plain value text, so // the row carries the same chevron a PickerRow does. These // open a chooser but looked completely inert without it. Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 9 Text { anchors.verticalCenter: parent.verticalCenter text: DefaultApps.busy ? "Loading…" : ( roleBlock.selectedEntry ? root.displayName(roleBlock.selectedEntry) : (root.currentHandler(roleBlock.modelData.key) || "Not set") ) color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSize elide: Text.ElideRight } Text { anchors.verticalCenter: parent.verticalCenter visible: roleBlock.choices.length > 0 text: roleRow.open ? "\u25B4" : "\u25BE" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } divider: root.expandedRole !== roleBlock.modelData.key && roleBlock.index < root.roles.length - 1 onActivated: { root.expandedRole = root.expandedRole === roleBlock.modelData.key ? "" : roleBlock.modelData.key; } } Column { width: parent.width visible: root.expandedRole === roleBlock.modelData.key Repeater { model: roleBlock.choices delegate: SettingRow { id: candidateRow required property var modelData required property int index readonly property string candidateId: root.desktopId(candidateRow.modelData) readonly property bool selected: candidateRow.candidateId === root.currentHandler(roleBlock.modelData.key) label: root.displayName(candidateRow.modelData) detail: String(candidateRow.modelData.genericName || candidateRow.modelData.comment || candidateRow.candidateId) value: candidateRow.selected ? "Current" : "" activatable: !candidateRow.selected && !DefaultApps.busy divider: candidateRow.index < roleBlock.choices.length - 1 || roleBlock.index < root.roles.length - 1 onActivated: { DefaultApps.setDefault(roleBlock.modelData.key, candidateRow.candidateId); root.expandedRole = ""; } } } } } } } // GNOME's Search panel, answered honestly. // // It configures which applications provide results in gnome-shell's // overview and which folders are indexed. gnome-shell does not run here, // so those settings would do nothing. Under Panama searching is the // launcher's job, and Vicinae carries its own preferences -- reimplementing // them here would give two places to change one thing. SettingsCard { title: "Search" subtitle: "Applications, files, the calculator, clipboard history, emoji, and open windows are all searched from the launcher." TextEntryRow { setting: "webSearchUrl"; placeholder: "https://duckduckgo.com/?q=" } TextRow { label: "Launcher" detail: SystemSettings.vicinaeActive ? "Running as a user service" : "Not running — Super+Shift+R opens the fallback launcher" value: "Vicinae" } TextRow { label: "Open search" detail: "Three keys open it, because Super+A and Super+R were GNOME's app grid and run dialog" value: "Super+Space" } ActionRow { label: "Change these shortcuts" detail: "Every launcher chord is rebindable, including clipboard history and emoji" action: "Open keyboard" divider: false onTriggered: ShellState.openSettings("shortcuts") } } SettingsCard { title: "User autostart" subtitle: "Choose what starts with your session. Entries live in your user configuration, not the compositor." ActionRow { label: "Add an application" detail: root.addingAutostart ? "Search the applications installed on this machine" : "Start another installed application when you sign in" action: root.addingAutostart ? "Close" : "Choose" divider: !root.addingAutostart || DefaultApps.autostartEntries.length > 0 enabled: !DefaultApps.busy onTriggered: root.addingAutostart = !root.addingAutostart } AutostartAppPicker { visible: root.addingAutostart width: parent.width existing: DefaultApps.autostartEntries.map(entry => entry.id) onPicked: id => { DefaultApps.addAutostart(id); root.addingAutostart = false; } } TextRow { visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0 label: "No user autostart entries" detail: "Applications can add entries to ~/.config/autostart." value: "" divider: false } Repeater { model: DefaultApps.autostartEntries delegate: SettingRow { id: autostartRow required property var modelData required property int index readonly property bool confirming: root.confirmingAutostartRemoval === String(autostartRow.modelData.id) label: autostartRow.modelData.name detail: autostartRow.confirming ? "Removing deletes this entry. Turning it off instead is reversible." : autostartRow.modelData.id divider: autostartRow.index < DefaultApps.autostartEntries.length - 1 controlWidth: 210 // A switch, not the words "Enabled"/"Disabled". The row always // toggled on click, but read as static text, so a control that // worked looked like a status nobody could change. Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 9 SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: autostartRow.confirming text: "Remove it" tone: "danger" enabled: !DefaultApps.busy onClicked: { root.confirmingAutostartRemoval = ""; DefaultApps.removeAutostart(String(autostartRow.modelData.id)); } } SettingsButton { anchors.verticalCenter: parent.verticalCenter text: autostartRow.confirming ? "Keep" : "Remove…" enabled: !DefaultApps.busy onClicked: root.confirmingAutostartRemoval = autostartRow.confirming ? "" : String(autostartRow.modelData.id) } SettingsToggle { anchors.verticalCenter: parent.verticalCenter checked: autostartRow.modelData.enabled onToggled: value => DefaultApps.setAutostart(autostartRow.modelData.id, value) } } } } } SettingsCard { title: "Compositor autostart" subtitle: "These are started from the Hyprland configuration. They are read-only here." TextRow { visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0 label: "No compositor entries found" detail: "No hl.exec_cmd entries were found in config/dot/hypr/autostart.lua." value: "" divider: false } Repeater { model: DefaultApps.luaAutostartEntries delegate: TextRow { id: luaRow required property var modelData required property int index label: luaRow.modelData.name detail: luaRow.modelData.command value: "Hyprland" divider: luaRow.index < DefaultApps.luaAutostartEntries.length - 1 } } } SettingsCard { title: "Refresh" ActionRow { label: "Reload application settings" detail: "Re-read desktop entries, defaults, and user autostart files" action: DefaultApps.busy ? "Refreshing…" : "Refresh" enabled: !DefaultApps.busy divider: false onTriggered: DefaultApps.refresh() } } }