// One installed application: a line you can read, unfolding in place into // everything this desktop can honestly say about it. // // Built like NotificationAppRow rather than out of SettingRow, for the same // reason -- the head carries the application's own icon and a badge saying // where it came from, and neither fits a row whose trailing slot is one fixed // width. What is different here is that the body's contents depend on where the // application came from: a Flatpak can be inspected and removed, a system // package can only be described. // // Nothing in here writes. Every control leaves through a signal so the page // owns the service calls, which is what keeps the uninstall confirmation a // single piece of page state rather than something each row remembers for // itself. import QtQuick import Quickshell import Quickshell.Widgets import qs.config Column { id: root // { entryId, name, icon, kind: "flatpak"|"system", flatpakId, size } required property var app property bool expanded: false property bool divider: true property bool busy: false // Whether ~/.config/autostart currently starts this application. property bool autostart: false // The helper's curated permission summary, as a list of human strings. // Empty while it is still being read, which the row says rather than // showing a blank line. property var permissionSummary: [] property bool permissionsKnown: false property bool flatsealAvailable: false // Jump chips appear only where a rule for this application actually // exists somewhere else. A chip that lands on a page with nothing about // this application on it is worse than no chip. property bool hasNotificationRule: false property bool hasSoundRule: false property bool hasPrivacyRule: false // Set by the page while this row's uninstall is one press from happening. property bool confirmingUninstall: false signal activated signal autostartToggled(bool value) signal jumped(string page) signal flatsealRequested signal uninstallArmed signal uninstallCancelled signal uninstallConfirmed readonly property string appName: String(root.app?.name ?? "") readonly property string entryId: String(root.app?.entryId ?? "") readonly property string flatpakId: String(root.app?.flatpakId ?? "") readonly property bool flatpak: String(root.app?.kind ?? "") === "flatpak" // The dnf package name, for the refusal row's exact command. The desktop // entry id is the closest thing to it this desktop can know without asking // rpm about every application on the machine, so the row says "the package // that provides" rather than claiming the name is the package. readonly property string systemName: { const id = root.entryId.replace(/\.desktop$/, ""); const short = id.split(".").pop(); return short !== "" ? short : id; } // `sizeBytes` when the helper could turn flatpak's answer into a number and // `size` -- flatpak's own "412.5 MB" -- when it could not. Whichever // arrived is shown as it arrived, rather than one being coerced into the // other and rounded to nothing. readonly property string subtitle: { if (!root.flatpak) return "Installed by the system package manager"; const bytes = Number(root.app?.sizeBytes ?? 0); if (Number.isFinite(bytes) && bytes > 0) return root.flatpakId + " · " + root.formatBytes(bytes); const text = String(root.app?.size ?? "").trim(); return text === "" || text === "0" ? root.flatpakId : root.flatpakId + " · " + text; } function formatBytes(bytes: real): string { if (!(bytes > 0)) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let value = bytes; let index = 0; while (value >= 1000 && index < units.length - 1) { value /= 1000; index += 1; } return value.toFixed(value < 10 && index > 1 ? 1 : 0) + " " + units[index]; } readonly property string iconSource: { const name = String(root.app?.icon ?? ""); return name === "" ? "" : Quickshell.iconPath(name, true); } // A stable color per application, so the tile keeps its identity as the // list re-sorts or the search narrows it. readonly property color tileColor: { const palette = [Theme.accent, Theme.teal, Theme.magenta, Theme.cyan, Theme.green, Theme.orange, Theme.pink]; let hash = 0; for (let index = 0; index < root.entryId.length; index++) hash = (hash * 31 + root.entryId.charCodeAt(index)) % 9973; return palette[hash % palette.length]; } width: parent ? parent.width : 620 spacing: 0 Item { id: head width: parent.width implicitHeight: Math.max(56, copy.implicitHeight + 20) Rectangle { anchors.fill: parent anchors.bottomMargin: 1 radius: 9 z: -1 visible: headHover.hovered color: Theme.alpha(Theme.fg, 0.05) border.width: 0 } Rectangle { id: tile anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter width: 30 height: 30 radius: 8 color: root.iconSource === "" ? root.tileColor : "transparent" border.width: 0 Text { anchors.centerIn: parent visible: root.iconSource === "" text: root.appName.length > 0 ? root.appName.charAt(0).toUpperCase() : "?" color: Theme.bgDark font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: Font.Bold } IconImage { anchors.centerIn: parent implicitSize: 24 asynchronous: true visible: root.iconSource !== "" source: root.iconSource } } Column { id: copy anchors.left: tile.right anchors.leftMargin: 12 anchors.right: chevron.left anchors.rightMargin: 16 anchors.verticalCenter: parent.verticalCenter spacing: 2 Row { width: parent.width spacing: 8 Text { anchors.verticalCenter: parent.verticalCenter width: Math.min(implicitWidth, Math.max(0, parent.width - badge.width - 8)) text: root.appName color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: Font.Medium elide: Text.ElideRight } // Where it came from, said once and in the same place on every // row -- the difference decides what the body can offer. StatusBadge { id: badge anchors.verticalCenter: parent.verticalCenter text: root.flatpak ? "Flatpak" : "System" tone: root.flatpak ? Theme.cyan : Theme.fgMuted } } Text { width: parent.width text: root.subtitle color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } } Text { id: chevron anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.expanded ? "▴" : "▾" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } Rectangle { anchors.left: copy.left anchors.right: parent.right anchors.bottom: parent.bottom height: 1 visible: root.divider && !root.expanded color: Theme.alpha(Theme.fg, 0.065) } HoverHandler { id: headHover cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: root.activated() } } // Indented under the head, so what is inside reads as belonging to the // application above it rather than to the card. Column { id: body x: 42 width: Math.max(0, parent.width - 42) visible: root.expanded // ── What it can reach ──────────────────────────────────────────────── SettingRow { width: parent.width visible: root.flatpak label: "Permissions" detail: !root.permissionsKnown ? "Reading what this application is allowed to reach…" : (root.permissionSummary.length > 0 ? root.permissionSummary.join(" · ") : "Nothing beyond its own sandbox") controlWidth: root.flatsealAvailable ? 130 : 0 SettingsButton { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: root.flatsealAvailable text: "Open Flatseal" onClicked: root.flatsealRequested() } } // The honest refusal, with the command that does the thing this page // will not. Removing a system package can take the desktop with it, so // it happens where the consequences are visible. TextRow { width: parent.width visible: !root.flatpak label: "Managed by dnf" detail: "Settings does not remove system packages — removing one can take the desktop" + " with it. To remove the package that provides this: sudo dnf remove " + root.systemName value: "" } // ── What it does at sign-in ────────────────────────────────────────── SettingRow { width: parent.width label: "Start with the session" detail: root.autostart ? "An entry in ~/.config/autostart starts this when you sign in" : "Nothing starts this automatically" controlWidth: 48 divider: root.flatpak || root.hasNotificationRule || root.hasSoundRule || root.hasPrivacyRule SettingsToggle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter checked: root.autostart onToggled: value => root.autostartToggled(value) } } // ── Where its other settings live ──────────────────────────────────── SettingRow { width: parent.width visible: root.hasNotificationRule || root.hasSoundRule || root.hasPrivacyRule label: "Elsewhere in Settings" detail: "This application already has a rule on these pages" controlWidth: 270 divider: root.flatpak Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 7 SettingsChip { anchors.verticalCenter: parent.verticalCenter visible: root.hasNotificationRule text: "Notifications" onClicked: root.jumped("notifications") } SettingsChip { anchors.verticalCenter: parent.verticalCenter visible: root.hasSoundRule text: "Sound" onClicked: root.jumped("sound") } SettingsChip { anchors.verticalCenter: parent.verticalCenter visible: root.hasPrivacyRule text: "Privacy" onClicked: root.jumped("privacy") } } } // ── Removing it ────────────────────────────────────────────────────── SettingRow { width: parent.width visible: root.flatpak label: "Uninstall" detail: root.confirmingUninstall ? "This removes the application. Data it kept under ~/.var/app stays behind." : "Removes the application, and its runtime if nothing else needs it" controlWidth: 210 divider: false Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 9 SettingsButton { anchors.verticalCenter: parent.verticalCenter visible: root.confirmingUninstall text: "Uninstall it" tone: "danger" enabled: !root.busy onClicked: root.uninstallConfirmed() } SettingsButton { anchors.verticalCenter: parent.verticalCenter text: root.confirmingUninstall ? "Keep" : "Uninstall…" enabled: !root.busy onClicked: root.confirmingUninstall ? root.uninstallCancelled() : root.uninstallArmed() } } } Item { width: 1; height: 6 } } }