From 93bd3eecd1ecd02dd6a3a06ddb5a5be255721afa Mon Sep 17 00:00:00 2001 From: Thomas Kroll Date: Mon, 12 Oct 2026 17:52:59 +0200 Subject: [PATCH] notifications: allow extra conditions per rule (#3683) * notifications: allow extra conditions per rule Co-Authored-By: Claude Opus 5.5 * notifications: add translator context to the rule condition strings Co-Authored-By: Claude Opus 5.5 * don't use deprecated widgets --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: bbedward --- quickshell/Common/SettingsData.qml | 2 +- .../Settings/NotificationRuleEditorDialog.qml | 92 +++++++++++++++++++ .../Modules/Settings/NotificationRulesTab.qml | 6 +- quickshell/Services/NotificationService.qml | 36 ++++---- 4 files changed, 117 insertions(+), 19 deletions(-) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index f72cfad3e..e863470fb 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -3399,7 +3399,7 @@ Singleton { if (!app && !desktop) return -1; return rules.findIndex(rule => { - if (!predicate(rule) || (rule.matchType || "contains").toString().toLowerCase() !== "exact") + if (!predicate(rule) || (rule.matchType || "contains").toString().toLowerCase() !== "exact" || (rule.conditions || []).length > 0) return false; const pattern = (rule.pattern || "").toString().toLowerCase(); const value = !rule.field || rule.field === "appName" ? app : rule.field === "desktopEntry" ? desktop : ""; diff --git a/quickshell/Modules/Settings/NotificationRuleEditorDialog.qml b/quickshell/Modules/Settings/NotificationRuleEditorDialog.qml index d6a5712a0..799dc063c 100644 --- a/quickshell/Modules/Settings/NotificationRuleEditorDialog.qml +++ b/quickshell/Modules/Settings/NotificationRuleEditorDialog.qml @@ -20,6 +20,7 @@ DDialog { property string ruleAction: "" property string ruleUrgency: "" property bool ruleBypassDnd: false + property var ruleConditions: [] readonly property bool isEditMode: editingIndex >= 0 signal ruleSubmitted @@ -54,6 +55,11 @@ DDialog { ruleAction = rule.action || actionOptions[0].value; ruleUrgency = rule.urgency || urgencyOptions[0].value; ruleBypassDnd = rule.bypassDnd === true; + ruleConditions = (rule.conditions || []).map(condition => ({ + field: condition.field || fieldOptions[0].value, + matchType: condition.matchType || matchTypeOptions[0].value, + pattern: condition.pattern || "" + })); patternInput.text = rule.pattern || ""; fieldDropdown.currentValue = labelOf(fieldOptions, ruleField); matchTypeDropdown.currentValue = labelOf(matchTypeOptions, ruleMatchType); @@ -66,6 +72,7 @@ DDialog { field: ruleField, pattern: patternInput.text, matchType: ruleMatchType, + conditions: ruleConditions.filter(condition => condition.pattern.trim() !== "").map(condition => Object.assign({}, condition)), action: ruleAction, urgency: ruleUrgency, bypassDnd: ruleBypassDnd @@ -77,6 +84,20 @@ DDialog { ruleSubmitted(); } + function addCondition() { + ruleConditions = ruleConditions.concat([ + { + field: fieldOptions[0].value, + matchType: matchTypeOptions[0].value, + pattern: "" + } + ]); + } + + function removeCondition(index) { + ruleConditions = ruleConditions.filter((_, i) => i !== index); + } + actions: [ DButton { text: I18n.tr("Cancel") @@ -129,6 +150,77 @@ DDialog { } } } + + // Edits go to the array entry in place: modelData is a copy, and reassigning the array would rebuild the rows and drop focus. + Repeater { + model: root.ruleConditions + + delegate: SettingsRow { + id: conditionRow + + required property var modelData + required property int index + + body: Row { + width: parent.width + spacing: Theme.spacingS + + DDropdown { + id: conditionFieldDropdown + width: Math.round(parent.width / 3) + anchors.verticalCenter: parent.verticalCenter + compactMode: true + Accessible.name: I18n.tr("Field", "notification rule dropdown label, which notification field to match") + options: root.fieldOptions.map(option => option.label) + currentValue: root.labelOf(root.fieldOptions, conditionRow.modelData.field) + onValueChanged: value => root.ruleConditions[conditionRow.index].field = root.valueOf(root.fieldOptions, value) + } + + DDropdown { + id: conditionMatchTypeDropdown + width: Math.round(parent.width / 4) + anchors.verticalCenter: parent.verticalCenter + compactMode: true + Accessible.name: I18n.tr("Match type", "notification rule dropdown label, how the pattern is compared") + options: root.matchTypeOptions.map(option => option.label) + currentValue: root.labelOf(root.matchTypeOptions, conditionRow.modelData.matchType) + onValueChanged: value => root.ruleConditions[conditionRow.index].matchType = root.valueOf(root.matchTypeOptions, value) + } + + DTextField { + id: conditionPatternInput + width: parent.width - conditionFieldDropdown.width - conditionMatchTypeDropdown.width - removeConditionButton.width - parent.spacing * 3 + anchors.verticalCenter: parent.verticalCenter + outlined: true + placeholderText: I18n.tr("Pattern", "noun, text field label for a match or filter pattern") + Component.onCompleted: text = conditionRow.modelData.pattern + onTextEdited: root.ruleConditions[conditionRow.index].pattern = text + onAccepted: root.submit() + } + + DActionButton { + id: removeConditionButton + anchors.verticalCenter: parent.verticalCenter + iconName: "delete" + iconColor: Theme.error + Accessible.name: I18n.tr("Remove condition", "button in the notification rule editor, removes this match condition") + onClicked: root.removeCondition(conditionRow.index) + } + } + } + } + + SettingsRow { + subtitle: root.ruleConditions.length > 0 ? I18n.tr("A notification must match every condition", "hint in the notification rule editor, all match conditions must apply") : "" + + DButton { + text: I18n.tr("Add condition", "button in the notification rule editor, adds another match condition") + iconName: "add" + backgroundColor: "transparent" + textColor: Theme.primary + onClicked: root.addCondition() + } + } } SettingsCard { diff --git a/quickshell/Modules/Settings/NotificationRulesTab.qml b/quickshell/Modules/Settings/NotificationRulesTab.qml index f2ec51c9d..d69b43ebd 100644 --- a/quickshell/Modules/Settings/NotificationRulesTab.qml +++ b/quickshell/Modules/Settings/NotificationRulesTab.qml @@ -109,7 +109,11 @@ Item { } function matchSummary(rule) { - return [getRuleOptionLabel(notificationRuleFieldOptions, rule.field, notificationRuleFieldOptions[0].label), getRuleOptionLabel(notificationRuleMatchTypeOptions, rule.matchType, notificationRuleMatchTypeOptions[0].label)].join(" · "); + const parts = [getRuleOptionLabel(notificationRuleFieldOptions, rule.field, notificationRuleFieldOptions[0].label), getRuleOptionLabel(notificationRuleMatchTypeOptions, rule.matchType, notificationRuleMatchTypeOptions[0].label)]; + const extra = (rule.conditions || []).length; + if (extra > 0) + parts.push(I18n.tr("+%1 conditions", "notification rule summary, %1 is the number of extra conditions").arg(extra)); + return parts.join(" · "); } function remainingLabel(rule) { diff --git a/quickshell/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml index 6779b9fc6..9991871f0 100644 --- a/quickshell/Services/NotificationService.qml +++ b/quickshell/Services/NotificationService.qml @@ -458,25 +458,27 @@ Singleton { if (rule.enabled === false) return false; - const pattern = (rule.pattern || "").toString(); - if (!pattern.trim()) - return false; - - const value = (_ruleFieldValue(rule.field, info) || "").toString(); - const matchType = (rule.matchType || "contains").toString().toLowerCase(); - - if (matchType === "exact") - return value.toLowerCase() === pattern.toLowerCase(); - if (matchType === "regex") { - try { - return new RegExp(pattern, "i").test(value); - } catch (e) { - log.warn("invalid notification rule regex:", pattern); + return [rule, ...(rule.conditions || [])].every(condition => { + const pattern = (condition.pattern || "").toString(); + if (!pattern.trim()) return false; - } - } - return value.toLowerCase().includes(pattern.toLowerCase()); + const value = (_ruleFieldValue(condition.field, info) || "").toString(); + const matchType = (condition.matchType || "contains").toString().toLowerCase(); + + if (matchType === "exact") + return value.toLowerCase() === pattern.toLowerCase(); + if (matchType === "regex") { + try { + return new RegExp(pattern, "i").test(value); + } catch (e) { + log.warn("invalid notification rule regex:", pattern); + return false; + } + } + + return value.toLowerCase().includes(pattern.toLowerCase()); + }); } function _allowedInDnd(urgency, bypassDnd) {