403 lines
15 KiB
QML
403 lines
15 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 colour 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.
|
|
Rectangle {
|
|
id: badge
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: badgeText.implicitWidth + 14
|
|
height: 17
|
|
radius: Theme.pillRadius
|
|
color: root.flatpak
|
|
? Theme.alpha(Theme.cyan, 0.09)
|
|
: Theme.alpha(Theme.fgMuted, 0.12)
|
|
border.width: 1
|
|
border.color: root.flatpak
|
|
? Theme.alpha(Theme.cyan, 0.28)
|
|
: Theme.alpha(Theme.fgMuted, 0.3)
|
|
|
|
Text {
|
|
id: badgeText
|
|
|
|
anchors.centerIn: parent
|
|
text: root.flatpak ? "FLATPAK" : "SYSTEM"
|
|
color: root.flatpak ? Theme.cyan : Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
|
|
font.weight: Font.DemiBold
|
|
}
|
|
}
|
|
}
|
|
|
|
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 }
|
|
}
|
|
}
|