From 76e008007ed03e6e751c6be5d41935bda65c7b89 Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Wed, 4 Feb 2026 14:11:30 +0100 Subject: [PATCH] notification changes --- Bar.qml | 36 +- Components/CustomFlickable.qml | 14 + Components/CustomIcon.qml | 10 + Components/CustomListView.qml | 15 + Components/CustomScrollBar.qml | 189 +++++++ Components/CustomSwitch.qml | 151 ++++++ Components/Elevation.qml | 18 + Components/ExtraIndicator.qml | 49 ++ Components/IconButton.qml | 82 +++ Components/OpacityMask.qml | 9 + Config/BarConfig.qml | 2 +- Config/Config.qml | 8 + Config/NotifConfig.qml | 18 + Config/Services.qml | 6 + Config/SidebarConfig.qml | 10 + Config/UtilConfig.qml | 35 ++ Daemons/NotifServer.qml | 81 ++- Drawers/Backgrounds.qml | 29 ++ Drawers/Panels.qml | 39 ++ Helpers/Icons.qml | 187 +++++++ Helpers/IdleInhibitor.qml | 56 ++ Helpers/Time.qml | 6 + Helpers/Visibilities.qml | 16 + Helpers/Weather.qml | 204 ++++++++ Modules/Bar/BarLoader.qml | 12 +- Modules/Content.qml | 9 + Modules/Dashboard/Dash.qml | 88 ++++ Modules/Dashboard/Dash/DateTime.qml | 49 ++ Modules/Dashboard/Dash/Weather.qml | 55 ++ Modules/Dashboard/Dashboard.qml | 133 +++++ Modules/Dashboard/Tabs.qml | 246 +++++++++ Modules/NotifBell.qml | 6 +- Modules/NotificationCenter.qml | 281 +++++----- Modules/Notifications/Background.qml | 54 ++ Modules/Notifications/Content.qml | 203 ++++++++ Modules/Notifications/Notification.qml | 478 ++++++++++++++++++ Modules/Notifications/Sidebar/Background.qml | 52 ++ Modules/Notifications/Sidebar/Content.qml | 39 ++ Modules/Notifications/Sidebar/Notif.qml | 165 ++++++ .../Notifications/Sidebar/NotifActionList.qml | 199 ++++++++ Modules/Notifications/Sidebar/NotifDock.qml | 199 ++++++++ .../Notifications/Sidebar/NotifDockList.qml | 169 +++++++ Modules/Notifications/Sidebar/NotifGroup.qml | 239 +++++++++ .../Notifications/Sidebar/NotifGroupList.qml | 214 ++++++++ Modules/Notifications/Sidebar/Props.qml | 7 + .../Sidebar/Utils/Background.qml | 55 ++ .../Sidebar/Utils/Cards/Toggles.qml | 57 +++ .../Notifications/Sidebar/Utils/Content.qml | 29 ++ .../Sidebar/Utils/IdleInhibit.qml | 125 +++++ .../Notifications/Sidebar/Utils/Wrapper.qml | 97 ++++ Modules/Notifications/Sidebar/Wrapper.qml | 69 +++ Modules/Notifications/Wrapper.qml | 40 ++ Modules/Polkit/Polkit.qml | 31 +- Modules/TrackedNotification.qml | 28 +- Modules/TrayItem.qml | 10 +- assets/shaders/opacitymask.frag | 19 + shell.qml | 12 +- 57 files changed, 4537 insertions(+), 202 deletions(-) create mode 100644 Components/CustomFlickable.qml create mode 100644 Components/CustomIcon.qml create mode 100644 Components/CustomListView.qml create mode 100644 Components/CustomScrollBar.qml create mode 100644 Components/CustomSwitch.qml create mode 100644 Components/Elevation.qml create mode 100644 Components/ExtraIndicator.qml create mode 100644 Components/IconButton.qml create mode 100644 Components/OpacityMask.qml create mode 100644 Config/NotifConfig.qml create mode 100644 Config/Services.qml create mode 100644 Config/SidebarConfig.qml create mode 100644 Config/UtilConfig.qml create mode 100644 Helpers/Icons.qml create mode 100644 Helpers/IdleInhibitor.qml create mode 100644 Helpers/Visibilities.qml create mode 100644 Helpers/Weather.qml create mode 100644 Modules/Dashboard/Dash.qml create mode 100644 Modules/Dashboard/Dash/DateTime.qml create mode 100644 Modules/Dashboard/Dash/Weather.qml create mode 100644 Modules/Dashboard/Dashboard.qml create mode 100644 Modules/Dashboard/Tabs.qml create mode 100644 Modules/Notifications/Background.qml create mode 100644 Modules/Notifications/Content.qml create mode 100644 Modules/Notifications/Notification.qml create mode 100644 Modules/Notifications/Sidebar/Background.qml create mode 100644 Modules/Notifications/Sidebar/Content.qml create mode 100644 Modules/Notifications/Sidebar/Notif.qml create mode 100644 Modules/Notifications/Sidebar/NotifActionList.qml create mode 100644 Modules/Notifications/Sidebar/NotifDock.qml create mode 100644 Modules/Notifications/Sidebar/NotifDockList.qml create mode 100644 Modules/Notifications/Sidebar/NotifGroup.qml create mode 100644 Modules/Notifications/Sidebar/NotifGroupList.qml create mode 100644 Modules/Notifications/Sidebar/Props.qml create mode 100644 Modules/Notifications/Sidebar/Utils/Background.qml create mode 100644 Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml create mode 100644 Modules/Notifications/Sidebar/Utils/Content.qml create mode 100644 Modules/Notifications/Sidebar/Utils/IdleInhibit.qml create mode 100644 Modules/Notifications/Sidebar/Utils/Wrapper.qml create mode 100644 Modules/Notifications/Sidebar/Wrapper.qml create mode 100644 Modules/Notifications/Wrapper.qml create mode 100644 assets/shaders/opacitymask.frag diff --git a/Bar.qml b/Bar.qml index 112ee32..91722c1 100644 --- a/Bar.qml +++ b/Bar.qml @@ -12,15 +12,15 @@ import qs.Config import qs.Helpers import qs.Drawers -Scope { - Variants { - model: Quickshell.screens - +Variants { + model: Quickshell.screens + Scope { + id: scope + required property var modelData PanelWindow { id: bar - required property var modelData property bool trayMenuVisible: false - screen: modelData + screen: scope.modelData color: "transparent" property var root: Quickshell.shellDir @@ -53,7 +53,7 @@ Scope { y: 34 property list nullRegions: [] - property bool hcurrent: panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") + property bool hcurrent: ( panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu") ) || visibilities.sidebar width: hcurrent ? 0 : bar.width height: hcurrent ? 0 : bar.screen.height - backgroundRect.implicitHeight @@ -72,11 +72,19 @@ Scope { x: modelData.x y: modelData.y + backgroundRect.implicitHeight width: modelData.width - height: panels.popouts.hasCurrent ? modelData.height : 0 + height: modelData.height intersection: Intersection.Subtract } } + PersistentProperties { + id: visibilities + + property bool sidebar + + Component.onCompleted: Visibilities.load(scope.modelData, this) + } + Item { anchors.fill: parent opacity: Config.transparency.enabled ? DynamicColors.transparency.base : 1 @@ -114,15 +122,19 @@ Scope { } onPressed: event => { - var withinX = mouseX >= panels.popouts.x + 8 && mouseX < panels.popouts.x + panels.popouts.implicitWidth; - var withinY = mouseY >= panels.popouts.y + exclusionZone.implicitHeight && mouseY < panels.popouts.y + exclusionZone.implicitHeight + panels.popouts.implicitHeight; + var traywithinX = mouseX >= panels.popouts.x + 8 && mouseX < panels.popouts.x + panels.popouts.implicitWidth; + var traywithinY = mouseY >= panels.popouts.y + exclusionZone.implicitHeight && mouseY < panels.popouts.y + exclusionZone.implicitHeight + panels.popouts.implicitHeight; + var sidebarwithinX = mouseX <= bar.width - panels.sidebar.width + console.log(sidebarwithinX) if ( panels.popouts.hasCurrent ) { - if ( withinX && withinY ) { + if ( traywithinX && traywithinY ) { } else { panels.popouts.hasCurrent = false; } + } else if ( visibilities.sidebar && sidebarwithinX ) { + visibilities.sidebar = false; } } @@ -130,6 +142,7 @@ Scope { id: panels screen: bar.modelData bar: backgroundRect + visibilities: visibilities } Rectangle { @@ -151,6 +164,7 @@ Scope { anchors.fill: parent popouts: panels.popouts bar: bar + visibilities: visibilities } WindowTitle { diff --git a/Components/CustomFlickable.qml b/Components/CustomFlickable.qml new file mode 100644 index 0000000..05ba9a8 --- /dev/null +++ b/Components/CustomFlickable.qml @@ -0,0 +1,14 @@ +import QtQuick +import qs.Modules + +Flickable { + id: root + + maximumFlickVelocity: 3000 + + rebound: Transition { + Anim { + properties: "x,y" + } + } +} diff --git a/Components/CustomIcon.qml b/Components/CustomIcon.qml new file mode 100644 index 0000000..930c852 --- /dev/null +++ b/Components/CustomIcon.qml @@ -0,0 +1,10 @@ +pragma ComponentBehavior: Bound + +import Quickshell.Widgets +import QtQuick + +IconImage { + id: root + + asynchronous: true +} diff --git a/Components/CustomListView.qml b/Components/CustomListView.qml new file mode 100644 index 0000000..e570513 --- /dev/null +++ b/Components/CustomListView.qml @@ -0,0 +1,15 @@ +import QtQuick +import qs.Config +import qs.Modules + +ListView { + id: root + + maximumFlickVelocity: 3000 + + rebound: Transition { + Anim { + properties: "x,y" + } + } +} diff --git a/Components/CustomScrollBar.qml b/Components/CustomScrollBar.qml new file mode 100644 index 0000000..d57cc43 --- /dev/null +++ b/Components/CustomScrollBar.qml @@ -0,0 +1,189 @@ +import qs.Config +import qs.Modules +import QtQuick +import QtQuick.Templates + +ScrollBar { + id: root + + required property Flickable flickable + property bool shouldBeActive + property real nonAnimPosition + property bool animating + + onHoveredChanged: { + if (hovered) + shouldBeActive = true; + else + shouldBeActive = flickable.moving; + } + + property bool _updatingFromFlickable: false + property bool _updatingFromUser: false + + // Sync nonAnimPosition with Qt's automatic position binding + onPositionChanged: { + if (_updatingFromUser) { + _updatingFromUser = false; + return; + } + if (position === nonAnimPosition) { + animating = false; + return; + } + if (!animating && !_updatingFromFlickable && !fullMouse.pressed) { + nonAnimPosition = position; + } + } + + // Sync nonAnimPosition with flickable when not animating + Connections { + target: flickable + function onContentYChanged() { + if (!animating && !fullMouse.pressed) { + _updatingFromFlickable = true; + const contentHeight = flickable.contentHeight; + const height = flickable.height; + if (contentHeight > height) { + nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height))); + } else { + nonAnimPosition = 0; + } + _updatingFromFlickable = false; + } + } + } + + Component.onCompleted: { + if (flickable) { + const contentHeight = flickable.contentHeight; + const height = flickable.height; + if (contentHeight > height) { + nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height))); + } + } + } + implicitWidth: 8 + + contentItem: CustomRect { + anchors.left: parent.left + anchors.right: parent.right + opacity: { + if (root.size === 1) + return 0; + if (fullMouse.pressed) + return 1; + if (mouse.containsMouse) + return 0.8; + if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive) + return 0.6; + return 0; + } + radius: 1000 + color: DynamicColors.palette.m3secondary + + MouseArea { + id: mouse + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + Behavior on opacity { + Anim {} + } + } + + Connections { + target: root.flickable + + function onMovingChanged(): void { + if (root.flickable.moving) + root.shouldBeActive = true; + else + hideDelay.restart(); + } + } + + Timer { + id: hideDelay + + interval: 600 + onTriggered: root.shouldBeActive = root.flickable.moving || root.hovered + } + + CustomMouseArea { + id: fullMouse + + anchors.fill: parent + preventStealing: true + + onPressed: event => { + root.animating = true; + root._updatingFromUser = true; + const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); + root.nonAnimPosition = newPos; + // Update flickable position + // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] + if (root.flickable) { + const contentHeight = root.flickable.contentHeight; + const height = root.flickable.height; + if (contentHeight > height) { + const maxContentY = contentHeight - height; + const maxPos = 1 - root.size; + const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; + root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); + } + } + } + + onPositionChanged: event => { + root._updatingFromUser = true; + const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); + root.nonAnimPosition = newPos; + // Update flickable position + // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] + if (root.flickable) { + const contentHeight = root.flickable.contentHeight; + const height = root.flickable.height; + if (contentHeight > height) { + const maxContentY = contentHeight - height; + const maxPos = 1 - root.size; + const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; + root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); + } + } + } + + function onWheel(event: WheelEvent): void { + root.animating = true; + root._updatingFromUser = true; + let newPos = root.nonAnimPosition; + if (event.angleDelta.y > 0) + newPos = Math.max(0, root.nonAnimPosition - 0.1); + else if (event.angleDelta.y < 0) + newPos = Math.min(1 - root.size, root.nonAnimPosition + 0.1); + root.nonAnimPosition = newPos; + // Update flickable position + // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] + if (root.flickable) { + const contentHeight = root.flickable.contentHeight; + const height = root.flickable.height; + if (contentHeight > height) { + const maxContentY = contentHeight - height; + const maxPos = 1 - root.size; + const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; + root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); + } + } + } + } + + Behavior on position { + enabled: !fullMouse.pressed + + Anim {} + } +} diff --git a/Components/CustomSwitch.qml b/Components/CustomSwitch.qml new file mode 100644 index 0000000..46aa299 --- /dev/null +++ b/Components/CustomSwitch.qml @@ -0,0 +1,151 @@ +import qs.Config +import qs.Modules +import QtQuick +import QtQuick.Templates +import QtQuick.Shapes + +Switch { + id: root + + property int cLayer: 1 + + implicitWidth: implicitIndicatorWidth + implicitHeight: implicitIndicatorHeight + + indicator: CustomRect { + radius: 1000 + color: root.checked ? DynamicColors.palette.m3primary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, root.cLayer) + + implicitWidth: implicitHeight * 1.7 + implicitHeight: 13 + 7 * 2 + + CustomRect { + readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight + + radius: 1000 + color: root.checked ? DynamicColors.palette.m3onPrimary : DynamicColors.layer(DynamicColors.palette.m3outline, root.cLayer + 1) + + x: root.checked ? parent.implicitWidth - nonAnimWidth - 10 / 2 : 10 / 2 + implicitWidth: nonAnimWidth + implicitHeight: parent.implicitHeight - 10 + anchors.verticalCenter: parent.verticalCenter + + CustomRect { + anchors.fill: parent + radius: parent.radius + + color: root.checked ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface + opacity: root.pressed ? 0.1 : root.hovered ? 0.08 : 0 + + Behavior on opacity { + Anim {} + } + } + + Shape { + id: icon + + property point start1: { + if (root.pressed) + return Qt.point(width * 0.2, height / 2); + if (root.checked) + return Qt.point(width * 0.15, height / 2); + return Qt.point(width * 0.15, height * 0.15); + } + property point end1: { + if (root.pressed) { + if (root.checked) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.8, height / 2); + } + if (root.checked) + return Qt.point(width * 0.4, height * 0.7); + return Qt.point(width * 0.85, height * 0.85); + } + property point start2: { + if (root.pressed) { + if (root.checked) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.2, height / 2); + } + if (root.checked) + return Qt.point(width * 0.4, height * 0.7); + return Qt.point(width * 0.15, height * 0.85); + } + property point end2: { + if (root.pressed) + return Qt.point(width * 0.8, height / 2); + if (root.checked) + return Qt.point(width * 0.85, height * 0.2); + return Qt.point(width * 0.85, height * 0.15); + } + + anchors.centerIn: parent + width: height + height: parent.implicitHeight - 10 * 2 + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + ShapePath { + strokeWidth: 20 * 0.15 + strokeColor: root.checked ? DynamicColors.palette.m3primary : DynamicColors.palette.m3surfaceContainerHighest + fillColor: "transparent" + capStyle: 1 === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + startX: icon.start1.x + startY: icon.start1.y + + PathLine { + x: icon.end1.x + y: icon.end1.y + } + PathMove { + x: icon.start2.x + y: icon.start2.y + } + PathLine { + x: icon.end2.x + y: icon.end2.y + } + + Behavior on strokeColor { + CAnim {} + } + } + + Behavior on start1 { + PropAnim {} + } + Behavior on end1 { + PropAnim {} + } + Behavior on start2 { + PropAnim {} + } + Behavior on end2 { + PropAnim {} + } + } + + Behavior on x { + Anim {} + } + + Behavior on implicitWidth { + Anim {} + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + enabled: false + } + + component PropAnim: PropertyAnimation { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + easing.type: Easing.BezierSpline + } +} diff --git a/Components/Elevation.qml b/Components/Elevation.qml new file mode 100644 index 0000000..83f9639 --- /dev/null +++ b/Components/Elevation.qml @@ -0,0 +1,18 @@ +import qs.Config +import qs.Modules +import QtQuick +import QtQuick.Effects + +RectangularShadow { + property int level + property real dp: [0, 1, 3, 6, 8, 12][level] + + color: Qt.alpha(DynamicColors.palette.m3shadow, 0.7) + blur: (dp * 5) ** 0.7 + spread: -dp * 0.3 + (dp * 0.1) ** 2 + offset.y: dp / 2 + + Behavior on dp { + Anim {} + } +} diff --git a/Components/ExtraIndicator.qml b/Components/ExtraIndicator.qml new file mode 100644 index 0000000..e67c284 --- /dev/null +++ b/Components/ExtraIndicator.qml @@ -0,0 +1,49 @@ +import qs.Config +import qs.Modules +import QtQuick + +CustomRect { + required property int extra + + anchors.right: parent.right + anchors.margins: 8 + + color: DynamicColors.palette.m3tertiary + radius: 8 + + implicitWidth: count.implicitWidth + 8 * 2 + implicitHeight: count.implicitHeight + 4 * 2 + + opacity: extra > 0 ? 1 : 0 + scale: extra > 0 ? 1 : 0.5 + + Elevation { + anchors.fill: parent + radius: parent.radius + opacity: parent.opacity + z: -1 + level: 2 + } + + CustomText { + id: count + + anchors.centerIn: parent + animate: parent.opacity > 0 + text: qsTr("+%1").arg(parent.extra) + color: DynamicColors.palette.m3onTertiary + } + + Behavior on opacity { + Anim { + duration: MaterialEasing.expressiveEffectsTime + } + } + + Behavior on scale { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } +} diff --git a/Components/IconButton.qml b/Components/IconButton.qml new file mode 100644 index 0000000..6bbc64c --- /dev/null +++ b/Components/IconButton.qml @@ -0,0 +1,82 @@ +import qs.Config +import qs.Modules +import QtQuick + +CustomRect { + id: root + + enum Type { + Filled, + Tonal, + Text + } + + property alias icon: label.text + property bool checked + property bool toggle + property real padding: type === IconButton.Text ? 10 / 2 : 7 + property alias font: label.font + property int type: IconButton.Filled + property bool disabled + + property alias stateLayer: stateLayer + property alias label: label + property alias radiusAnim: radiusAnim + + property bool internalChecked + property color activeColour: type === IconButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondary + property color inactiveColour: { + if (!toggle && type === IconButton.Filled) + return DynamicColors.palette.m3primary; + return type === IconButton.Filled ? DynamicColors.tPalette.m3surfaceContainer : DynamicColors.palette.m3secondaryContainer; + } + property color activeOnColour: type === IconButton.Filled ? DynamicColors.palette.m3onPrimary : type === IconButton.Tonal ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3primary + property color inactiveOnColour: { + if (!toggle && type === IconButton.Filled) + return DynamicColors.palette.m3onPrimary; + return type === IconButton.Tonal ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurfaceVariant; + } + property color disabledColour: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) + property color disabledOnColour: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) + + signal clicked + + onCheckedChanged: internalChecked = checked + + radius: internalChecked ? 6 : implicitHeight / 2 * Math.min(1, 1) + color: type === IconButton.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour + + implicitWidth: implicitHeight + implicitHeight: label.implicitHeight + padding * 2 + + StateLayer { + id: stateLayer + + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + disabled: root.disabled + + function onClicked(): void { + if (root.toggle) + root.internalChecked = !root.internalChecked; + root.clicked(); + } + } + + MaterialIcon { + id: label + + anchors.centerIn: parent + color: root.disabled ? root.disabledOnColour : root.internalChecked ? root.activeOnColour : root.inactiveOnColour + fill: !root.toggle || root.internalChecked ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + Behavior on radius { + Anim { + id: radiusAnim + } + } +} diff --git a/Components/OpacityMask.qml b/Components/OpacityMask.qml new file mode 100644 index 0000000..22e4249 --- /dev/null +++ b/Components/OpacityMask.qml @@ -0,0 +1,9 @@ +import Quickshell +import QtQuick + +ShaderEffect { + required property Item source + required property Item maskSource + + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/opacitymask.frag.qsb`) +} diff --git a/Config/BarConfig.qml b/Config/BarConfig.qml index 407edaf..df6afb1 100644 --- a/Config/BarConfig.qml +++ b/Config/BarConfig.qml @@ -49,7 +49,7 @@ JsonObject { component Popouts: JsonObject { property bool tray: true property bool audio: true - property bool activeWindow: false + property bool activeWindow: true property bool resources: true property bool clock: true } diff --git a/Config/Config.qml b/Config/Config.qml index 862e10b..3dd36f2 100644 --- a/Config/Config.qml +++ b/Config/Config.qml @@ -25,6 +25,10 @@ Singleton { property alias lock: adapter.lock property alias idle: adapter.idle property alias overview: adapter.overview + property alias services: adapter.services + property alias notifs: adapter.notifs + property alias sidebar: adapter.sidebar + property alias utilities: adapter.utilities FileView { id: root @@ -58,6 +62,10 @@ Singleton { property LockConf lock: LockConf {} property IdleTimeout idle: IdleTimeout {} property Overview overview: Overview {} + property Services services: Services {} + property NotifConfig notifs: NotifConfig {} + property SidebarConfig sidebar: SidebarConfig {} + property UtilConfig utilities: UtilConfig {} } } } diff --git a/Config/NotifConfig.qml b/Config/NotifConfig.qml new file mode 100644 index 0000000..8f68ba4 --- /dev/null +++ b/Config/NotifConfig.qml @@ -0,0 +1,18 @@ +import Quickshell.Io + +JsonObject { + property bool expire: true + property int defaultExpireTimeout: 5000 + property real clearThreshold: 0.3 + property int expandThreshold: 20 + property bool actionOnClick: false + property int groupPreviewNum: 3 + property bool openExpanded: false + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property int width: 400 + property int image: 41 + property int badge: 20 + } +} diff --git a/Config/Services.qml b/Config/Services.qml new file mode 100644 index 0000000..df76e9a --- /dev/null +++ b/Config/Services.qml @@ -0,0 +1,6 @@ +import Quickshell.Io +import QtQuick + +JsonObject { + property string weatherLocation: "" +} diff --git a/Config/SidebarConfig.qml b/Config/SidebarConfig.qml new file mode 100644 index 0000000..ba48822 --- /dev/null +++ b/Config/SidebarConfig.qml @@ -0,0 +1,10 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property int width: 430 + } +} diff --git a/Config/UtilConfig.qml b/Config/UtilConfig.qml new file mode 100644 index 0000000..cf46446 --- /dev/null +++ b/Config/UtilConfig.qml @@ -0,0 +1,35 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int maxToasts: 4 + + property Sizes sizes: Sizes {} + property Toasts toasts: Toasts {} + property Vpn vpn: Vpn {} + + component Sizes: JsonObject { + property int width: 430 + property int toastWidth: 430 + } + + component Toasts: JsonObject { + property bool configLoaded: true + property bool chargingChanged: true + property bool gameModeChanged: true + property bool dndChanged: true + property bool audioOutputChanged: true + property bool audioInputChanged: true + property bool capsLockChanged: true + property bool numLockChanged: true + property bool kbLayoutChanged: true + property bool kbLimit: true + property bool vpnChanged: true + property bool nowPlaying: false + } + + component Vpn: JsonObject { + property bool enabled: false + property list provider: ["netbird"] + } +} diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index 59d54bf..df0bb19 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -4,6 +4,7 @@ pragma ComponentBehavior: Bound import Quickshell import Quickshell.Io import Quickshell.Services.Notifications +import Quickshell.Hyprland import QtQuick import ZShell import qs.Modules @@ -101,6 +102,40 @@ Singleton { } } + GlobalShortcut { + name: "clearNotifs" + description: "Clear all notifications" + onPressed: { + for (const notif of root.list.slice()) + notif.close(); + } + } + + IpcHandler { + target: "notifs" + + function clear(): void { + for (const notif of root.list.slice()) + notif.close(); + } + + function isDndEnabled(): bool { + return props.dnd; + } + + function toggleDnd(): void { + props.dnd = !props.dnd; + } + + function enableDnd(): void { + props.dnd = true; + } + + function disableDnd(): void { + props.dnd = false; + } + } + component Notif: QtObject { id: notif @@ -140,10 +175,21 @@ Singleton { property list actions readonly property Timer timer: Timer { - running: true - interval: 5000 + property int totalTime: 5000 + property int remainingTime: totalTime + property bool paused: false + + running: !paused + repeat: true + interval: 50 onTriggered: { - notif.popup = false; + remainingTime -= interval; + + if ( remainingTime <= 0 ) { + remainingTime = 0; + notif.popup = false; + stop(); + } } } @@ -151,22 +197,14 @@ Singleton { active: false PanelWindow { - implicitWidth: 48 - implicitHeight: 48 + implicitWidth: Config.notifs.sizes.image + implicitHeight: Config.notifs.sizes.image color: "transparent" mask: Region {} - visible: false Image { - anchors.fill: parent - source: Qt.resolvedUrl(notif.image) - fillMode: Image.PreserveAspectCrop - cache: false - asynchronous: true - opacity: 0 - - onStatusChanged: { - if (status !== Image.Ready) + function tryCache(): void { + if (status !== Image.Ready || width != Config.notifs.sizes.image || height != Config.notifs.sizes.image) return; const cacheKey = notif.appName + notif.summary + notif.id; @@ -183,11 +221,22 @@ Singleton { const hash = (h2 >>> 0).toString(16).padStart(8, 0) + (h1 >>> 0).toString(16).padStart(8, 0); const cache = `${Paths.notifimagecache}/${hash}.png`; - ZShellIo.saveItem(this, Qt.resolvedUrl(cache), () => { + ZShell.saveItem(this, Qt.resolvedUrl(cache), () => { notif.image = cache; notif.dummyImageLoader.active = false; }); } + + anchors.fill: parent + source: Qt.resolvedUrl(notif.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + opacity: 0 + + onStatusChanged: tryCache() + onWidthChanged: tryCache() + onHeightChanged: tryCache() } } } diff --git a/Drawers/Backgrounds.qml b/Drawers/Backgrounds.qml index 5d0d329..0696d38 100644 --- a/Drawers/Backgrounds.qml +++ b/Drawers/Backgrounds.qml @@ -1,6 +1,9 @@ import QtQuick import QtQuick.Shapes import qs.Modules as Modules +import qs.Modules.Notifications as Notifications +import qs.Modules.Notifications.Sidebar as Sidebar +import qs.Modules.Notifications.Sidebar.Utils as Utils Shape { id: root @@ -20,4 +23,30 @@ Shape { startX: wrapper.x - 8 startY: wrapper.y } + + Notifications.Background { + wrapper: root.panels.notifications + sidebar: sidebar + + startX: root.width + startY: 0 + } + + Utils.Background { + wrapper: root.panels.utilities + sidebar: sidebar + + startX: root.width + startY: root.height + } + + Sidebar.Background { + id: sidebar + + wrapper: root.panels.sidebar + panels: root.panels + + startX: root.width + startY: root.panels.notifications.height + } } diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index a065b86..296c6e8 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -2,6 +2,9 @@ import Quickshell import QtQuick import QtQuick.Shapes import qs.Modules as Modules +import qs.Modules.Notifications as Notifications +import qs.Modules.Notifications.Sidebar as Sidebar +import qs.Modules.Notifications.Sidebar.Utils as Utils import qs.Config Item { @@ -9,8 +12,12 @@ Item { required property ShellScreen screen required property Item bar + required property PersistentProperties visibilities readonly property alias popouts: popouts + readonly property alias sidebar: sidebar + readonly property alias notifications: notifications + readonly property alias utilities: utilities anchors.fill: parent // anchors.margins: 8 @@ -31,4 +38,36 @@ Item { return Math.floor( Math.max( off, 0 )); } } + + Notifications.Wrapper { + id: notifications + + visibilities: root.visibilities + panels: root + + anchors.top: parent.top + anchors.right: parent.right + } + + Utils.Wrapper { + id: utilities + + visibilities: root.visibilities + sidebar: sidebar + popouts: popouts + + anchors.bottom: parent.bottom + anchors.right: parent.right + } + + Sidebar.Wrapper { + id: sidebar + + visibilities: root.visibilities + panels: root + + anchors.top: notifications.bottom + anchors.bottom: utilities.top + anchors.right: parent.right + } } diff --git a/Helpers/Icons.qml b/Helpers/Icons.qml new file mode 100644 index 0000000..9524055 --- /dev/null +++ b/Helpers/Icons.qml @@ -0,0 +1,187 @@ +pragma Singleton + +import qs.Config +import Quickshell +import Quickshell.Services.Notifications +import QtQuick + +Singleton { + id: root + + readonly property var weatherIcons: ({ + "0": "clear_day", + "1": "clear_day", + "2": "partly_cloudy_day", + "3": "cloud", + "45": "foggy", + "48": "foggy", + "51": "rainy", + "53": "rainy", + "55": "rainy", + "56": "rainy", + "57": "rainy", + "61": "rainy", + "63": "rainy", + "65": "rainy", + "66": "rainy", + "67": "rainy", + "71": "cloudy_snowing", + "73": "cloudy_snowing", + "75": "snowing_heavy", + "77": "cloudy_snowing", + "80": "rainy", + "81": "rainy", + "82": "rainy", + "85": "cloudy_snowing", + "86": "snowing_heavy", + "95": "thunderstorm", + "96": "thunderstorm", + "99": "thunderstorm" + }) + + readonly property var categoryIcons: ({ + WebBrowser: "web", + Printing: "print", + Security: "security", + Network: "chat", + Archiving: "archive", + Compression: "archive", + Development: "code", + IDE: "code", + TextEditor: "edit_note", + Audio: "music_note", + Music: "music_note", + Player: "music_note", + Recorder: "mic", + Game: "sports_esports", + FileTools: "files", + FileManager: "files", + Filesystem: "files", + FileTransfer: "files", + Settings: "settings", + DesktopSettings: "settings", + HardwareSettings: "settings", + TerminalEmulator: "terminal", + ConsoleOnly: "terminal", + Utility: "build", + Monitor: "monitor_heart", + Midi: "graphic_eq", + Mixer: "graphic_eq", + AudioVideoEditing: "video_settings", + AudioVideo: "music_video", + Video: "videocam", + Building: "construction", + Graphics: "photo_library", + "2DGraphics": "photo_library", + RasterGraphics: "photo_library", + TV: "tv", + System: "host", + Office: "content_paste" + }) + + function getAppIcon(name: string, fallback: string): string { + const icon = DesktopEntries.heuristicLookup(name)?.icon; + if (fallback !== "undefined") + return Quickshell.iconPath(icon, fallback); + return Quickshell.iconPath(icon); + } + + function getAppCategoryIcon(name: string, fallback: string): string { + const categories = DesktopEntries.heuristicLookup(name)?.categories; + + if (categories) + for (const [key, value] of Object.entries(categoryIcons)) + if (categories.includes(key)) + return value; + return fallback; + } + + function getNetworkIcon(strength: int, isSecure = false): string { + if (isSecure) { + if (strength >= 80) + return "network_wifi_locked"; + if (strength >= 60) + return "network_wifi_3_bar_locked"; + if (strength >= 40) + return "network_wifi_2_bar_locked"; + if (strength >= 20) + return "network_wifi_1_bar_locked"; + return "signal_wifi_0_bar"; + } else { + if (strength >= 80) + return "network_wifi"; + if (strength >= 60) + return "network_wifi_3_bar"; + if (strength >= 40) + return "network_wifi_2_bar"; + if (strength >= 20) + return "network_wifi_1_bar"; + return "signal_wifi_0_bar"; + } + } + + function getBluetoothIcon(icon: string): string { + if (icon.includes("headset") || icon.includes("headphones")) + return "headphones"; + if (icon.includes("audio")) + return "speaker"; + if (icon.includes("phone")) + return "smartphone"; + if (icon.includes("mouse")) + return "mouse"; + if (icon.includes("keyboard")) + return "keyboard"; + return "bluetooth"; + } + + function getWeatherIcon(code: string): string { + if (weatherIcons.hasOwnProperty(code)) + return weatherIcons[code]; + return "air"; + } + + function getNotifIcon(summary: string, urgency: int): string { + summary = summary.toLowerCase(); + if (summary.includes("reboot")) + return "restart_alt"; + if (summary.includes("recording")) + return "screen_record"; + if (summary.includes("battery")) + return "power"; + if (summary.includes("screenshot")) + return "screenshot_monitor"; + if (summary.includes("welcome")) + return "waving_hand"; + if (summary.includes("time") || summary.includes("a break")) + return "schedule"; + if (summary.includes("installed")) + return "download"; + if (summary.includes("update")) + return "update"; + if (summary.includes("unable to")) + return "deployed_code_alert"; + if (summary.includes("profile")) + return "person"; + if (summary.includes("file")) + return "folder_copy"; + if (urgency === NotificationUrgency.Critical) + return "release_alert"; + return "chat"; + } + + function getVolumeIcon(volume: real, isMuted: bool): string { + if (isMuted) + return "no_sound"; + if (volume >= 0.5) + return "volume_up"; + if (volume > 0) + return "volume_down"; + return "volume_mute"; + } + + function getMicVolumeIcon(volume: real, isMuted: bool): string { + if (!isMuted && volume > 0) + return "mic"; + return "mic_off"; + } +} diff --git a/Helpers/IdleInhibitor.qml b/Helpers/IdleInhibitor.qml new file mode 100644 index 0000000..29409ab --- /dev/null +++ b/Helpers/IdleInhibitor.qml @@ -0,0 +1,56 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import Quickshell.Wayland + +Singleton { + id: root + + property alias enabled: props.enabled + readonly property alias enabledSince: props.enabledSince + + onEnabledChanged: { + if (enabled) + props.enabledSince = new Date(); + } + + PersistentProperties { + id: props + + property bool enabled + property date enabledSince + + reloadableId: "idleInhibitor" + } + + IdleInhibitor { + enabled: props.enabled + window: PanelWindow { + implicitWidth: 0 + implicitHeight: 0 + color: "transparent" + mask: Region {} + } + } + + IpcHandler { + target: "idleInhibitor" + + function isEnabled(): bool { + return props.enabled; + } + + function toggle(): void { + props.enabled = !props.enabled; + } + + function enable(): void { + props.enabled = true; + } + + function disable(): void { + props.enabled = false; + } + } +} diff --git a/Helpers/Time.qml b/Helpers/Time.qml index c4b3913..11a67f7 100644 --- a/Helpers/Time.qml +++ b/Helpers/Time.qml @@ -9,6 +9,12 @@ Singleton { readonly property int minutes: clock.minutes readonly property int seconds: clock.seconds + readonly property string timeStr: format("hh:mm") + readonly property list timeComponents: timeStr.split(":") + readonly property string hourStr: timeComponents[0] ?? "" + readonly property string minuteStr: timeComponents[1] ?? "" + readonly property string amPmStr: timeComponents[2] ?? "" + function format(fmt: string): string { return Qt.formatDateTime(clock.date, fmt); } diff --git a/Helpers/Visibilities.qml b/Helpers/Visibilities.qml new file mode 100644 index 0000000..5ddde0c --- /dev/null +++ b/Helpers/Visibilities.qml @@ -0,0 +1,16 @@ +pragma Singleton + +import Quickshell + +Singleton { + property var screens: new Map() + property var bars: new Map() + + function load(screen: ShellScreen, visibilities: var): void { + screens.set(Hypr.monitorFor(screen), visibilities); + } + + function getForActive(): PersistentProperties { + return screens.get(Hypr.focusedMonitor); + } +} diff --git a/Helpers/Weather.qml b/Helpers/Weather.qml new file mode 100644 index 0000000..c0aefb9 --- /dev/null +++ b/Helpers/Weather.qml @@ -0,0 +1,204 @@ +pragma Singleton + +import qs.Config +import Quickshell +import QtQuick + +Singleton { + id: root + + property string city + property string loc + property var cc + property list forecast + property list hourlyForecast + + readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" + readonly property string description: cc?.weatherDesc ?? qsTr("No weather") + readonly property string temp: `${cc?.tempC ?? 0}°C` + readonly property string feelsLike: `${cc?.feelsLikeC ?? 0}°C` + readonly property int humidity: cc?.humidity ?? 0 + readonly property real windSpeed: cc?.windSpeed ?? 0 + readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), "h:mm") : "--:--" + readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), "h:mm") : "--:--" + + readonly property var cachedCities: new Map() + + function reload(): void { + const configLocation = Config.services.weatherLocation; + + if (configLocation) { + if (configLocation.indexOf(",") !== -1 && !isNaN(parseFloat(configLocation.split(",")[0]))) { + loc = configLocation; + fetchCityFromCoords(configLocation); + } else { + fetchCoordsFromCity(configLocation); + } + } else if (!loc || timer.elapsed() > 900) { + Requests.get("https://ipinfo.io/json", text => { + const response = JSON.parse(text); + if (response.loc) { + loc = response.loc; + city = response.city ?? ""; + timer.restart(); + } + }); + } + } + + function fetchCityFromCoords(coords: string): void { + if (cachedCities.has(coords)) { + city = cachedCities.get(coords); + return; + } + + const [lat, lon] = coords.split(","); + const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson`; + Requests.get(url, text => { + const geo = JSON.parse(text).features?.[0]?.properties.geocoding; + if (geo) { + const geoCity = geo.type === "city" ? geo.name : geo.city; + city = geoCity; + cachedCities.set(coords, geoCity); + } else { + city = "Unknown City"; + } + }); + } + + function fetchCoordsFromCity(cityName: string): void { + const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=en&format=json`; + + Requests.get(url, text => { + const json = JSON.parse(text); + if (json.results && json.results.length > 0) { + const result = json.results[0]; + loc = result.latitude + "," + result.longitude; + city = result.name; + } else { + loc = ""; + reload(); + } + }); + } + + function fetchWeatherData(): void { + const url = getWeatherUrl(); + if (url === "") + return; + + Requests.get(url, text => { + const json = JSON.parse(text); + if (!json.current || !json.daily) + return; + + cc = { + weatherCode: json.current.weather_code, + weatherDesc: getWeatherCondition(json.current.weather_code), + tempC: Math.round(json.current.temperature_2m), + tempF: Math.round(toFahrenheit(json.current.temperature_2m)), + feelsLikeC: Math.round(json.current.apparent_temperature), + feelsLikeF: Math.round(toFahrenheit(json.current.apparent_temperature)), + humidity: json.current.relative_humidity_2m, + windSpeed: json.current.wind_speed_10m, + isDay: json.current.is_day, + sunrise: json.daily.sunrise[0], + sunset: json.daily.sunset[0] + }; + + const forecastList = []; + for (let i = 0; i < json.daily.time.length; i++) + forecastList.push({ + date: json.daily.time[i], + maxTempC: Math.round(json.daily.temperature_2m_max[i]), + maxTempF: Math.round(toFahrenheit(json.daily.temperature_2m_max[i])), + minTempC: Math.round(json.daily.temperature_2m_min[i]), + minTempF: Math.round(toFahrenheit(json.daily.temperature_2m_min[i])), + weatherCode: json.daily.weather_code[i], + icon: Icons.getWeatherIcon(json.daily.weather_code[i]) + }); + forecast = forecastList; + + const hourlyList = []; + const now = new Date(); + for (let i = 0; i < json.hourly.time.length; i++) { + const time = new Date(json.hourly.time[i]); + if (time < now) + continue; + + hourlyList.push({ + timestamp: json.hourly.time[i], + hour: time.getHours(), + tempC: Math.round(json.hourly.temperature_2m[i]), + tempF: Math.round(toFahrenheit(json.hourly.temperature_2m[i])), + weatherCode: json.hourly.weather_code[i], + icon: Icons.getWeatherIcon(json.hourly.weather_code[i]) + }); + } + hourlyForecast = hourlyList; + }); + } + + function toFahrenheit(celcius: real): real { + return celcius * 9 / 5 + 32; + } + + function getWeatherUrl(): string { + if (!loc || loc.indexOf(",") === -1) + return ""; + + const [lat, lon] = loc.split(","); + const baseUrl = "https://api.open-meteo.com/v1/forecast"; + const params = ["latitude=" + lat, "longitude=" + lon, "hourly=weather_code,temperature_2m", "daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset", "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m", "timezone=auto", "forecast_days=7"]; + + return baseUrl + "?" + params.join("&"); + } + + function getWeatherCondition(code: string): string { + const conditions = { + "0": "Clear", + "1": "Clear", + "2": "Partly cloudy", + "3": "Overcast", + "45": "Fog", + "48": "Fog", + "51": "Drizzle", + "53": "Drizzle", + "55": "Drizzle", + "56": "Freezing drizzle", + "57": "Freezing drizzle", + "61": "Light rain", + "63": "Rain", + "65": "Heavy rain", + "66": "Light rain", + "67": "Heavy rain", + "71": "Light snow", + "73": "Snow", + "75": "Heavy snow", + "77": "Snow", + "80": "Light rain", + "81": "Rain", + "82": "Heavy rain", + "85": "Light snow showers", + "86": "Heavy snow showers", + "95": "Thunderstorm", + "96": "Thunderstorm with hail", + "99": "Thunderstorm with hail" + }; + return conditions[code] || "Unknown"; + } + + onLocChanged: fetchWeatherData() + + // Refresh current location hourly + Timer { + interval: 3600000 // 1 hour + running: true + repeat: true + onTriggered: fetchWeatherData() + } + + ElapsedTimer { + id: timer + } +} diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 4d09208..e31a47e 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -16,6 +16,7 @@ RowLayout { readonly property int vPadding: 6 required property Wrapper popouts + required property PersistentProperties visibilities required property PanelWindow bar function checkPopout(x: real): void { @@ -26,6 +27,9 @@ RowLayout { return; } + if ( visibilities.sidebar ) + return; + const id = ch.id; const top = ch.x; const item = ch.item; @@ -56,6 +60,10 @@ RowLayout { popouts.currentName = "calendar"; popouts.currentCenter = Qt.binding( () => item.mapToItem( root, itemWidth / 2, 0 ).x ); popouts.hasCurrent = true; + } else if ( x > (root.width / 2 + 50) && x < (root.width / 2 - 50) && Config.barConfig.popouts.activeWindow ) { + popouts.currentName = "dash"; + popouts.currentCenter = root.width / 2; + popouts.hasCurrent = true; } } @@ -128,7 +136,9 @@ RowLayout { DelegateChoice { roleValue: "notifBell" delegate: WrappedLoader { - sourceComponent: NotifBell {} + sourceComponent: NotifBell { + visibilities: root.visibilities + } } } DelegateChoice { diff --git a/Modules/Content.qml b/Modules/Content.qml index af91265..0e9fabb 100644 --- a/Modules/Content.qml +++ b/Modules/Content.qml @@ -7,6 +7,7 @@ import qs.Config import qs.Modules.Calendar import qs.Modules.WSOverview import qs.Modules.Polkit +import qs.Modules.Dashboard Item { id: root @@ -88,6 +89,14 @@ Item { screen: root.wrapper.screen } } + + Popout { + name: "dash" + + sourceComponent: Dashboard { + wrapper: root.wrapper + } + } } component Popout: Loader { diff --git a/Modules/Dashboard/Dash.qml b/Modules/Dashboard/Dash.qml new file mode 100644 index 0000000..febbcb3 --- /dev/null +++ b/Modules/Dashboard/Dash.qml @@ -0,0 +1,88 @@ +import Quickshell +import QtQuick.Layouts +import qs.Helpers +import qs.Components +import qs.Modules +import qs.Config +import qs.Modules.Dashboard.Dash + +GridLayout { + id: root + + required property PersistentProperties state + + rowSpacing: 8 + columnSpacing: 8 + + Rect { + Layout.column: 2 + Layout.columnSpan: 3 + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 + + radius: 8 + + CachingImage { + path: Quickshell.env("HOME") + "/.face" + } + } + + Rect { + Layout.row: 0 + Layout.columnSpan: 2 + Layout.preferredWidth: Config.dashboard.sizes.weatherWidth + Layout.fillHeight: true + + radius: 8 + + Weather {} + } + + Rect { + Layout.row: 1 + Layout.preferredWidth: dateTime.implicitWidth + Layout.fillHeight: true + + radius: 8 + + DateTime { + id: dateTime + } + } + + Rect { + Layout.row: 1 + Layout.column: 1 + Layout.columnSpan: 3 + Layout.fillWidth: true + Layout.preferredHeight: 100 + + radius: 8 + + } + + Rect { + Layout.row: 1 + Layout.column: 4 + Layout.preferredWidth: 100 + Layout.fillHeight: true + + radius: 8 + + } + + Rect { + Layout.row: 0 + Layout.column: 5 + Layout.rowSpan: 2 + Layout.preferredWidth: 100 + Layout.fillHeight: true + + radius: 8 + + } + + component Rect: CustomRect { + color: DynamicColors.tPalette.m3surfaceContainer + } +} diff --git a/Modules/Dashboard/Dash/DateTime.qml b/Modules/Dashboard/Dash/DateTime.qml new file mode 100644 index 0000000..e31c3a6 --- /dev/null +++ b/Modules/Dashboard/Dash/DateTime.qml @@ -0,0 +1,49 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Config + +Item { + id: root + + anchors.top: parent.top + anchors.bottom: parent.bottom + implicitWidth: 110 + + ColumnLayout { + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + Layout.bottomMargin: -(font.pointSize * 0.4) + Layout.alignment: Qt.AlignHCenter + text: Time.hourStr + color: DynamicColors.palette.m3secondary + font.pointSize: 18 + font.family: "Rubik" + font.weight: 600 + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: "•••" + color: DynamicColors.palette.m3primary + font.pointSize: 18 * 0.9 + font.family: "Rubik" + } + + CustomText { + Layout.topMargin: -(font.pointSize * 0.4) + Layout.alignment: Qt.AlignHCenter + text: Time.minuteStr + color: DynamicColors.palette.m3secondary + font.pointSize: 18 + font.family: "Rubik" + font.weight: 600 + } + } +} diff --git a/Modules/Dashboard/Dash/Weather.qml b/Modules/Dashboard/Dash/Weather.qml new file mode 100644 index 0000000..ec64811 --- /dev/null +++ b/Modules/Dashboard/Dash/Weather.qml @@ -0,0 +1,55 @@ +import QtQuick +import qs.Helpers +import qs.Components +import qs.Config + +Item { + id: root + + anchors.centerIn: parent + + implicitWidth: icon.implicitWidth + info.implicitWidth + info.anchors.leftMargin + + Component.onCompleted: Weather.reload() + + MaterialIcon { + id: icon + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + + animate: true + text: Weather.icon + color: DynamicColors.palette.m3secondary + font.pointSize: 24 + } + + Column { + id: info + + anchors.verticalCenter: parent.verticalCenter + anchors.left: icon.right + + spacing: 8 + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + + animate: true + text: Weather.temp + color: DynamicColors.palette.m3primary + font.pointSize: Appearance.font.size.extraLarge + font.weight: 500 + } + + CustomText { + anchors.horizontalCenter: parent.horizontalCenter + + animate: true + text: Weather.description + + elide: Text.ElideRight + width: Math.min(implicitWidth, root.parent.width - icon.implicitWidth - info.anchors.leftMargin - 24 * 2) + } + } +} diff --git a/Modules/Dashboard/Dashboard.qml b/Modules/Dashboard/Dashboard.qml new file mode 100644 index 0000000..de09385 --- /dev/null +++ b/Modules/Dashboard/Dashboard.qml @@ -0,0 +1,133 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import qs.Config +import qs.Modules + +Item { + id: root + + required property var wrapper + readonly property PersistentProperties state: PersistentProperties { + property int currentTab + property date currentDate: new Date() + + reloadableId: "dashboardState" + } + readonly property real nonAnimWidth: view.implicitWidth + viewWrapper.anchors.margins * 2 + readonly property real nonAnimHeight: tabs.implicitHeight + tabs.anchors.topMargin + view.implicitHeight + viewWrapper.anchors.margins * 2 + + implicitWidth: nonAnimWidth + implicitHeight: nonAnimHeight + + Tabs { + id: tabs + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + nonAnimWidth: root.nonAnimWidth - anchors.margins * 2 + state: root.state + } + + ClippingRectangle { + id: viewWrapper + + anchors.top: tabs.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + + radius: 8 + color: "transparent" + + Flickable { + id: view + + readonly property int currentIndex: root.state.currentTab + readonly property Item currentItem: row.children[currentIndex] + + anchors.fill: parent + + flickableDirection: Flickable.HorizontalFlick + + implicitWidth: currentItem.implicitWidth + implicitHeight: currentItem.implicitHeight + + contentX: currentItem.x + contentWidth: row.implicitWidth + contentHeight: row.implicitHeight + + onContentXChanged: { + if (!moving) + return; + + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 2) + root.state.currentTab = Math.min(root.state.currentTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 2) + root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + } + + onDragEnded: { + const x = contentX - currentItem.x; + if (x > currentItem.implicitWidth / 10) + root.state.currentTab = Math.min(root.state.currentTab + 1, tabs.count - 1); + else if (x < -currentItem.implicitWidth / 10) + root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + else + contentX = Qt.binding(() => currentItem.x); + } + + RowLayout { + id: row + + Pane { + index: 0 + sourceComponent: Dash { + state: root.state + } + } + } + + Behavior on contentX { + Anim {} + } + } + } + + Behavior on implicitWidth { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Behavior on implicitHeight { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + component Pane: Loader { + id: pane + + required property int index + + Layout.alignment: Qt.AlignTop + + Component.onCompleted: active = Qt.binding(() => { + // Always keep current tab loaded + if (pane.index === view.currentIndex) + return true; + const vx = Math.floor(view.visibleArea.xPosition * view.contentWidth); + const vex = Math.floor(vx + view.visibleArea.widthRatio * view.contentWidth); + return (vx >= x && vx <= x + implicitWidth) || (vex >= x && vex <= x + implicitWidth); + }) + } +} diff --git a/Modules/Dashboard/Tabs.qml b/Modules/Dashboard/Tabs.qml new file mode 100644 index 0000000..83246f4 --- /dev/null +++ b/Modules/Dashboard/Tabs.qml @@ -0,0 +1,246 @@ +pragma ComponentBehavior: Bound + +import qs.Components +import qs.Config +import qs.Helpers +import qs.Modules +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Controls + +Item { + id: root + + required property real nonAnimWidth + required property PersistentProperties state + readonly property alias count: bar.count + + implicitHeight: bar.implicitHeight + indicator.implicitHeight + indicator.anchors.topMargin + separator.implicitHeight + + TabBar { + id: bar + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + + currentIndex: root.state.currentTab + background: null + + onCurrentIndexChanged: root.state.currentTab = currentIndex + + Tab { + iconName: "dashboard" + text: qsTr("Dashboard") + } + + Tab { + iconName: "queue_music" + text: qsTr("Media") + } + + Tab { + iconName: "speed" + text: qsTr("Performance") + } + + Tab { + iconName: "cloud" + text: qsTr("Weather") + } + + // Tab { + // iconName: "workspaces" + // text: qsTr("Workspaces") + // } + } + + Item { + id: indicator + + anchors.top: bar.bottom + + implicitWidth: bar.currentItem.implicitWidth + implicitHeight: 40 + + x: { + const tab = bar.currentItem; + const width = (root.nonAnimWidth - bar.spacing * (bar.count - 1)) / bar.count; + return width * tab.TabBar.index + (width - tab.implicitWidth) / 2; + } + + clip: true + + CustomRect { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: parent.implicitHeight * 2 + + color: DynamicColors.palette.m3primary + radius: 1000 + } + + Behavior on x { + Anim {} + } + + Behavior on implicitWidth { + Anim {} + } + } + + CustomRect { + id: separator + + anchors.top: indicator.bottom + anchors.left: parent.left + anchors.right: parent.right + + implicitHeight: 1 + color: DynamicColors.palette.m3outlineVariant + } + + component Tab: TabButton { + id: tab + + required property string iconName + readonly property bool current: TabBar.tabBar.currentItem === this + + background: null + + contentItem: CustomMouseArea { + id: mouse + + implicitWidth: Math.max(icon.width, label.width) + implicitHeight: icon.height + label.height + + cursorShape: Qt.PointingHandCursor + + onPressed: event => { + root.state.currentTab = tab.TabBar.index; + + const stateY = stateWrapper.y; + rippleAnim.x = event.x; + rippleAnim.y = event.y - stateY; + + const dist = (ox, oy) => ox * ox + oy * oy; + rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y + stateY), dist(event.x, stateWrapper.height - event.y), dist(width - event.x, event.y + stateY), dist(width - event.x, stateWrapper.height - event.y))); + + rippleAnim.restart(); + } + + function onWheel(event: WheelEvent): void { + if (event.angleDelta.y < 0) + root.state.currentTab = Math.min(root.state.currentTab + 1, bar.count - 1); + else if (event.angleDelta.y > 0) + root.state.currentTab = Math.max(root.state.currentTab - 1, 0); + } + + SequentialAnimation { + id: rippleAnim + + property real x + property real y + property real radius + + PropertyAction { + target: ripple + property: "x" + value: rippleAnim.x + } + PropertyAction { + target: ripple + property: "y" + value: rippleAnim.y + } + PropertyAction { + target: ripple + property: "opacity" + value: 0.08 + } + Anim { + target: ripple + properties: "implicitWidth,implicitHeight" + from: 0 + to: rippleAnim.radius * 2 + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + Anim { + target: ripple + property: "opacity" + to: 0 + easing.type: Easing.BezierSpline + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + ClippingRectangle { + id: stateWrapper + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + implicitHeight: parent.height + 8 * 2 + + color: "transparent" + radius: 8 + + CustomRect { + id: stateLayer + + anchors.fill: parent + + color: tab.current ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface + opacity: mouse.pressed ? 0.1 : tab.hovered ? 0.08 : 0 + + Behavior on opacity { + Anim {} + } + } + + CustomRect { + id: ripple + + radius: 1000 + color: tab.current ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface + opacity: 0 + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } + + MaterialIcon { + id: icon + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: label.top + + text: tab.iconName + color: tab.current ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurfaceVariant + fill: tab.current ? 1 : 0 + font.pointSize: 18 + + Behavior on fill { + Anim {} + } + } + + CustomText { + id: label + + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + text: tab.text + color: tab.current ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurfaceVariant + } + } + } +} diff --git a/Modules/NotifBell.qml b/Modules/NotifBell.qml index f1e4df1..e375e37 100644 --- a/Modules/NotifBell.qml +++ b/Modules/NotifBell.qml @@ -1,3 +1,4 @@ +import Quickshell import Quickshell.Hyprland import QtQuick import qs.Config @@ -7,6 +8,8 @@ import qs.Components Item { id: root + required property PersistentProperties visibilities + implicitWidth: 20 anchors.top: parent.top anchors.bottom: parent.bottom @@ -33,7 +36,8 @@ Item { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { - Hyprland.dispatch("global zshell-nc:toggle-nc"); + // Hyprland.dispatch("global zshell-nc:toggle-nc"); + root.visibilities.sidebar = !root.visibilities.sidebar; } } } diff --git a/Modules/NotificationCenter.qml b/Modules/NotificationCenter.qml index ac5936a..0e153fd 100644 --- a/Modules/NotificationCenter.qml +++ b/Modules/NotificationCenter.qml @@ -11,173 +11,168 @@ import qs.Helpers import qs.Daemons import qs.Effects -PanelWindow { - id: root - color: "transparent" - anchors { - top: true - right: true - left: true - bottom: true - } +Scope { + Variants { + model: Quickshell.screens - WlrLayershell.namespace: "ZShell-Notifs" - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand - property bool centerShown: false - property alias posX: backgroundRect.x - visible: false - - mask: Region { item: backgroundRect } - - Connections { - target: Hypr - - function onFocusedMonitorChanged(): void { - if ( !root.centerShown ) { - root.screen = Hypr.getActiveScreen(); + PanelWindow { + id: root + color: "transparent" + anchors { + top: true + right: true + left: true + bottom: true } - } - } - GlobalShortcut { - appid: "zshell-nc" - name: "toggle-nc" - onPressed: { - root.screen = Hypr.getActiveScreen(); - root.centerShown = !root.centerShown; - } - } + WlrLayershell.namespace: "ZShell-Notifs" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + property bool centerShown: false + property alias posX: backgroundRect.x + visible: false - onVisibleChanged: { - if ( root.visible ) { - showAnimation.start(); - } - } + mask: Region { item: backgroundRect } - onCenterShownChanged: { - if ( !root.centerShown ) { - closeAnimation.start(); - closeTimer.start(); - } else { - root.visible = true; - } - } + GlobalShortcut { + appid: "zshell-nc" + name: "toggle-nc" + onPressed: { + root.centerShown = !root.centerShown; + } + } - Keys.onPressed: { - if ( event.key === Qt.Key_Escape ) { - root.centerShown = false; - event.accepted = true; - } - } + onVisibleChanged: { + if ( root.visible ) { + showAnimation.start(); + } + } - Timer { - id: closeTimer - interval: 300 - onTriggered: { - root.visible = false; - } - } + onCenterShownChanged: { + if ( !root.centerShown ) { + closeAnimation.start(); + closeTimer.start(); + } else if ( Hypr.getActiveScreen() === root.screen ) { + root.visible = true; + } + } - NumberAnimation { - id: showAnimation - target: backgroundRect - property: "x" - to: Math.round(root.screen.width - backgroundRect.implicitWidth - 10) - from: root.screen.width - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - onStopped: { - focusGrab.active = true; - } - } + Keys.onPressed: { + if ( event.key === Qt.Key_Escape ) { + root.centerShown = false; + event.accepted = true; + } + } - NumberAnimation { - id: closeAnimation - target: backgroundRect - property: "x" - from: root.screen.width - backgroundRect.implicitWidth - 10 - to: root.screen.width - duration: MaterialEasing.expressiveEffectsTime - easing.bezierCurve: MaterialEasing.expressiveEffects - } + Timer { + id: closeTimer + interval: 300 + onTriggered: { + root.visible = false; + } + } - HyprlandFocusGrab { - id: focusGrab - active: false - windows: [ root ] - onCleared: { - root.centerShown = false; - } - } + NumberAnimation { + id: showAnimation + target: backgroundRect + property: "x" + to: Math.round(root.screen.width - backgroundRect.implicitWidth - 10) + from: root.screen.width + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + onStopped: { + focusGrab.active = true; + } + } - TrackedNotification { - centerShown: root.centerShown - screen: root.screen - } + NumberAnimation { + id: closeAnimation + target: backgroundRect + property: "x" + from: root.screen.width - backgroundRect.implicitWidth - 10 + to: root.screen.width + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } - ShadowRect { - anchors.fill: backgroundRect - radius: backgroundRect.radius - } + HyprlandFocusGrab { + id: focusGrab + active: false + windows: [ root ] + onCleared: { + root.centerShown = false; + } + } - Rectangle { - id: backgroundRect - y: 10 - x: Screen.width - z: 1 + TrackedNotification { + centerShown: root.centerShown + screen: root.screen + } - property color backgroundColor: Config.useDynamicColors ? DynamicColors.tPalette.m3surface : Config.baseBgColor - - implicitWidth: 400 - implicitHeight: root.height - 20 - color: backgroundColor - radius: 8 - border.color: "#555555" - border.width: Config.useDynamicColors ? 0 : 1 - ColumnLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 10 - - NotificationCenterHeader { } + ShadowRect { + anchors.fill: backgroundRect + radius: backgroundRect.radius + } Rectangle { - color: "#333333" - Layout.preferredHeight: Config.useDynamicColors ? 0 : 1 - Layout.fillWidth: true - } + id: backgroundRect + y: 10 + x: Screen.width + z: 1 - Flickable { - Layout.fillWidth: true - Layout.fillHeight: true - pixelAligned: true - contentHeight: notificationColumn.implicitHeight - clip: true + property color backgroundColor: Config.useDynamicColors ? DynamicColors.tPalette.m3surface : Config.baseBgColor - Column { - id: notificationColumn - width: parent.width + implicitWidth: 400 + implicitHeight: root.height - 20 + color: backgroundColor + radius: 8 + border.color: "#555555" + border.width: Config.useDynamicColors ? 0 : 1 + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 spacing: 10 - add: Transition { - NumberAnimation { - properties: "x"; - duration: 300; - easing.type: Easing.OutCubic - } + NotificationCenterHeader { } + + Rectangle { + color: "#333333" + Layout.preferredHeight: Config.useDynamicColors ? 0 : 1 + Layout.fillWidth: true } - move: Transition { - NumberAnimation { - properties: "x"; - duration: 200; - easing.type: Easing.OutCubic + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + pixelAligned: true + contentHeight: notificationColumn.implicitHeight + clip: true + + Column { + id: notificationColumn + width: parent.width + spacing: 10 + + add: Transition { + NumberAnimation { + properties: "x"; + duration: 300; + easing.type: Easing.OutCubic + } + } + + move: Transition { + NumberAnimation { + properties: "x"; + duration: 200; + easing.type: Easing.OutCubic + } + } + + GroupListView { } + } } - - GroupListView { } - } } } diff --git a/Modules/Notifications/Background.qml b/Modules/Notifications/Background.qml new file mode 100644 index 0000000..07e45f1 --- /dev/null +++ b/Modules/Notifications/Background.qml @@ -0,0 +1,54 @@ +import qs.Components +import qs.Config +import qs.Modules as Modules +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + required property var sidebar + readonly property real rounding: 8 + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + + strokeWidth: -1 + fillColor: DynamicColors.palette.m3surface + + PathLine { + relativeX: -(root.wrapper.width + root.rounding) + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.roundingY * 2 + } + PathArc { + relativeX: root.sidebar.notifsRoundingX + relativeY: root.roundingY + radiusX: root.sidebar.notifsRoundingX + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.sidebar.notifsRoundingX : root.wrapper.width + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: root.rounding + radiusX: root.rounding + radiusY: root.rounding + } + + Behavior on fillColor { + Modules.CAnim {} + } +} diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml new file mode 100644 index 0000000..67b24fa --- /dev/null +++ b/Modules/Notifications/Content.qml @@ -0,0 +1,203 @@ +import qs.Components +import qs.Config +import qs.Daemons +import Quickshell +import Quickshell.Widgets +import QtQuick + +Item { + id: root + + required property PersistentProperties visibilities + required property Item panels + readonly property int padding: 8 + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: Config.notifs.sizes.width + padding * 2 + implicitHeight: { + const count = list.count; + if (count === 0) + return 0; + + let height = (count - 1) * 8; + for (let i = 0; i < count; i++) + height += list.itemAtIndex(i)?.nonAnimHeight ?? 0; + + if (visibilities && panels) { + if (visibilities.osd) { + const h = panels.osd.y - 8 * 2 - padding * 2; + if (height > h) + height = h; + } + + if (visibilities.session) { + const h = panels.session.y - 8 * 2 - padding * 2; + if (height > h) + height = h; + } + } + + return Math.min((QsWindow.window?.screen?.height ?? 0) - 1 * 2, height + padding * 2); + } + + ClippingWrapperRectangle { + anchors.fill: parent + anchors.margins: root.padding + + color: "transparent" + radius: 8 + + CustomListView { + id: list + + model: ScriptModel { + values: NotifServer.popups.filter(n => !n.closed) + } + + anchors.fill: parent + + orientation: Qt.Vertical + spacing: 0 + cacheBuffer: QsWindow.window?.screen.height ?? 0 + + delegate: Item { + id: wrapper + + required property NotifServer.Notif modelData + required property int index + readonly property alias nonAnimHeight: notif.nonAnimHeight + property int idx + + onIndexChanged: { + if (index !== -1) + idx = index; + } + + implicitWidth: notif.implicitWidth + implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : 8) + + ListView.onRemove: removeAnim.start() + + SequentialAnimation { + id: removeAnim + + PropertyAction { + target: wrapper + property: "ListView.delayRemove" + value: true + } + PropertyAction { + target: wrapper + property: "enabled" + value: false + } + PropertyAction { + target: wrapper + property: "implicitHeight" + value: 0 + } + PropertyAction { + target: wrapper + property: "z" + value: 1 + } + Anim { + target: notif + property: "x" + to: (notif.x >= 0 ? Config.notifs.sizes.width : -Config.notifs.sizes.width) * 2 + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + PropertyAction { + target: wrapper + property: "ListView.delayRemove" + value: false + } + } + + ClippingRectangle { + anchors.top: parent.top + anchors.topMargin: wrapper.idx === 0 ? 0 : 8 + + color: "transparent" + radius: notif.radius + implicitWidth: notif.implicitWidth + implicitHeight: notif.implicitHeight + + Notification { + id: notif + + modelData: wrapper.modelData + } + } + } + + move: Transition { + Anim { + property: "y" + } + } + + displaced: Transition { + Anim { + property: "y" + } + } + + ExtraIndicator { + anchors.top: parent.top + extra: { + const count = list.count; + if (count === 0) + return 0; + + const scrollY = list.contentY; + + let height = 0; + for (let i = 0; i < count; i++) { + height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + 8; + + if (height - 8 >= scrollY) + return i; + } + + return count; + } + } + + ExtraIndicator { + anchors.bottom: parent.bottom + extra: { + const count = list.count; + if (count === 0) + return 0; + + const scrollY = list.contentHeight - (list.contentY + list.height); + + let height = 0; + for (let i = count - 1; i >= 0; i--) { + height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + 8; + + if (height - 8 >= scrollY) + return count - i - 1; + } + + return 0; + } + } + } + } + + Behavior on implicitHeight { + Anim {} + } + + component Anim: NumberAnimation { + easing.type: Easing.BezierSpline + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } +} diff --git a/Modules/Notifications/Notification.qml b/Modules/Notifications/Notification.qml new file mode 100644 index 0000000..e6963a2 --- /dev/null +++ b/Modules/Notifications/Notification.qml @@ -0,0 +1,478 @@ +pragma ComponentBehavior: Bound + +import qs.Components +import qs.Config +import qs.Modules +import qs.Daemons +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications +import QtQuick +import QtQuick.Layouts + +CustomRect { + id: root + + required property NotifServer.Notif modelData + readonly property bool hasImage: modelData.image.length > 0 + readonly property bool hasAppIcon: modelData.appIcon.length > 0 + readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 + property bool expanded: Config.notifs.openExpanded + + color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondaryContainer : DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.normal + implicitWidth: Config.notifs.sizes.width + implicitHeight: inner.implicitHeight + + x: Config.notifs.sizes.width + Component.onCompleted: { + x = 0; + modelData.lock(this); + } + Component.onDestruction: modelData.unlock(this) + + Behavior on x { + Anim { + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + MouseArea { + property int startY + + anchors.fill: parent + hoverEnabled: true + cursorShape: root.expanded && body.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + preventStealing: true + + onEntered: root.modelData.timer.stop() + onExited: { + if (!pressed) + root.modelData.timer.start(); + } + + drag.target: parent + drag.axis: Drag.XAxis + + onPressed: event => { + root.modelData.timer.stop(); + startY = event.y; + if (event.button === Qt.MiddleButton) + root.modelData.close(); + } + onReleased: event => { + if (!containsMouse) + root.modelData.timer.start(); + + if (Math.abs(root.x) < Config.notifs.sizes.width * Config.notifs.clearThreshold) + root.x = 0; + else + root.modelData.popup = false; + } + onPositionChanged: event => { + if (pressed) { + const diffY = event.y - startY; + if (Math.abs(diffY) > Config.notifs.expandThreshold) + root.expanded = diffY > 0; + } + } + onClicked: event => { + if (!Config.notifs.actionOnClick || event.button !== Qt.LeftButton) + return; + + const actions = root.modelData.actions; + if (actions?.length === 1) + actions[0].invoke(); + } + + Item { + id: inner + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + + implicitHeight: root.nonAnimHeight + + Behavior on implicitHeight { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Loader { + id: image + + active: root.hasImage + + anchors.left: parent.left + anchors.top: parent.top + width: Config.notifs.sizes.image + height: Config.notifs.sizes.image + visible: root.hasImage || root.hasAppIcon + + sourceComponent: ClippingRectangle { + radius: 1000 + implicitWidth: Config.notifs.sizes.image + implicitHeight: Config.notifs.sizes.image + + Image { + anchors.fill: parent + source: Qt.resolvedUrl(root.modelData.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + } + } + } + + Loader { + id: appIcon + + active: root.hasAppIcon || !root.hasImage + + anchors.horizontalCenter: root.hasImage ? undefined : image.horizontalCenter + anchors.verticalCenter: root.hasImage ? undefined : image.verticalCenter + anchors.right: root.hasImage ? image.right : undefined + anchors.bottom: root.hasImage ? image.bottom : undefined + + sourceComponent: CustomRect { + radius: 1000 + color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) : DynamicColors.palette.m3secondaryContainer + implicitWidth: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image + implicitHeight: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image + + Loader { + id: icon + + active: root.hasAppIcon + + anchors.centerIn: parent + + width: Math.round(parent.width * 0.6) + height: Math.round(parent.width * 0.6) + + sourceComponent: CustomIcon { + anchors.fill: parent + source: Quickshell.iconPath(root.modelData.appIcon) + layer.enabled: root.modelData.appIcon.endsWith("symbolic") + } + } + + Loader { + active: !root.hasAppIcon + anchors.centerIn: parent + anchors.horizontalCenterOffset: -18 * 0.02 + anchors.verticalCenterOffset: 18 * 0.02 + + sourceComponent: MaterialIcon { + text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) + + color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer + font.pointSize: 18 + } + } + } + } + + CustomText { + id: appName + + anchors.top: parent.top + anchors.left: image.right + anchors.leftMargin: 10 + + animate: true + text: appNameMetrics.elidedText + maximumLineCount: 1 + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: 10 + + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + TextMetrics { + id: appNameMetrics + + text: root.modelData.appName + font.family: appName.font.family + font.pointSize: appName.font.pointSize + elide: Text.ElideRight + elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - 7 * 3 + } + + CustomText { + id: summary + + anchors.top: parent.top + anchors.left: image.right + anchors.leftMargin: 10 + + animate: true + text: summaryMetrics.elidedText + maximumLineCount: 1 + height: implicitHeight + + states: State { + name: "expanded" + when: root.expanded + + PropertyChanges { + summary.maximumLineCount: undefined + } + + AnchorChanges { + target: summary + anchors.top: appName.bottom + } + } + + transitions: Transition { + PropertyAction { + target: summary + property: "maximumLineCount" + } + AnchorAnimation { + easing.type: Easing.BezierSpline + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Behavior on height { + Anim {} + } + } + + TextMetrics { + id: summaryMetrics + + text: root.modelData.summary + font.family: summary.font.family + font.pointSize: summary.font.pointSize + elide: Text.ElideRight + elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - 7 * 3 + } + + CustomText { + id: timeSep + + anchors.top: parent.top + anchors.left: summary.right + anchors.leftMargin: 7 + + text: "•" + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: 10 + + states: State { + name: "expanded" + when: root.expanded + + AnchorChanges { + target: timeSep + anchors.left: appName.right + } + } + + transitions: Transition { + AnchorAnimation { + easing.type: Easing.BezierSpline + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + } + + CustomText { + id: time + + anchors.top: parent.top + anchors.left: timeSep.right + anchors.leftMargin: 7 + + animate: true + horizontalAlignment: Text.AlignLeft + text: root.modelData.timeStr + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: 10 + } + + Item { + id: expandBtn + + anchors.right: parent.right + anchors.top: parent.top + + implicitWidth: expandIcon.height + implicitHeight: expandIcon.height + + StateLayer { + radius: 1000 + color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface + + function onClicked() { + root.expanded = !root.expanded; + } + } + + MaterialIcon { + id: expandIcon + + anchors.centerIn: parent + + animate: true + text: root.expanded ? "expand_less" : "expand_more" + font.pointSize: 13 + } + } + + CustomText { + id: bodyPreview + + anchors.left: summary.left + anchors.right: expandBtn.left + anchors.top: summary.bottom + anchors.rightMargin: 7 + + animate: true + textFormat: Text.MarkdownText + text: bodyPreviewMetrics.elidedText + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: 10 + + opacity: root.expanded ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + TextMetrics { + id: bodyPreviewMetrics + + text: root.modelData.body + font.family: bodyPreview.font.family + font.pointSize: bodyPreview.font.pointSize + elide: Text.ElideRight + elideWidth: bodyPreview.width + } + + CustomText { + id: body + + anchors.left: summary.left + anchors.right: expandBtn.left + anchors.top: summary.bottom + anchors.rightMargin: 7 + + animate: true + textFormat: Text.MarkdownText + text: root.modelData.body + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: 10 + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + height: text ? implicitHeight : 0 + + onLinkActivated: link => { + if (!root.expanded) + return; + + Quickshell.execDetached(["app2unit", "-O", "--", link]); + root.modelData.popup = false; + } + + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + } + + RowLayout { + id: actions + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: body.bottom + anchors.topMargin: 7 + + spacing: 10 + + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + Anim {} + } + + Action { + modelData: QtObject { + readonly property string text: qsTr("Close") + function invoke(): void { + root.modelData.close(); + } + } + } + + Repeater { + model: root.modelData.actions + + delegate: Component { + Action {} + } + } + } + } + } + + component Action: CustomRect { + id: action + + required property var modelData + + radius: 1000 + color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3secondary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + + Layout.preferredWidth: actionText.width + 8 * 2 + Layout.preferredHeight: actionText.height + 4 * 2 + implicitWidth: actionText.width + 8 * 2 + implicitHeight: actionText.height + 4 * 2 + + StateLayer { + radius: 1000 + color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurface + + function onClicked(): void { + action.modelData.invoke(); + } + } + + CustomText { + id: actionText + + anchors.centerIn: parent + text: actionTextMetrics.elidedText + color: root.modelData.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSurfaceVariant + font.pointSize: 10 + } + + TextMetrics { + id: actionTextMetrics + + text: action.modelData.text + font.family: actionText.font.family + font.pointSize: actionText.font.pointSize + elide: Text.ElideRight + elideWidth: { + const numActions = root.modelData.actions.length + 1; + return (inner.width - actions.spacing * (numActions - 1)) / numActions - 8 * 2; + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Background.qml b/Modules/Notifications/Sidebar/Background.qml new file mode 100644 index 0000000..8749d21 --- /dev/null +++ b/Modules/Notifications/Sidebar/Background.qml @@ -0,0 +1,52 @@ +import qs.Components +import qs.Config +import qs.Modules as Modules +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + required property var panels + + readonly property real rounding: 8 + + readonly property real notifsWidthDiff: panels.notifications.width - wrapper.width + readonly property real notifsRoundingX: panels.notifications.height > 0 && notifsWidthDiff < rounding * 2 ? notifsWidthDiff / 2 : rounding + + readonly property real utilsWidthDiff: panels.utilities.width - wrapper.width + readonly property real utilsRoundingX: utilsWidthDiff < rounding * 2 ? utilsWidthDiff / 2 : rounding + + strokeWidth: -1 + fillColor: DynamicColors.palette.m3surface + + PathLine { + relativeX: -root.wrapper.width - root.notifsRoundingX + relativeY: 0 + } + PathArc { + relativeX: root.notifsRoundingX + relativeY: root.rounding + radiusX: root.notifsRoundingX + radiusY: root.rounding + } + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.rounding * 2 + } + PathArc { + relativeX: -root.utilsRoundingX + relativeY: root.rounding + radiusX: root.utilsRoundingX + radiusY: root.rounding + } + PathLine { + relativeX: root.wrapper.width + root.utilsRoundingX + relativeY: 0 + } + + Behavior on fillColor { + Modules.CAnim {} + } +} diff --git a/Modules/Notifications/Sidebar/Content.qml b/Modules/Notifications/Sidebar/Content.qml new file mode 100644 index 0000000..7465efc --- /dev/null +++ b/Modules/Notifications/Sidebar/Content.qml @@ -0,0 +1,39 @@ +import qs.Components +import qs.Config +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Props props + required property var visibilities + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: 8 + + CustomRect { + Layout.fillWidth: true + Layout.fillHeight: true + + radius: 8 + color: DynamicColors.tPalette.m3surfaceContainerLow + + NotifDock { + props: root.props + visibilities: root.visibilities + } + } + + CustomRect { + Layout.topMargin: 8 - layout.spacing + Layout.fillWidth: true + implicitHeight: 1 + + color: DynamicColors.tPalette.m3outlineVariant + } + } +} diff --git a/Modules/Notifications/Sidebar/Notif.qml b/Modules/Notifications/Sidebar/Notif.qml new file mode 100644 index 0000000..c85c728 --- /dev/null +++ b/Modules/Notifications/Sidebar/Notif.qml @@ -0,0 +1,165 @@ +pragma ComponentBehavior: Bound + +import qs.Components +import qs.Config +import qs.Daemons +import qs.Modules +import Quickshell +import QtQuick +import QtQuick.Layouts + +CustomRect { + id: root + + required property NotifServer.Notif modelData + required property Props props + required property bool expanded + required property var visibilities + + readonly property CustomText body: expandedContent.item?.body ?? null + readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + 10 * 2 : summaryHeightMetrics.height + + implicitHeight: nonAnimHeight + + radius: 6 + color: { + const c = root.modelData.urgency === "critical" ? DynamicColors.palette.m3secondaryContainer : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2); + return expanded ? c : Qt.alpha(c, 0); + } + + states: State { + name: "expanded" + when: root.expanded + + PropertyChanges { + summary.anchors.margins: 10 + dummySummary.anchors.margins: 10 + compactBody.anchors.margins: 10 + timeStr.anchors.margins: 10 + expandedContent.anchors.margins: 10 + summary.width: root.width - 10 * 2 - timeStr.implicitWidth - 7 + summary.maximumLineCount: Number.MAX_SAFE_INTEGER + } + } + + transitions: Transition { + Anim { + properties: "margins,width,maximumLineCount" + } + } + + TextMetrics { + id: summaryHeightMetrics + + font: summary.font + text: " " // Use this height to prevent weird characters from changing the line height + } + + CustomText { + id: summary + + anchors.top: parent.top + anchors.left: parent.left + + width: parent.width + text: root.modelData.summary + color: root.modelData.urgency === "critical" ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 1 + } + + CustomText { + id: dummySummary + + anchors.top: parent.top + anchors.left: parent.left + + visible: false + text: root.modelData.summary + } + + WrappedLoader { + id: compactBody + + shouldBeActive: !root.expanded + anchors.top: parent.top + anchors.left: dummySummary.right + anchors.right: parent.right + anchors.leftMargin: 7 + + sourceComponent: CustomText { + text: root.modelData.body.replace(/\n/g, " ") + color: root.modelData.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3outline + elide: Text.ElideRight + } + } + + WrappedLoader { + id: timeStr + + shouldBeActive: root.expanded + anchors.top: parent.top + anchors.right: parent.right + + sourceComponent: CustomText { + animate: true + text: root.modelData.timeStr + color: DynamicColors.palette.m3outline + font.pointSize: 11 + } + } + + WrappedLoader { + id: expandedContent + + shouldBeActive: root.expanded + anchors.top: summary.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 7 / 2 + + sourceComponent: ColumnLayout { + readonly property alias body: body + + spacing: 10 + + CustomText { + id: body + + Layout.fillWidth: true + textFormat: Text.MarkdownText + text: root.modelData.body.replace(/(.)\n(?!\n)/g, "$1\n\n") || qsTr("No body here! :/") + color: root.modelData.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3outline + wrapMode: Text.WordWrap + + onLinkActivated: link => { + Quickshell.execDetached(["app2unit", "-O", "--", link]); + root.visibilities.sidebar = false; + } + } + + NotifActionList { + notif: root.modelData + } + } + } + + Behavior on implicitHeight { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + component WrappedLoader: Loader { + required property bool shouldBeActive + + opacity: shouldBeActive ? 1 : 0 + active: opacity > 0 + + Behavior on opacity { + Anim {} + } + } +} diff --git a/Modules/Notifications/Sidebar/NotifActionList.qml b/Modules/Notifications/Sidebar/NotifActionList.qml new file mode 100644 index 0000000..9df80f0 --- /dev/null +++ b/Modules/Notifications/Sidebar/NotifActionList.qml @@ -0,0 +1,199 @@ +pragma ComponentBehavior: Bound + +import qs.Components +import qs.Config +import qs.Modules +import qs.Daemons +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property NotifServer.Notif notif + + Layout.fillWidth: true + implicitHeight: flickable.contentHeight + + layer.enabled: true + layer.smooth: true + layer.effect: OpacityMask { + maskSource: gradientMask + } + + Item { + id: gradientMask + + anchors.fill: parent + layer.enabled: true + visible: false + + Rectangle { + anchors.fill: parent + + gradient: Gradient { + orientation: Gradient.Horizontal + + GradientStop { + position: 0 + color: Qt.rgba(0, 0, 0, 0) + } + GradientStop { + position: 0.1 + color: Qt.rgba(0, 0, 0, 1) + } + GradientStop { + position: 0.9 + color: Qt.rgba(0, 0, 0, 1) + } + GradientStop { + position: 1 + color: Qt.rgba(0, 0, 0, 0) + } + } + } + + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + + implicitWidth: parent.width / 2 + opacity: flickable.contentX > 0 ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: parent.right + + implicitWidth: parent.width / 2 + opacity: flickable.contentX < flickable.contentWidth - parent.width ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + } + + CustomFlickable { + id: flickable + + anchors.fill: parent + contentWidth: Math.max(width, actionList.implicitWidth) + contentHeight: actionList.implicitHeight + + RowLayout { + id: actionList + + anchors.fill: parent + spacing: 7 + + Repeater { + model: [ + { + isClose: true + }, + ...root.notif.actions, + { + isCopy: true + } + ] + + CustomRect { + id: action + + required property var modelData + + Layout.fillWidth: true + Layout.fillHeight: true + implicitWidth: actionInner.implicitWidth + 10 * 2 + implicitHeight: actionInner.implicitHeight + 10 * 2 + + Layout.preferredWidth: implicitWidth + (actionStateLayer.pressed ? 18 : 0) + radius: actionStateLayer.pressed ? 6 / 2 : 6 + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 4) + + Timer { + id: copyTimer + + interval: 3000 + onTriggered: actionInner.item.text = "content_copy" + } + + StateLayer { + id: actionStateLayer + + function onClicked(): void { + if (action.modelData.isClose) { + root.notif.close(); + } else if (action.modelData.isCopy) { + Quickshell.clipboardText = root.notif.body; + actionInner.item.text = "inventory"; + copyTimer.start(); + } else if (action.modelData.invoke) { + action.modelData.invoke(); + } else if (!root.notif.resident) { + root.notif.close(); + } + } + } + + Loader { + id: actionInner + + anchors.centerIn: parent + sourceComponent: action.modelData.isClose || action.modelData.isCopy ? iconBtn : root.notif.hasActionIcons ? iconComp : textComp + } + + Component { + id: iconBtn + + MaterialIcon { + animate: action.modelData.isCopy ?? false + text: action.modelData.isCopy ? "content_copy" : "close" + color: DynamicColors.palette.m3onSurfaceVariant + } + } + + Component { + id: iconComp + + IconImage { + source: Quickshell.iconPath(action.modelData.identifier) + } + } + + Component { + id: textComp + + CustomText { + text: action.modelData.text + color: DynamicColors.palette.m3onSurfaceVariant + } + } + + Behavior on Layout.preferredWidth { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Behavior on radius { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/NotifDock.qml b/Modules/Notifications/Sidebar/NotifDock.qml new file mode 100644 index 0000000..5d5b798 --- /dev/null +++ b/Modules/Notifications/Sidebar/NotifDock.qml @@ -0,0 +1,199 @@ +pragma ComponentBehavior: Bound + +import qs.Components +import qs.Config +import qs.Modules +import qs.Daemons +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Props props + required property var visibilities + readonly property int notifCount: NotifServer.list.reduce((acc, n) => n.closed ? acc : acc + 1, 0) + + anchors.fill: parent + anchors.margins: 8 + + Component.onCompleted: NotifServer.list.forEach(n => n.popup = false) + + Item { + id: title + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 4 + + implicitHeight: Math.max(count.implicitHeight, titleText.implicitHeight) + + CustomText { + id: count + + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: root.notifCount > 0 ? 0 : -width - titleText.anchors.leftMargin + opacity: root.notifCount > 0 ? 1 : 0 + + text: root.notifCount + color: DynamicColors.palette.m3outline + font.pointSize: 13 + font.family: "CaskaydiaCove NF" + font.weight: 500 + + Behavior on anchors.leftMargin { + Anim {} + } + + Behavior on opacity { + Anim {} + } + } + + CustomText { + id: titleText + + anchors.verticalCenter: parent.verticalCenter + anchors.left: count.right + anchors.right: parent.right + anchors.leftMargin: 7 + + text: root.notifCount > 0 ? qsTr("notification%1").arg(root.notifCount === 1 ? "" : "s") : qsTr("Notifications") + color: DynamicColors.palette.m3outline + font.pointSize: 13 + font.family: "CaskaydiaCove NF" + font.weight: 500 + elide: Text.ElideRight + } + } + + ClippingRectangle { + id: clipRect + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: title.bottom + anchors.bottom: parent.bottom + anchors.topMargin: 10 + + radius: 6 + color: "transparent" + + Loader { + anchors.centerIn: parent + active: opacity > 0 + opacity: root.notifCount > 0 ? 0 : 1 + + sourceComponent: ColumnLayout { + spacing: 20 + + Image { + asynchronous: true + source: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/dino.png`) + fillMode: Image.PreserveAspectFit + sourceSize.width: clipRect.width * 0.8 + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No Notifications") + color: DynamicColors.palette.m3outlineVariant + font.pointSize: 18 + font.family: "CaskaydiaCove NF" + font.weight: 500 + } + } + + Behavior on opacity { + Anim { + duration: MaterialEasing.expressiveEffectsTime + } + } + } + + CustomFlickable { + id: view + + anchors.fill: parent + + flickableDirection: Flickable.VerticalFlick + contentWidth: width + contentHeight: notifList.implicitHeight + + CustomScrollBar.vertical: CustomScrollBar { + flickable: view + } + + NotifDockList { + id: notifList + + props: root.props + visibilities: root.visibilities + container: view + } + } + } + + Timer { + id: clearTimer + + repeat: true + interval: 50 + onTriggered: { + let next = null; + for (let i = 0; i < notifList.repeater.count; i++) { + next = notifList.repeater.itemAt(i); + if (!next?.closed) + break; + } + if (next) + next.closeAll(); + else + stop(); + } + } + + Loader { + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.margins: 8 + + scale: root.notifCount > 0 ? 1 : 0.5 + opacity: root.notifCount > 0 ? 1 : 0 + active: opacity > 0 + + sourceComponent: IconButton { + id: clearBtn + + icon: "clear_all" + radius: 8 + padding: 8 + font.pointSize: Math.round(18 * 1.2) + onClicked: clearTimer.start() + + Elevation { + anchors.fill: parent + radius: parent.radius + z: -1 + level: clearBtn.stateLayer.containsMouse ? 4 : 3 + } + } + + Behavior on scale { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Behavior on opacity { + Anim { + duration: MaterialEasing.expressiveEffectsTime + } + } + } +} diff --git a/Modules/Notifications/Sidebar/NotifDockList.qml b/Modules/Notifications/Sidebar/NotifDockList.qml new file mode 100644 index 0000000..8252806 --- /dev/null +++ b/Modules/Notifications/Sidebar/NotifDockList.qml @@ -0,0 +1,169 @@ +pragma ComponentBehavior: Bound + +import qs.Components +import qs.Config +import qs.Modules +import qs.Daemons +import Quickshell +import QtQuick + +Item { + id: root + + required property Props props + required property Flickable container + required property var visibilities + + readonly property alias repeater: repeater + readonly property int spacing: 8 + property bool flag + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: { + const item = repeater.itemAt(repeater.count - 1); + return item ? item.y + item.implicitHeight : 0; + } + + Repeater { + id: repeater + + model: ScriptModel { + values: { + const map = new Map(); + for (const n of NotifServer.notClosed) + map.set(n.appName, null); + for (const n of NotifServer.list) + map.set(n.appName, null); + console.log(map.keys()) + return [...map.keys()]; + } + onValuesChanged: root.flagChanged() + } + + MouseArea { + id: notif + + required property int index + required property string modelData + + readonly property bool closed: notifInner.notifCount === 0 + readonly property alias nonAnimHeight: notifInner.nonAnimHeight + property int startY + + function closeAll(): void { + for (const n of NotifServer.notClosed.filter(n => n.appName === modelData)) + n.close(); + } + + y: { + root.flag; // Force update + let y = 0; + for (let i = 0; i < index; i++) { + const item = repeater.itemAt(i); + if (!item.closed) + y += item.nonAnimHeight + root.spacing; + } + return y; + } + + containmentMask: QtObject { + function contains(p: point): bool { + if (!root.container.contains(notif.mapToItem(root.container, p))) + return false; + return notifInner.contains(p); + } + } + + implicitWidth: root.width + implicitHeight: notifInner.implicitHeight + + hoverEnabled: true + cursorShape: pressed ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + preventStealing: true + enabled: !closed + + drag.target: this + drag.axis: Drag.XAxis + + onPressed: event => { + startY = event.y; + if (event.button === Qt.RightButton) + notifInner.toggleExpand(!notifInner.expanded); + else if (event.button === Qt.MiddleButton) + closeAll(); + } + onPositionChanged: event => { + if (pressed) { + const diffY = event.y - startY; + if (Math.abs(diffY) > Config.notifs.expandThreshold) + notifInner.toggleExpand(diffY > 0); + } + } + onReleased: event => { + if (Math.abs(x) < width * Config.notifs.clearThreshold) + x = 0; + else + closeAll(); + } + + ParallelAnimation { + running: true + + Anim { + target: notif + property: "opacity" + from: 0 + to: 1 + } + Anim { + target: notif + property: "scale" + from: 0 + to: 1 + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + ParallelAnimation { + running: notif.closed + + Anim { + target: notif + property: "opacity" + to: 0 + } + Anim { + target: notif + property: "scale" + to: 0.6 + } + } + + NotifGroup { + id: notifInner + + modelData: notif.modelData + props: root.props + container: root.container + visibilities: root.visibilities + } + + Behavior on x { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Behavior on y { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + } + } +} diff --git a/Modules/Notifications/Sidebar/NotifGroup.qml b/Modules/Notifications/Sidebar/NotifGroup.qml new file mode 100644 index 0000000..cff822e --- /dev/null +++ b/Modules/Notifications/Sidebar/NotifGroup.qml @@ -0,0 +1,239 @@ +pragma ComponentBehavior: Bound + +import qs.Components +import qs.Config +import qs.Modules +import qs.Daemons +import qs.Helpers +import Quickshell +import Quickshell.Services.Notifications +import QtQuick +import QtQuick.Layouts + +CustomRect { + id: root + + required property string modelData + required property Props props + required property Flickable container + required property var visibilities + + readonly property list notifs: NotifServer.list.filter(n => n.appName === modelData) + readonly property int notifCount: notifs.reduce((acc, n) => n.closed ? acc : acc + 1, 0) + readonly property string image: notifs.find(n => !n.closed && n.image.length > 0)?.image ?? "" + readonly property string appIcon: notifs.find(n => !n.closed && n.appIcon.length > 0)?.appIcon ?? "" + readonly property int urgency: notifs.some(n => !n.closed && n.urgency === NotificationUrgency.Critical) ? NotificationUrgency.Critical : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? NotificationUrgency.Normal : NotificationUrgency.Low + + readonly property int nonAnimHeight: { + const headerHeight = header.implicitHeight + (root.expanded ? Math.round(7 / 2) : 0); + const columnHeight = headerHeight + notifList.nonAnimHeight + column.Layout.topMargin + column.Layout.bottomMargin; + return Math.round(Math.max(Config.notifs.sizes.image, columnHeight) + 10 * 2); + } + readonly property bool expanded: props.expandedNotifs.includes(modelData) + + function toggleExpand(expand: bool): void { + if (expand) { + if (!expanded) + props.expandedNotifs.push(modelData); + } else if (expanded) { + props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + } + } + + Component.onDestruction: { + if (notifCount === 0 && expanded) + props.expandedNotifs.splice(props.expandedNotifs.indexOf(modelData), 1); + } + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: content.implicitHeight + 10 * 2 + + clip: true + radius: 8 + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainer, 2) + + RowLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 10 + + spacing: 10 + + Item { + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + implicitWidth: Config.notifs.sizes.image + implicitHeight: Config.notifs.sizes.image + + Component { + id: imageComp + + Image { + source: Qt.resolvedUrl(root.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + width: Config.notifs.sizes.image + height: Config.notifs.sizes.image + } + } + + Component { + id: appIconComp + + CustomIcon { + implicitSize: Math.round(Config.notifs.sizes.image * 0.6) + source: Quickshell.iconPath(root.appIcon) + layer.enabled: root.appIcon.endsWith("symbolic") + } + } + + Component { + id: materialIconComp + + MaterialIcon { + text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency) + color: root.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : root.urgency === NotificationUrgency.Low ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer + font.pointSize: 18 + } + } + + CustomClippingRect { + anchors.fill: parent + color: root.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3error : root.urgency === NotificationUrgency.Low ? DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 3) : DynamicColors.palette.m3secondaryContainer + radius: 1000 + + Loader { + anchors.centerIn: parent + sourceComponent: root.image ? imageComp : root.appIcon ? appIconComp : materialIconComp + } + } + + Loader { + anchors.right: parent.right + anchors.bottom: parent.bottom + active: root.appIcon && root.image + + sourceComponent: CustomRect { + implicitWidth: Config.notifs.sizes.badge + implicitHeight: Config.notifs.sizes.badge + + color: root.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3error : root.urgency === NotificationUrgency.Low ? DynamicColors.palette.m3surfaceContainerHigh : DynamicColors.palette.m3secondaryContainer + radius: 1000 + + CustomIcon { + anchors.centerIn: parent + implicitSize: Math.round(Config.notifs.sizes.badge * 0.6) + source: Quickshell.iconPath(root.appIcon) + layer.enabled: root.appIcon.endsWith("symbolic") + } + } + } + } + + ColumnLayout { + id: column + + Layout.topMargin: -10 + Layout.bottomMargin: -10 / 2 + Layout.fillWidth: true + spacing: 0 + + RowLayout { + id: header + + Layout.bottomMargin: root.expanded ? Math.round(7 / 2) : 0 + Layout.fillWidth: true + spacing: 5 + + CustomText { + Layout.fillWidth: true + text: root.modelData + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: 11 + elide: Text.ElideRight + } + + CustomText { + animate: true + text: root.notifs.find(n => !n.closed)?.timeStr ?? "" + color: DynamicColors.palette.m3outline + font.pointSize: 11 + } + + CustomRect { + implicitWidth: expandBtn.implicitWidth + 7 * 2 + implicitHeight: groupCount.implicitHeight + 10 + + color: root.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3error : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 3) + radius: 1000 + + StateLayer { + color: root.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onSurface + + function onClicked(): void { + root.toggleExpand(!root.expanded); + } + } + + RowLayout { + id: expandBtn + + anchors.centerIn: parent + spacing: 7 / 2 + + CustomText { + id: groupCount + + Layout.leftMargin: 10 / 2 + animate: true + text: root.notifCount + color: root.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onSurface + font.pointSize: 11 + } + + MaterialIcon { + Layout.rightMargin: -10 / 2 + text: "expand_more" + color: root.urgency === NotificationUrgency.Critical ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onSurface + rotation: root.expanded ? 180 : 0 + Layout.topMargin: root.expanded ? -Math.floor(7 / 2) : 0 + + Behavior on rotation { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Behavior on Layout.topMargin { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + } + } + } + + Behavior on Layout.bottomMargin { + Anim {} + } + } + + NotifGroupList { + id: notifList + + props: root.props + notifs: root.notifs + expanded: root.expanded + container: root.container + visibilities: root.visibilities + onRequestToggleExpand: expand => root.toggleExpand(expand) + } + } + } +} diff --git a/Modules/Notifications/Sidebar/NotifGroupList.qml b/Modules/Notifications/Sidebar/NotifGroupList.qml new file mode 100644 index 0000000..382f028 --- /dev/null +++ b/Modules/Notifications/Sidebar/NotifGroupList.qml @@ -0,0 +1,214 @@ +pragma ComponentBehavior: Bound + +import qs.Components +import qs.Config +import qs.Modules +import qs.Daemons +import Quickshell +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property Props props + required property list notifs + required property bool expanded + required property Flickable container + required property var visibilities + + readonly property real nonAnimHeight: { + let h = -root.spacing; + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i); + if (!item.modelData.closed && !item.previewHidden) + h += item.nonAnimHeight + root.spacing; + } + return h; + } + + readonly property int spacing: Math.round(7 / 2) + property bool showAllNotifs + property bool flag + + signal requestToggleExpand(expand: bool) + + onExpandedChanged: { + if (expanded) { + clearTimer.stop(); + showAllNotifs = true; + } else { + clearTimer.start(); + } + } + + Layout.fillWidth: true + implicitHeight: nonAnimHeight + + Timer { + id: clearTimer + + interval: MaterialEasing.standardTime + onTriggered: root.showAllNotifs = false + } + + Repeater { + id: repeater + + model: ScriptModel { + values: root.showAllNotifs ? root.notifs : root.notifs.slice(0, Config.notifs.groupPreviewNum + 1) + onValuesChanged: root.flagChanged() + } + + MouseArea { + id: notif + + required property int index + required property NotifServer.Notif modelData + + readonly property alias nonAnimHeight: notifInner.nonAnimHeight + readonly property bool previewHidden: { + if (root.expanded) + return false; + + let extraHidden = 0; + for (let i = 0; i < index; i++) + if (root.notifs[i].closed) + extraHidden++; + + return index >= Config.notifs.groupPreviewNum + extraHidden; + } + property int startY + + y: { + root.flag; // Force update + let y = 0; + for (let i = 0; i < index; i++) { + const item = repeater.itemAt(i); + if (!item.modelData.closed && !item.previewHidden) + y += item.nonAnimHeight + root.spacing; + } + return y; + } + + containmentMask: QtObject { + function contains(p: point): bool { + if (!root.container.contains(notif.mapToItem(root.container, p))) + return false; + return notifInner.contains(p); + } + } + + opacity: previewHidden ? 0 : 1 + scale: previewHidden ? 0.7 : 1 + + implicitWidth: root.width + implicitHeight: notifInner.implicitHeight + + hoverEnabled: true + cursorShape: notifInner.body?.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + preventStealing: !root.expanded + enabled: !modelData.closed + + drag.target: this + drag.axis: Drag.XAxis + + onPressed: event => { + startY = event.y; + if (event.button === Qt.RightButton) + root.requestToggleExpand(!root.expanded); + else if (event.button === Qt.MiddleButton) + modelData.close(); + } + onPositionChanged: event => { + if (pressed && !root.expanded) { + const diffY = event.y - startY; + if (Math.abs(diffY) > Config.notifs.expandThreshold) + root.requestToggleExpand(diffY > 0); + } + } + onReleased: event => { + if (Math.abs(x) < width * Config.notifs.clearThreshold) + x = 0; + else + modelData.close(); + } + + Component.onCompleted: modelData.lock(this) + Component.onDestruction: modelData.unlock(this) + + ParallelAnimation { + Component.onCompleted: running = !notif.previewHidden + + Anim { + target: notif + property: "opacity" + from: 0 + to: 1 + } + Anim { + target: notif + property: "scale" + from: 0.7 + to: 1 + } + } + + ParallelAnimation { + running: notif.modelData.closed + onFinished: notif.modelData.unlock(notif) + + Anim { + target: notif + property: "opacity" + to: 0 + } + Anim { + target: notif + property: "x" + to: notif.x >= 0 ? notif.width : -notif.width + } + } + + Notif { + id: notifInner + + anchors.fill: parent + modelData: notif.modelData + props: root.props + expanded: root.expanded + visibilities: root.visibilities + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on x { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Behavior on y { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + } + } + + Behavior on implicitHeight { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } +} diff --git a/Modules/Notifications/Sidebar/Props.qml b/Modules/Notifications/Sidebar/Props.qml new file mode 100644 index 0000000..4613942 --- /dev/null +++ b/Modules/Notifications/Sidebar/Props.qml @@ -0,0 +1,7 @@ +import Quickshell + +PersistentProperties { + property list expandedNotifs: [] + + reloadableId: "sidebar" +} diff --git a/Modules/Notifications/Sidebar/Utils/Background.qml b/Modules/Notifications/Sidebar/Utils/Background.qml new file mode 100644 index 0000000..a081c24 --- /dev/null +++ b/Modules/Notifications/Sidebar/Utils/Background.qml @@ -0,0 +1,55 @@ +import qs.Components +import qs.Config +import qs.Modules as Modules +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + required property Wrapper wrapper + required property var sidebar + readonly property real rounding: 8 + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + + strokeWidth: -1 + fillColor: DynamicColors.palette.m3surface + + PathLine { + relativeX: -(root.wrapper.width + root.rounding) + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: -root.roundingY + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: 0 + relativeY: -(root.wrapper.height - root.roundingY * 2) + } + PathArc { + relativeX: root.sidebar.utilsRoundingX + relativeY: -root.roundingY + radiusX: root.sidebar.utilsRoundingX + radiusY: Math.min(root.rounding, root.wrapper.height) + } + PathLine { + relativeX: root.wrapper.height > 0 ? root.wrapper.width - root.rounding - root.sidebar.utilsRoundingX : root.wrapper.width + relativeY: 0 + } + PathArc { + relativeX: root.rounding + relativeY: -root.rounding + radiusX: root.rounding + radiusY: root.rounding + direction: PathArc.Counterclockwise + } + + Behavior on fillColor { + Modules.CAnim {} + } +} diff --git a/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml new file mode 100644 index 0000000..221fbbf --- /dev/null +++ b/Modules/Notifications/Sidebar/Utils/Cards/Toggles.qml @@ -0,0 +1,57 @@ +import qs.Components +import qs.Config +import qs.Modules +import qs.Daemons +import Quickshell +import Quickshell.Bluetooth +import QtQuick +import QtQuick.Layouts + +CustomRect { + id: root + + required property var visibilities + required property Item popouts + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + 18 * 2 + + radius: 8 + color: DynamicColors.tPalette.m3surfaceContainer + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: 18 + spacing: 10 + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 7 + + Toggle { + icon: "notifications_off" + checked: NotifServer.dnd + onClicked: NotifServer.dnd = !NotifServer.dnd + } + } + } + + component Toggle: IconButton { + Layout.fillWidth: true + Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? 18 : internalChecked ? 7 : 0) + radius: stateLayer.pressed ? 6 / 2 : internalChecked ? 6 : 8 + inactiveColour: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) + toggle: true + radiusAnim.duration: MaterialEasing.expressiveEffectsTime + radiusAnim.easing.bezierCurve: MaterialEasing.expressiveEffects + + Behavior on Layout.preferredWidth { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + } +} diff --git a/Modules/Notifications/Sidebar/Utils/Content.qml b/Modules/Notifications/Sidebar/Utils/Content.qml new file mode 100644 index 0000000..6508f47 --- /dev/null +++ b/Modules/Notifications/Sidebar/Utils/Content.qml @@ -0,0 +1,29 @@ +import qs.Modules.Notifications.Sidebar.Utils.Cards +import qs.Config +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + required property var props + required property var visibilities + required property Item popouts + + implicitWidth: layout.implicitWidth + implicitHeight: layout.implicitHeight + + ColumnLayout { + id: layout + + anchors.fill: parent + spacing: 8 + + IdleInhibit {} + + Toggles { + visibilities: root.visibilities + popouts: root.popouts + } + } +} diff --git a/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml b/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml new file mode 100644 index 0000000..1bd2a18 --- /dev/null +++ b/Modules/Notifications/Sidebar/Utils/IdleInhibit.qml @@ -0,0 +1,125 @@ +import qs.Components +import qs.Config +import qs.Modules as Modules +import qs.Helpers +import QtQuick +import QtQuick.Layouts + +CustomRect { + id: root + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + (IdleInhibitor.enabled ? activeChip.implicitHeight + activeChip.anchors.topMargin : 0) + 18 * 2 + + radius: 8 + color: DynamicColors.tPalette.m3surfaceContainer + clip: true + + RowLayout { + id: layout + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 18 + spacing: 10 + + CustomRect { + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + 7 * 2 + + radius: 1000 + color: IdleInhibitor.enabled ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3secondaryContainer + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: "coffee" + color: IdleInhibitor.enabled ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3onSecondaryContainer + font.pointSize: 18 + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + Layout.fillWidth: true + text: qsTr("Keep Awake") + font.pointSize: 13 + elide: Text.ElideRight + } + + CustomText { + Layout.fillWidth: true + text: IdleInhibitor.enabled ? qsTr("Preventing sleep mode") : qsTr("Normal power management") + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: 11 + elide: Text.ElideRight + } + } + + CustomSwitch { + checked: IdleInhibitor.enabled + onToggled: IdleInhibitor.enabled = checked + } + } + + Loader { + id: activeChip + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.topMargin: 20 + anchors.bottomMargin: IdleInhibitor.enabled ? 18 : -implicitHeight + anchors.leftMargin: 18 + + opacity: IdleInhibitor.enabled ? 1 : 0 + scale: IdleInhibitor.enabled ? 1 : 0.5 + + Component.onCompleted: active = Qt.binding(() => opacity > 0) + + sourceComponent: CustomRect { + implicitWidth: activeText.implicitWidth + 10 * 2 + implicitHeight: activeText.implicitHeight + 10 * 2 + + radius: 1000 + color: DynamicColors.palette.m3primary + + CustomText { + id: activeText + + anchors.centerIn: parent + text: qsTr("Active since %1").arg(Qt.formatTime(IdleInhibitor.enabledSince, Config.services.useTwelveHourClock ? "hh:mm a" : "hh:mm")) + color: DynamicColors.palette.m3onPrimary + font.pointSize: Math.round(11 * 0.9) + } + } + + Behavior on anchors.bottomMargin { + Modules.Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Behavior on opacity { + Modules.Anim { + duration: MaterialEasing.expressiveEffectsTime + } + } + + Behavior on scale { + Modules.Anim {} + } + } + + Behavior on implicitHeight { + Modules.Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } +} diff --git a/Modules/Notifications/Sidebar/Utils/Wrapper.qml b/Modules/Notifications/Sidebar/Utils/Wrapper.qml new file mode 100644 index 0000000..3557d37 --- /dev/null +++ b/Modules/Notifications/Sidebar/Utils/Wrapper.qml @@ -0,0 +1,97 @@ +pragma ComponentBehavior: Bound + +import qs.Components +import qs.Config +import qs.Modules as Modules +import Quickshell +import QtQuick + +Item { + id: root + + required property var visibilities + required property Item sidebar + required property Item popouts + + readonly property PersistentProperties props: PersistentProperties { + property bool recordingListExpanded: false + property string recordingConfirmDelete + property string recordingMode + + reloadableId: "utilities" + } + readonly property bool shouldBeActive: visibilities.sidebar + + visible: height > 0 + implicitHeight: 0 + implicitWidth: sidebar.visible ? sidebar.width : Config.utilities.sizes.width + + onStateChanged: { + if (state === "visible" && timer.running) { + timer.triggered(); + timer.stop(); + } + } + + states: State { + name: "visible" + when: root.shouldBeActive + + PropertyChanges { + root.implicitHeight: content.implicitHeight + 18 * 2 + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Modules.Anim { + target: root + property: "implicitHeight" + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + }, + Transition { + from: "visible" + to: "" + + Modules.Anim { + target: root + property: "implicitHeight" + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + ] + + Timer { + id: timer + + running: true + interval: 1000 + onTriggered: { + content.active = Qt.binding(() => root.shouldBeActive || root.visible); + content.visible = true; + } + } + + Loader { + id: content + + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: 18 + + visible: false + active: true + + sourceComponent: Content { + implicitWidth: root.implicitWidth - 18 * 2 + props: root.props + visibilities: root.visibilities + popouts: root.popouts + } + } +} diff --git a/Modules/Notifications/Sidebar/Wrapper.qml b/Modules/Notifications/Sidebar/Wrapper.qml new file mode 100644 index 0000000..4fb280b --- /dev/null +++ b/Modules/Notifications/Sidebar/Wrapper.qml @@ -0,0 +1,69 @@ +pragma ComponentBehavior: Bound + +import qs.Components +import qs.Config +import qs.Modules as Modules +import QtQuick + +Item { + id: root + + required property var visibilities + required property var panels + readonly property Props props: Props {} + + visible: width > 0 + implicitWidth: 0 + + states: State { + name: "visible" + when: root.visibilities.sidebar + + PropertyChanges { + root.implicitWidth: Config.sidebar.sizes.width + } + } + + transitions: [ + Transition { + from: "" + to: "visible" + + Modules.Anim { + target: root + property: "implicitWidth" + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + }, + Transition { + from: "visible" + to: "" + + Modules.Anim { + target: root + property: "implicitWidth" + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + ] + + Loader { + id: content + + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: 8 + anchors.bottomMargin: 0 + + active: true + Component.onCompleted: active = Qt.binding(() => (root.visibilities.sidebar && Config.sidebar.enabled) || root.visible) + + sourceComponent: Content { + implicitWidth: Config.sidebar.sizes.width - 8 * 2 + props: root.props + visibilities: root.visibilities + } + } +} diff --git a/Modules/Notifications/Wrapper.qml b/Modules/Notifications/Wrapper.qml new file mode 100644 index 0000000..46e1763 --- /dev/null +++ b/Modules/Notifications/Wrapper.qml @@ -0,0 +1,40 @@ +import QtQuick +import qs.Components +import qs.Config +import qs.Modules as Modules + +Item { + id: root + + required property var visibilities + required property Item panels + + visible: height > 0 + implicitWidth: Math.max(panels.sidebar.width, content.implicitWidth) + implicitHeight: content.implicitHeight + + states: State { + name: "hidden" + when: root.visibilities.sidebar + + PropertyChanges { + root.implicitHeight: 0 + } + } + + transitions: Transition { + Modules.Anim { + target: root + property: "implicitHeight" + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Content { + id: content + + visibilities: root.visibilities + panels: root.panels + } +} diff --git a/Modules/Polkit/Polkit.qml b/Modules/Polkit/Polkit.qml index ea49199..c807435 100644 --- a/Modules/Polkit/Polkit.qml +++ b/Modules/Polkit/Polkit.qml @@ -89,15 +89,31 @@ Scope { id: contentRow spacing: 24 - IconImage { - source: Quickshell.iconPath(polkitAgent.flow?.iconName, true) ?? "" - implicitSize: 64 - mipmap: true - - Layout.preferredWidth: implicitSize - Layout.preferredHeight: implicitSize + Item { + Layout.preferredWidth: icon.implicitSize + Layout.preferredHeight: icon.implicitSize Layout.leftMargin: 16 Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + IconImage { + id: icon + + anchors.fill: parent + visible: `${source}`.includes("://") + + source: Quickshell.iconPath(polkitAgent.flow?.iconName, true) ?? "" + implicitSize: 64 + mipmap: true + } + + MaterialIcon { + visible: !icon.visible + + text: "security" + anchors.fill: parent + font.pointSize: 64 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } } ColumnLayout { @@ -277,6 +293,7 @@ Scope { Layout.alignment: Qt.AlignRight onClicked: { root.shouldShow = false + console.log(icon.source, icon.visible) polkitAgent.flow.cancelAuthenticationRequest() passInput.text = "" } diff --git a/Modules/TrackedNotification.qml b/Modules/TrackedNotification.qml index 689cf1a..6bf095c 100644 --- a/Modules/TrackedNotification.qml +++ b/Modules/TrackedNotification.qml @@ -4,6 +4,7 @@ import Quickshell.Wayland import Quickshell.Hyprland import QtQuick.Layouts import QtQuick +import qs.Components import qs.Config import qs.Daemons import qs.Helpers @@ -122,7 +123,7 @@ PanelWindow { radius: backgroundRect.radius } - Rectangle { + CustomClippingRect { id: backgroundRect implicitWidth: 400 implicitHeight: contentLayout.childrenRect.height + 16 @@ -131,6 +132,19 @@ PanelWindow { border.color: "#555555" radius: 8 + CustomRect { + anchors.bottom: parent.bottom + anchors.right: parent.right + color: DynamicColors.palette.m3primary + + implicitHeight: 4 + implicitWidth: ( rootItem.modelData.timer.remainingTime / rootItem.modelData.timer.totalTime ) * parent.width + + Behavior on implicitWidth { + Anim {} + } + } + Component.onCompleted: { root.notifRegions.push( notifRegion.createObject(root, { item: backgroundRect })); } @@ -228,18 +242,6 @@ PanelWindow { ElapsedTimer { id: timer } - - } - MouseArea { - z: 1 - anchors.fill: parent - hoverEnabled: true - onEntered: { - rootItem.modelData.timer.stop(); - } - onExited: { - rootItem.modelData.timer.start(); - } } } } diff --git a/Modules/TrayItem.qml b/Modules/TrayItem.qml index b4bb9dd..3e5831b 100644 --- a/Modules/TrayItem.qml +++ b/Modules/TrayItem.qml @@ -28,9 +28,13 @@ Item { if ( mouse.button === Qt.LeftButton ) { root.item.activate(); } else if ( mouse.button === Qt.RightButton ) { - root.popouts.currentName = `traymenu${ root.ind }`; - root.popouts.currentCenter = Qt.binding( () => root.mapToItem( root.loader, root.implicitWidth / 2, 0 ).x ); - root.popouts.hasCurrent = true; + if ( visibilities.sidebar ) { + return; + } else { + root.popouts.currentName = `traymenu${ root.ind }`; + root.popouts.currentCenter = Qt.binding( () => root.mapToItem( root.loader, root.implicitWidth / 2, 0 ).x ); + root.popouts.hasCurrent = true; + } } } } diff --git a/assets/shaders/opacitymask.frag b/assets/shaders/opacitymask.frag new file mode 100644 index 0000000..7d93b06 --- /dev/null +++ b/assets/shaders/opacitymask.frag @@ -0,0 +1,19 @@ +#version 440 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf { + // qt_Matrix and qt_Opacity must always be both present + // if the built-in vertex shader is used. + mat4 qt_Matrix; + float qt_Opacity; +}; + +layout(binding = 1) uniform sampler2D source; +layout(binding = 2) uniform sampler2D maskSource; + +void main() { + fragColor = texture(source, qt_TexCoord0.st) * + (texture(maskSource, qt_TexCoord0.st).a) * qt_Opacity; +} diff --git a/shell.qml b/shell.qml index 4ade83e..691af97 100644 --- a/shell.qml +++ b/shell.qml @@ -3,7 +3,7 @@ //@ pragma Env QS_NO_RELOAD_POPUP=1 import Quickshell import qs.Modules -import qs.Modules.Lock +import qs.Modules.Lock as Lock import qs.Helpers import qs.Modules.Polkit @@ -12,17 +12,17 @@ Scope { Wallpaper {} Launcher {} AreaPicker {} - Lock { + Lock.Lock { id: lock } - IdleInhibitor { + Lock.IdleInhibitor { lock: lock } - NotificationCenter { - - } + // NotificationCenter { + // + // } Polkit {} }