298 lines
9.7 KiB
QML
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()
|
|
}
|
|
}
|
|
}
|