diff --git a/.scripts/change-colortheme b/.scripts/change-colortheme index 93c372d..9699e4c 100755 --- a/.scripts/change-colortheme +++ b/.scripts/change-colortheme @@ -415,7 +415,7 @@ def main(): except Exception as e: print(f"Error while tweaking {app}: {e}") - os.system(f'notify-send "Color theme changed" "Palette: {palette_name}\nFlavor: {flavor}"') + os.system(f'notify-send -a "change-colortheme" "Color theme changed" "Palette: {palette_name}\nFlavor: {flavor}"') if __name__ == "__main__": diff --git a/.scripts/change-wallpaper b/.scripts/change-wallpaper index 789ef91..13df236 100755 --- a/.scripts/change-wallpaper +++ b/.scripts/change-wallpaper @@ -44,7 +44,7 @@ blurred_image="$blur_dir/blurred-${random_name}.$ext" ## Time consuming task (magick -blur) in background ( - # notify-send "Generating Blurred Wallpaper" "This may take a few seconds..." + # notify-send -a "change-wallpaper" "Generating Blurred Wallpaper" "This may take a few seconds..." sigma=$(magick identify -format "%w %h" "$image_copied" | awk -v f=0.01 '{ m=($1>$2)?$1:$2; @@ -70,7 +70,7 @@ blurred_image="$blur_dir/blurred-${random_name}.$ext" swww img -n backdrop "$blurred_image" --transition-type fade --transition-duration 2 > /dev/null 2> /dev/null fi - notify-send "Blurred Wallpaper Generated" "$blurred_image" + notify-send -a "change-wallpaper" "Blurred Wallpaper Generated" "$blurred_image" -i "$blurred_image" ) & # Apply wallpaper @@ -78,13 +78,13 @@ blurred_image="$blur_dir/blurred-${random_name}.$ext" if [ "$XDG_CURRENT_DESKTOP" = "Hyprland" ]; then swww img -n background "$image_copied" --transition-type fade --transition-duration 2 > /dev/null 2> /dev/null - notify-send "Wallpaper Changed" "$image" + notify-send -a "change-wallpaper" "Wallpaper Changed" "$image" -i "$image_copied" change-colortheme -i "$image_copied" !quickshell || exit 1 elif [ "$XDG_CURRENT_DESKTOP" = "niri" ]; then swww img -n background "$image_copied" --transition-type fade --transition-duration 2 > /dev/null 2> /dev/null - notify-send "Wallpaper Changed" "$image" + notify-send -a "change-wallpaper" "Wallpaper Changed" "$image" -i "$image_copied" change-colortheme -i "$image_copied" !waybar !eww || exit 1 else diff --git a/README.md b/README.md index e1abeb4..7b95f95 100644 --- a/README.md +++ b/README.md @@ -15,24 +15,24 @@ ## Setup Overview -- **OS**: Archlinux -- **WM**: Hyprland | **Niri** -- **Bar**: Waybar | **Quickshell** -- **Shell**: Fish -- **Prompt**: Oh My Posh -- **Terminal**: **Kitty** & Ghostty -- **Colorscheme**: Catppuccin Mocha -- **App Launcher**: Rofi -- **Logout Screen**: Wlogout -- **Desktop Widgets**: Eww | **Quickshell** -- **Wallpaper Daemon**: Swww -- **Notification Daemon**: Mako +- OS: Archlinux +- WM: Hyprland | **Niri** +- Bar: Waybar | **Quickshell** +- Shell: Fish +- Prompt: Oh My Posh +- Terminal: **Kitty** & Ghostty +- Colorscheme: Catppuccin Mocha +- App Launcher: Rofi +- Logout Screen: Wlogout +- Desktop Widgets: Eww | **Quickshell** +- Wallpaper Daemon: Swww +- Notification Daemon: Mako | **Quickshell** (**bold**: I currently prefer) ## Hyprland & friends -Based on an old version of [end-4/dots-hyprland](https://github.com/end-4/dots-hyprland) but without ags amd tons of other stuff. +Based on an old version of [end-4/dots-hyprland](https://github.com/end-4/dots-hyprland) but without ags, quickshell, eww and tons of other stuff. ## Niri diff --git a/niri/config.kdl b/niri/config.kdl index 73945cc..e746b19 100644 --- a/niri/config.kdl +++ b/niri/config.kdl @@ -134,7 +134,6 @@ spawn-at-startup "blueman-applet" spawn-at-startup "nm-applet" spawn-sh-at-startup "gnome-keyring-daemon --start --components=secrets" spawn-at-startup "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1" -spawn-at-startup "mako" // Idle spawn-sh-at-startup "hypridle" diff --git a/niri/config.kdl.template b/niri/config.kdl.template index 1dbe5cc..6ec1f52 100644 --- a/niri/config.kdl.template +++ b/niri/config.kdl.template @@ -134,7 +134,6 @@ spawn-at-startup "blueman-applet" spawn-at-startup "nm-applet" spawn-sh-at-startup "gnome-keyring-daemon --start --components=secrets" spawn-at-startup "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1" -spawn-at-startup "mako" // Idle spawn-sh-at-startup "hypridle" diff --git a/quickshell/Assets/Config/.gitignore b/quickshell/Assets/Config/.gitignore index fe3dbf8..57f40e3 100644 --- a/quickshell/Assets/Config/.gitignore +++ b/quickshell/Assets/Config/.gitignore @@ -1,4 +1,5 @@ # some sensitive files Location.json GeoInfoToken.txt -IpCache.json \ No newline at end of file +IpCache.json +Notifications.json \ No newline at end of file diff --git a/quickshell/Assets/Config/Settings.json b/quickshell/Assets/Config/Settings.json index 9b10fe3..429d32c 100644 --- a/quickshell/Assets/Config/Settings.json +++ b/quickshell/Assets/Config/Settings.json @@ -1,4 +1,8 @@ { + "notifications": { + "doNotDisturb": false, + "lastSeenTs": 1760375164000 + }, "primaryColor": "#89b4fa", "showLyricsBar": false } diff --git a/quickshell/Constants/Color.qml b/quickshell/Constants/Color.qml index 90f9f29..f925b86 100644 --- a/quickshell/Constants/Color.qml +++ b/quickshell/Constants/Color.qml @@ -19,7 +19,7 @@ Singleton { property color mSurface: Colors.base property color mOnSurface: Colors.text property color mSurfaceVariant: Colors.surface - property color mOnSurfaceVariant: Colors.text + property color mOnSurfaceVariant: Colors.overlay1 property color mOutline: Colors.primary property color mShadow: Colors.crust property color transparent: "transparent" diff --git a/quickshell/Modules/Bar/Bar.qml b/quickshell/Modules/Bar/Bar.qml index bdc280f..4d29a61 100644 --- a/quickshell/Modules/Bar/Bar.qml +++ b/quickshell/Modules/Bar/Bar.qml @@ -11,255 +11,241 @@ import qs.Modules.Bar.Misc import qs.Modules.Misc import qs.Services -Scope { - id: rootScope - - property var shell +Variants { + model: Quickshell.screens Item { - id: barRootItem + property var modelData - anchors.fill: parent + PanelWindow { + id: panel - Variants { - model: Quickshell.screens + screen: modelData + WlrLayershell.namespace: "quickshell-bar" + color: Colors.transparent + implicitHeight: Style.barHeight - Item { - property var modelData + anchors { + left: true + right: true + top: true + } - PanelWindow { - id: panel + Rectangle { + id: barBackground - screen: modelData - WlrLayershell.namespace: "quickshell-bar" - color: Colors.transparent - implicitHeight: Style.barHeight + anchors.fill: parent + color: Niri.noFocus ? null : Colors.base - anchors { - left: true - right: true - top: true - } - - Rectangle { - id: barBackground - - anchors.fill: parent - color: Niri.noFocus ? null : Colors.base - - gradient: Gradient { - GradientStop { - position: 0 - color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0.8 : 1) - - Behavior on color { - ColorAnimation { - duration: Style.animationSlowest - easing.type: Easing.InOutCubic - } - - } - - } - - GradientStop { - position: 1 - color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0 : 1) - - Behavior on color { - ColorAnimation { - duration: Style.animationSlowest - easing.type: Easing.InOutCubic - } - - } + gradient: Gradient { + GradientStop { + position: 0 + color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0.8 : 1) + Behavior on color { + ColorAnimation { + duration: Style.animationSlowest + easing.type: Easing.InOutCubic } } } - RowLayout { - id: leftLayout + GradientStop { + position: 1 + color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0 : 1) - height: parent.height - 10 - - anchors { - left: parent.left - verticalCenter: parent.verticalCenter - leftMargin: 5 - } - - SymbolButton { - symbol: Icons.distro - buttonColor: Colors.distroColor - onClicked: { - PanelService.getPanel("controlCenterPanel")?.toggle(this) - } - onRightClicked: { - if (action.running) { - action.signal(15); - return ; - } - action.exec(["rofi", "-show", "drun"]); - } - } - - Separator { - } - - Workspace { - screen: modelData - } - - Separator { - } - - Item { - width: 10 - } - - CavaBar { - count: 6 - } - - Item { - width: 10 - } - - Separator { - } - - Item { - width: 10 - } - - FocusedWindow { - maxWidth: 400 - } - - } - - RowLayout { - id: middleLayout - - height: parent.height - 10 - - anchors { - horizontalCenter: parent.horizontalCenter - verticalCenter: parent.verticalCenter - } - - Time { - } - - } - - RowLayout { - id: rightLayout - - height: parent.height - 10 - - anchors { - right: parent.right - verticalCenter: parent.verticalCenter - rightMargin: 5 - } - - RowLayout { - id: monitorsLayout - visible: !SettingsService.showLyricsBar - - height: parent.height - NetworkSpeed { - } - - Separator { - } - - Item { - width: 10 - } - - Ip { - showCountryCode: true - } - - CpuTemp { - } - - MemUsage { - } - - CpuUsage { - } - - Battery { - } - - Brightness { - screen: modelData - } - - Volume { - } - } - - LyricsBar { - id: lyricsBar - visible: SettingsService.showLyricsBar - width: 600 - } - - Item { - width: 5 - } - - Separator { - } - - Item { - width: 5 - } - - TrayExpander { - screen: modelData - } - - SymbolButton { - symbol: Caffeine.isInhibited ? Icons.idleInhibitorActivated : Icons.idleInhibitorDeactivated - buttonColor: Caffeine.isInhibited ? Colors.peach : Colors.yellow - onClicked: { - Caffeine.manualToggle(); + Behavior on color { + ColorAnimation { + duration: Style.animationSlowest + easing.type: Easing.InOutCubic } } - SymbolButton { - symbol: Icons.powerMenu - buttonColor: Colors.red - onClicked: { - if (action.running) { - action.signal(15); - return ; - } - action.exec(["wlogout"]); - } - } - - } - - Process { - id: action - - running: false } } } + RowLayout { + id: leftLayout + + height: parent.height - 10 + + anchors { + left: parent.left + verticalCenter: parent.verticalCenter + leftMargin: 5 + } + + SymbolButton { + symbol: Icons.distro + buttonColor: Colors.distroColor + onClicked: { + PanelService.getPanel("controlCenterPanel")?.toggle(this) + } + onRightClicked: { + if (action.running) { + action.signal(15); + return ; + } + action.exec(["rofi", "-show", "drun"]); + } + } + + Separator { + } + + Workspace { + screen: modelData + } + + Separator { + } + + Item { + width: 10 + } + + CavaBar { + count: 6 + } + + Item { + width: 10 + } + + Separator { + } + + Item { + width: 10 + } + + FocusedWindow { + maxWidth: 400 + } + + } + + RowLayout { + id: middleLayout + + height: parent.height - 10 + + anchors { + horizontalCenter: parent.horizontalCenter + verticalCenter: parent.verticalCenter + } + + Time { + } + + } + + RowLayout { + id: rightLayout + + height: parent.height - 10 + + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + rightMargin: 5 + } + + RowLayout { + id: monitorsLayout + visible: !SettingsService.showLyricsBar + + height: parent.height + NetworkSpeed { + } + + Separator { + } + + Item { + width: 10 + } + + Ip { + showCountryCode: true + } + + CpuTemp { + } + + MemUsage { + } + + CpuUsage { + } + + Battery { + } + + Brightness { + screen: modelData + } + + Volume { + } + } + + LyricsBar { + id: lyricsBar + visible: SettingsService.showLyricsBar + width: 600 + } + + Item { + width: 5 + } + + Separator { + } + + Item { + width: 5 + } + + TrayExpander { + screen: modelData + } + + SymbolButton { + symbol: Caffeine.isInhibited ? Icons.idleInhibitorActivated : Icons.idleInhibitorDeactivated + buttonColor: Caffeine.isInhibited ? Colors.peach : Colors.yellow + onClicked: { + Caffeine.manualToggle(); + } + + } + + SymbolButton { + symbol: Icons.powerMenu + buttonColor: Colors.red + onClicked: { + if (action.running) { + action.signal(15); + return ; + } + Quickshell.execDetached(["wlogout"]); + } + } + + } + + Process { + id: action + + running: false + } + } } diff --git a/quickshell/Modules/Bar/Components/FocusedWindow.qml b/quickshell/Modules/Bar/Components/FocusedWindow.qml index 7df1628..c19bcab 100644 --- a/quickshell/Modules/Bar/Components/FocusedWindow.qml +++ b/quickshell/Modules/Bar/Components/FocusedWindow.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import Quickshell.Io +import Quickshell import Quickshell.Widgets import qs.Constants import qs.Services @@ -80,12 +80,6 @@ Item { font.family: Fonts.primary color: Colors.primary - Process { - id: action - - running: false - } - MouseArea { id: mouseArea @@ -102,28 +96,20 @@ Item { windowTitle.x = 0; } onClicked: function(mouse) { - if (mouse.button === Qt.MiddleButton) { - action.command = ["niri", "msg", "action", "close-window"]; - action.startDetached(); - } else if (mouse.button === Qt.LeftButton) { - action.command = ["niri", "msg", "action", "center-window"]; - action.startDetached(); - } + if (mouse.button === Qt.MiddleButton) + Quickshell.execDetached(["niri", "msg", "action", "close-window"]); + else if (mouse.button === Qt.LeftButton) + Quickshell.execDetached(["niri", "msg", "action", "center-window"]); } onWheel: function(wheel) { - if (wheel.angleDelta.y > 0) { - action.command = ["niri", "msg", "action", "set-column-width", "+10%"]; - action.startDetached(); - } else if (wheel.angleDelta.y < 0) { - action.command = ["niri", "msg", "action", "set-column-width", "-10%"]; - action.startDetached(); - } else if (wheel.angleDelta.x > 0) { - action.command = ["niri", "msg", "action", "focus-column-left"]; - action.startDetached(); - } else if (wheel.angleDelta.x < 0) { - action.command = ["niri", "msg", "action", "focus-column-right"]; - action.startDetached(); - } + if (wheel.angleDelta.y > 0) + Quickshell.execDetached(["niri", "msg", "action", "set-column-width", "+10%"]); + else if (wheel.angleDelta.y < 0) + Quickshell.execDetached(["niri", "msg", "action", "set-column-width", "-10%"]); + else if (wheel.angleDelta.x > 0) + Quickshell.execDetached(["niri", "msg", "action", "focus-column-left"]); + else if (wheel.angleDelta.x < 0) + Quickshell.execDetached(["niri", "msg", "action", "focus-column-right"]); } } diff --git a/quickshell/Modules/Bar/Components/Time.qml b/quickshell/Modules/Bar/Components/Time.qml index 008108e..b9ea3a6 100644 --- a/quickshell/Modules/Bar/Components/Time.qml +++ b/quickshell/Modules/Bar/Components/Time.qml @@ -11,8 +11,12 @@ Text { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: { - PanelService.getPanel("calendarPanel")?.toggle(this) + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) + PanelService.getPanel("calendarPanel")?.toggle(this) + else if (mouse.button === Qt.RightButton) + PanelService.getPanel("notificationHistoryPanel")?.toggle(this) } } } diff --git a/quickshell/Modules/Misc/Notification.qml b/quickshell/Modules/Misc/Notification.qml new file mode 100644 index 0000000..606bb59 --- /dev/null +++ b/quickshell/Modules/Misc/Notification.qml @@ -0,0 +1,439 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Wayland +import Quickshell.Widgets +import qs.Constants +import qs.Noctalia +import qs.Services +import qs.Utils + +// Simple notification popup - displays multiple notifications +Variants { + // Force removal without animation as fallback + + // If no notification display activated in settings, then show them all + model: Quickshell.screens + + delegate: Loader { + id: root + + required property ShellScreen modelData + property real scaling: 1 + // Access the notification model from the service + property ListModel notificationModel: NotificationService.activeList + + // Loader is active when there are notifications + active: notificationModel.count > 0 || delayTimer.running + + // Keep loader active briefly after last notification to allow animations to complete + Timer { + id: delayTimer + + interval: Style.animationSlow + 200 // Animation duration + buffer + repeat: false + } + + // Start delay timer when last notification is removed + Connections { + function onCountChanged() { + if (notificationModel.count === 0 && root.active) + delayTimer.restart(); + + } + + target: notificationModel + } + + sourceComponent: PanelWindow { + readonly property string location: "top_right" + readonly property bool isTop: (location === "top") || (location.length >= 3 && location.substring(0, 3) === "top") + readonly property bool isBottom: (location === "bottom") || (location.length >= 6 && location.substring(0, 6) === "bottom") + readonly property bool isLeft: location.indexOf("_left") >= 0 + readonly property bool isRight: location.indexOf("_right") >= 0 + readonly property bool isCentered: (location === "top" || location === "bottom") + // Store connection for cleanup + property var animateConnection: null + + screen: modelData + WlrLayershell.namespace: "noctalia-notifications" + WlrLayershell.layer: WlrLayer.Overlay + color: Color.transparent + // Anchor selection based on location (window edges) + anchors.top: isTop + anchors.bottom: isBottom + anchors.left: isLeft + anchors.right: isRight + // Margins depending on bar position and chosen location + margins.top: Style.barHeight + Style.marginM + margins.bottom: 0 + margins.left: 0 + margins.right: Style.marginM + implicitWidth: 360 + implicitHeight: notificationStack.implicitHeight + WlrLayershell.exclusionMode: ExclusionMode.Ignore + // Connect to animation signal from service + Component.onCompleted: { + animateConnection = NotificationService.animateAndRemove.connect(function(notificationId) { + // Find the delegate by notification ID + var delegate = null; + if (notificationStack && notificationStack.children && notificationStack.children.length > 0) { + for (var i = 0; i < notificationStack.children.length; i++) { + var child = notificationStack.children[i]; + if (child && child.notificationId === notificationId) { + delegate = child; + break; + } + } + } + if (delegate && delegate.animateOut) + delegate.animateOut(); + else + NotificationService.dismissActiveNotification(notificationId); + }); + } + // Disconnect when destroyed to prevent memory leaks + Component.onDestruction: { + if (animateConnection) { + NotificationService.animateAndRemove.disconnect(animateConnection); + animateConnection = null; + } + } + + // Main notification container + ColumnLayout { + id: notificationStack + + // Anchor the stack inside the window based on chosen location + anchors.top: parent.isTop ? parent.top : undefined + anchors.bottom: parent.isBottom ? parent.bottom : undefined + anchors.left: parent.isLeft ? parent.left : undefined + anchors.right: parent.isRight ? parent.right : undefined + anchors.horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined + spacing: Style.marginS + width: 360 + visible: true + + // Multiple notifications display + Repeater { + model: notificationModel + + delegate: Rectangle { + id: card + + // Store the notification ID and data for reference + property string notificationId: model.id + property var notificationData: model + // Animation properties + property real scaleValue: 0.8 + property real opacityValue: 0 + property bool isRemoving: false + + // Animate out when being removed + function animateOut() { + if (isRemoving) + return ; + + // Prevent multiple animations + isRemoving = true; + scaleValue = 0.8; + opacityValue = 0; + } + + Layout.preferredWidth: 360 + Layout.preferredHeight: notificationLayout.implicitHeight + (Style.marginL * 2) + Layout.maximumHeight: Layout.preferredHeight + radius: Style.radiusL + border.color: Colors.overlay0 + border.width: Math.max(1, Style.borderS) + color: Color.mSurface + // Scale and fade-in animation + scale: scaleValue + opacity: opacityValue + // Animate in when the item is created + Component.onCompleted: { + scaleValue = 1; + opacityValue = 1; + } + // Check if this notification is being removed + onIsRemovingChanged: { + if (isRemoving) + removalTimer.start(); + + } + + // Optimized progress bar container + Rectangle { + id: progressBarContainer + + // Pre-calculate available width for the progress bar + readonly property real availableWidth: parent.width - (2 * parent.radius) + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 2 + color: Color.transparent + + // Actual progress bar - centered and symmetric + Rectangle { + id: progressBar + + height: parent.height + // Center the bar and make it shrink symmetrically + x: parent.parent.radius + (parent.availableWidth * (1 - model.progress)) / 2 + width: parent.availableWidth * model.progress + color: { + if (model.urgency === NotificationUrgency.Critical || model.urgency === 2) + return Colors.red; + else if (model.urgency === NotificationUrgency.Low || model.urgency === 0) + return Colors.green; + else + return Colors.primary; + } + antialiasing: true + + // Smooth progress animation + Behavior on width { + enabled: !card.isRemoving // Disable during removal animation + + NumberAnimation { + duration: 100 // Quick but smooth + easing.type: Easing.Linear + } + + } + + Behavior on x { + enabled: !card.isRemoving + + NumberAnimation { + duration: 100 + easing.type: Easing.Linear + } + + } + + } + + } + + // Right-click to dismiss + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) + animateOut(); + + } + } + + // Timer for delayed removal after animation + Timer { + id: removalTimer + + interval: Style.animationSlow + repeat: false + onTriggered: { + NotificationService.dismissActiveNotification(notificationId); + } + } + + ColumnLayout { + id: notificationLayout + + anchors.fill: parent + anchors.margins: Style.marginM + anchors.rightMargin: (Style.marginM + 32) // Leave space for close button + spacing: Style.marginM + + // Main content section + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + ColumnLayout { + // For real-time notification always show the original image + // as the cached version is most likely still processing. + NImageCircled { + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + Layout.alignment: Qt.AlignTop + Layout.topMargin: 30 + imagePath: model.originalImage || "" + borderColor: Color.transparent + borderWidth: 0 + fallbackIcon: "bell" + fallbackIconSize: 24 + } + + Item { + Layout.fillHeight: true + } + + } + + // Text content + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginS + + // Header section with app name and timestamp + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + Rectangle { + Layout.preferredWidth: 6 + Layout.preferredHeight: 6 + radius: Style.radiusXS + color: { + if (model.urgency === NotificationUrgency.Critical || model.urgency === 2) + return Color.mError; + else if (model.urgency === NotificationUrgency.Low || model.urgency === 0) + return Color.mOnSurface; + else + return Color.mPrimary; + } + Layout.alignment: Qt.AlignVCenter + } + + NText { + text: `${model.appName || I18n.tr("system.unknown-app")} · ${Time.formatRelativeTime(model.timestamp)}` + color: Color.mSecondary + pointSize: Style.fontSizeXS + family: Fonts.sans + } + + Item { + Layout.fillWidth: true + } + + } + + NText { + text: model.summary || I18n.tr("general.no-summary") + pointSize: Style.fontSizeL + font.weight: Style.fontWeightMedium + color: Color.mOnSurface + textFormat: Text.PlainText + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + Layout.fillWidth: true + maximumLineCount: 3 + elide: Text.ElideRight + family: Fonts.sans + visible: text.length > 0 + } + + NText { + text: model.body || "" + pointSize: Style.fontSizeM + color: Color.mOnSurface + textFormat: Text.PlainText + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + Layout.fillWidth: true + maximumLineCount: 5 + elide: Text.ElideRight + family: Fonts.sans + visible: text.length > 0 + } + + // Notification actions + Flow { + // Store the notification ID for access in button delegates + property string parentNotificationId: notificationId + // Parse actions from JSON string + property var parsedActions: { + try { + return model.actionsJson ? JSON.parse(model.actionsJson) : []; + } catch (e) { + return []; + } + } + + Layout.fillWidth: true + spacing: Style.marginS + Layout.topMargin: Style.marginM + flow: Flow.LeftToRight + layoutDirection: Qt.LeftToRight + visible: parsedActions.length > 0 + + Repeater { + model: parent.parsedActions + + delegate: NButton { + property var actionData: modelData + + text: { + var actionText = actionData.text || "Open"; + // If text contains comma, take the part after the comma (the display text) + if (actionText.includes(",")) + return actionText.split(",")[1] || actionText; + + return actionText; + } + fontFamily: Fonts.sans + fontSize: Style.fontSizeS + backgroundColor: Color.mPrimary + textColor: hovered ? Color.mOnTertiary : Color.mOnPrimary + hoverColor: Color.mTertiary + outlined: false + implicitHeight: 24 + onClicked: { + NotificationService.invokeAction(parent.parentNotificationId, actionData.identifier); + } + } + + } + + } + + } + + } + + } + + // Close button positioned absolutely + NIconButton { + icon: "close" + baseSize: Style.baseWidgetSize * 0.6 + anchors.top: parent.top + anchors.topMargin: Style.marginM + anchors.right: parent.right + anchors.rightMargin: Style.marginM + onClicked: { + animateOut(); + } + } + + // Animation behaviors + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutExpo + } + + } + + Behavior on opacity { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutQuad + } + + } + + } + + } + + } + + } + + } + +} diff --git a/quickshell/Modules/Panel/CalendarPanel.qml b/quickshell/Modules/Panel/CalendarPanel.qml index be4beb2..d34e69b 100644 --- a/quickshell/Modules/Panel/CalendarPanel.qml +++ b/quickshell/Modules/Panel/CalendarPanel.qml @@ -75,7 +75,7 @@ NPanel { Layout.alignment: Qt.AlignHCenter icon: weatherReady ? LocationService.weatherSymbolFromCode(LocationService.data.weather.current_weather.weathercode) : "cloud" pointSize: Style.fontSizeXXL - color: Color.mOnSurfaceVariant + color: Colors.text } NText { @@ -91,7 +91,7 @@ NPanel { } pointSize: Style.fontSizeM font.weight: Style.fontWeightBold - color: Color.mOnSurfaceVariant + color: Colors.text } } @@ -103,7 +103,7 @@ NPanel { text: Time.date.getDate() pointSize: Style.fontSizeXXXL * 1.5 font.weight: Style.fontWeightBold - color: Color.mOnSurfaceVariant + color: Colors.text } Item { @@ -123,7 +123,7 @@ NPanel { text: Qt.locale().monthName(grid.month, Locale.LongFormat).toUpperCase() pointSize: Style.fontSizeXL * 1.2 font.weight: Style.fontWeightBold - color: Color.mOnSurfaceVariant + color: Colors.text Layout.alignment: Qt.AlignBaseline Layout.maximumWidth: 150 elide: Text.ElideRight @@ -133,7 +133,7 @@ NPanel { text: ` ${grid.year}` pointSize: Style.fontSizeL font.weight: Style.fontWeightBold - color: Qt.alpha(Color.mOnSurfaceVariant, 0.7) + color: Qt.alpha(Colors.text, 0.7) Layout.alignment: Qt.AlignBaseline } @@ -152,7 +152,7 @@ NPanel { } pointSize: Style.fontSizeM font.weight: Style.fontWeightMedium - color: Color.mOnSurfaceVariant + color: Colors.text Layout.maximumWidth: 150 elide: Text.ElideRight } @@ -161,7 +161,7 @@ NPanel { text: weatherReady ? ` (${LocationService.data.weather.timezone_abbreviation})` : "" pointSize: Style.fontSizeXS font.weight: Style.fontWeightMedium - color: Qt.alpha(Color.mOnSurfaceVariant, 0.7) + color: Qt.alpha(Colors.text, 0.7) } } @@ -197,13 +197,13 @@ NPanel { ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); ctx.lineWidth = 2.5; - ctx.strokeStyle = Qt.alpha(Color.mOnSurfaceVariant, 0.15); + ctx.strokeStyle = Qt.alpha(Colors.text, 0.15); ctx.stroke(); // Progress arc ctx.beginPath(); ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI); ctx.lineWidth = 2.5; - ctx.strokeStyle = Color.mOnSurfaceVariant; + ctx.strokeStyle = Colors.text; ctx.lineCap = "round"; ctx.stroke(); } @@ -230,7 +230,7 @@ NPanel { } pointSize: Style.fontSizeXS font.weight: Style.fontWeightBold - color: Color.mOnSurfaceVariant + color: Colors.text family: Fonts.sans Layout.alignment: Qt.AlignHCenter } @@ -239,7 +239,7 @@ NPanel { text: Qt.formatTime(Time.date, "mm") pointSize: Style.fontSizeXXS font.weight: Style.fontWeightBold - color: Color.mOnSurfaceVariant + color: Colors.text family: Fonts.sans Layout.alignment: Qt.AlignHCenter } @@ -298,7 +298,7 @@ NPanel { return `${max}°/${min}°`; } pointSize: Style.fontSizeXS - color: Color.mOnSurfaceVariant + color: Colors.text font.weight: Style.fontWeightMedium } diff --git a/quickshell/Modules/Panel/NotificationHistoryPanel.qml b/quickshell/Modules/Panel/NotificationHistoryPanel.qml new file mode 100644 index 0000000..5ae8b53 --- /dev/null +++ b/quickshell/Modules/Panel/NotificationHistoryPanel.qml @@ -0,0 +1,344 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Wayland +import qs.Constants +import qs.Noctalia +import qs.Services +import qs.Utils + +// Notification History panel +NPanel { + id: root + + preferredWidth: 380 + preferredHeight: 480 + onOpened: function() { + SettingsService.notifications.lastSeenTs = Time.timestamp * 1000; + } + + panelContent: Rectangle { + id: notificationRect + + color: Color.transparent + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + // Header section + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NIcon { + icon: "bell" + pointSize: Style.fontSizeXXL + color: Color.mPrimary + } + + NText { + text: "Notifications" + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NIconButton { + icon: SettingsService.notifications.doNotDisturb ? "bell-off" : "bell" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: SettingsService.notifications.doNotDisturb = !SettingsService.notifications.doNotDisturb + colorFg: SettingsService.notifications.doNotDisturb ? Colors.base : Colors.green + colorBg: SettingsService.notifications.doNotDisturb ? Colors.green : Color.transparent + colorFgHover: Colors.base + colorBgHover: Colors.green + } + + NIconButton { + icon: "trash" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + NotificationService.clearHistory(); + // Close panel as there is nothing more to see. + root.close(); + } + colorFg: Colors.red + colorBg: Color.transparent + colorFgHover: Colors.base + colorBgHover: Colors.red + } + + NIconButton { + icon: "close" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: root.close() + colorFg: Colors.blue + colorBg: Color.transparent + colorFgHover: Colors.base + colorBgHover: Colors.blue + } + + } + + NDivider { + Layout.fillWidth: true + } + + // Empty state when no notifications + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignHCenter + visible: NotificationService.historyList.count === 0 + spacing: Style.marginL + + Item { + Layout.fillHeight: true + } + + NIcon { + icon: "bell-off" + pointSize: 64 + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + NText { + text: "No Notifications" + pointSize: Style.fontSizeL + color: Color.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + Item { + Layout.fillHeight: true + } + + } + + // Notification list + NListView { + id: notificationList + + // Track which notification is expanded + property string expandedId: "" + + Layout.fillWidth: true + Layout.fillHeight: true + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + model: NotificationService.historyList + spacing: Style.marginM + clip: true + boundsBehavior: Flickable.StopAtBounds + visible: NotificationService.historyList.count > 0 + + delegate: NBox { + property string notificationId: model.id + property bool isExpanded: notificationList.expandedId === notificationId + + width: notificationList.width + height: notificationLayout.implicitHeight + (Style.marginM * 2) + + // Click to expand/collapse + MouseArea { + anchors.fill: parent + // Don't capture clicks on the delete button + anchors.rightMargin: 48 + enabled: (summaryText.truncated || bodyText.truncated) + onClicked: { + if (notificationList.expandedId === notificationId) + notificationList.expandedId = ""; + else + notificationList.expandedId = notificationId; + } + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + } + + RowLayout { + id: notificationLayout + + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + ColumnLayout { + NImageCircled { + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + Layout.alignment: Qt.AlignTop + Layout.topMargin: 20 + imagePath: model.cachedImage || model.originalImage || "" + borderColor: Color.transparent + borderWidth: 0 + fallbackIcon: "bell" + fallbackIconSize: 24 + } + + Item { + Layout.fillHeight: true + } + + } + + // Notification content column + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: Style.marginXS + Layout.rightMargin: -(Style.marginM + Style.baseWidgetSize * 0.6) + + // Header row with app name and timestamp + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + // Urgency indicator + Rectangle { + Layout.preferredWidth: 6 + Layout.preferredHeight: 6 + Layout.alignment: Qt.AlignVCenter + radius: 3 + visible: model.urgency !== 1 + color: { + if (model.urgency === 2) + return Color.mError; + else if (model.urgency === 0) + return Color.mOnSurfaceVariant; + else + return Color.transparent; + } + } + + NText { + text: model.appName || "Unknown App" + pointSize: Style.fontSizeXS + color: Color.mSecondary + family: Fonts.sans + } + + NText { + text: Time.formatRelativeTime(model.timestamp) + pointSize: Style.fontSizeXS + color: Color.mSecondary + family: Fonts.sans + } + + Item { + Layout.fillWidth: true + } + + } + + // Summary + NText { + id: summaryText + + text: model.summary || "No Summary" + pointSize: Style.fontSizeM + font.weight: Font.Medium + color: Color.mOnSurface + textFormat: Text.PlainText + wrapMode: Text.Wrap + Layout.fillWidth: true + maximumLineCount: isExpanded ? 999 : 2 + family: Fonts.sans + elide: Text.ElideRight + } + + // Body + NText { + id: bodyText + + text: model.body || "" + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + textFormat: Text.PlainText + wrapMode: Text.Wrap + Layout.fillWidth: true + maximumLineCount: isExpanded ? 999 : 3 + elide: Text.ElideRight + family: Fonts.sans + visible: text.length > 0 + } + + // Spacer for expand indicator + Item { + Layout.fillWidth: true + Layout.preferredHeight: (!isExpanded && (summaryText.truncated || bodyText.truncated)) ? (Style.marginS) : 0 + } + + // Expand indicator + RowLayout { + Layout.fillWidth: true + visible: !isExpanded && (summaryText.truncated || bodyText.truncated) + spacing: Style.marginXS + + Item { + Layout.fillWidth: true + } + + NText { + text: "Click to expand" + pointSize: Style.fontSizeXS + color: Color.mPrimary + family: Fonts.sans + font.weight: Font.Medium + } + + NIcon { + icon: "chevron-down" + pointSize: Style.fontSizeS + color: Color.mPrimary + } + + } + + } + + // Delete button + NIconButton { + icon: "trash" + baseSize: Style.baseWidgetSize * 0.7 + Layout.alignment: Qt.AlignTop + onClicked: { + // Remove from history using the service API + NotificationService.removeFromHistory(notificationId); + } + colorFg: Colors.red + colorBg: Color.transparent + colorFgHover: Colors.base + colorBgHover: Colors.red + } + + } + + Behavior on height { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutQuad + } + + } + + // Smooth color transition on hover + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + + } + + } + + } + + } + + } + +} diff --git a/quickshell/Noctalia/NButton.qml b/quickshell/Noctalia/NButton.qml new file mode 100644 index 0000000..a237000 --- /dev/null +++ b/quickshell/Noctalia/NButton.qml @@ -0,0 +1,183 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Constants +import qs.Services + +Rectangle { + id: root + + // Public properties + property string text: "" + property string icon: "" + property string tooltipText + property color backgroundColor: Color.mPrimary + property color textColor: Color.mOnPrimary + property color hoverColor: Color.mTertiary + property bool enabled: true + property real fontSize: Style.fontSizeM * scaling + property int fontWeight: Style.fontWeightBold + property string fontFamily: Fonts.primary + property real iconSize: Style.fontSizeL * scaling + property bool outlined: false + // Internal properties + property bool hovered: false + property bool pressed: false + + // Signals + signal clicked() + signal rightClicked() + signal middleClicked() + + // Dimensions + implicitWidth: contentRow.implicitWidth + (Style.marginL * 2 * scaling) + implicitHeight: Math.max(Style.baseWidgetSize * scaling, contentRow.implicitHeight + (Style.marginM * scaling)) + // Appearance + radius: Style.radiusS * scaling + color: { + if (!enabled) + return outlined ? Color.transparent : Qt.lighter(Color.mSurfaceVariant, 1.2); + + if (hovered) + return hoverColor; + + return outlined ? Color.transparent : backgroundColor; + } + border.width: outlined ? Math.max(1, Style.borderS * scaling) : 0 + border.color: { + if (!enabled) + return Color.mOutline; + + if (pressed || hovered) + return backgroundColor; + + return outlined ? backgroundColor : Color.transparent; + } + opacity: enabled ? 1 : 0.6 + + // Content + RowLayout { + id: contentRow + + anchors.centerIn: parent + spacing: Style.marginXS * scaling + + // Icon (optional) + NIcon { + Layout.alignment: Qt.AlignVCenter + visible: root.icon !== "" + icon: root.icon + pointSize: root.iconSize + color: { + if (!root.enabled) + return Color.mOnSurfaceVariant; + + if (root.outlined) { + if (root.pressed || root.hovered) + return root.backgroundColor; + + return root.backgroundColor; + } + return root.textColor; + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + + } + + } + + // Text + NText { + Layout.alignment: Qt.AlignVCenter + visible: root.text !== "" + text: root.text + pointSize: root.fontSize + font.weight: root.fontWeight + family: root.fontFamily + color: { + if (!root.enabled) + return Color.mOnSurfaceVariant; + + if (root.outlined) { + if (root.hovered) + return root.textColor; + + return root.backgroundColor; + } + return root.textColor; + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + + } + + } + + } + + // Mouse interaction + MouseArea { + id: mouseArea + + anchors.fill: parent + enabled: root.enabled + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onEntered: { + root.hovered = true; + if (tooltipText) + TooltipService.show(Screen, root, root.tooltipText); + + } + onExited: { + root.hovered = false; + if (tooltipText) + TooltipService.hide(); + + } + onPressed: (mouse) => { + if (tooltipText) + TooltipService.hide(); + + if (mouse.button === Qt.LeftButton) + root.clicked(); + else if (mouse.button == Qt.RightButton) + root.rightClicked(); + else if (mouse.button == Qt.MiddleButton) + root.middleClicked(); + } + onCanceled: { + root.hovered = false; + if (tooltipText) + TooltipService.hide(); + + } + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + + } + + Behavior on border.color { + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + + } + +} diff --git a/quickshell/Services/LocationService.qml b/quickshell/Services/LocationService.qml index d40617f..3e4ff1a 100644 --- a/quickshell/Services/LocationService.qml +++ b/quickshell/Services/LocationService.qml @@ -118,10 +118,10 @@ Singleton { function _fetchWeather(latitude, longitude, errorCallback) { Logger.log("Location", "Fetching weather from api.open-meteo.com"); var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "¤t_weather=true¤t=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto"; - curl.fetch(url, function(success, data) { + curl.fetch(url, function(success, fetchedData) { if (success) { try { - var weatherData = JSON.parse(data); + var weatherData = JSON.parse(fetchedData); // Save core data data.weather = weatherData; data.weatherLastFetch = Time.timestamp; diff --git a/quickshell/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml new file mode 100644 index 0000000..1d272cb --- /dev/null +++ b/quickshell/Services/NotificationService.qml @@ -0,0 +1,474 @@ +pragma Singleton + +import QtQuick +import QtQuick.Window +import Quickshell +import Quickshell.Io +import Quickshell.Services.Notifications +import qs.Utils +import qs.Services +import qs.Constants +import "../Utils/sha256.js" as Checksum + +Singleton { + id: root + + // Configuration + property int maxVisible: 5 + property int maxHistory: 100 + property string historyFile: Qt.resolvedUrl("../Assets/Config/Notifications.json") + property string cacheDirImagesNotifications: Quickshell.env("HOME") + "/.cache/quickshell/notifications/" + property real lowUrgencyDuration: 3 + property real normalUrgencyDuration: 8 + property real criticalUrgencyDuration: 15 + + // Models + property ListModel activeList: ListModel {} + property ListModel historyList: ListModel {} + + // Internal state + property var activeMap: ({}) + property var imageQueue: [] + + // Performance optimization: Track notification metadata separately + property var notificationMetadata: ({}) // Stores timestamp and duration for each notification + + PanelWindow { + implicitHeight: 1 + implicitWidth: 1 + color: Color.transparent + mask: Region {} + + Image { + id: cacher + width: 64 + height: 64 + visible: true + cache: false + asynchronous: true + mipmap: true + antialiasing: true + + onStatusChanged: { + if (imageQueue.length === 0) + return + const req = imageQueue[0] + + if (status === Image.Ready) { + Quickshell.execDetached(["mkdir", "-p", cacheDirImagesNotifications]) + grabToImage(result => { + if (result.saveToFile(req.dest)) + updateImagePath(req.imageId, req.dest) + processNextImage() + }) + } else if (status === Image.Error) { + processNextImage() + } + } + + function processNextImage() { + imageQueue.shift() + if (imageQueue.length > 0) { + source = imageQueue[0].src + } else { + source = "" + } + } + } + } + + // Notification server + NotificationServer { + keepOnReload: false + imageSupported: true + actionsSupported: true + onNotification: notification => handleNotification(notification) + } + + // Main handler + function handleNotification(notification) { + const data = createData(notification) + addToHistory(data) + + if (SettingsService.notifications.doNotDisturb) + return + + activeMap[data.id] = notification + notification.tracked = true + notification.closed.connect(() => removeActive(data.id)) + + // Store metadata for efficient progress calculation + const durations = [lowUrgencyDuration * 1000, normalUrgencyDuration * 1000, criticalUrgencyDuration * 1000] + + let expire = 0 + if (data.expireTimeout === 0) { + expire = -1 // Never expire + } else if (data.expireTimeout > 0) { + expire = data.expireTimeout + } else { + expire = durations[data.urgency] + } + + notificationMetadata[data.id] = { + "timestamp": data.timestamp.getTime(), + "duration": expire, + "urgency": data.urgency + } + + activeList.insert(0, data) + while (activeList.count > maxVisible) { + const last = activeList.get(activeList.count - 1) + activeMap[last.id]?.dismiss() + activeList.remove(activeList.count - 1) + delete notificationMetadata[last.id] + } + } + + function createData(n) { + const time = new Date() + const id = Checksum.sha256(JSON.stringify({ + "summary": n.summary, + "body": n.body, + "app": n.appName, + "time": time.getTime() + })) + + const image = n.image || getIcon(n.appIcon) + const imageId = generateImageId(n, image) + queueImage(image, imageId) + + return { + "id": id, + "summary": (n.summary || ""), + "body": stripTags(n.body || ""), + "appName": getAppName(n.appName), + "urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency, + "expireTimeout": n.expireTimeout, + "timestamp": time, + "progress": 1.0, + "originalImage": image, + "cachedImage": imageId ? (cacheDirImagesNotifications + imageId + ".png") : image, + "actionsJson": JSON.stringify((n.actions || []).map(a => ({ + "text": a.text || "Action", + "identifier": a.identifier || "" + }))) + } + } + + function queueImage(path, imageId) { + if (!path || !path.startsWith("image://") || !imageId) + return + + const dest = cacheDirImagesNotifications + imageId + ".png" + + for (const req of imageQueue) { + if (req.imageId === imageId) + return + } + + imageQueue.push({ + "src": path, + "dest": dest, + "imageId": imageId + }) + + if (imageQueue.length === 1) + cacher.source = path + } + + function updateImagePath(id, path) { + updateModel(activeList, id, "cachedImage", path) + updateModel(historyList, id, "cachedImage", path) + saveHistory() + } + + function updateModel(model, id, prop, value) { + for (var i = 0; i < model.count; i++) { + if (model.get(i).id === id) { + model.setProperty(i, prop, value) + break + } + } + } + + function removeActive(id) { + for (var i = 0; i < activeList.count; i++) { + if (activeList.get(i).id === id) { + activeList.remove(i) + delete activeMap[id] + delete notificationMetadata[id] + break + } + } + } + + // Optimized batch progress update + Timer { + interval: 50 // Reduced from 10ms to 50ms (20 updates/sec instead of 100) + repeat: true + running: activeList.count > 0 + onTriggered: updateAllProgress() + } + + function updateAllProgress() { + const now = Date.now() + const toRemove = [] + const updates = [] // Batch updates + + // Collect all updates first + for (var i = 0; i < activeList.count; i++) { + const notif = activeList.get(i) + const meta = notificationMetadata[notif.id] + + if (!meta || meta.duration === -1) + continue + + // Skip infinite notifications + const elapsed = now - meta.timestamp + const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0) + + if (progress <= 0) { + toRemove.push(notif.id) + } else if (Math.abs(notif.progress - progress) > 0.005) { + // Only update if change is significant + updates.push({ + "index": i, + "progress": progress + }) + } + } + + // Apply batch updates + for (const update of updates) { + activeList.setProperty(update.index, "progress", update.progress) + } + + // Remove expired notifications (one at a time to allow animation) + if (toRemove.length > 0) { + animateAndRemove(toRemove[0]) + } + } + + // History management + function addToHistory(data) { + historyList.insert(0, data) + + while (historyList.count > maxHistory) { + const old = historyList.get(historyList.count - 1) + if (old.cachedImage && !old.cachedImage.startsWith("image://")) { + Quickshell.execDetached(["rm", "-f", old.cachedImage]) + } + historyList.remove(historyList.count - 1) + } + saveHistory() + } + + // Persistence + FileView { + id: historyFileView + path: historyFile + printErrors: false + onLoaded: loadHistory() + onLoadFailed: error => { + if (error === 2) + writeAdapter() + } + + JsonAdapter { + id: adapter + property var notifications: [] + } + } + + Timer { + id: saveTimer + interval: 200 + onTriggered: performSaveHistory() + } + + function saveHistory() { + saveTimer.restart() + } + + function performSaveHistory() { + try { + const items = [] + for (var i = 0; i < historyList.count; i++) { + const n = historyList.get(i) + const copy = Object.assign({}, n) + copy.timestamp = n.timestamp.getTime() + items.push(copy) + } + adapter.notifications = items + historyFileView.writeAdapter() + } catch (e) { + Logger.error("Notifications", "Save history failed:", e) + } + } + + function loadHistory() { + try { + historyList.clear() + for (const item of adapter.notifications || []) { + const time = new Date(item.timestamp) + + let cachedImage = item.cachedImage || "" + if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) { + const imageId = generateImageId(item, item.originalImage) + if (imageId) { + cachedImage = cacheDirImagesNotifications + imageId + ".png" + } + } + + historyList.append({ + "id": item.id || "", + "summary": item.summary || "", + "body": item.body || "", + "appName": item.appName || "", + "urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency, + "timestamp": time, + "originalImage": item.originalImage || "", + "cachedImage": cachedImage + }) + } + } catch (e) { + Logger.error("Notifications", "Load failed:", e) + } + } + + function getAppName(name) { + if (!name || name.trim() === "") + return "Unknown" + + name = name.trim() + + if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) { + const parts = name.split(".") + let appPart = parts[parts.length - 1] + + if (!appPart || appPart === "app" || appPart === "desktop") { + appPart = parts[parts.length - 2] || parts[0] + } + + if (appPart) { + name = appPart + } + } + + if (name.includes(".")) { + const parts = name.split(".") + let displayName = parts[parts.length - 1] + + if (!displayName || /^\d+$/.test(displayName)) { + displayName = parts[parts.length - 2] || parts[0] + } + + if (displayName) { + displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1) + displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2') + displayName = displayName.replace(/app$/i, '').trim() + displayName = displayName.replace(/desktop$/i, '').trim() + displayName = displayName.replace(/flatpak$/i, '').trim() + + if (!displayName) { + displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1) + } + } + + return displayName || name + } + + let displayName = name.charAt(0).toUpperCase() + name.slice(1) + displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2') + displayName = displayName.replace(/app$/i, '').trim() + displayName = displayName.replace(/desktop$/i, '').trim() + + return displayName || name + } + + function getIcon(icon) { + if (!icon) + return "" + if (icon.startsWith("/") || icon.startsWith("file://")) + return icon + return ThemeIcons.iconFromName(icon) + } + + function stripTags(text) { + return text.replace(/<[^>]*>?/gm, '') + } + + function generateImageId(notification, image) { + if (image && image.startsWith("image://")) { + if (image.startsWith("image://qsimage/")) { + const key = (notification.appName || "") + "|" + (notification.summary || "") + return Checksum.sha256(key) + } + return Checksum.sha256(image) + } + return "" + } + + // Public API + function dismissActiveNotification(id) { + activeMap[id]?.dismiss() + removeActive(id) + } + + function dismissAllActive() { + Object.values(activeMap).forEach(n => n.dismiss()) + activeList.clear() + activeMap = {} + notificationMetadata = {} + } + + function invokeAction(id, actionId) { + const n = activeMap[id] + if (!n?.actions) + return false + + for (const action of n.actions) { + if (action.identifier === actionId && action.invoke) { + action.invoke() + return true + } + } + return false + } + + function removeFromHistory(notificationId) { + for (var i = 0; i < historyList.count; i++) { + const notif = historyList.get(i) + if (notif.id === notificationId) { + if (notif.cachedImage && !notif.cachedImage.startsWith("image://")) { + Quickshell.execDetached(["rm", "-f", notif.cachedImage]) + } + historyList.remove(i) + saveHistory() + return true + } + } + return false + } + + function clearHistory() { + try { + Quickshell.execDetached(["sh", "-c", `rm -rf "${cacheDirImagesNotifications}"*`]) + } catch (e) { + Logger.error("Notifications", "Failed to clear cache directory:", e) + } + + historyList.clear() + saveHistory() + } + + // Signals & connections + signal animateAndRemove(string notificationId) + + Connections { + target: SettingsService.notifications + onDoNotDisturbChanged: { + const enabled = SettingsService.notifications.doNotDisturb + } + } +} diff --git a/quickshell/Services/SendNotification.qml b/quickshell/Services/SendNotification.qml index 197fc0f..e00ccaa 100644 --- a/quickshell/Services/SendNotification.qml +++ b/quickshell/Services/SendNotification.qml @@ -8,16 +8,9 @@ Singleton { function show(title, message, timeout = 5000, icon = "", urgency = "normal") { if (icon) - action.command = ["notify-send", "-u", urgency, "-i", icon, "-t", timeout.toString(), title, message]; + Quickshell.execDetached(["notify-send", "-u", urgency, "-i", icon, "-t", timeout.toString(), title, message, "-a", "quickshell"]); else - action.command = ["notify-send", "-u", urgency, "-t", timeout.toString(), title, message]; - action.startDetached(); - } - - Process { - id: action - - running: false + Quickshell.execDetached(["notify-send", "-u", urgency, "-t", timeout.toString(), title, message, "-a", "quickshell"]); } } diff --git a/quickshell/Services/SettingsService.qml b/quickshell/Services/SettingsService.qml index ed8ac6c..e817a7b 100644 --- a/quickshell/Services/SettingsService.qml +++ b/quickshell/Services/SettingsService.qml @@ -8,6 +8,7 @@ pragma Singleton Singleton { property alias primaryColor: adapter.primaryColor property alias showLyricsBar: adapter.showLyricsBar + property alias notifications: adapter.notifications property string settingsFilePath: Qt.resolvedUrl("../Assets/Config/Settings.json") FileView { @@ -16,20 +17,22 @@ Singleton { path: settingsFilePath watchChanges: true onFileChanged: reload() + onAdapterUpdated: writeAdapter() JsonAdapter { id: adapter property string primaryColor: "#89b4fa" property bool showLyricsBar: false + property JsonObject notifications + + notifications: JsonObject { + property bool doNotDisturb: false + property real lastSeenTs: 0 + } + } } - Connections { - target: adapter - onPrimaryColorChanged: settingsFile.writeAdapter() - onShowLyricsBarChanged: settingsFile.writeAdapter() - } - } diff --git a/quickshell/Utils/sha256.js b/quickshell/Utils/sha256.js new file mode 100644 index 0000000..39f255d --- /dev/null +++ b/quickshell/Utils/sha256.js @@ -0,0 +1,192 @@ +function sha256(message) { + // SHA-256 constants + const K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + ]; + + // Initial hash values + let h0 = 0x6a09e667; + let h1 = 0xbb67ae85; + let h2 = 0x3c6ef372; + let h3 = 0xa54ff53a; + let h4 = 0x510e527f; + let h5 = 0x9b05688c; + let h6 = 0x1f83d9ab; + let h7 = 0x5be0cd19; + + // Convert string to UTF-8 bytes manually + const msgBytes = stringToUtf8Bytes(message); + const msgLength = msgBytes.length; + const bitLength = msgLength * 8; + + // Calculate padding + // Message + 1 bit (0x80) + padding zeros + 8 bytes for length = multiple of 64 bytes + const totalBitsNeeded = bitLength + 1 + 64; // message bits + padding bit + 64-bit length + const totalBytesNeeded = Math.ceil(totalBitsNeeded / 8); + const paddedLength = Math.ceil(totalBytesNeeded / 64) * 64; // Round up to multiple of 64 + + const paddedMsg = new Array(paddedLength).fill(0); + + // Copy original message + for (let i = 0; i < msgLength; i++) { + paddedMsg[i] = msgBytes[i]; + } + + // Add padding bit (0x80 = 10000000 in binary) + paddedMsg[msgLength] = 0x80; + + // Add length as 64-bit big-endian integer at the end + // JavaScript numbers are not precise enough for 64-bit integers, so we handle high/low separately + const highBits = Math.floor(bitLength / 0x100000000); + const lowBits = bitLength % 0x100000000; + + // Write 64-bit length in big-endian format + paddedMsg[paddedLength - 8] = (highBits >>> 24) & 0xFF; + paddedMsg[paddedLength - 7] = (highBits >>> 16) & 0xFF; + paddedMsg[paddedLength - 6] = (highBits >>> 8) & 0xFF; + paddedMsg[paddedLength - 5] = highBits & 0xFF; + paddedMsg[paddedLength - 4] = (lowBits >>> 24) & 0xFF; + paddedMsg[paddedLength - 3] = (lowBits >>> 16) & 0xFF; + paddedMsg[paddedLength - 2] = (lowBits >>> 8) & 0xFF; + paddedMsg[paddedLength - 1] = lowBits & 0xFF; + + // Process message in 512-bit (64-byte) chunks + for (let chunk = 0; chunk < paddedLength; chunk += 64) { + const w = new Array(64); + + // Break chunk into sixteen 32-bit big-endian words + for (let i = 0; i < 16; i++) { + const offset = chunk + i * 4; + w[i] = (paddedMsg[offset] << 24) | + (paddedMsg[offset + 1] << 16) | + (paddedMsg[offset + 2] << 8) | + paddedMsg[offset + 3]; + // Ensure unsigned 32-bit + w[i] = w[i] >>> 0; + } + + // Extend the sixteen 32-bit words into sixty-four 32-bit words + for (let i = 16; i < 64; i++) { + const s0 = rightRotate(w[i - 15], 7) ^ rightRotate(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rightRotate(w[i - 2], 17) ^ rightRotate(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + + // Initialize working variables for this chunk + let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7; + + // Main loop + for (let i = 0; i < 64; i++) { + const S1 = rightRotate(e, 6) ^ rightRotate(e, 11) ^ rightRotate(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (h + S1 + ch + K[i] + w[i]) >>> 0; + const S0 = rightRotate(a, 2) ^ rightRotate(a, 13) ^ rightRotate(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + maj) >>> 0; + + h = g; + g = f; + f = e; + e = (d + temp1) >>> 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) >>> 0; + } + + // Add this chunk's hash to result so far + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + h5 = (h5 + f) >>> 0; + h6 = (h6 + g) >>> 0; + h7 = (h7 + h) >>> 0; + } + + // Produce the final hash value as a hex string + return [h0, h1, h2, h3, h4, h5, h6, h7] + .map(h => h.toString(16).padStart(8, '0')) + .join(''); +} + +function stringToUtf8Bytes(str) { + const bytes = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + + if (code < 0x80) { + // 1-byte character (ASCII) + bytes.push(code); + } else if (code < 0x800) { + // 2-byte character + bytes.push(0xC0 | (code >> 6)); + bytes.push(0x80 | (code & 0x3F)); + } else if (code < 0xD800 || code > 0xDFFF) { + // 3-byte character (not surrogate) + bytes.push(0xE0 | (code >> 12)); + bytes.push(0x80 | ((code >> 6) & 0x3F)); + bytes.push(0x80 | (code & 0x3F)); + } else { + // 4-byte character (surrogate pair) + i++; // Move to next character + const code2 = str.charCodeAt(i); + const codePoint = 0x10000 + (((code & 0x3FF) << 10) | (code2 & 0x3FF)); + bytes.push(0xF0 | (codePoint >> 18)); + bytes.push(0x80 | ((codePoint >> 12) & 0x3F)); + bytes.push(0x80 | ((codePoint >> 6) & 0x3F)); + bytes.push(0x80 | (codePoint & 0x3F)); + } + } + return bytes; +} + +function rightRotate(value, amount) { + return ((value >>> amount) | (value << (32 - amount))) >>> 0; +} + +// Test function to verify implementation +// function testSHA256() { +// const tests = [ +// { +// input: "", +// expected: +// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", +// }, +// { +// input: "Hello World", +// expected: +// "a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e", +// }, +// { +// input: "abc", +// expected: +// "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", +// }, +// { +// input: "The quick brown fox jumps over the lazy dog", +// expected: +// "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", +// }, +// ]; + +// console.log("Running SHA-256 tests:"); +// tests.forEach((test, i) => { +// const result = Crypto.sha256(test.input); +// const passed = result === test.expected; +// console.log(`Test ${i + 1}: ${passed ? "PASS" : "FAIL"}`); +// if (!passed) { +// console.log(` Input: "${test.input}"`); +// console.log(` Expected: ${test.expected}`); +// console.log(` Got: ${result}`); +// } +// }); +// } diff --git a/quickshell/shell.qml b/quickshell/shell.qml index f32dddf..97dcd1e 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -7,23 +7,23 @@ import qs.Modules.Misc import qs.Modules.Panel import qs.Services -Scope { +ShellRoot { id: root + Notification { + id: notification + } + IPCService { id: ipcService } Bar { id: bar - - shell: root } Corners { id: corners - - shell: root } CalendarPanel { @@ -38,4 +38,10 @@ Scope { objectName: "controlCenterPanel" } + NotificationHistoryPanel { + id: notificationHistoryPanel + + objectName: "notificationHistoryPanel" + } + }