Files

384 lines
14 KiB
QML

// 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 }
}
}