Files
Panama/config/dot/quickshell/modules/settings/NotificationAppRow.qml
T

298 lines
9.7 KiB
QML

// One application's notification rule: a line you can read, unfolding in place
// into the four controls that make up the rule.
//
// The head is hand-built rather than a SettingRow because it needs two things
// SettingRow has no room for -- the application's own icon, and a subtitle
// whose second half is colored when the rule is no longer the default, so a
// muted or redirected application is obvious without opening it.
//
// Every write leaves through `edited`/`forgotten`. The page owns the service
// calls, so a rule changed here goes down exactly the same path as one changed
// anywhere else.
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.config
Column {
id: root
required property string appId
required property string appName
// Notifs.appRule(appId), passed in as a binding so a rule changed elsewhere
// still reaches this row.
required property var rule
// Relative last-seen ("Today", "3 days ago"), or "" when nothing has been
// recorded for this application yet.
property string lastSeen: ""
// The live-resolved icon name from Notifs.applications. Falls back to the
// one cached on the rule, which is what a no-longer-installed application
// still has.
property string iconName: ""
property bool expanded: false
property bool divider: true
signal activated
signal edited(var patch)
signal forgotten
// What is different from the defaults, in the page's own words. Empty when
// the rule is untouched, which is what puts the raw id in the subtitle
// instead -- an honest identity beats an empty half-line.
readonly property string stateSummary: {
const parts = [];
if (root.rule.enabled === false)
parts.push("Notifications off");
if (root.rule.display === "history")
parts.push("History only");
if (root.rule.sound === false)
parts.push("sound off");
if (root.rule.urgency === "low")
parts.push("treat as low");
else if (root.rule.urgency === "critical")
parts.push("treat as critical");
return parts.join(" · ");
}
// Nothing resolvable leaves this empty, and the letter tile below shows
// through instead of a broken image.
readonly property string iconSource: {
const name = root.iconName !== "" ? root.iconName : String(root.rule.icon ?? "");
return name === "" ? "" : Quickshell.iconPath(name, true);
}
// A stable color per application, so the tile is a recognizable shape
// rather than a different color every time the list re-sorts.
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.appId.length; index++)
hash = (hash * 31 + root.appId.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: controls.left
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.appName
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Row {
width: parent.width
spacing: 0
Text {
id: seenLabel
visible: root.lastSeen !== ""
width: visible ? implicitWidth : 0
text: root.lastSeen + " · "
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: Math.max(0, parent.width - seenLabel.width)
text: root.stateSummary === "" ? root.appId : root.stateSummary
color: root.stateSummary === "" ? Theme.fgDim : Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
Row {
id: controls
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 11
SettingsToggle {
anchors.verticalCenter: parent.verticalCenter
checked: root.rule.enabled !== false
onToggled: value => root.edited({ enabled: value })
}
Text {
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
}
// The whole row is the expand target except the trailing controls: a tap
// meant for the switch must not also unfold the row underneath it.
TapHandler {
onTapped: eventPoint => {
if (eventPoint.position.x >= controls.x)
return;
root.activated();
}
}
}
// Indented under the head, so the controls read as belonging to the
// application above them rather than to the card.
Column {
id: body
x: 42
width: Math.max(0, parent.width - 42)
visible: root.expanded
SettingRow {
width: parent.width
label: "Play sound"
detail: "The notification chime, for this application only"
controlWidth: 48
SettingsToggle {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: root.rule.sound !== false
onToggled: value => root.edited({ sound: value })
}
}
OptionPickerRow {
width: parent.width
label: "Show as"
options: [
{
value: "banners",
label: "Banners & history",
detail: "A banner on arrival, kept in the notification center afterwards"
},
{
value: "history",
label: "History only",
detail: "Filed in the notification center with no banner and no sound"
}
]
current: String(root.rule.display ?? "banners")
onPicked: value => root.edited({ display: value })
}
OptionPickerRow {
width: parent.width
label: "Urgency"
detail: "Override what the application claims its notifications are"
options: [
{
value: "auto",
label: "Application decides",
detail: "Whatever urgency the notification arrives with"
},
{
value: "low",
label: "Treat as low",
detail: "Never chimes, and never breaks through Do Not Disturb"
},
{
value: "critical",
label: "Treat as critical",
detail: "Marked urgent, and kept up for the critical duration"
}
]
current: String(root.rule.urgency ?? "auto")
onPicked: value => root.edited({ urgency: value })
}
ActionRow {
width: parent.width
label: "Forget this application"
detail: "Remove its rule; it returns on its next notification"
action: "Forget"
divider: false
onTriggered: root.forgotten()
}
}
}