From d7d4d86da2952e44642c65502aaaefaa69c6d948 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 25 Jun 2025 20:52:15 +1000 Subject: lock: add notifs and status --- config/LockConfig.qml | 2 + modules/lock/Backgrounds.qml | 70 ++++++++++++ modules/lock/LockSurface.qml | 15 +++ modules/lock/Notification.qml | 220 +++++++++++++++++++++++++++++++++++++ modules/lock/Status.qml | 249 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 556 insertions(+) create mode 100644 modules/lock/Notification.qml create mode 100644 modules/lock/Status.qml diff --git a/config/LockConfig.qml b/config/LockConfig.qml index dcb7d70..5745dee 100644 --- a/config/LockConfig.qml +++ b/config/LockConfig.qml @@ -1,6 +1,8 @@ import Quickshell.Io JsonObject { + property int maxNotifs: 5 + property JsonObject sizes: JsonObject { property int border: 100 property int clockWidth: 800 diff --git a/modules/lock/Backgrounds.qml b/modules/lock/Backgrounds.qml index e561c66..b02d2a3 100644 --- a/modules/lock/Backgrounds.qml +++ b/modules/lock/Backgrounds.qml @@ -12,6 +12,8 @@ Item { required property real weatherWidth required property real buttonsWidth required property real buttonsHeight + required property real statusWidth + required property real statusHeight required property bool isNormal required property bool isLarge @@ -21,6 +23,8 @@ Item { readonly property real weatherRight: innerMask.anchors.margins + weatherPath.width readonly property real buttonsTop: innerMask.anchors.margins + buttonsPath.height readonly property real buttonsLeft: innerMask.anchors.margins + buttonsPath.width + readonly property real statusBottom: innerMask.anchors.margins + statusPath.height + readonly property real statusLeft: innerMask.anchors.margins + statusPath.width readonly property real mediaX: innerMask.anchors.margins + mediaPath.width readonly property real mediaY: innerMask.anchors.margins + mediaPath.height @@ -400,6 +404,72 @@ Item { } } } + + ShapePath { + id: statusPath + + property int width: root.locked ? root.statusWidth - Config.lock.sizes.border / 4 : 0 + property real height: root.locked ? root.statusHeight - Config.lock.sizes.border / 4 : 0 + + readonly property real rounding: Appearance.rounding.large * 2 + readonly property real roundingX: width < rounding * 2 ? width / 2 : rounding + readonly property real roundingY: height < rounding * 2 ? height / 2 : rounding + + strokeWidth: -1 + fillColor: root.isLarge ? Config.border.colour : "transparent" + + startX: Math.ceil(innerMask.width) + startY: height + roundingY + + PathArc { + relativeX: -statusPath.roundingX + relativeY: -statusPath.roundingY + radiusX: Math.min(statusPath.rounding, statusPath.width) + radiusY: Math.min(statusPath.rounding, statusPath.height) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: -(statusPath.width - statusPath.roundingX * 2) + relativeY: 0 + } + PathArc { + relativeX: -statusPath.roundingX + relativeY: -statusPath.roundingY + radiusX: Math.min(statusPath.rounding, statusPath.width) + radiusY: Math.min(statusPath.rounding, statusPath.height) + } + PathLine { + relativeX: 0 + relativeY: -(statusPath.height - statusPath.roundingY * 2) + } + PathArc { + relativeX: -statusPath.roundingX + relativeY: -statusPath.roundingY + radiusX: Math.min(statusPath.rounding, statusPath.width) + radiusY: Math.min(statusPath.rounding, statusPath.height) + direction: PathArc.Counterclockwise + } + PathLine { + relativeX: statusPath.width + statusPath.roundingX + relativeY: 0 + } + + Behavior on width { + Anim {} + } + + Behavior on height { + Anim {} + } + + Behavior on fillColor { + ColorAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard + } + } + } } component Anim: NumberAnimation { diff --git a/modules/lock/LockSurface.qml b/modules/lock/LockSurface.qml index 047116f..83fb89f 100644 --- a/modules/lock/LockSurface.qml +++ b/modules/lock/LockSurface.qml @@ -75,6 +75,8 @@ WlSessionLockSurface { weatherWidth: weather.implicitWidth buttonsWidth: buttons.item?.nonAnimWidth ?? 0 buttonsHeight: buttons.item?.nonAnimHeight ?? 0 + statusWidth: status.item?.nonAnimWidth ?? 0 + statusHeight: status.item?.nonAnimHeight ?? 0 isNormal: root.screen.width > Config.lock.sizes.smallScreenWidth isLarge: root.screen.width > Config.lock.sizes.largeScreenWidth visible: false @@ -168,6 +170,19 @@ WlSessionLockSurface { sourceComponent: Buttons {} } + Loader { + id: status + + active: root.screen.width > Config.lock.sizes.largeScreenWidth + + anchors.bottom: parent.top + anchors.left: parent.right + anchors.bottomMargin: -backgrounds.statusBottom + anchors.leftMargin: -backgrounds.statusLeft + + sourceComponent: Status {} + } + component Anim: NumberAnimation { duration: Appearance.anim.durations.large easing.type: Easing.BezierSpline diff --git a/modules/lock/Notification.qml b/modules/lock/Notification.qml new file mode 100644 index 0000000..1d3e1c7 --- /dev/null +++ b/modules/lock/Notification.qml @@ -0,0 +1,220 @@ +pragma ComponentBehavior: Bound + +import "root:/widgets" +import "root:/services" +import "root:/config" +import "root:/utils" +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property Notifs.Notif modelData + readonly property bool hasImage: modelData.image.length > 0 + readonly property bool hasAppIcon: modelData.appIcon.length > 0 + readonly property int nonAnimHeight: Math.max(image.height, details.implicitHeight) + Appearance.padding.normal * 2 + + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondaryContainer : Colours.palette.m3surfaceContainer + radius: Appearance.rounding.normal + implicitWidth: Config.notifs.sizes.width + + Component.onCompleted: implicitHeight = Qt.binding(() => nonAnimHeight) + + Behavior on implicitHeight { + NumberAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard + } + } + + Behavior on x { + NumberAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.emphasizedDecel + } + } + + RetainableLock { + object: root.modelData.notification + locked: true + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: pressed ? Qt.ClosedHandCursor : undefined + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + preventStealing: true + + onEntered: root.modelData.timer.stop() + onExited: root.modelData.timer.start() + + drag.target: parent + drag.axis: Drag.XAxis + + onPressed: event => { + if (event.button === Qt.MiddleButton) + root.modelData.notification.dismiss(); + } + onReleased: event => { + if (Math.abs(root.x) < Config.notifs.sizes.width * Config.notifs.clearThreshold) + root.x = 0; + else + root.modelData.notification.dismiss(); // TODO: change back to popup when notif dock impled + } + } + + Loader { + id: image + + active: root.hasImage + asynchronous: true + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Appearance.padding.normal + + width: Config.notifs.sizes.image + height: Config.notifs.sizes.image + visible: root.hasImage || root.hasAppIcon + + sourceComponent: ClippingRectangle { + radius: Appearance.rounding.full + 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 + asynchronous: true + + 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: StyledRect { + radius: Appearance.rounding.full + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3surfaceContainerHighest : Colours.palette.m3tertiaryContainer + 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 + asynchronous: true + + anchors.centerIn: parent + visible: !root.modelData.appIcon.endsWith("symbolic") + + width: Math.round(parent.width * 0.6) + height: Math.round(parent.width * 0.6) + + sourceComponent: IconImage { + implicitSize: Math.round(parent.width * 0.6) + source: Quickshell.iconPath(root.modelData.appIcon) + asynchronous: true + } + } + + Loader { + active: root.modelData.appIcon.endsWith("symbolic") + asynchronous: true + anchors.fill: icon + + sourceComponent: Colouriser { + source: icon + colorizationColor: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onTertiaryContainer + } + } + + Loader { + active: !root.hasAppIcon + asynchronous: true + anchors.centerIn: parent + anchors.horizontalCenterOffset: -Appearance.font.size.large * 0.02 + anchors.verticalCenterOffset: Appearance.font.size.large * 0.02 + + sourceComponent: MaterialIcon { + text: Icons.getNotifIcon(root.modelData.summary.toLowerCase(), root.modelData.urgency) + + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onTertiaryContainer + font.pointSize: Appearance.font.size.large + font.variableAxes: ({ + opsz: Appearance.font.size.large + }) + } + } + } + } + + ColumnLayout { + id: details + + anchors.verticalCenter: parent.verticalCenter + anchors.left: image.right + anchors.right: parent.right + anchors.leftMargin: Appearance.spacing.smaller + anchors.rightMargin: Appearance.padding.larger + + spacing: 0 + + RowLayout { + Layout.fillWidth: true + + spacing: Appearance.spacing.small + + StyledText { + Layout.fillWidth: true + Layout.maximumWidth: implicitWidth + + animate: true + text: root.modelData.summary + elide: Text.ElideRight + maximumLineCount: 1 + } + + StyledText { + text: "•" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + + StyledText { + animate: true + text: root.modelData.timeStr + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + } + } + + StyledText { + Layout.fillWidth: true + + animate: true + text: root.modelData.body + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + maximumLineCount: 1 + } + } +} diff --git a/modules/lock/Status.qml b/modules/lock/Status.qml new file mode 100644 index 0000000..e11fa25 --- /dev/null +++ b/modules/lock/Status.qml @@ -0,0 +1,249 @@ +import "root:/widgets" +import "root:/services" +import "root:/config" +import "root:/utils" +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.UPower +import QtQuick +import QtQuick.Layouts + +WrapperItem { + readonly property real nonAnimWidth: (notifs.count > 0 ? Config.notifs.sizes.width : status.implicitWidth) + margin + readonly property real nonAnimHeight: { + if (notifs.count > 0) { + const count = Math.min(notifs.count, Config.lock.maxNotifs); + let height = status.implicitHeight + Appearance.spacing.normal + Appearance.spacing.smaller * (count - 1); + for (let i = 0; i < count; i++) + height += notifs.itemAtIndex(i)?.nonAnimHeight ?? 0; + return height + margin; + } + + return status.implicitHeight + margin; + } + + implicitWidth: nonAnimWidth + implicitHeight: nonAnimHeight + + margin: Appearance.padding.large * 2 + rightMargin: 0 + topMargin: 0 + + Behavior on implicitWidth { + Anim { + duration: Appearance.anim.durations.large + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.large + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + + ColumnLayout { + spacing: Appearance.spacing.normal + + RowLayout { + id: status + + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Loader { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + + active: UPower.displayDevice.isLaptopBattery + asynchronous: true + + sourceComponent: StyledText { + animate: true + text: qsTr("%1%2 remaining").arg(UPower.onBattery ? "" : "(+) ").arg(UPower.displayDevice.percentage) + color: !UPower.onBattery || UPower.displayDevice.percentage > 0.2 ? Colours.palette.m3onSurface : Colours.palette.m3error + } + } + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + + animate: true + text: Network.active ? Icons.getNetworkIcon(Network.active.strength ?? 0) : "wifi_off" + font.pointSize: Appearance.font.size.large + } + + Loader { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + Layout.maximumWidth: item?.implicitWidth ?? 0 + + active: !UPower.displayDevice.isLaptopBattery + asynchronous: true + + sourceComponent: StyledText { + animate: true + text: Network.active?.ssid ?? "" + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + } + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + + animate: true + text: Bluetooth.powered ? "bluetooth" : "bluetooth_disabled" + font.pointSize: Appearance.font.size.large + } + + Loader { + Layout.alignment: Qt.AlignVCenter + active: !UPower.displayDevice.isLaptopBattery + asynchronous: true + + sourceComponent: StyledText { + animate: true + text: qsTr("%n device(s) connected", "", Bluetooth.devices.filter(d => d.connected).length) + font.pointSize: Appearance.font.size.normal + } + } + } + + ListView { + id: notifs + + Layout.fillWidth: true + Layout.fillHeight: true + + model: ScriptModel { + values: [...Notifs.popups].reverse() + } + + orientation: Qt.Vertical + spacing: 0 + clip: true + interactive: false + + delegate: Item { + id: wrapper + + required property Notifs.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.nonAnimHeight + (idx === 0 ? 0 : Appearance.spacing.smaller) + + 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: Appearance.anim.durations.normal + easing.bezierCurve: Appearance.anim.curves.emphasized + } + PropertyAction { + target: wrapper + property: "ListView.delayRemove" + value: false + } + } + + ClippingRectangle { + anchors.top: parent.top + anchors.topMargin: wrapper.idx === 0 ? 0 : Appearance.spacing.smaller + + color: "transparent" + radius: notif.radius + implicitWidth: notif.implicitWidth + implicitHeight: notif.nonAnimHeight + + Notification { + id: notif + + modelData: wrapper.modelData + } + } + } + + move: Transition { + Anim { + property: "y" + } + } + + displaced: Transition { + Anim { + property: "y" + } + } + + StyledRect { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: Appearance.padding.normal + + color: Colours.palette.m3tertiaryContainer + radius: Appearance.rounding.small + + implicitWidth: count.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: count.implicitHeight + Appearance.padding.small * 2 + + scale: Notifs.popups.length > Config.lock.maxNotifs ? 1 : 0 + + StyledText { + id: count + + anchors.centerIn: parent + text: qsTr("+%1").arg(Notifs.popups.length - Config.lock.maxNotifs) + color: Colours.palette.m3onTertiaryContainer + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + } + } + + component Anim: NumberAnimation { + duration: Appearance.anim.durations.normal + easing.type: Easing.BezierSpline + easing.bezierCurve: Appearance.anim.curves.standard + } +} -- cgit v1.2.3-freya