// 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 coloured 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 colour per application, so the tile is a recognisable shape // rather than a different colour 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 app claims its notifications are" options: [ { value: "auto", label: "App 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 app" detail: "Remove its rule; it returns on its next notification" action: "Forget" divider: false onTriggered: root.forgotten() } } }