quickshell: notification daemon
This commit is contained in:
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
26
README.md
26
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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
3
quickshell/Assets/Config/.gitignore
vendored
3
quickshell/Assets/Config/.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
# some sensitive files
|
||||
Location.json
|
||||
GeoInfoToken.txt
|
||||
IpCache.json
|
||||
IpCache.json
|
||||
Notifications.json
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"notifications": {
|
||||
"doNotDisturb": false,
|
||||
"lastSeenTs": 1760375164000
|
||||
},
|
||||
"primaryColor": "#89b4fa",
|
||||
"showLyricsBar": false
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
439
quickshell/Modules/Misc/Notification.qml
Normal file
439
quickshell/Modules/Misc/Notification.qml
Normal file
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
344
quickshell/Modules/Panel/NotificationHistoryPanel.qml
Normal file
344
quickshell/Modules/Panel/NotificationHistoryPanel.qml
Normal file
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
183
quickshell/Noctalia/NButton.qml
Normal file
183
quickshell/Noctalia/NButton.qml
Normal file
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
474
quickshell/Services/NotificationService.qml
Normal file
474
quickshell/Services/NotificationService.qml
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
192
quickshell/Utils/sha256.js
Normal file
192
quickshell/Utils/sha256.js
Normal file
@@ -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}`);
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user