From 2bf85bc4dd8f1103f1c4088cef0e5a292b3d6b20 Mon Sep 17 00:00:00 2001 From: bbedward Date: Sat, 3 Jan 2026 13:08:48 -0500 Subject: [PATCH] motifications: add support for configurable persistent history fixes #929 --- quickshell/Common/SettingsData.qml | 6 + quickshell/Common/settings/SettingsSpec.js | 6 + quickshell/Modals/NotificationModal.qml | 32 +- .../Center/HistoryNotificationCard.qml | 189 ++++++++++++ .../Center/HistoryNotificationList.qml | 276 ++++++++++++++++++ .../Center/NotificationCenterPopout.qml | 25 +- .../Center/NotificationHeader.qml | 208 +++++++------ .../Center/NotificationSettings.qml | 218 +++++++++++--- .../Modules/Settings/NotificationsTab.qml | 99 +++++++ quickshell/Services/NotificationService.qml | 213 +++++++++++++- quickshell/Widgets/DankFilterChips.qml | 101 +++++++ 11 files changed, 1233 insertions(+), 140 deletions(-) create mode 100644 quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml create mode 100644 quickshell/Modules/Notifications/Center/HistoryNotificationList.qml create mode 100644 quickshell/Widgets/DankFilterChips.qml diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 1d81fac5..08981d6c 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -351,6 +351,12 @@ Singleton { property int notificationTimeoutNormal: 5000 property int notificationTimeoutCritical: 0 property int notificationPopupPosition: SettingsData.Position.Top + property bool notificationHistoryEnabled: true + property int notificationHistoryMaxCount: 50 + property int notificationHistoryMaxAgeDays: 7 + property bool notificationHistorySaveLow: true + property bool notificationHistorySaveNormal: true + property bool notificationHistorySaveCritical: true property bool osdAlwaysShowValue: false property int osdPosition: SettingsData.Position.BottomCenter diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 80e74290..5b7d94db 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -240,6 +240,12 @@ var SPEC = { notificationTimeoutNormal: { def: 5000 }, notificationTimeoutCritical: { def: 0 }, notificationPopupPosition: { def: 0 }, + notificationHistoryEnabled: { def: true }, + notificationHistoryMaxCount: { def: 50 }, + notificationHistoryMaxAgeDays: { def: 7 }, + notificationHistorySaveLow: { def: true }, + notificationHistorySaveNormal: { def: true }, + notificationHistorySaveCritical: { def: true }, osdAlwaysShowValue: { def: false }, osdPosition: { def: 5 }, diff --git a/quickshell/Modals/NotificationModal.qml b/quickshell/Modals/NotificationModal.qml index d08f80ba..c3b44554 100644 --- a/quickshell/Modals/NotificationModal.qml +++ b/quickshell/Modals/NotificationModal.qml @@ -18,6 +18,8 @@ DankModal { property bool notificationModalOpen: false property var notificationListRef: null + property var historyListRef: null + property int currentTab: 0 function show() { notificationModalOpen = true; @@ -61,7 +63,7 @@ DankModal { NotificationService.clearAllNotifications(); } - function dismissAllPopups () { + function dismissAllPopups() { NotificationService.dismissAllPopups(); } @@ -80,7 +82,18 @@ DankModal { NotificationService.onOverlayClose(); } } - modalFocusScope.Keys.onPressed: event => modalKeyboardController.handleKey(event) + modalFocusScope.Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + hide(); + event.accepted = true; + return; + } + if (currentTab === 1 && historyListRef) { + historyListRef.handleKey(event); + return; + } + modalKeyboardController.handleKey(event); + } NotificationKeyboardController { id: modalKeyboardController @@ -145,21 +158,20 @@ DankModal { NotificationHeader { id: notificationHeader - keyboardController: modalKeyboardController + onCurrentTabChanged: notificationModal.currentTab = currentTab } NotificationSettings { id: notificationSettings - expanded: notificationHeader.showSettings } KeyboardNavigatedNotificationList { id: notificationList - width: parent.width height: parent.height - y + visible: notificationHeader.currentTab === 0 keyboardController: modalKeyboardController Component.onCompleted: { notificationModal.notificationListRef = notificationList; @@ -169,6 +181,14 @@ DankModal { } } } + + HistoryNotificationList { + id: historyList + width: parent.width + height: parent.height - y + visible: notificationHeader.currentTab === 1 + Component.onCompleted: notificationModal.historyListRef = historyList + } } NotificationKeyboardHints { @@ -178,7 +198,7 @@ DankModal { anchors.left: parent.left anchors.right: parent.right anchors.margins: Theme.spacingL - showHints: modalKeyboardController.showKeyboardHints + showHints: notificationHeader.currentTab === 0 ? modalKeyboardController.showKeyboardHints : historyList.showKeyboardHints } } } diff --git a/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml b/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml new file mode 100644 index 00000000..065b1402 --- /dev/null +++ b/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml @@ -0,0 +1,189 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +Rectangle { + id: root + + required property var historyItem + property bool isSelected: false + property bool keyboardNavigationActive: false + + width: parent ? parent.width : 400 + height: 116 + radius: Theme.cornerRadius + clip: true + + color: { + if (isSelected && keyboardNavigationActive) + return Theme.primaryPressed; + return Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency); + } + border.color: { + if (isSelected && keyboardNavigationActive) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5); + if (historyItem.urgency === 2) + return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3); + return Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05); + } + border.width: { + if (isSelected && keyboardNavigationActive) + return 1.5; + if (historyItem.urgency === 2) + return 2; + return 1; + } + + Behavior on border.color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: historyItem.urgency === 2 + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { + position: 0.0 + color: Theme.primary + } + GradientStop { + position: 0.02 + color: Theme.primary + } + GradientStop { + position: 0.021 + color: "transparent" + } + } + } + + Item { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 12 + anchors.leftMargin: 16 + anchors.rightMargin: 56 + height: 92 + + DankCircularImage { + id: iconContainer + readonly property bool hasNotificationImage: historyItem.image && historyItem.image !== "" + + width: 63 + height: 63 + anchors.left: parent.left + anchors.top: parent.top + anchors.topMargin: 14 + + imageSource: { + if (hasNotificationImage) + return historyItem.image; + if (historyItem.appIcon) { + const appIcon = historyItem.appIcon; + if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://")) + return appIcon; + return Quickshell.iconPath(appIcon, true); + } + return ""; + } + + hasImage: hasNotificationImage + fallbackIcon: "" + fallbackText: { + const appName = historyItem.appName || "?"; + return appName.charAt(0).toUpperCase(); + } + + Rectangle { + anchors.fill: parent + anchors.margins: -2 + radius: width / 2 + color: "transparent" + border.color: root.color + border.width: 5 + visible: parent.hasImage + antialiasing: true + } + } + + Rectangle { + anchors.left: iconContainer.right + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.bottomMargin: 8 + color: "transparent" + + Item { + width: parent.width + height: parent.height + anchors.top: parent.top + anchors.topMargin: -2 + + Column { + width: parent.width + spacing: 2 + + StyledText { + width: parent.width + text: { + const timeStr = NotificationService.formatHistoryTime(historyItem.timestamp); + const appName = historyItem.appName || ""; + return timeStr.length > 0 ? `${appName} • ${timeStr}` : appName; + } + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + text: historyItem.summary || "" + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + width: parent.width + elide: Text.ElideRight + maximumLineCount: 1 + visible: text.length > 0 + } + + StyledText { + id: descriptionText + text: historyItem.htmlBody || historyItem.body || "" + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + width: parent.width + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.WordWrap + visible: text.length > 0 + linkColor: Theme.primary + onLinkActivated: link => Qt.openUrlExternally(link) + } + } + } + } + } + + DankActionButton { + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 12 + anchors.rightMargin: 16 + iconName: "close" + iconSize: 18 + buttonSize: 28 + onClicked: NotificationService.removeFromHistory(historyItem.id) + } +} diff --git a/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml b/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml new file mode 100644 index 00000000..d1c75313 --- /dev/null +++ b/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml @@ -0,0 +1,276 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + property string selectedFilterKey: "all" + property var keyboardController: null + property bool keyboardActive: false + property int selectedIndex: -1 + property bool showKeyboardHints: false + + function getStartOfDay(date) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; + } + + function getFilterRange(key) { + const now = new Date(); + const startOfToday = getStartOfDay(now); + const startOfYesterday = new Date(startOfToday.getTime() - 86400000); + + switch (key) { + case "all": + return { + start: null, + end: null + }; + case "1h": + return { + start: new Date(now.getTime() - 3600000), + end: null + }; + case "today": + return { + start: startOfToday, + end: null + }; + case "yesterday": + return { + start: startOfYesterday, + end: startOfToday + }; + case "older": + return { + start: null, + end: getOlderCutoff() + }; + case "7d": + return { + start: new Date(now.getTime() - 7 * 86400000), + end: null + }; + case "30d": + return { + start: new Date(now.getTime() - 30 * 86400000), + end: null + }; + default: + return { + start: null, + end: null + }; + } + } + + function countForFilter(key) { + const range = getFilterRange(key); + if (!range.start && !range.end) + return NotificationService.historyList.length; + return NotificationService.historyList.filter(n => { + const ts = n.timestamp; + if (range.start && ts < range.start.getTime()) + return false; + if (range.end && ts >= range.end.getTime()) + return false; + return true; + }).length; + } + + readonly property var allFilters: [ + { label: I18n.tr("All", "notification history filter"), key: "all", maxDays: 0 }, + { label: I18n.tr("Last hour", "notification history filter"), key: "1h", maxDays: 1 }, + { label: I18n.tr("Today", "notification history filter"), key: "today", maxDays: 1 }, + { label: I18n.tr("Yesterday", "notification history filter"), key: "yesterday", maxDays: 2 }, + { label: I18n.tr("7 days", "notification history filter"), key: "7d", maxDays: 7 }, + { label: I18n.tr("30 days", "notification history filter"), key: "30d", maxDays: 30 }, + { label: I18n.tr("Older", "notification history filter for content older than other filters"), key: "older", maxDays: 0 } + ] + + function filterRelevantForRetention(filter) { + const retention = SettingsData.notificationHistoryMaxAgeDays; + if (filter.key === "older") { + if (retention === 0) return true; + return retention > 2 && retention < 7 || retention > 30; + } + if (retention === 0) return true; + if (filter.maxDays === 0) return true; + return filter.maxDays <= retention; + } + + function getOlderCutoff() { + const retention = SettingsData.notificationHistoryMaxAgeDays; + const now = new Date(); + if (retention === 0 || retention > 30) + return new Date(now.getTime() - 30 * 86400000); + if (retention >= 7) + return new Date(now.getTime() - 7 * 86400000); + const startOfToday = getStartOfDay(now); + return new Date(startOfToday.getTime() - 86400000); + } + + readonly property var visibleFilters: { + const result = []; + const retention = SettingsData.notificationHistoryMaxAgeDays; + for (let i = 0; i < allFilters.length; i++) { + const f = allFilters[i]; + if (!filterRelevantForRetention(f)) continue; + const count = countForFilter(f.key); + if (f.key === "all" || count > 0) { + result.push({ label: f.label, key: f.key, count: count }); + } + } + return result; + } + + onVisibleFiltersChanged: { + let found = false; + for (let i = 0; i < visibleFilters.length; i++) { + if (visibleFilters[i].key === selectedFilterKey) { + found = true; + break; + } + } + if (!found) + selectedFilterKey = "all"; + } + + function getFilteredHistory() { + const range = getFilterRange(selectedFilterKey); + if (!range.start && !range.end) + return NotificationService.historyList; + return NotificationService.historyList.filter(n => { + const ts = n.timestamp; + if (range.start && ts < range.start.getTime()) + return false; + if (range.end && ts >= range.end.getTime()) + return false; + return true; + }); + } + + function getChipIndex() { + for (let i = 0; i < visibleFilters.length; i++) { + if (visibleFilters[i].key === selectedFilterKey) + return i; + } + return 0; + } + + function enableAutoScroll() { + } + + Column { + anchors.fill: parent + spacing: Theme.spacingS + + DankFilterChips { + id: filterChips + width: parent.width + currentIndex: root.getChipIndex() + showCounts: true + model: root.visibleFilters + onSelectionChanged: index => { + if (index >= 0 && index < root.visibleFilters.length) { + root.selectedFilterKey = root.visibleFilters[index].key; + } + } + } + + DankListView { + id: historyListView + width: parent.width + height: parent.height - filterChips.height - Theme.spacingS + clip: true + spacing: Theme.spacingS + + model: ScriptModel { + id: historyModel + values: root.getFilteredHistory() + objectProp: "id" + } + + NotificationEmptyState { + visible: historyListView.count === 0 + y: Theme.spacingL + anchors.horizontalCenter: parent.horizontalCenter + } + + delegate: HistoryNotificationCard { + required property var modelData + required property int index + + width: ListView.view.width + historyItem: modelData + isSelected: root.keyboardActive && root.selectedIndex === index + keyboardNavigationActive: root.keyboardActive + } + } + } + + function selectNext() { + if (historyModel.values.length === 0) + return; + keyboardActive = true; + selectedIndex = Math.min(selectedIndex + 1, historyModel.values.length - 1); + historyListView.positionViewAtIndex(selectedIndex, ListView.Contain); + } + + function selectPrevious() { + if (historyModel.values.length === 0) + return; + if (selectedIndex <= 0) { + keyboardActive = false; + selectedIndex = -1; + return; + } + selectedIndex = Math.max(selectedIndex - 1, 0); + historyListView.positionViewAtIndex(selectedIndex, ListView.Contain); + } + + function clearSelected() { + if (selectedIndex < 0 || selectedIndex >= historyModel.values.length) + return; + const item = historyModel.values[selectedIndex]; + NotificationService.removeFromHistory(item.id); + if (historyModel.values.length === 0) { + keyboardActive = false; + selectedIndex = -1; + } else { + selectedIndex = Math.min(selectedIndex, historyModel.values.length - 1); + } + } + + function handleKey(event) { + if (event.key === Qt.Key_Down || event.key === 16777237) { + if (!keyboardActive) { + keyboardActive = true; + selectedIndex = 0; + } else { + selectNext(); + } + event.accepted = true; + } else if (event.key === Qt.Key_Up || event.key === 16777235) { + if (keyboardActive) { + selectPrevious(); + } + event.accepted = true; + } else if (keyboardActive && (event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace)) { + clearSelected(); + event.accepted = true; + } else if ((event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) && (event.modifiers & Qt.ShiftModifier)) { + NotificationService.clearHistory(); + keyboardActive = false; + selectedIndex = -1; + event.accepted = true; + } else if (event.key === Qt.Key_F10) { + showKeyboardHints = !showKeyboardHints; + event.accepted = true; + } + } +} diff --git a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml index 90ed87b0..b2413735 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml @@ -2,7 +2,6 @@ import QtQuick import qs.Common import qs.Services import qs.Widgets -import qs.Modules.Notifications.Center DankPopout { id: root @@ -112,8 +111,11 @@ DankPopout { baseHeight += Theme.spacingM * 2; const settingsHeight = notificationSettings.expanded ? notificationSettings.contentHeight : 0; - let listHeight = notificationList.listContentHeight; - if (NotificationService.groupedNotifications.length === 0) { + let listHeight = notificationHeader.currentTab === 0 ? notificationList.listContentHeight : Math.max(200, NotificationService.historyList.length * 80); + if (notificationHeader.currentTab === 0 && NotificationService.groupedNotifications.length === 0) { + listHeight = 200; + } + if (notificationHeader.currentTab === 1 && NotificationService.historyList.length === 0) { listHeight = 200; } @@ -143,7 +145,13 @@ DankPopout { if (event.key === Qt.Key_Escape) { notificationHistoryVisible = false; event.accepted = true; - } else if (externalKeyboardController) { + return; + } + if (notificationHeader.currentTab === 1) { + historyList.handleKey(event); + return; + } + if (externalKeyboardController) { externalKeyboardController.handleKey(event); } } @@ -187,7 +195,14 @@ DankPopout { KeyboardNavigatedNotificationList { id: notificationList objectName: "notificationList" + visible: notificationHeader.currentTab === 0 + width: parent.width + height: parent.height - notificationContent.cachedHeaderHeight - notificationSettings.height - contentColumnInner.spacing * 2 + } + HistoryNotificationList { + id: historyList + visible: notificationHeader.currentTab === 1 width: parent.width height: parent.height - notificationContent.cachedHeaderHeight - notificationSettings.height - contentColumnInner.spacing * 2 } @@ -200,7 +215,7 @@ DankPopout { anchors.left: parent.left anchors.right: parent.right anchors.margins: Theme.spacingL - showHints: (externalKeyboardController && externalKeyboardController.showKeyboardHints) || false + showHints: notificationHeader.currentTab === 0 ? (externalKeyboardController && externalKeyboardController.showKeyboardHints) || false : historyList.showKeyboardHints z: 200 } } diff --git a/quickshell/Modules/Notifications/Center/NotificationHeader.qml b/quickshell/Modules/Notifications/Center/NotificationHeader.qml index ab26ea95..201e7169 100644 --- a/quickshell/Modules/Notifications/Center/NotificationHeader.qml +++ b/quickshell/Modules/Notifications/Center/NotificationHeader.qml @@ -8,108 +8,150 @@ Item { property var keyboardController: null property bool showSettings: false + property int currentTab: 0 + + onCurrentTabChanged: { + if (currentTab === 1 && !SettingsData.notificationHistoryEnabled) + currentTab = 0; + } + + Connections { + target: SettingsData + function onNotificationHistoryEnabledChanged() { + if (!SettingsData.notificationHistoryEnabled) + root.currentTab = 0; + } + } width: parent.width - height: 32 + height: headerColumn.implicitHeight DankTooltipV2 { id: sharedTooltip } - Row { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS + Column { + id: headerColumn + width: parent.width + spacing: Theme.spacingS - StyledText { - text: I18n.tr("Notifications") - font.pixelSize: Theme.fontSizeLarge - color: Theme.surfaceText - font.weight: Font.Medium - anchors.verticalCenter: parent.verticalCenter - } - - DankActionButton { - id: doNotDisturbButton - - iconName: SessionData.doNotDisturb ? "notifications_off" : "notifications" - iconColor: SessionData.doNotDisturb ? Theme.error : Theme.surfaceText - buttonSize: 28 - anchors.verticalCenter: parent.verticalCenter - onClicked: SessionData.setDoNotDisturb(!SessionData.doNotDisturb) - onEntered: { - sharedTooltip.show(I18n.tr("Do Not Disturb"), doNotDisturbButton, 0, 0, "bottom"); - } - onExited: { - sharedTooltip.hide(); - } - } - } - - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - DankActionButton { - id: helpButton - iconName: "info" - iconColor: (keyboardController && keyboardController.showKeyboardHints) ? Theme.primary : Theme.surfaceText - buttonSize: 28 - visible: keyboardController !== null - anchors.verticalCenter: parent.verticalCenter - onClicked: { - if (keyboardController) { - keyboardController.showKeyboardHints = !keyboardController.showKeyboardHints; - } - } - } - - DankActionButton { - id: settingsButton - iconName: "settings" - iconColor: root.showSettings ? Theme.primary : Theme.surfaceText - buttonSize: 28 - anchors.verticalCenter: parent.verticalCenter - onClicked: root.showSettings = !root.showSettings - } - - Rectangle { - id: clearAllButton - - width: 120 - height: 28 - radius: Theme.cornerRadius - visible: NotificationService.notifications.length > 0 - color: clearArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) + Item { + width: parent.width + height: Math.max(titleRow.implicitHeight, actionsRow.implicitHeight) Row { - anchors.centerIn: parent + id: titleRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter spacing: Theme.spacingXS - DankIcon { - name: "delete_sweep" - size: Theme.iconSizeSmall - color: clearArea.containsMouse ? Theme.primary : Theme.surfaceText - anchors.verticalCenter: parent.verticalCenter - } - StyledText { - text: I18n.tr("Clear") - font.pixelSize: Theme.fontSizeSmall - color: clearArea.containsMouse ? Theme.primary : Theme.surfaceText + text: I18n.tr("Notifications") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText font.weight: Font.Medium anchors.verticalCenter: parent.verticalCenter } + + DankActionButton { + id: doNotDisturbButton + iconName: SessionData.doNotDisturb ? "notifications_off" : "notifications" + iconColor: SessionData.doNotDisturb ? Theme.error : Theme.surfaceText + buttonSize: Theme.iconSize + Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + onClicked: SessionData.setDoNotDisturb(!SessionData.doNotDisturb) + onEntered: sharedTooltip.show(I18n.tr("Do Not Disturb"), doNotDisturbButton, 0, 0, "bottom") + onExited: sharedTooltip.hide() + } } - MouseArea { - id: clearArea + Row { + id: actionsRow + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: NotificationService.clearAllNotifications() + DankActionButton { + id: helpButton + iconName: "info" + iconColor: (keyboardController && keyboardController.showKeyboardHints) ? Theme.primary : Theme.surfaceText + buttonSize: Theme.iconSize + Theme.spacingS + visible: keyboardController !== null + anchors.verticalCenter: parent.verticalCenter + onClicked: { + if (keyboardController) + keyboardController.showKeyboardHints = !keyboardController.showKeyboardHints; + } + } + + DankActionButton { + id: settingsButton + iconName: "settings" + iconColor: root.showSettings ? Theme.primary : Theme.surfaceText + buttonSize: Theme.iconSize + Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + onClicked: root.showSettings = !root.showSettings + } + + Rectangle { + id: clearAllButton + width: clearButtonContent.implicitWidth + Theme.spacingM * 2 + height: Theme.iconSize + Theme.spacingS + radius: Theme.cornerRadius + visible: root.currentTab === 0 ? NotificationService.notifications.length > 0 : NotificationService.historyList.length > 0 + color: clearArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) + + Row { + id: clearButtonContent + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: "delete_sweep" + size: Theme.iconSizeSmall + color: clearArea.containsMouse ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Clear") + font.pixelSize: Theme.fontSizeSmall + color: clearArea.containsMouse ? Theme.primary : Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: clearArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.currentTab === 0) { + NotificationService.clearAllNotifications(); + } else { + NotificationService.clearHistory(); + } + } + } + } + } + } + + DankButtonGroup { + id: tabGroup + width: parent.width + currentIndex: root.currentTab + buttonHeight: 32 + buttonPadding: Theme.spacingM + checkEnabled: false + textSize: Theme.fontSizeSmall + visible: SettingsData.notificationHistoryEnabled + model: [I18n.tr("Current", "notification center tab") + " (" + NotificationService.notifications.length + ")", I18n.tr("History", "notification center tab") + " (" + NotificationService.historyList.length + ")"] + onSelectionChanged: (index, selected) => { + if (selected) + root.currentTab = index; } } } diff --git a/quickshell/Modules/Notifications/Center/NotificationSettings.qml b/quickshell/Modules/Notifications/Center/NotificationSettings.qml index 704acc39..19473539 100644 --- a/quickshell/Modules/Notifications/Center/NotificationSettings.qml +++ b/quickshell/Modules/Notifications/Center/NotificationSettings.qml @@ -1,7 +1,5 @@ import QtQuick -import QtQuick.Controls import qs.Common -import qs.Services import qs.Widgets Rectangle { @@ -36,64 +34,77 @@ Rectangle { } } - readonly property var timeoutOptions: [{ + readonly property var timeoutOptions: [ + { "text": "Never", "value": 0 - }, { + }, + { "text": "1 second", "value": 1000 - }, { + }, + { "text": "3 seconds", "value": 3000 - }, { + }, + { "text": "5 seconds", "value": 5000 - }, { + }, + { "text": "8 seconds", "value": 8000 - }, { + }, + { "text": "10 seconds", "value": 10000 - }, { + }, + { "text": "15 seconds", "value": 15000 - }, { + }, + { "text": "30 seconds", "value": 30000 - }, { + }, + { "text": "1 minute", "value": 60000 - }, { + }, + { "text": "2 minutes", "value": 120000 - }, { + }, + { "text": "5 minutes", "value": 300000 - }, { + }, + { "text": "10 minutes", "value": 600000 - }] + } + ] function getTimeoutText(value) { if (value === undefined || value === null || isNaN(value)) { - return "5 seconds" + return "5 seconds"; } for (let i = 0; i < timeoutOptions.length; i++) { if (timeoutOptions[i].value === value) { - return timeoutOptions[i].text + return timeoutOptions[i].text; } } if (value === 0) { - return "Never" + return "Never"; } if (value < 1000) { - return value + "ms" + return value + "ms"; } if (value < 60000) { - return Math.round(value / 1000) + " seconds" + return Math.round(value / 1000) + " seconds"; } - return Math.round(value / 60000) + " minutes" + return Math.round(value / 60000) + " minutes"; } Column { @@ -113,9 +124,10 @@ Rectangle { Item { width: parent.width - height: 36 + height: Math.max(dndRow.implicitHeight, dndToggle.implicitHeight) + Theme.spacingS Row { + id: dndRow anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter spacing: Theme.spacingM @@ -136,6 +148,7 @@ Rectangle { } DankToggle { + id: dndToggle anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter checked: SessionData.doNotDisturb @@ -162,13 +175,13 @@ Rectangle { currentValue: getTimeoutText(SettingsData.notificationTimeoutLow) options: timeoutOptions.map(opt => opt.text) onValueChanged: value => { - for (let i = 0; i < timeoutOptions.length; i++) { - if (timeoutOptions[i].text === value) { - SettingsData.set("notificationTimeoutLow", timeoutOptions[i].value) - break - } - } - } + for (let i = 0; i < timeoutOptions.length; i++) { + if (timeoutOptions[i].text === value) { + SettingsData.set("notificationTimeoutLow", timeoutOptions[i].value); + break; + } + } + } } DankDropdown { @@ -177,13 +190,13 @@ Rectangle { currentValue: getTimeoutText(SettingsData.notificationTimeoutNormal) options: timeoutOptions.map(opt => opt.text) onValueChanged: value => { - for (let i = 0; i < timeoutOptions.length; i++) { - if (timeoutOptions[i].text === value) { - SettingsData.set("notificationTimeoutNormal", timeoutOptions[i].value) - break - } - } - } + for (let i = 0; i < timeoutOptions.length; i++) { + if (timeoutOptions[i].text === value) { + SettingsData.set("notificationTimeoutNormal", timeoutOptions[i].value); + break; + } + } + } } DankDropdown { @@ -192,13 +205,13 @@ Rectangle { currentValue: getTimeoutText(SettingsData.notificationTimeoutCritical) options: timeoutOptions.map(opt => opt.text) onValueChanged: value => { - for (let i = 0; i < timeoutOptions.length; i++) { - if (timeoutOptions[i].text === value) { - SettingsData.set("notificationTimeoutCritical", timeoutOptions[i].value) - break - } - } - } + for (let i = 0; i < timeoutOptions.length; i++) { + if (timeoutOptions[i].text === value) { + SettingsData.set("notificationTimeoutCritical", timeoutOptions[i].value); + break; + } + } + } } Rectangle { @@ -209,9 +222,10 @@ Rectangle { Item { width: parent.width - height: 36 + height: Math.max(overlayRow.implicitHeight, overlayToggle.implicitHeight) + Theme.spacingS Row { + id: overlayRow anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter spacing: Theme.spacingM @@ -242,11 +256,127 @@ Rectangle { } DankToggle { + id: overlayToggle anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter checked: SettingsData.notificationOverlayEnabled onToggled: toggled => SettingsData.set("notificationOverlayEnabled", toggled) } } + + Rectangle { + width: parent.width + height: 1 + color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1) + } + + StyledText { + text: I18n.tr("History Settings") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceVariantText + } + + Item { + width: parent.width + height: Math.max(lowRow.implicitHeight, lowToggle.implicitHeight) + Theme.spacingS + + Row { + id: lowRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "low_priority" + size: Theme.iconSizeSmall + color: SettingsData.notificationHistorySaveLow ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Low Priority") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + DankToggle { + id: lowToggle + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: SettingsData.notificationHistorySaveLow + onToggled: toggled => SettingsData.set("notificationHistorySaveLow", toggled) + } + } + + Item { + width: parent.width + height: Math.max(normalRow.implicitHeight, normalToggle.implicitHeight) + Theme.spacingS + + Row { + id: normalRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "notifications" + size: Theme.iconSizeSmall + color: SettingsData.notificationHistorySaveNormal ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Normal Priority") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + DankToggle { + id: normalToggle + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: SettingsData.notificationHistorySaveNormal + onToggled: toggled => SettingsData.set("notificationHistorySaveNormal", toggled) + } + } + + Item { + width: parent.width + height: Math.max(criticalRow.implicitHeight, criticalToggle.implicitHeight) + Theme.spacingS + + Row { + id: criticalRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + DankIcon { + name: "priority_high" + size: Theme.iconSizeSmall + color: SettingsData.notificationHistorySaveCritical ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Critical Priority") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + DankToggle { + id: criticalToggle + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: SettingsData.notificationHistorySaveCritical + onToggled: toggled => SettingsData.set("notificationHistorySaveCritical", toggled) + } + } } } diff --git a/quickshell/Modules/Settings/NotificationsTab.qml b/quickshell/Modules/Settings/NotificationsTab.qml index 26c1ae32..14867469 100644 --- a/quickshell/Modules/Settings/NotificationsTab.qml +++ b/quickshell/Modules/Settings/NotificationsTab.qml @@ -219,6 +219,105 @@ Item { } } } + + SettingsCard { + width: parent.width + iconName: "history" + title: I18n.tr("History Settings") + settingKey: "notificationHistory" + + SettingsToggleRow { + settingKey: "notificationHistoryEnabled" + tags: ["notification", "history", "enable", "disable", "save"] + text: I18n.tr("Enable History", "notification history toggle label") + description: I18n.tr("Save dismissed notifications to history", "notification history toggle description") + checked: SettingsData.notificationHistoryEnabled + onToggled: checked => SettingsData.set("notificationHistoryEnabled", checked) + } + + SettingsSliderRow { + settingKey: "notificationHistoryMaxCount" + tags: ["notification", "history", "max", "count", "limit"] + text: I18n.tr("Maximum History") + description: I18n.tr("Maximum number of notifications to keep", "notification history limit") + value: SettingsData.notificationHistoryMaxCount + minimum: 10 + maximum: 200 + step: 10 + unit: "" + defaultValue: 50 + onSliderValueChanged: newValue => SettingsData.set("notificationHistoryMaxCount", newValue) + } + + SettingsDropdownRow { + settingKey: "notificationHistoryMaxAgeDays" + tags: ["notification", "history", "max", "age", "days", "retention"] + text: I18n.tr("History Retention", "notification history retention settings label") + description: I18n.tr("Auto-delete notifications older than this", "notification history setting") + currentValue: { + switch (SettingsData.notificationHistoryMaxAgeDays) { + case 0: + return I18n.tr("Forever", "notification history retention option"); + case 1: + return I18n.tr("1 day", "notification history retention option"); + case 3: + return I18n.tr("3 days", "notification history retention option"); + case 7: + return I18n.tr("7 days", "notification history retention option"); + case 14: + return I18n.tr("14 days", "notification history retention option"); + case 30: + return I18n.tr("30 days", "notification history retention option"); + default: + return SettingsData.notificationHistoryMaxAgeDays + " " + I18n.tr("days"); + } + } + options: [I18n.tr("Forever", "notification history retention option"), I18n.tr("1 day", "notification history retention option"), I18n.tr("3 days", "notification history retention option"), I18n.tr("7 days", "notification history retention option"), I18n.tr("14 days", "notification history retention option"), I18n.tr("30 days", "notification history retention option")] + onValueChanged: value => { + let days = 7; + if (value === I18n.tr("Forever", "notification history retention option")) + days = 0; + else if (value === I18n.tr("1 day", "notification history retention option")) + days = 1; + else if (value === I18n.tr("3 days", "notification history retention option")) + days = 3; + else if (value === I18n.tr("7 days", "notification history retention option")) + days = 7; + else if (value === I18n.tr("14 days", "notification history retention option")) + days = 14; + else if (value === I18n.tr("30 days", "notification history retention option")) + days = 30; + SettingsData.set("notificationHistoryMaxAgeDays", days); + } + } + + SettingsToggleRow { + settingKey: "notificationHistorySaveLow" + tags: ["notification", "history", "save", "low", "priority"] + text: I18n.tr("Low Priority") + description: I18n.tr("Save low priority notifications to history", "notification history setting") + checked: SettingsData.notificationHistorySaveLow + onToggled: checked => SettingsData.set("notificationHistorySaveLow", checked) + } + + SettingsToggleRow { + settingKey: "notificationHistorySaveNormal" + tags: ["notification", "history", "save", "normal", "priority"] + text: I18n.tr("Normal Priority") + description: I18n.tr("Save normal priority notifications to history", "notification history setting") + checked: SettingsData.notificationHistorySaveNormal + onToggled: checked => SettingsData.set("notificationHistorySaveNormal", checked) + } + + SettingsToggleRow { + settingKey: "notificationHistorySaveCritical" + tags: ["notification", "history", "save", "critical", "priority"] + text: I18n.tr("Critical Priority") + description: I18n.tr("Save critical priority notifications to history", "notification history setting") + checked: SettingsData.notificationHistorySaveCritical + onToggled: checked => SettingsData.set("notificationHistorySaveCritical", checked) + } + } } } } diff --git a/quickshell/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml index 83b62955..5c794a6e 100644 --- a/quickshell/Services/NotificationService.qml +++ b/quickshell/Services/NotificationService.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell +import Quickshell.Io import Quickshell.Services.Notifications import qs.Common import "../Common/markdown2html.js" as Markdown2Html @@ -14,6 +15,10 @@ Singleton { readonly property list allWrappers: [] readonly property list popups: allWrappers.filter(n => n && n.popup) + property var historyList: [] + readonly property string historyFile: Paths.strip(Paths.cache) + "/notification_history.json" + property bool historyLoaded: false + property list notificationQueue: [] property list visibleNotifications: [] property int maxVisibleNotifications: 3 @@ -26,7 +31,7 @@ Singleton { property int maxIngressPerSecond: 20 property double _lastIngressSec: 0 property int _ingressCountThisSec: 0 - property int maxStoredNotifications: 50 + property int maxStoredNotifications: SettingsData.notificationHistoryMaxCount property var _dismissQueue: [] property int _dismissBatchSize: 8 @@ -40,6 +45,165 @@ Singleton { Component.onCompleted: { _recomputeGroups(); + Quickshell.execDetached(["mkdir", "-p", Paths.strip(Paths.cache)]); + } + + FileView { + id: historyFileView + path: root.historyFile + printErrors: false + onLoaded: root.loadHistory() + onLoadFailed: error => { + if (error === 2) { + root.historyLoaded = true; + historyFileView.writeAdapter(); + } + } + + JsonAdapter { + id: historyAdapter + property var notifications: [] + } + } + + Timer { + id: historySaveTimer + interval: 200 + onTriggered: root.performSaveHistory() + } + + function addToHistory(wrapper) { + if (!wrapper) + return; + const urg = typeof wrapper.urgency === "number" ? wrapper.urgency : 1; + const data = { + id: wrapper.notification?.id?.toString() || Date.now().toString(), + summary: wrapper.summary || "", + body: wrapper.body || "", + htmlBody: wrapper.htmlBody || wrapper.body || "", + appName: wrapper.appName || "", + appIcon: wrapper.appIcon || "", + image: wrapper.cleanImage || "", + urgency: urg, + timestamp: wrapper.time.getTime(), + desktopEntry: wrapper.desktopEntry || "" + }; + let newList = [data, ...historyList]; + if (newList.length > SettingsData.notificationHistoryMaxCount) { + newList = newList.slice(0, SettingsData.notificationHistoryMaxCount); + } + historyList = newList; + saveHistory(); + } + + function saveHistory() { + historySaveTimer.restart(); + } + + function performSaveHistory() { + try { + historyAdapter.notifications = historyList; + historyFileView.writeAdapter(); + } catch (e) { + console.warn("NotificationService: save history failed:", e); + } + } + + function loadHistory() { + try { + const maxAgeDays = SettingsData.notificationHistoryMaxAgeDays; + const now = Date.now(); + const maxAgeMs = maxAgeDays > 0 ? maxAgeDays * 24 * 60 * 60 * 1000 : 0; + const loaded = []; + + for (const item of historyAdapter.notifications || []) { + if (maxAgeMs > 0 && (now - item.timestamp) > maxAgeMs) + continue; + const urg = typeof item.urgency === "number" ? item.urgency : 1; + const body = item.body || ""; + let htmlBody = item.htmlBody || ""; + if (!htmlBody && body) { + htmlBody = (body.includes('<') && body.includes('>')) ? body : Markdown2Html.markdownToHtml(body); + } + loaded.push({ + id: item.id || "", + summary: item.summary || "", + body: body, + htmlBody: htmlBody, + appName: item.appName || "", + appIcon: item.appIcon || "", + image: item.image || "", + urgency: urg, + timestamp: item.timestamp || 0, + desktopEntry: item.desktopEntry || "" + }); + } + historyList = loaded; + historyLoaded = true; + if (maxAgeMs > 0 && loaded.length !== (historyAdapter.notifications || []).length) + saveHistory(); + } catch (e) { + console.warn("NotificationService: load history failed:", e); + historyLoaded = true; + } + } + + function removeFromHistory(notificationId) { + const idx = historyList.findIndex(n => n.id === notificationId); + if (idx >= 0) { + historyList = historyList.filter((_, i) => i !== idx); + saveHistory(); + return true; + } + return false; + } + + function clearHistory() { + historyList = []; + saveHistory(); + } + + function getHistoryTimeRange(timestamp) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const itemDate = new Date(timestamp); + const itemDay = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate()); + const diffDays = Math.floor((today - itemDay) / (1000 * 60 * 60 * 24)); + if (diffDays === 0) + return 0; + if (diffDays === 1) + return 1; + return 2; + } + + function getHistoryCountForRange(range) { + if (range === -1) + return historyList.length; + return historyList.filter(n => getHistoryTimeRange(n.timestamp) === range).length; + } + + function formatHistoryTime(timestamp) { + root.timeUpdateTick; + root.clockFormatChanged; + const now = new Date(); + const date = new Date(timestamp); + const diff = now.getTime() - timestamp; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + if (hours < 1) { + if (minutes < 1) + return I18n.tr("now"); + return I18n.tr("%1m ago").arg(minutes); + } + const nowDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const itemDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const daysDiff = Math.floor((nowDate - itemDate) / (1000 * 60 * 60 * 24)); + const timeStr = SettingsData.use24HourClock ? date.toLocaleTimeString(Qt.locale(), "HH:mm") : date.toLocaleTimeString(Qt.locale(), "h:mm AP"); + if (daysDiff === 0) + return timeStr; + if (daysDiff === 1) + return I18n.tr("yesterday") + ", " + timeStr; + return I18n.tr("%1 days ago").arg(daysDiff); } function _nowSec() { @@ -84,6 +248,40 @@ Singleton { wrapper.isPersistent = isCritical || (timeoutMs === 0); } + function _shouldSaveToHistory(urgency) { + if (!SettingsData.notificationHistoryEnabled) + return false; + switch (urgency) { + case NotificationUrgency.Low: + return SettingsData.notificationHistorySaveLow; + case NotificationUrgency.Critical: + return SettingsData.notificationHistorySaveCritical; + default: + return SettingsData.notificationHistorySaveNormal; + } + } + + function pruneHistory() { + const maxAgeDays = SettingsData.notificationHistoryMaxAgeDays; + if (maxAgeDays <= 0) + return; + + const now = Date.now(); + const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000; + const pruned = historyList.filter(item => (now - item.timestamp) <= maxAgeMs); + + if (pruned.length !== historyList.length) { + historyList = pruned; + saveHistory(); + } + } + + function deleteHistory() { + historyList = []; + historyAdapter.notifications = []; + historyFileView.writeAdapter(); + } + function _trimStored() { if (notifications.length > maxStoredNotifications) { const overflow = notifications.length - maxStoredNotifications; @@ -121,6 +319,7 @@ Singleton { } visibleNotifications = []; _recomputeGroupsLater(); + pruneHistory(); } function onOverlayClose() { @@ -234,9 +433,11 @@ Singleton { if (wrapper) { root.allWrappers.push(wrapper); - if (!isTransient) { + const shouldSave = !isTransient && _shouldSaveToHistory(notif.urgency); + if (shouldSave) { root.notifications.push(wrapper); _trimStored(); + root.addToHistory(wrapper); } Qt.callLater(() => { @@ -703,5 +904,13 @@ Singleton { function onUse24HourClockChanged() { root.clockFormatChanged = !root.clockFormatChanged; } + function onNotificationHistoryMaxAgeDaysChanged() { + root.pruneHistory(); + } + function onNotificationHistoryEnabledChanged() { + if (!SettingsData.notificationHistoryEnabled) { + root.deleteHistory(); + } + } } } diff --git a/quickshell/Widgets/DankFilterChips.qml b/quickshell/Widgets/DankFilterChips.qml new file mode 100644 index 00000000..9a7926fd --- /dev/null +++ b/quickshell/Widgets/DankFilterChips.qml @@ -0,0 +1,101 @@ +import QtQuick +import qs.Common +import qs.Widgets + +Flow { + id: root + + property var model: [] + property int currentIndex: 0 + property int chipHeight: 32 + property int chipPadding: Theme.spacingM + property bool showCheck: true + property bool showCounts: true + + signal selectionChanged(int index) + + spacing: Theme.spacingS + width: parent ? parent.width : 400 + + Repeater { + model: root.model + + Rectangle { + id: chip + required property var modelData + required property int index + + property bool selected: index === root.currentIndex + property bool hovered: mouseArea.containsMouse + property bool pressed: mouseArea.pressed + property string label: typeof modelData === "string" ? modelData : (modelData.label || "") + property int count: typeof modelData === "object" ? (modelData.count || 0) : 0 + property bool showCount: root.showCounts && count > 0 + + width: contentRow.implicitWidth + root.chipPadding * 2 + height: root.chipHeight + radius: height / 2 + + color: selected ? Theme.primary : Theme.surfaceVariant + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: { + if (pressed) + return chip.selected ? Theme.primaryPressed : Theme.surfaceTextHover; + if (hovered) + return chip.selected ? Theme.primaryHover : Theme.surfaceTextHover; + return "transparent"; + } + + Behavior on color { + ColorAnimation { + duration: Theme.shorterDuration + easing.type: Theme.standardEasing + } + } + } + + Row { + id: contentRow + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: "check" + size: 16 + anchors.verticalCenter: parent.verticalCenter + color: Theme.primaryText + visible: root.showCheck && chip.selected + } + + StyledText { + text: chip.label + (chip.showCount ? " (" + chip.count + ")" : "") + font.pixelSize: Theme.fontSizeSmall + font.weight: chip.selected ? Font.Medium : Font.Normal + color: chip.selected ? Theme.primaryText : Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.currentIndex = chip.index; + root.selectionChanged(chip.index); + } + } + } + } +}