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,4 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#000000">
<path d="M18.4923 2.33034L21.671 5.50911C22.5497 6.38779 22.5497 7.81241 21.671 8.69109L19.0866 11.275C20.1696 11.4375 21 12.3718 21 13.5V18.75C21 19.9926 19.9926 21 18.75 21H5.25C4.00736 21 3 19.9926 3 18.75V5.25001C3 4.00736 4.00736 3.00001 5.25 3.00001H10.5C11.6289 3.00001 12.5637 3.83146 12.7253 4.91541L15.3103 2.33034C16.189 1.45166 17.6136 1.45166 18.4923 2.33034ZM4.5 18.75C4.5 19.1642 4.83579 19.5 5.25 19.5L11.249 19.4993L11.25 12.75L4.5 12.7493V18.75ZM12.749 19.4993L18.75 19.5C19.1642 19.5 19.5 19.1642 19.5 18.75V13.5C19.5 13.0858 19.1642 12.75 18.75 12.75L12.749 12.7493V19.4993ZM10.5 4.50001H5.25C4.83579 4.50001 4.5 4.83579 4.5 5.25001V11.2493H11.25V5.25001C11.25 4.83579 10.9142 4.50001 10.5 4.50001ZM12.75 9.30933V11.25L14.69 11.2493L12.75 9.30933Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 930 B

@@ -0,0 +1,4 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#000000">
<path d="M18.4923 2.33088L21.671 5.50966C22.5497 6.38834 22.5497 7.81296 21.671 8.69164L19.0866 11.2756C20.1696 11.438 21 12.3723 21 13.5006V18.7506C21 19.9932 19.9926 21.0006 18.75 21.0006H5.25C4.00736 21.0006 3 19.9932 3 18.7506V5.25055C3 4.00791 4.00736 3.00055 5.25 3.00055H10.5C11.6289 3.00055 12.5637 3.83201 12.7253 4.91596L15.3103 2.33088C16.189 1.45221 17.6136 1.45221 18.4923 2.33088ZM4.5 18.7506C4.5 19.1648 4.83579 19.5006 5.25 19.5006L11.249 19.4999L11.25 12.7506L4.5 12.7499V18.7506ZM12.749 19.4999L18.75 19.5006C19.1642 19.5006 19.5 19.1648 19.5 18.7506V13.5006C19.5 13.0863 19.1642 12.7506 18.75 12.7506L12.749 12.7499V19.4999ZM10.5 4.50055H5.25C4.83579 4.50055 4.5 4.83634 4.5 5.25055V11.2499H11.25V5.25055C11.25 4.83634 10.9142 4.50055 10.5 4.50055ZM12.75 9.30988V11.2506L14.69 11.2499L12.75 9.30988ZM16.3709 3.39154L13.1922 6.57032C12.8993 6.86321 12.8993 7.33808 13.1922 7.63098L16.3709 10.8097C16.6638 11.1026 17.1387 11.1026 17.4316 10.8097L20.6104 7.63098C20.9033 7.33808 20.9033 6.86321 20.6104 6.57032L17.4316 3.39154C17.1387 3.09865 16.6638 3.09865 16.3709 3.39154Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#000000">
<path d="M8 12C8 13.1046 7.10457 14 6 14C4.89543 14 4 13.1046 4 12C4 10.8954 4.89543 10 6 10C7.10457 10 8 10.8954 8 12ZM14 12C14 13.1046 13.1046 14 12 14C10.8954 14 10 13.1046 10 12C10 10.8954 10.8954 10 12 10C13.1046 10 14 10.8954 14 12ZM18 14C19.1046 14 20 13.1046 20 12C20 10.8954 19.1046 10 18 10C16.8954 10 16 10.8954 16 12C16 13.1046 16.8954 14 18 14Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

@@ -0,0 +1,4 @@
<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="#000000">
<path d="M7.75 12C7.75 12.9665 6.9665 13.75 6 13.75C5.0335 13.75 4.25 12.9665 4.25 12C4.25 11.0335 5.0335 10.25 6 10.25C6.9665 10.25 7.75 11.0335 7.75 12ZM13.75 12C13.75 12.9665 12.9665 13.75 12 13.75C11.0335 13.75 10.25 12.9665 10.25 12C10.25 11.0335 11.0335 10.25 12 10.25C12.9665 10.25 13.75 11.0335 13.75 12ZM18 13.75C18.9665 13.75 19.75 12.9665 19.75 12C19.75 11.0335 18.9665 10.25 18 10.25C17.0335 10.25 16.25 11.0335 16.25 12C16.25 12.9665 17.0335 13.75 18 13.75Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 633 B

@@ -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
}
}
}
}
}