waffles: notifications, kind of

This commit is contained in:
end-4
2025-11-27 23:25:59 +01:00
parent b7ad7361d6
commit 2fd25af353
19 changed files with 470 additions and 116 deletions
@@ -0,0 +1,87 @@
pragma Singleton
import Quickshell
Singleton {
id: root
/**
* @param { string } summary
* @returns { string }
*/
function findSuitableMaterialSymbol(summary = "") {
const defaultType = 'chat';
if (summary.length === 0) return defaultType;
const keywordsToTypes = {
'reboot': 'restart_alt',
'record': 'screen_record',
'battery': 'power',
'power': 'power',
'screenshot': 'screenshot_monitor',
'welcome': 'waving_hand',
'time': 'scheduleb',
'installed': 'download',
'configuration reloaded': 'reset_wrench',
'unable': 'question_mark',
"couldn't": 'question_mark',
'config': 'reset_wrench',
'update': 'update',
'ai response': 'neurology',
'control': 'settings',
'upsca': 'compare',
'music': 'queue_music',
'install': 'deployed_code_update',
'input': 'keyboard_alt',
'preedit': 'keyboard_alt',
'startswith:file': 'folder_copy', // Declarative startsWith check
};
const lowerSummary = summary.toLowerCase();
for (const [keyword, type] of Object.entries(keywordsToTypes)) {
if (keyword.startsWith('startswith:')) {
const startsWithKeyword = keyword.replace('startswith:', '');
if (lowerSummary.startsWith(startsWithKeyword)) {
return type;
}
} else if (lowerSummary.includes(keyword)) {
return type;
}
}
return defaultType;
}
/**
* @param { number | string | Date } timestamp
* @returns { string }
*/
function getFriendlyNotifTimeString(timestamp) {
if (!timestamp) return '';
const messageTime = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - messageTime.getTime();
// Less than 1 minute
if (diffMs < 60000)
return 'Now';
// Same day - show relative time
if (messageTime.toDateString() === now.toDateString()) {
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
if (diffHours > 0) {
return `${diffHours}h`;
} else {
return `${diffMinutes}m`;
}
}
// Yesterday
if (messageTime.toDateString() === new Date(now.getTime() - 86400000).toDateString())
return 'Yesterday';
// Older dates
return Qt.formatDateTime(messageTime, "MMMM dd");
}
}
@@ -1,5 +1,5 @@
import qs.modules.common
import "notification_utils.js" as NotificationUtils
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import Quickshell
@@ -1,7 +1,6 @@
import qs.services
import qs.modules.common
import qs.modules.common.functions
import "notification_utils.js" as NotificationUtils
import QtQuick
import QtQuick.Layouts
import Quickshell
@@ -136,7 +135,7 @@ MouseArea { // Notification group area
}
clip: true
implicitHeight: expanded ?
implicitHeight: root.expanded ?
row.implicitHeight + padding * 2 :
Math.min(80, row.implicitHeight + padding * 2)
@@ -157,8 +156,8 @@ MouseArea { // Notification group area
Layout.alignment: Qt.AlignTop
Layout.fillWidth: false
image: root?.multipleNotifications ? "" : notificationGroup?.notifications[0]?.image ?? ""
appIcon: notificationGroup?.appIcon
summary: notificationGroup?.notifications[root.notificationCount - 1]?.summary
appIcon: root.notificationGroup?.appIcon
summary: root.notificationGroup?.notifications[root.notificationCount - 1]?.summary
urgency: root.notifications.some(n => n.urgency === NotificationUrgency.Critical.toString()) ?
NotificationUrgency.Critical : NotificationUrgency.Normal
}
@@ -1,82 +0,0 @@
/**
* @param { string } summary
* @returns { string }
*/
function findSuitableMaterialSymbol(summary = "") {
const defaultType = 'chat';
if(summary.length === 0) return defaultType;
const keywordsToTypes = {
'reboot': 'restart_alt',
'record': 'screen_record',
'battery': 'power',
'power': 'power',
'screenshot': 'screenshot_monitor',
'welcome': 'waving_hand',
'time': 'scheduleb',
'installed': 'download',
'configuration reloaded': 'reset_wrench',
'unable': 'question_mark',
"couldn't": 'question_mark',
'config': 'reset_wrench',
'update': 'update',
'ai response': 'neurology',
'control': 'settings',
'upsca': 'compare',
'music': 'queue_music',
'install': 'deployed_code_update',
'input': 'keyboard_alt',
'preedit': 'keyboard_alt',
'startswith:file': 'folder_copy', // Declarative startsWith check
};
const lowerSummary = summary.toLowerCase();
for (const [keyword, type] of Object.entries(keywordsToTypes)) {
if (keyword.startsWith('startswith:')) {
const startsWithKeyword = keyword.replace('startswith:', '');
if (lowerSummary.startsWith(startsWithKeyword)) {
return type;
}
} else if (lowerSummary.includes(keyword)) {
return type;
}
}
return defaultType;
}
/**
* @param { number | string | Date } timestamp
* @returns { string }
*/
const getFriendlyNotifTimeString = (timestamp) => {
if (!timestamp) return '';
const messageTime = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - messageTime.getTime();
// Less than 1 minute
if (diffMs < 60000)
return 'Now';
// Same day - show relative time
if (messageTime.toDateString() === now.toDateString()) {
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
if (diffHours > 0) {
return `${diffHours}h`;
} else {
return `${diffMinutes}m`;
}
}
// Yesterday
if (messageTime.toDateString() === new Date(now.getTime() - 86400000).toDateString())
return 'Yesterday';
// Older dates
return Qt.formatDateTime(messageTime, "MMMM dd");
};
@@ -11,8 +11,8 @@ WButton {
colBackground: Looks.colors.bg2
colBackgroundHover: Looks.colors.bg2Hover
colBackgroundActive: Looks.colors.bg2Active
border.color: Looks.colors.bg2Border
property color colBorder: Looks.colors.bg2Border
property color colBorderToggled: Looks.colors.accent
border.color: checked ? colBorderToggled : colBorder
border.width: root.pressed ? 2 : 1
}
@@ -14,6 +14,7 @@ Item {
property real radius: Looks.radius.large
property alias border: borderRect
property alias borderColor: borderRect.border.color
property alias borderWidth: borderRect.border.width
implicitWidth: borderRect.implicitWidth
implicitHeight: borderRect.implicitHeight
@@ -54,35 +54,19 @@ FooterRectangle {
Layout.fillWidth: true
}
SmallBorderedIconButton {
leftPadding: 12
rightPadding: 12
implicitWidth: focusButtonContent.implicitWidth + leftPadding + rightPadding
SmallBorderedIconAndTextButton {
iconName: TimerService.pomodoroRunning ? "stop" : "play"
text: TimerService.pomodoroRunning ? Translation.tr("End session") : Translation.tr("Focus")
onClicked: {
if (TimerService.pomodoroRunning) {
TimerService.togglePomodoro()
TimerService.resetPomodoro()
TimerService.togglePomodoro();
TimerService.resetPomodoro();
} else {
TimerService.togglePomodoro()
TimerService.togglePomodoro();
Quickshell.execDetached(["qs", "-p", Quickshell.shellPath(""), "ipc", "call", "sidebarRight", "toggle"]);
}
}
contentItem: Row {
id: focusButtonContent
spacing: 4
FluentIcon {
icon: TimerService.pomodoroRunning ? "stop" : "play"
filled: true
implicitSize: 14
anchors.verticalCenter: parent.verticalCenter
}
WText {
anchors.verticalCenter: parent.verticalCenter
text: TimerService.pomodoroRunning ? Translation.tr("End session") : Translation.tr("Focus")
}
}
}
}
}
@@ -19,16 +19,40 @@ WBarAttachedPanelContent {
property bool collapsed: false
contentItem: Column {
contentItem: ColumnLayout {
anchors {
horizontalCenter: parent.horizontalCenter
top: root.barAtBottom ? undefined : parent.top
bottom: root.barAtBottom ? parent.bottom : undefined
top: parent.top
bottom: parent.bottom
}
spacing: 12
Item {
id: notificationArea
Layout.fillHeight: true
implicitWidth: notificationPane.implicitWidth
WPane {
id: notificationPane
anchors {
bottom: parent.bottom
left: parent.left
right: parent.right
}
contentItem: NotificationPaneContent {
implicitWidth: calendarColumnLayout.implicitWidth
implicitHeight: Notifications.list.length > 0 ? (notificationArea.height - notificationPane.borderWidth * 2) : 230
Behavior on implicitHeight {
animation: Looks.transition.enter.createObject(this)
}
}
}
}
WPane {
contentItem: ColumnLayout {
id: calendarColumnLayout
spacing: 0
DateHeader {
Layout.fillWidth: true
@@ -37,7 +61,9 @@ WBarAttachedPanelContent {
}
}
WPanelSeparator { visible: !root.collapsed }
WPanelSeparator {
visible: !root.collapsed
}
CalendarWidget {
Layout.fillWidth: true
@@ -0,0 +1,25 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import qs.modules.waffle.looks
WBorderlessButton {
id: headerButton
Layout.fillWidth: false
implicitWidth: 16
implicitHeight: 16
color: "transparent"
contentItem: Item {
FluentIcon {
anchors.centerIn: parent
implicitSize: 16
icon: headerButton.icon.name
color: headerButton.hovered && !headerButton.pressed ? Looks.colors.fg : Looks.colors.fg1
}
}
}
@@ -0,0 +1,81 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import qs.modules.waffle.looks
BodyRectangle {
id: root
anchors.fill: parent
implicitHeight: 230
ColumnLayout {
id: contentLayout
anchors.fill: parent
anchors.margins: 4
spacing: 12
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 12
Layout.rightMargin: 12
Layout.topMargin: 8
spacing: 8
WText {
Layout.fillWidth: true
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
text: Translation.tr("Notifications")
font.pixelSize: Looks.font.pixelSize.large
}
SmallBorderedIconButton {
icon.name: "alert-snooze"
checked: Notifications.silent
onClicked: {
Notifications.silent = !Notifications.silent;
}
}
SmallBorderedIconAndTextButton {
visible: Notifications.list.length > 0
iconVisible: false
text: Translation.tr("Clear all")
onClicked: {
Notifications.discardAllNotifications();
}
}
}
StyledListView {
Layout.fillWidth: true
Layout.fillHeight: true
animateAppearance: false
clip: true
model: Notifications.appNameList
delegate: WNotificationGroup {
required property int index
required property var modelData
width: ListView.view.width
notificationGroup: Notifications.groupsByAppName[modelData]
}
EmptyPlaceholder {
visible: Notifications.list.length === 0
anchors.centerIn: parent
}
}
}
component EmptyPlaceholder: WText {
horizontalAlignment: Text.AlignHCenter
text: Translation.tr("No new notifications")
}
}
@@ -0,0 +1,34 @@
import QtQuick
import qs
import qs.services
import qs.modules.common
import qs.modules.waffle.looks
SmallBorderedIconButton {
id: root
property bool iconVisible: true
property string iconName: ""
property bool iconFilled: true
leftPadding: 12
rightPadding: 12
implicitWidth: focusButtonContent.implicitWidth + leftPadding + rightPadding
contentItem: Row {
id: focusButtonContent
spacing: 4
FluentIcon {
visible: root.iconVisible
icon: root.iconName
filled: root.iconFilled
implicitSize: 14
anchors.verticalCenter: parent.verticalCenter
}
WText {
anchors.verticalCenter: parent.verticalCenter
text: root.text
}
}
}
@@ -13,6 +13,7 @@ WBorderedButton {
anchors.centerIn: parent
implicitSize: 12
icon: root.icon.name
color: root.fgColor
}
}
}
@@ -0,0 +1,30 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import org.kde.kirigami as Kirigami
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import qs.modules.waffle.looks
Item {
id: root
property string icon: ""
property real implicitSize: 16
implicitWidth: implicitSize
implicitHeight: implicitSize
Kirigami.Icon {
anchors.fill: parent
implicitWidth: root.implicitSize
implicitHeight: root.implicitSize
source: root.icon || fallback
fallback: `${Looks.iconsPath}/apps.svg`
roundToIconSize: false
isMask: !root.icon
color: Looks.colors.fg
}
}
@@ -0,0 +1,93 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import qs.modules.waffle.looks
MouseArea {
id: root
required property var notificationGroup
readonly property var notifications: notificationGroup?.notifications ?? []
implicitWidth: contentLayout.implicitWidth
implicitHeight: contentLayout.implicitHeight
ColumnLayout {
id: contentLayout
anchors.fill: parent
spacing: 4
GroupHeader {
id: notifHeader
Layout.fillWidth: true
Layout.margins: 11
}
ListView {
Layout.fillWidth: true
implicitWidth: notifHeader.implicitWidth
implicitHeight: contentHeight
interactive: false
spacing: 4
model: ScriptModel {
values: root.notifications.slice().reverse()
}
delegate: WSingleNotification {
required property var modelData
width: ListView.view.width
notification: modelData
}
}
}
component GroupHeader: MouseArea {
id: headerMouseArea
hoverEnabled: true
acceptedButtons: Qt.NoButton
implicitWidth: appHeader.implicitWidth
implicitHeight: appHeader.implicitHeight
RowLayout {
id: appHeader
anchors.fill: parent
spacing: 7
WNotificationAppIcon {
Layout.alignment: Qt.AlignVCenter
icon: root.notificationGroup?.appIcon ?? ""
}
WText {
Layout.fillWidth: true
horizontalAlignment: Text.AlignLeft
elide: Text.ElideRight
text: root.notificationGroup?.appName ?? ""
}
// NotificationHeaderButton { // TODO: More notification functionality needed so we can have this button
// visible: headerMouseArea.containsMouse
// Layout.leftMargin: 25
// Layout.rightMargin: 25
// icon.name: "more-horizontal"
// }
NotificationHeaderButton {
visible: headerMouseArea.containsMouse
Layout.rightMargin: 3
icon.name: "dismiss"
onClicked: {
root.notifications.forEach(notif => {
Qt.callLater(() => {
Notifications.discardNotification(notif.notificationId);
});
});
}
}
}
}
}
@@ -0,0 +1,59 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import qs.modules.waffle.looks
MouseArea {
id: root
required property var notification
property bool expanded: false
implicitHeight: contentItem.implicitHeight
implicitWidth: contentItem.implicitWidth
Rectangle {
id: contentItem
anchors.fill: parent
color: Looks.colors.bgPanelBody
radius: Looks.radius.medium
property real padding: 12
implicitHeight: notificationContent.implicitHeight + padding * 2
implicitWidth: notificationContent.implicitWidth + padding * 2
border.width: 1
border.color: Looks.applyContentTransparency(Looks.colors.ambientShadow)
ColumnLayout {
id: notificationContent
anchors.fill: parent
anchors.margins: contentItem.padding
RowLayout {
Layout.fillWidth: true
WText {
text: NotificationUtils.getFriendlyNotifTimeString(root.notification?.time)
}
}
ColumnLayout {
Layout.fillWidth: true
WText {
Layout.fillWidth: true
elide: Text.ElideRight
text: root.notification.summary
}
WText {
Layout.fillWidth: true
elide: Text.ElideRight
wrapMode: Text.Wrap
maximumLineCount: root.expanded ? 100 : 1
}
}
}
}
}