From 7d9ba3d570e03f0ad3aa529f33988ec811eccbb2 Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Tue, 17 Feb 2026 00:28:57 +0100 Subject: [PATCH] lockscreen? --- Components/CircularIndicator.qml | 108 ++++ Components/CircularProgress.qml | 69 +++ Config/LockConf.qml | 12 +- Config/Services.qml | 2 +- Modules/AudioWidget.qml | 2 +- Modules/Clock.qml | 2 +- Modules/Lock/Center.qml | 415 ++++++++++++++ Modules/Lock/Content.qml | 82 +++ Modules/Lock/Fetch.qml | 164 ++++++ Modules/Lock/InputField.qml | 150 +++++ Modules/Lock/Lock.qml | 13 - Modules/Lock/LockSurface.qml | 523 ++++++------------ Modules/Lock/Media.qml | 205 +++++++ Modules/Lock/NotifDock.qml | 145 +++++ Modules/Lock/NotifGroup.qml | 316 +++++++++++ Modules/Lock/Pam.qml | 242 +++++--- Modules/Lock/Resources.qml | 80 +++ Modules/Lock/WeatherInfo.qml | 175 ++++++ Modules/Osd/Background.qml | 2 +- Modules/Time.qml | 23 +- Plugins/ZShell/Internal/CMakeLists.txt | 3 + .../Internal/circularindicatormanager.cpp | 211 +++++++ .../Internal/circularindicatormanager.hpp | 72 +++ 23 files changed, 2574 insertions(+), 442 deletions(-) create mode 100644 Components/CircularIndicator.qml create mode 100644 Components/CircularProgress.qml create mode 100644 Modules/Lock/Center.qml create mode 100644 Modules/Lock/Content.qml create mode 100644 Modules/Lock/Fetch.qml create mode 100644 Modules/Lock/InputField.qml create mode 100644 Modules/Lock/Media.qml create mode 100644 Modules/Lock/NotifDock.qml create mode 100644 Modules/Lock/NotifGroup.qml create mode 100644 Modules/Lock/Resources.qml create mode 100644 Modules/Lock/WeatherInfo.qml create mode 100644 Plugins/ZShell/Internal/circularindicatormanager.cpp create mode 100644 Plugins/ZShell/Internal/circularindicatormanager.hpp diff --git a/Components/CircularIndicator.qml b/Components/CircularIndicator.qml new file mode 100644 index 0000000..c398d48 --- /dev/null +++ b/Components/CircularIndicator.qml @@ -0,0 +1,108 @@ +import qs.Helpers +import qs.Config +import qs.Modules +import ZShell.Internal +import QtQuick +import QtQuick.Templates + +BusyIndicator { + id: root + + enum AnimType { + Advance = 0, + Retreat + } + + enum AnimState { + Stopped, + Running, + Completing + } + + property real implicitSize: Appearance.font.size.normal * 3 + property real strokeWidth: Appearance.padding.small * 0.8 + property color fgColour: DynamicColors.palette.m3primary + property color bgColour: DynamicColors.palette.m3secondaryContainer + + property alias type: manager.indeterminateAnimationType + readonly property alias progress: manager.progress + + property real internalStrokeWidth: strokeWidth + property int animState + + padding: 0 + implicitWidth: implicitSize + implicitHeight: implicitSize + + Component.onCompleted: { + if (running) { + running = false; + running = true; + } + } + + onRunningChanged: { + if (running) { + manager.completeEndProgress = 0; + animState = CircularIndicator.Running; + } else { + if (animState == CircularIndicator.Running) + animState = CircularIndicator.Completing; + } + } + + states: State { + name: "stopped" + when: !root.running + + PropertyChanges { + root.opacity: 0 + root.internalStrokeWidth: root.strokeWidth / 3 + } + } + + transitions: Transition { + Anim { + properties: "opacity,internalStrokeWidth" + duration: manager.completeEndDuration * Appearance.anim.durations.scale + } + } + + contentItem: CircularProgress { + anchors.fill: parent + strokeWidth: root.internalStrokeWidth + fgColour: root.fgColour + bgColour: root.bgColour + padding: root.padding + rotation: manager.rotation + startAngle: manager.startFraction * 360 + value: manager.endFraction - manager.startFraction + } + + CircularIndicatorManager { + id: manager + } + + NumberAnimation { + running: root.animState !== CircularIndicator.Stopped + loops: Animation.Infinite + target: manager + property: "progress" + from: 0 + to: 1 + duration: manager.duration * Appearance.anim.durations.scale + } + + NumberAnimation { + running: root.animState === CircularIndicator.Completing + target: manager + property: "completeEndProgress" + from: 0 + to: 1 + duration: manager.completeEndDuration * Appearance.anim.durations.scale + onFinished: { + if (root.animState === CircularIndicator.Completing) + root.animState = CircularIndicator.Stopped; + } + } +} diff --git a/Components/CircularProgress.qml b/Components/CircularProgress.qml new file mode 100644 index 0000000..ef98fd6 --- /dev/null +++ b/Components/CircularProgress.qml @@ -0,0 +1,69 @@ +import QtQuick +import QtQuick.Shapes +import qs.Helpers +import qs.Config +import qs.Modules + +Shape { + id: root + + property real value + property int startAngle: -90 + property int strokeWidth: Appearance.padding.smaller + property int padding: 0 + property int spacing: Appearance.spacing.small + property color fgColour: DynamicColors.palette.m3primary + property color bgColour: DynamicColors.palette.m3secondaryContainer + + readonly property real size: Math.min(width, height) + readonly property real arcRadius: (size - padding - strokeWidth) / 2 + readonly property real vValue: value || 1 / 360 + readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI) + + preferredRendererType: Shape.CurveRenderer + asynchronous: true + + ShapePath { + fillColor: "transparent" + strokeColor: root.bgColour + strokeWidth: root.strokeWidth + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + PathAngleArc { + startAngle: root.startAngle + 360 * root.vValue + root.gapAngle + sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2) + radiusX: root.arcRadius + radiusY: root.arcRadius + centerX: root.size / 2 + centerY: root.size / 2 + } + + Behavior on strokeColor { + CAnim { + duration: Appearance.anim.durations.large + } + } + } + + ShapePath { + fillColor: "transparent" + strokeColor: root.fgColour + strokeWidth: root.strokeWidth + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + + PathAngleArc { + startAngle: root.startAngle + sweepAngle: 360 * root.vValue + radiusX: root.arcRadius + radiusY: root.arcRadius + centerX: root.size / 2 + centerY: root.size / 2 + } + + Behavior on strokeColor { + CAnim { + duration: Appearance.anim.durations.large + } + } + } +} diff --git a/Config/LockConf.qml b/Config/LockConf.qml index af451b8..d34fa83 100644 --- a/Config/LockConf.qml +++ b/Config/LockConf.qml @@ -1,6 +1,14 @@ import Quickshell.Io JsonObject { - property bool fixLockScreen: false - property bool useWallpaper: true + property bool recolorLogo: false + property bool enableFprint: true + property int maxFprintTries: 3 + property Sizes sizes: Sizes {} + + component Sizes: JsonObject { + property real heightMult: 0.7 + property real ratio: 16 / 9 + property int centerWidth: 600 + } } diff --git a/Config/Services.qml b/Config/Services.qml index 559fbb1..668a9cd 100644 --- a/Config/Services.qml +++ b/Config/Services.qml @@ -3,7 +3,7 @@ import QtQuick JsonObject { property string weatherLocation: "" - property bool useFahrenheit: [Locale.ImperialUSSystem, Locale.ImperialSystem].includes(Qt.locale().measurementSystem) + property bool useFahrenheit: false property bool useTwelveHourClock: Qt.locale().timeFormat(Locale.ShortFormat).toLowerCase().includes("a") property string gpuType: "" property real audioIncrement: 0.1 diff --git a/Modules/AudioWidget.qml b/Modules/AudioWidget.qml index c58fafa..745cf3c 100644 --- a/Modules/AudioWidget.qml +++ b/Modules/AudioWidget.qml @@ -115,7 +115,7 @@ Item { implicitWidth: parent.width * ( Pipewire.defaultAudioSource?.audio.volume ?? 0 ) radius: parent.radius - color: ( Pipewire.defaultAudioSource?.audio.muted ?? false ) ? (Config.useDynamicColors ? DynamicColors.palette.m3onError : "#ff4444") : root.barColor + color: ( Pipewire.defaultAudioSource?.audio.muted ?? false ) ? (Config.useDynamicColors ? DynamicColors.palette.m3error : "#ff4444") : root.barColor Behavior on color { CAnim {} diff --git a/Modules/Clock.qml b/Modules/Clock.qml index 86a69ed..b04cbb4 100644 --- a/Modules/Clock.qml +++ b/Modules/Clock.qml @@ -26,7 +26,7 @@ Item { anchors.centerIn: parent - text: Time.time + text: Time.dateStr color: Config.useDynamicColors ? DynamicColors.palette.m3tertiary : "white" Behavior on color { diff --git a/Modules/Lock/Center.qml b/Modules/Lock/Center.qml new file mode 100644 index 0000000..a66f281 --- /dev/null +++ b/Modules/Lock/Center.qml @@ -0,0 +1,415 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Paths +import qs.Components +import qs.Helpers +import qs.Config +import qs.Modules + +ColumnLayout { + id: root + + required property var lock + readonly property real centerScale: Math.min(1, (lock.screen?.height ?? 1440) / 1440) + readonly property int centerWidth: Config.lock.sizes.centerWidth * centerScale + + Layout.preferredWidth: centerWidth + Layout.fillWidth: false + Layout.fillHeight: true + + spacing: Appearance.spacing.large * 2 + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Appearance.spacing.small + + CustomText { + Layout.alignment: Qt.AlignVCenter + text: Time.hourStr + color: DynamicColors.palette.m3secondary + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) + font.family: Appearance.font.family.clock + font.bold: true + } + + CustomText { + Layout.alignment: Qt.AlignVCenter + text: ":" + color: DynamicColors.palette.m3primary + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) + font.family: Appearance.font.family.clock + font.bold: true + } + + CustomText { + Layout.alignment: Qt.AlignVCenter + text: Time.minuteStr + color: DynamicColors.palette.m3secondary + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) + font.family: Appearance.font.family.clock + font.bold: true + } + + Loader { + Layout.leftMargin: Appearance.spacing.small + Layout.alignment: Qt.AlignVCenter + + active: Config.services.useTwelveHourClock + visible: active + + sourceComponent: CustomText { + text: Time.amPmStr + color: DynamicColors.palette.m3primary + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 2 * root.centerScale) + font.family: Appearance.font.family.clock + font.bold: true + } + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: -Appearance.padding.large * 2 + + text: Time.format("dddd, d MMMM yyyy") + color: DynamicColors.palette.m3tertiary + font.pointSize: Math.floor(Appearance.font.size.extraLarge * root.centerScale) + font.family: Appearance.font.family.mono + font.bold: true + } + + CustomClippingRect { + Layout.topMargin: Appearance.spacing.large * 2 + Layout.alignment: Qt.AlignHCenter + + implicitWidth: root.centerWidth / 2 + implicitHeight: root.centerWidth / 2 + + color: DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.full + + MaterialIcon { + anchors.centerIn: parent + + text: "person" + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Math.floor(root.centerWidth / 4) + } + + CachingImage { + id: pfp + + anchors.fill: parent + path: `${Paths.home}/.face` + } + } + + CustomRect { + Layout.alignment: Qt.AlignHCenter + + implicitWidth: root.centerWidth * 0.8 + implicitHeight: input.implicitHeight + Appearance.padding.small * 2 + + color: DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.full + + focus: true + onActiveFocusChanged: { + if (!activeFocus) + forceActiveFocus(); + } + + Keys.onPressed: event => { + if (root.lock.unlocking) + return; + + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) + inputField.placeholder.animate = false; + + root.lock.pam.handleKey(event); + } + + StateLayer { + hoverEnabled: false + cursorShape: Qt.IBeamCursor + + function onClicked(): void { + parent.forceActiveFocus(); + } + } + + RowLayout { + id: input + + anchors.fill: parent + anchors.margins: Appearance.padding.small + spacing: Appearance.spacing.normal + + Item { + implicitWidth: implicitHeight + implicitHeight: fprintIcon.implicitHeight + Appearance.padding.small * 2 + + MaterialIcon { + id: fprintIcon + + anchors.centerIn: parent + animate: true + text: { + if (root.lock.pam.fprint.tries >= Config.lock.maxFprintTries) + return "fingerprint_off"; + if (root.lock.pam.fprint.active) + return "fingerprint"; + return "lock"; + } + color: root.lock.pam.fprint.tries >= Config.lock.maxFprintTries ? DynamicColors.palette.m3error : DynamicColors.palette.m3onSurface + opacity: root.lock.pam.passwd.active ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + CircularIndicator { + anchors.fill: parent + running: root.lock.pam.passwd.active + } + } + + InputField { + id: inputField + + pam: root.lock.pam + } + + CustomRect { + implicitWidth: implicitHeight + implicitHeight: enterIcon.implicitHeight + Appearance.padding.small * 2 + + color: root.lock.pam.buffer ? DynamicColors.palette.m3primary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + radius: Appearance.rounding.full + + StateLayer { + color: root.lock.pam.buffer ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + + function onClicked(): void { + root.lock.pam.passwd.start(); + } + } + + MaterialIcon { + id: enterIcon + + anchors.centerIn: parent + text: "arrow_forward" + color: root.lock.pam.buffer ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + font.weight: 500 + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.topMargin: -Appearance.spacing.large + + implicitHeight: Math.max(message.implicitHeight, stateMessage.implicitHeight) + + Behavior on implicitHeight { + Anim {} + } + + CustomText { + id: stateMessage + + readonly property string msg: { + if (Hypr.kbLayout !== Hypr.defaultKbLayout) { + if (Hypr.capsLock && Hypr.numLock) + return qsTr("Caps lock and Num lock are ON.\nKeyboard layout: %1").arg(Hypr.kbLayoutFull); + if (Hypr.capsLock) + return qsTr("Caps lock is ON. Kb layout: %1").arg(Hypr.kbLayoutFull); + if (Hypr.numLock) + return qsTr("Num lock is ON. Kb layout: %1").arg(Hypr.kbLayoutFull); + return qsTr("Keyboard layout: %1").arg(Hypr.kbLayoutFull); + } + + if (Hypr.capsLock && Hypr.numLock) + return qsTr("Caps lock and Num lock are ON."); + if (Hypr.capsLock) + return qsTr("Caps lock is ON."); + if (Hypr.numLock) + return qsTr("Num lock is ON."); + + return ""; + } + + property bool shouldBeVisible + + onMsgChanged: { + if (msg) { + if (opacity > 0) { + animate = true; + text = msg; + animate = false; + } else { + text = msg; + } + shouldBeVisible = true; + } else { + shouldBeVisible = false; + } + } + + anchors.left: parent.left + anchors.right: parent.right + + scale: shouldBeVisible && !message.msg ? 1 : 0.7 + opacity: shouldBeVisible && !message.msg ? 1 : 0 + color: DynamicColors.palette.m3onSurfaceVariant + animateProp: "opacity" + + font.family: Appearance.font.family.mono + horizontalAlignment: Qt.AlignHCenter + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + lineHeight: 1.2 + + Behavior on scale { + Anim {} + } + + Behavior on opacity { + Anim {} + } + } + + CustomText { + id: message + + readonly property Pam pam: root.lock.pam + readonly property string msg: { + if (pam.fprintState === "error") + return qsTr("FP ERROR: %1").arg(pam.fprint.message); + if (pam.state === "error") + return qsTr("PW ERROR: %1").arg(pam.passwd.message); + + if (pam.lockMessage) + return pam.lockMessage; + + if (pam.state === "max" && pam.fprintState === "max") + return qsTr("Maximum password and fingerprint attempts reached."); + if (pam.state === "max") { + if (pam.fprint.available) + return qsTr("Maximum password attempts reached. Please use fingerprint."); + return qsTr("Maximum password attempts reached."); + } + if (pam.fprintState === "max") + return qsTr("Maximum fingerprint attempts reached. Please use password."); + + if (pam.state === "fail") { + if (pam.fprint.available) + return qsTr("Incorrect password. Please try again or use fingerprint."); + return qsTr("Incorrect password. Please try again."); + } + if (pam.fprintState === "fail") + return qsTr("Fingerprint not recognized (%1/%2). Please try again or use password.").arg(pam.fprint.tries).arg(Config.lock.maxFprintTries); + + return ""; + } + + anchors.left: parent.left + anchors.right: parent.right + + scale: 0.7 + opacity: 0 + color: DynamicColors.palette.m3error + + font.pointSize: Appearance.font.size.small + font.family: Appearance.font.family.mono + horizontalAlignment: Qt.AlignHCenter + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + onMsgChanged: { + if (msg) { + if (opacity > 0) { + animate = true; + text = msg; + animate = false; + + exitAnim.stop(); + if (scale < 1) + appearAnim.restart(); + else + flashAnim.restart(); + } else { + text = msg; + exitAnim.stop(); + appearAnim.restart(); + } + } else { + appearAnim.stop(); + flashAnim.stop(); + exitAnim.start(); + } + } + + Connections { + target: root.lock.pam + + function onFlashMsg(): void { + exitAnim.stop(); + if (message.scale < 1) + appearAnim.restart(); + else + flashAnim.restart(); + } + } + + Anim { + id: appearAnim + + target: message + properties: "scale,opacity" + to: 1 + onFinished: flashAnim.restart() + } + + SequentialAnimation { + id: flashAnim + + loops: 2 + + FlashAnim { + to: 0.3 + } + FlashAnim { + to: 1 + } + } + + ParallelAnimation { + id: exitAnim + + Anim { + target: message + property: "scale" + to: 0.7 + duration: Appearance.anim.durations.large + } + Anim { + target: message + property: "opacity" + to: 0 + duration: Appearance.anim.durations.large + } + } + } + } + + component FlashAnim: NumberAnimation { + target: message + property: "opacity" + duration: Appearance.anim.durations.small + easing.type: Easing.Linear + } +} diff --git a/Modules/Lock/Content.qml b/Modules/Lock/Content.qml new file mode 100644 index 0000000..3174d8c --- /dev/null +++ b/Modules/Lock/Content.qml @@ -0,0 +1,82 @@ +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Helpers +import qs.Config + +RowLayout { + id: root + + required property var lock + + spacing: Appearance.spacing.large * 2 + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + CustomRect { + Layout.fillWidth: true + implicitHeight: weather.implicitHeight + + topLeftRadius: Appearance.rounding.large + radius: Appearance.rounding.small + color: DynamicColors.tPalette.m3surfaceContainer + + WeatherInfo { + id: weather + + rootHeight: root.height + } + } + + CustomRect { + Layout.fillWidth: true + implicitHeight: resources.implicitHeight + + radius: Appearance.rounding.small + color: DynamicColors.tPalette.m3surfaceContainer + + Resources { + id: resources + } + } + + CustomClippingRect { + Layout.fillWidth: true + Layout.fillHeight: true + + bottomLeftRadius: Appearance.rounding.large + radius: Appearance.rounding.small + color: DynamicColors.tPalette.m3surfaceContainer + + Media { + id: media + + lock: root.lock + } + } + } + + Center { + lock: root.lock + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + CustomRect { + Layout.fillWidth: true + Layout.fillHeight: true + + topRightRadius: Appearance.rounding.large + bottomRightRadius: Appearance.rounding.large + radius: Appearance.rounding.small + color: DynamicColors.tPalette.m3surfaceContainer + + NotifDock { + lock: root.lock + } + } + } +} diff --git a/Modules/Lock/Fetch.qml b/Modules/Lock/Fetch.qml new file mode 100644 index 0000000..bdc93ef --- /dev/null +++ b/Modules/Lock/Fetch.qml @@ -0,0 +1,164 @@ +pragma ComponentBehavior: Bound + +import Quickshell.Services.UPower +import QtQuick +import QtQuick.Layouts +import qs.Modules +import qs.Components +import qs.Helpers +import qs.Config + +ColumnLayout { + id: root + + anchors.fill: parent + anchors.margins: Appearance.padding.large * 2 + anchors.topMargin: Appearance.padding.large + + spacing: Appearance.spacing.small + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: false + spacing: Appearance.spacing.normal + + CustomRect { + implicitWidth: prompt.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: prompt.implicitHeight + Appearance.padding.normal * 2 + + color: DynamicColors.palette.m3primary + radius: Appearance.rounding.small + + MonoText { + id: prompt + + anchors.centerIn: parent + text: ">" + font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal + color: DynamicColors.palette.m3onPrimary + } + } + + MonoText { + Layout.fillWidth: true + text: "caelestiafetch.sh" + font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal + elide: Text.ElideRight + } + + WrappedLoader { + Layout.fillHeight: true + active: !iconLoader.active + + sourceComponent: OsLogo {} + } + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: false + spacing: height * 0.15 + + WrappedLoader { + id: iconLoader + + Layout.fillHeight: true + active: root.width > 320 + + sourceComponent: OsLogo {} + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: Appearance.padding.normal + Layout.bottomMargin: Appearance.padding.normal + Layout.leftMargin: iconLoader.active ? 0 : width * 0.1 + spacing: Appearance.spacing.normal + + WrappedLoader { + Layout.fillWidth: true + active: !batLoader.active && root.height > 200 + + sourceComponent: FetchText { + text: `OS : ${SystemInfo.osPrettyName || SysInfo.osName}` + } + } + + WrappedLoader { + Layout.fillWidth: true + active: root.height > (batLoader.active ? 200 : 110) + + sourceComponent: FetchText { + text: `WM : ${SystemInfo.wm}` + } + } + + WrappedLoader { + Layout.fillWidth: true + active: !batLoader.active || root.height > 110 + + sourceComponent: FetchText { + text: `USER: ${SystemInfo.user}` + } + } + + FetchText { + text: `UP : ${SystemInfo.uptime}` + } + + WrappedLoader { + id: batLoader + + Layout.fillWidth: true + active: UPower.displayDevice.isLaptopBattery + + sourceComponent: FetchText { + text: `BATT: ${[UPowerDeviceState.Charging, UPowerDeviceState.FullyCharged, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state) ? "(+) " : ""}${Math.round(UPower.displayDevice.percentage * 100)}%` + } + } + } + } + + WrappedLoader { + Layout.alignment: Qt.AlignHCenter + active: root.height > 180 + + sourceComponent: RowLayout { + spacing: Appearance.spacing.large + + Repeater { + model: Math.max(0, Math.min(8, root.width / (Appearance.font.size.larger * 2 + Appearance.spacing.large))) + + CustomRect { + required property int index + + implicitWidth: implicitHeight + implicitHeight: Appearance.font.size.larger * 2 + color: DynamicColors.palette[`term${index}`] + radius: Appearance.rounding.small + } + } + } + } + + component WrappedLoader: Loader { + visible: active + } + + component OsLogo: ColoredIcon { + source: SystemInfo.osLogo + implicitSize: height + color: DynamicColors.palette.m3primary + layer.enabled: Config.lock.recolorLogo || SystemInfo.isDefaultLogo + } + + component FetchText: MonoText { + Layout.fillWidth: true + font.pointSize: root.width > 400 ? Appearance.font.size.larger : Appearance.font.size.normal + elide: Text.ElideRight + } + + component MonoText: CustomText { + font.family: Appearance.font.family.mono + } +} diff --git a/Modules/Lock/InputField.qml b/Modules/Lock/InputField.qml new file mode 100644 index 0000000..1bfc40d --- /dev/null +++ b/Modules/Lock/InputField.qml @@ -0,0 +1,150 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import qs.Modules +import qs.Components +import qs.Helpers +import qs.Config + +Item { + id: root + + required property Pam pam + readonly property alias placeholder: placeholder + property string buffer + + Layout.fillWidth: true + Layout.fillHeight: true + + clip: true + + Connections { + target: root.pam + + function onBufferChanged(): void { + if (root.pam.buffer.length > root.buffer.length) { + charList.bindImWidth(); + } else if (root.pam.buffer.length === 0) { + charList.implicitWidth = charList.implicitWidth; + placeholder.animate = true; + } + + root.buffer = root.pam.buffer; + } + } + + CustomText { + id: placeholder + + anchors.centerIn: parent + + text: { + if (root.pam.passwd.active) + return qsTr("Loading..."); + if (root.pam.state === "max") + return qsTr("You have reached the maximum number of tries"); + return qsTr("Enter your password"); + } + + animate: true + color: root.pam.passwd.active ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.normal + font.family: Appearance.font.family.mono + + opacity: root.buffer ? 0 : 1 + + Behavior on opacity { + Anim {} + } + } + + ListView { + id: charList + + readonly property int fullWidth: count * (implicitHeight + spacing) - spacing + + function bindImWidth(): void { + imWidthBehavior.enabled = false; + implicitWidth = Qt.binding(() => fullWidth); + imWidthBehavior.enabled = true; + } + + anchors.centerIn: parent + anchors.horizontalCenterOffset: implicitWidth > root.width ? -(implicitWidth - root.width) / 2 : 0 + + implicitWidth: fullWidth + implicitHeight: Appearance.font.size.normal + + orientation: Qt.Horizontal + spacing: Appearance.spacing.small / 2 + interactive: false + + model: ScriptModel { + values: root.buffer.split("") + } + + delegate: CustomRect { + id: ch + + implicitWidth: implicitHeight + implicitHeight: charList.implicitHeight + + color: DynamicColors.palette.m3onSurface + radius: Appearance.rounding.small / 2 + + opacity: 0 + scale: 0 + Component.onCompleted: { + opacity = 1; + scale = 1; + } + ListView.onRemove: removeAnim.start() + + SequentialAnimation { + id: removeAnim + + PropertyAction { + target: ch + property: "ListView.delayRemove" + value: true + } + ParallelAnimation { + Anim { + target: ch + property: "opacity" + to: 0 + } + Anim { + target: ch + property: "scale" + to: 0.5 + } + } + PropertyAction { + target: ch + property: "ListView.delayRemove" + value: false + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } + + Behavior on implicitWidth { + id: imWidthBehavior + + Anim {} + } + } +} diff --git a/Modules/Lock/Lock.qml b/Modules/Lock/Lock.qml index 0254d7f..90b6358 100644 --- a/Modules/Lock/Lock.qml +++ b/Modules/Lock/Lock.qml @@ -14,18 +14,6 @@ Scope { property alias lock: lock property int seenOnce: 0 - Timer { - interval: 500 - running: true - repeat: false - onTriggered: { - if ( Config.lock.fixLockScreen ) { - Quickshell.execDetached(["hyprctl", "keyword", "misc:session_lock_xray", "true"]); - console.log("Fixed lock screen X-ray issue."); - } - } - } - WlSessionLock { id: lock @@ -36,7 +24,6 @@ Scope { id: lockSurface lock: lock pam: pam - scope: root } } diff --git a/Modules/Lock/LockSurface.qml b/Modules/Lock/LockSurface.qml index faaa180..1c4648e 100644 --- a/Modules/Lock/LockSurface.qml +++ b/Modules/Lock/LockSurface.qml @@ -10,379 +10,220 @@ import qs.Config import qs.Helpers import qs.Effects import qs.Components -import qs.Modules +import qs.Modules as Modules WlSessionLockSurface { - id: root + id: root - required property WlSessionLock lock - required property Pam pam - required property Scope scope + required property WlSessionLock lock + required property Pam pam - property string buffer + readonly property alias unlocking: unlockAnim.running - color: "transparent" + color: "transparent" - Connections { - target: root.pam + Connections { + target: root.lock - function onBufferChanged(): void { - if (root.pam.buffer.length > root.buffer.length) { - charList.bindImWidth(); - } else if (root.pam.buffer.length === 0) { - charList.implicitWidth = charList.implicitWidth; - } + function onUnlock(): void { + unlockAnim.start(); + } + } - root.buffer = root.pam.buffer; - } - } + SequentialAnimation { + id: unlockAnim - Timer { - interval: 5 - running: true - repeat: false - onTriggered: { - if ( Config.lock.fixLockScreen && root.scope.seenOnce === 0 ) { - Quickshell.execDetached(["hyprctl", "keyword", "misc:session_lock_xray", "false;"]); - root.scope.seenOnce += 1; - } - } - } + ParallelAnimation { + Modules.Anim { + target: lockContent + properties: "implicitWidth,implicitHeight" + to: lockContent.size + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + Modules.Anim { + target: lockBg + property: "radius" + to: lockContent.radius + } + Modules.Anim { + target: content + property: "scale" + to: 0 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + Modules.Anim { + target: content + property: "opacity" + to: 0 + duration: Appearance.anim.durations.small + } + Modules.Anim { + target: lockIcon + property: "opacity" + to: 1 + duration: Appearance.anim.durations.large + } + Modules.Anim { + target: background + property: "opacity" + to: 0 + duration: Appearance.anim.durations.large + } + SequentialAnimation { + PauseAnimation { + duration: Appearance.anim.durations.small + } + Modules.Anim { + target: lockContent + property: "opacity" + to: 0 + } + } + } + PropertyAction { + target: root.lock + property: "locked" + value: false + } + } - TextInput { - id: hiddenInput - focus: true - visible: false + ParallelAnimation { + id: initAnim - Keys.onPressed: function(event: KeyEvent): void { - root.pam.handleKey(event); - event.accepted = true; - } + running: true - onTextChanged: text = "" - } - - ScreencopyView { - id: background - - live: false - - anchors.fill: parent - captureSource: root.screen - opacity: 1 - visible: !Config.lock.useWallpaper - - layer.enabled: true - layer.effect: MultiEffect { - autoPaddingEnabled: false - blurEnabled: true - blur: 0.8 - blurMax: 64 - blurMultiplier: 1 - brightness: 0 - } - } + Modules.Anim { + target: background + property: "opacity" + to: 1 + duration: Appearance.anim.durations.large + } + SequentialAnimation { + ParallelAnimation { + Modules.Anim { + target: lockContent + property: "scale" + to: 1 + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + Modules.Anim { + target: lockContent + property: "rotation" + to: 360 + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.standardAccel + } + } + ParallelAnimation { + Modules.Anim { + target: lockIcon + property: "rotation" + to: 360 + easing.bezierCurve: Appearance.anim.curves.standardDecel + } + Modules.Anim { + target: lockIcon + property: "opacity" + to: 0 + } + Modules.Anim { + target: content + property: "opacity" + to: 1 + } + Modules.Anim { + target: content + property: "scale" + to: 1 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + Modules.Anim { + target: lockBg + property: "radius" + to: Appearance.rounding.large * 1.5 + } + Modules.Anim { + target: lockContent + property: "implicitWidth" + to: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult * Config.lock.sizes.ratio + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + Modules.Anim { + target: lockContent + property: "implicitHeight" + to: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } CachingImage { - id: backgroundImage + id: background anchors.fill: parent asynchronous: false path: WallpaperPath.currentWallpaperPath - visible: Config.lock.useWallpaper Component.onCompleted: { console.log(source); } } - Rectangle { - id: overlay - anchors.fill: parent - color: "transparent" + Item { + id: lockContent - visible: background.hasContent + readonly property int size: lockIcon.implicitHeight + Appearance.padding.large * 4 + readonly property int radius: size / 4 * Appearance.rounding.scale - Rectangle { - id: contentBox - anchors.bottom: !Config.lock.useWallpaper ? "" : parent.bottom - anchors.centerIn: !Config.lock.useWallpaper ? parent : "" - anchors.horizontalCenter: !Config.lock.useWallpaper ? "" : parent.horizontalCenter + anchors.centerIn: parent + implicitWidth: size + implicitHeight: size - color: DynamicColors.tPalette.m3surfaceContainer - radius: 28 + rotation: 180 + scale: 0 - implicitWidth: Math.floor(childrenRect.width + 48) - implicitHeight: Math.floor(childrenRect.height + 64) + CustomRect { + id: lockBg - layer.enabled: true - layer.effect: MultiEffect { - source: contentBox - blurEnabled: false - blurMax: 12 - shadowBlur: 1 - shadowColor: DynamicColors.palette.m3shadow - shadowOpacity: 1 - shadowEnabled: true - autoPaddingEnabled: true - } + anchors.fill: parent + color: DynamicColors.palette.m3surface + radius: parent.radius + opacity: DynamicColors.transparency.enabled ? DynamicColors.transparency.base : 1 - ColumnLayout { - id: mainLayout - anchors.centerIn: parent - width: childrenRect.width - spacing: 0 + layer.enabled: true + layer.effect: MultiEffect { + shadowEnabled: true + blurMax: 15 + shadowColor: Qt.alpha(DynamicColors.palette.m3shadow, 0.7) + } + } - RowLayout { - Layout.alignment: Qt.AlignHCenter - Layout.bottomMargin: 16 - spacing: 16 + MaterialIcon { + id: lockIcon - UserImage { - Layout.alignment: Qt.AlignHCenter | Qt.AlignLeft - Layout.bottomMargin: 16 - Layout.preferredWidth: 128 - Layout.preferredHeight: 128 - } + anchors.centerIn: parent + text: "lock" + font.pointSize: Appearance.font.size.extraLarge * 4 + font.bold: true + rotation: 180 + } - LockTime { - Layout.alignment: Qt.AlignHCenter - Layout.bottomMargin: 8 - } - } + Content { + id: content - Rectangle { - id: inputContainer - Layout.alignment: Qt.AlignHCenter - Layout.bottomMargin: 16 + anchors.centerIn: parent + width: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult * Config.lock.sizes.ratio - Appearance.padding.large * 2 + height: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult - Appearance.padding.large * 2 - Layout.preferredWidth: 320 - Layout.preferredHeight: 48 - - color: DynamicColors.tPalette.m3surfaceContainerHigh - radius: 1000 - - border.width: 1 - border.color: { - if (root.pam.state === "error" || root.pam.state === "fail") { - return DynamicColors.palette.m3error; - } - return DynamicColors.palette.m3outline; - } - - clip: true - - Behavior on border.color { - ColorAnimation { duration: 150 } - } - - transform: Translate { - id: wobbleTransform - x: 0 - } - - SequentialAnimation { - id: wobbleAnimation - - ColorAnimation { - target: inputContainer - property: "color" - to: DynamicColors.tPalette.m3onError - duration: MaterialEasing.expressiveEffectsTime - } - ParallelAnimation { - NumberAnimation { - target: wobbleTransform - property: "x" - to: -8 - duration: MaterialEasing.expressiveFastSpatialTime / 4 - easing.bezierCurve: MaterialEasing.expressiveFastSpatial - } - } - NumberAnimation { - target: wobbleTransform - property: "x" - to: 8 - duration: MaterialEasing.expressiveFastSpatialTime / 4 - easing.bezierCurve: MaterialEasing.expressiveFastSpatial - } - NumberAnimation { - target: wobbleTransform - property: "x" - to: -8 - duration: MaterialEasing.expressiveFastSpatialTime / 4 - easing.bezierCurve: MaterialEasing.expressiveFastSpatial - } - NumberAnimation { - target: wobbleTransform - property: "x" - to: 0 - duration: MaterialEasing.expressiveFastSpatialTime / 4 - easing.bezierCurve: MaterialEasing.expressiveFastSpatial - } - ColorAnimation { - target: inputContainer - property: "color" - to: DynamicColors.tPalette.m3surfaceContainerHigh - duration: MaterialEasing.expressiveEffectsTime - } - } - - Connections { - target: root.pam - - function onStateChanged(): void { - if (root.pam.state === "error" || root.pam.state === "fail") { - wobbleAnimation.start(); - } - } - } - - CustomText { - id: messageDisplay - - anchors.centerIn: inputContainer - - text: { - if ( root.pam.buffer.length > 0 || root.pam.passwd.active ) { - return ""; - } - if (root.pam.lockMessage) { - return root.pam.lockMessage; - } - if (root.pam.state === "error") { - return "Authentication error"; - } - if (root.pam.state === "fail") { - return "Invalid password"; - } - if (root.pam.state === "max") { - return "Maximum attempts reached"; - } - return "Enter your password"; - } - visible: true - font.pointSize: 14 - font.weight: Font.Medium - - animate: true - - Behavior on text { - SequentialAnimation { - OAnim { - target: messageDisplay - property: "opacity" - to: 0 - } - PropertyAction {} - OAnim { - target: messageDisplay - property: "opacity" - to: 1 - } - } - } - - component OAnim: NumberAnimation { - target: messageDisplay - property: "opacity" - duration: 100 - } - - color: root.pam.state === "max" ? DynamicColors.palette.m3error : DynamicColors.palette.m3onSurfaceVariant - wrapMode: Text.WordWrap - horizontalAlignment: Text.AlignHCenter - } - - ListView { - id: charList - - readonly property int fullWidth: count * (implicitHeight + spacing) - - function bindImWidth(): void { - imWidthBehavior.enabled = false; - implicitWidth = Qt.binding(() => fullWidth); - imWidthBehavior.enabled = true; - } - - anchors.centerIn: parent - anchors.horizontalCenterOffset: implicitWidth > inputContainer.width - 20 ? -(implicitWidth - inputContainer.width + 20) / 2 : 0 - - implicitWidth: fullWidth - implicitHeight: 16 - - orientation: Qt.Horizontal - spacing: 8 - interactive: false - - model: ScriptModel { - values: root.pam.buffer.split("") - } - - delegate: CustomRect { - id: ch - - implicitWidth: implicitHeight - implicitHeight: charList.implicitHeight - - color: DynamicColors.palette.m3onSurface - radius: 1000 - - opacity: 0 - scale: 0 - Component.onCompleted: { - opacity = 1; - scale = 1; - } - ListView.onRemove: removeAnim.start() - - SequentialAnimation { - id: removeAnim - - PropertyAction { - target: ch - property: "ListView.delayRemove" - value: true - } - ParallelAnimation { - Anim { - target: ch - property: "opacity" - to: 0 - } - Anim { - target: ch - property: "scale" - to: 0.5 - } - } - PropertyAction { - target: ch - property: "ListView.delayRemove" - value: false - } - } - - Behavior on opacity { - Anim {} - } - - Behavior on scale { - Anim { - duration: MaterialEasing.expressiveFastSpatialTime - easing.bezierCurve: MaterialEasing.expressiveFastSpatial - } - } - } - - Behavior on implicitWidth { - id: imWidthBehavior - - Anim {} - } - } - } - } - } - Component.onCompleted: hiddenInput.forceActiveFocus() - } + lock: root + opacity: 0 + scale: 0 + } + } } diff --git a/Modules/Lock/Media.qml b/Modules/Lock/Media.qml new file mode 100644 index 0000000..2d3d167 --- /dev/null +++ b/Modules/Lock/Media.qml @@ -0,0 +1,205 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Modules +import qs.Components +import qs.Helpers +import qs.Config + +Item { + id: root + + required property var lock + + anchors.fill: parent + + Image { + anchors.fill: parent + source: Players.active?.trackArtUrl ?? "" + + asynchronous: true + fillMode: Image.PreserveAspectCrop + sourceSize.width: width + sourceSize.height: height + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: mask + } + + opacity: status === Image.Ready ? 1 : 0 + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.extraLarge + } + } + } + + Rectangle { + id: mask + + anchors.fill: parent + layer.enabled: true + visible: false + + gradient: Gradient { + orientation: Gradient.Horizontal + + GradientStop { + position: 0 + color: Qt.rgba(0, 0, 0, 0.5) + } + GradientStop { + position: 0.4 + color: Qt.rgba(0, 0, 0, 0.2) + } + GradientStop { + position: 0.8 + color: Qt.rgba(0, 0, 0, 0) + } + } + } + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.large + + CustomText { + Layout.topMargin: Appearance.padding.large + Layout.bottomMargin: Appearance.spacing.larger + text: qsTr("Now playing") + color: DynamicColors.palette.m3onSurfaceVariant + font.family: Appearance.font.family.mono + font.weight: 500 + } + + CustomText { + Layout.fillWidth: true + animate: true + text: Players.active?.trackArtist ?? qsTr("No media") + color: DynamicColors.palette.m3primary + horizontalAlignment: Text.AlignHCenter + font.pointSize: Appearance.font.size.large + font.family: Appearance.font.family.mono + font.weight: 600 + elide: Text.ElideRight + } + + CustomText { + Layout.fillWidth: true + animate: true + text: Players.active?.trackTitle ?? qsTr("No media") + horizontalAlignment: Text.AlignHCenter + font.pointSize: Appearance.font.size.larger + font.family: Appearance.font.family.mono + elide: Text.ElideRight + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.spacing.large * 1.2 + Layout.bottomMargin: Appearance.padding.large + + spacing: Appearance.spacing.large + + PlayerControl { + icon: "skip_previous" + + function onClicked(): void { + if (Players.active?.canGoPrevious) + Players.active.previous(); + } + } + + PlayerControl { + animate: true + icon: active ? "pause" : "play_arrow" + colour: "Primary" + level: active ? 2 : 1 + active: Players.active?.isPlaying ?? false + + function onClicked(): void { + if (Players.active?.canTogglePlaying) + Players.active.togglePlaying(); + } + } + + PlayerControl { + icon: "skip_next" + + function onClicked(): void { + if (Players.active?.canGoNext) + Players.active.next(); + } + } + } + } + + component PlayerControl: CustomRect { + id: control + + property alias animate: controlIcon.animate + property alias icon: controlIcon.text + property bool active + property string colour: "Secondary" + property int level: 1 + + function onClicked(): void { + } + + Layout.preferredWidth: implicitWidth + (controlState.pressed ? Appearance.padding.normal * 2 : active ? Appearance.padding.small * 2 : 0) + implicitWidth: controlIcon.implicitWidth + Appearance.padding.large * 2 + implicitHeight: controlIcon.implicitHeight + Appearance.padding.normal * 2 + + color: active ? DynamicColors.palette[`m3${colour.toLowerCase()}`] : DynamicColors.palette[`m3${colour.toLowerCase()}Container`] + radius: active || controlState.pressed ? Appearance.rounding.normal : Math.min(implicitWidth, implicitHeight) / 2 * Math.min(1, Appearance.rounding.scale) + + Elevation { + anchors.fill: parent + radius: parent.radius + z: -1 + level: controlState.containsMouse && !controlState.pressed ? control.level + 1 : control.level + } + + StateLayer { + id: controlState + + color: control.active ? DynamicColors.palette[`m3on${control.colour}`] : DynamicColors.palette[`m3on${control.colour}Container`] + + function onClicked(): void { + control.onClicked(); + } + } + + MaterialIcon { + id: controlIcon + + anchors.centerIn: parent + color: control.active ? DynamicColors.palette[`m3on${control.colour}`] : DynamicColors.palette[`m3on${control.colour}Container`] + font.pointSize: Appearance.font.size.large + fill: control.active ? 1 : 0 + + Behavior on fill { + Anim {} + } + } + + Behavior on Layout.preferredWidth { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + Behavior on radius { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + } +} diff --git a/Modules/Lock/NotifDock.qml b/Modules/Lock/NotifDock.qml new file mode 100644 index 0000000..6fca53c --- /dev/null +++ b/Modules/Lock/NotifDock.qml @@ -0,0 +1,145 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Widgets +import QtQuick +import QtQuick.Layouts +import qs.Modules +import qs.Components +import qs.Helpers +import qs.Config +import qs.Daemons + +ColumnLayout { + id: root + + required property var lock + + anchors.fill: parent + anchors.margins: Appearance.padding.large + + spacing: Appearance.spacing.smaller + + CustomText { + Layout.fillWidth: true + text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications") + color: DynamicColors.palette.m3outline + font.family: Appearance.font.family.mono + font.weight: 500 + elide: Text.ElideRight + } + + ClippingRectangle { + id: clipRect + + Layout.fillWidth: true + Layout.fillHeight: true + + radius: Appearance.rounding.small + color: "transparent" + + Loader { + anchors.centerIn: parent + active: opacity > 0 + opacity: NotifServer.list.length > 0 ? 0 : 1 + + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.large + + Image { + asynchronous: true + source: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/dino.png`) + fillMode: Image.PreserveAspectFit + sourceSize.width: clipRect.width * 0.8 + + layer.enabled: true + layer.effect: Coloriser { + colorizationColor: DynamicColors.palette.m3outlineVariant + brightness: 1 + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No Notifications") + color: DynamicColors.palette.m3outlineVariant + font.pointSize: Appearance.font.size.large + font.family: Appearance.font.family.mono + font.weight: 500 + } + } + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.extraLarge + } + } + } + + CustomListView { + anchors.fill: parent + + spacing: Appearance.spacing.small + clip: true + + model: ScriptModel { + values: { + const list = NotifServer.notClosed.map(n => [n.appName, null]); + return [...new Map(list).keys()]; + } + } + + delegate: NotifGroup {} + + add: Transition { + Anim { + property: "opacity" + from: 0 + to: 1 + } + Anim { + property: "scale" + from: 0 + to: 1 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + remove: Transition { + Anim { + property: "opacity" + to: 0 + } + Anim { + property: "scale" + to: 0.6 + } + } + + move: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + Anim { + property: "y" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + displaced: Transition { + Anim { + properties: "opacity,scale" + to: 1 + } + Anim { + property: "y" + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } +} diff --git a/Modules/Lock/NotifGroup.qml b/Modules/Lock/NotifGroup.qml new file mode 100644 index 0000000..9c74445 --- /dev/null +++ b/Modules/Lock/NotifGroup.qml @@ -0,0 +1,316 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications +import QtQuick +import QtQuick.Layouts +import qs.Modules +import qs.Components +import qs.Helpers +import qs.Config +import qs.Daemons + +CustomRect { + id: root + + required property string modelData + + readonly property list notifs: NotifServer.list.filter(notif => notif.appName === modelData) + readonly property string image: notifs.find(n => n.image.length > 0)?.image ?? "" + readonly property string appIcon: notifs.find(n => n.appIcon.length > 0)?.appIcon ?? "" + readonly property string urgency: notifs.some(n => n.urgency === NotificationUrgency.Critical) ? "critical" : notifs.some(n => n.urgency === NotificationUrgency.Normal) ? "normal" : "low" + + property bool expanded + + anchors.left: parent?.left + anchors.right: parent?.right + implicitHeight: content.implicitHeight + Appearance.padding.normal * 2 + + clip: true + radius: Appearance.rounding.normal + color: root.urgency === "critical" ? DynamicColors.palette.m3secondaryContainer : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + + RowLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Appearance.padding.normal + + spacing: Appearance.spacing.normal + + 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 + + ColoredIcon { + implicitSize: Math.round(Config.notifs.sizes.image * 0.6) + source: Quickshell.iconPath(root.appIcon) + color: root.urgency === "critical" ? DynamicColors.palette.m3onError : root.urgency === "low" ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer + layer.enabled: root.appIcon.endsWith("symbolic") + } + } + + Component { + id: materialIconComp + + MaterialIcon { + text: Icons.getNotifIcon(root.notifs[0]?.summary, root.urgency) + color: root.urgency === "critical" ? DynamicColors.palette.m3onError : root.urgency === "low" ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer + font.pointSize: Appearance.font.size.large + } + } + + ClippingRectangle { + anchors.fill: parent + color: root.urgency === "critical" ? DynamicColors.palette.m3error : root.urgency === "low" ? DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 3) : DynamicColors.palette.m3secondaryContainer + radius: Appearance.rounding.full + + 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 === "critical" ? DynamicColors.palette.m3error : root.urgency === "low" ? DynamicColors.palette.m3surfaceContainerHighest : DynamicColors.palette.m3secondaryContainer + radius: Appearance.rounding.full + + ColoredIcon { + anchors.centerIn: parent + implicitSize: Math.round(Config.notifs.sizes.badge * 0.6) + source: Quickshell.iconPath(root.appIcon) + color: root.urgency === "critical" ? DynamicColors.palette.m3onError : root.urgency === "low" ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSecondaryContainer + layer.enabled: root.appIcon.endsWith("symbolic") + } + } + } + } + + ColumnLayout { + Layout.topMargin: -Appearance.padding.small + Layout.bottomMargin: -Appearance.padding.small / 2 - (root.expanded ? 0 : spacing) + Layout.fillWidth: true + spacing: Math.round(Appearance.spacing.small / 2) + + RowLayout { + Layout.bottomMargin: -parent.spacing + Layout.fillWidth: true + spacing: Appearance.spacing.smaller + + CustomText { + Layout.fillWidth: true + text: root.modelData + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + elide: Text.ElideRight + } + + CustomText { + animate: true + text: root.notifs[0]?.timeStr ?? "" + color: DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.small + } + + CustomRect { + implicitWidth: expandBtn.implicitWidth + Appearance.padding.smaller * 2 + implicitHeight: groupCount.implicitHeight + Appearance.padding.small + + color: root.urgency === "critical" ? DynamicColors.palette.m3error : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 2) + radius: Appearance.rounding.full + + opacity: root.notifs.length > Config.notifs.groupPreviewNum ? 1 : 0 + Layout.preferredWidth: root.notifs.length > Config.notifs.groupPreviewNum ? implicitWidth : 0 + + StateLayer { + color: root.urgency === "critical" ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onSurface + + function onClicked(): void { + root.expanded = !root.expanded; + } + } + + RowLayout { + id: expandBtn + + anchors.centerIn: parent + spacing: Appearance.spacing.small / 2 + + CustomText { + id: groupCount + + Layout.leftMargin: Appearance.padding.small / 2 + animate: true + text: root.notifs.length + color: root.urgency === "critical" ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onSurface + font.pointSize: Appearance.font.size.small + } + + MaterialIcon { + Layout.rightMargin: -Appearance.padding.small / 2 + animate: true + text: root.expanded ? "expand_less" : "expand_more" + color: root.urgency === "critical" ? DynamicColors.palette.m3onError : DynamicColors.palette.m3onSurface + } + } + + Behavior on opacity { + Anim {} + } + + Behavior on Layout.preferredWidth { + Anim {} + } + } + } + + Repeater { + model: ScriptModel { + values: root.notifs.slice(0, Config.notifs.groupPreviewNum) + } + + NotifLine { + id: notif + + ParallelAnimation { + running: true + + Anim { + target: notif + property: "opacity" + from: 0 + to: 1 + } + Anim { + target: notif + property: "scale" + from: 0.7 + to: 1 + } + Anim { + target: notif.Layout + property: "preferredHeight" + from: 0 + to: notif.implicitHeight + } + } + + ParallelAnimation { + running: notif.modelData.closed + onFinished: notif.modelData.unlock(notif) + + Anim { + target: notif + property: "opacity" + to: 0 + } + Anim { + target: notif + property: "scale" + to: 0.7 + } + Anim { + target: notif.Layout + property: "preferredHeight" + to: 0 + } + } + } + } + + Loader { + Layout.fillWidth: true + + opacity: root.expanded ? 1 : 0 + Layout.preferredHeight: root.expanded ? implicitHeight : 0 + active: opacity > 0 + + sourceComponent: ColumnLayout { + Repeater { + model: ScriptModel { + values: root.notifs.slice(Config.notifs.groupPreviewNum) + } + + NotifLine {} + } + } + + Behavior on opacity { + Anim {} + } + } + } + } + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + + component NotifLine: CustomText { + id: notifLine + + required property NotifServer.Notif modelData + + Layout.fillWidth: true + textFormat: Text.MarkdownText + text: { + const summary = modelData.summary.replace(/\n/g, " "); + const body = modelData.body.replace(/\n/g, " "); + const color = root.urgency === "critical" ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3outline; + + if (metrics.text === metrics.elidedText) + return `${summary} ${body}`; + + const t = metrics.elidedText.length - 3; + if (t < summary.length) + return `${summary.slice(0, t)}...`; + + return `${summary} ${body.slice(0, t - summary.length)}...`; + } + color: root.urgency === "critical" ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface + + Component.onCompleted: modelData.lock(this) + Component.onDestruction: modelData.unlock(this) + + TextMetrics { + id: metrics + + text: `${notifLine.modelData.summary} ${notifLine.modelData.body}`.replace(/\n/g, " ") + font.pointSize: notifLine.font.pointSize + font.family: notifLine.font.family + elideWidth: notifLine.width + elide: Text.ElideRight + } + } +} diff --git a/Modules/Lock/Pam.qml b/Modules/Lock/Pam.qml index 47dd541..9009b83 100644 --- a/Modules/Lock/Pam.qml +++ b/Modules/Lock/Pam.qml @@ -1,97 +1,193 @@ import Quickshell +import Quickshell.Io import Quickshell.Wayland import Quickshell.Services.Pam import QtQuick +import qs.Config Scope { - id: root + id: root - required property WlSessionLock lock + required property WlSessionLock lock - readonly property alias passwd: passwd - property string lockMessage - property string state - property string fprintState - property string buffer + readonly property alias passwd: passwd + readonly property alias fprint: fprint + property string lockMessage + property string state + property string fprintState + property string buffer - signal flashMsg + signal flashMsg - function handleKey(event: KeyEvent): void { - if (passwd.active || state === "max") - return; + function handleKey(event: KeyEvent): void { + if (passwd.active || state === "max") + return; - if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { - passwd.start(); - } else if (event.key === Qt.Key_Backspace) { - if ( event.modifiers & Qt.ControlModifier ) { - buffer = ""; - } else { - buffer = buffer.slice(0, -1); - } - } else if ( event.key === Qt.Key_Escape ) { - buffer = ""; - } else if (" abcdefghijklmnopqrstuvwxyz1234567890`~!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?".includes(event.text.toLowerCase())) { - buffer += event.text; - } - } + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + passwd.start(); + } else if (event.key === Qt.Key_Backspace) { + if (event.modifiers & Qt.ControlModifier) { + buffer = ""; + } else { + buffer = buffer.slice(0, -1); + } + } else if (" abcdefghijklmnopqrstuvwxyz1234567890`~!@#$%^&*()-_=+[{]}\\|;:'\",<.>/?".includes(event.text.toLowerCase())) { + // No illegal characters (you are insane if you use unicode in your password) + buffer += event.text; + } + } - PamContext { - id: passwd + PamContext { + id: passwd - config: "passwd" - configDirectory: Quickshell.shellDir + "/assets/pam.d" + config: "passwd" + configDirectory: Quickshell.shellDir + "/assets/pam.d" - onMessageChanged: { - if ( message.startsWith( "The account is locked" )) - root.lockMessage = message; - else if ( root.lockMessage && message.endsWith( " left to unlock)" )) - root.lockMessage += "\n" + message; - } + onMessageChanged: { + if (message.startsWith("The account is locked")) + root.lockMessage = message; + else if (root.lockMessage && message.endsWith(" left to unlock)")) + root.lockMessage += "\n" + message; + } - onResponseRequiredChanged: { - if ( !responseRequired ) - return; + onResponseRequiredChanged: { + if (!responseRequired) + return; - respond(root.buffer); - root.buffer = ""; - } + respond(root.buffer); + root.buffer = ""; + } - onCompleted: res => { - if (res === PamResult.Success) - return root.lock.locked = false; + onCompleted: res => { + if (res === PamResult.Success) + return root.lock.unlock(); - if (res === PamResult.Error) - root.state = "error"; - else if (res === PamResult.MaxTries) - root.state = "max"; - else if (res === PamResult.Failed) - root.state = "fail"; + if (res === PamResult.Error) + root.state = "error"; + else if (res === PamResult.MaxTries) + root.state = "max"; + else if (res === PamResult.Failed) + root.state = "fail"; - root.flashMsg(); - stateReset.restart(); - } - } + root.flashMsg(); + stateReset.restart(); + } + } - Timer { - id: stateReset + PamContext { + id: fprint - interval: 4000 - onTriggered: { - if (root.state !== "max") - root.state = ""; - } - } + property bool available + property int tries + property int errorTries - Connections { - target: root.lock + function checkAvail(): void { + if (!available || !Config.lock.enableFprint || !root.lock.secure) { + abort(); + return; + } - function onSecureChanged(): void { - if (root.lock.secure) { - root.buffer = ""; - root.state = ""; - root.fprintState = ""; - root.lockMessage = ""; - } - } - } + tries = 0; + errorTries = 0; + start(); + } + + config: "fprint" + configDirectory: Quickshell.shellDir + "/assets/pam.d" + + onCompleted: res => { + if (!available) + return; + + if (res === PamResult.Success) + return root.lock.unlock(); + + if (res === PamResult.Error) { + root.fprintState = "error"; + errorTries++; + if (errorTries < 5) { + abort(); + errorRetry.restart(); + } + } else if (res === PamResult.MaxTries) { + // Isn't actually the real max tries as pam only reports completed + // when max tries is reached. + tries++; + if (tries < Config.lock.maxFprintTries) { + // Restart if not actually real max tries + root.fprintState = "fail"; + start(); + } else { + root.fprintState = "max"; + abort(); + } + } + + root.flashMsg(); + fprintStateReset.start(); + } + } + + Process { + id: availProc + + command: ["sh", "-c", "fprintd-list $USER"] + onExited: code => { + fprint.available = code === 0; + fprint.checkAvail(); + } + } + + Timer { + id: errorRetry + + interval: 800 + onTriggered: fprint.start() + } + + Timer { + id: stateReset + + interval: 4000 + onTriggered: { + if (root.state !== "max") + root.state = ""; + } + } + + Timer { + id: fprintStateReset + + interval: 4000 + onTriggered: { + root.fprintState = ""; + fprint.errorTries = 0; + } + } + + Connections { + target: root.lock + + function onSecureChanged(): void { + if (root.lock.secure) { + availProc.running = true; + root.buffer = ""; + root.state = ""; + root.fprintState = ""; + root.lockMessage = ""; + } + } + + function onUnlock(): void { + fprint.abort(); + } + } + + Connections { + target: Config.lock + + function onEnableFprintChanged(): void { + fprint.checkAvail(); + } + } } diff --git a/Modules/Lock/Resources.qml b/Modules/Lock/Resources.qml new file mode 100644 index 0000000..3eaa2d7 --- /dev/null +++ b/Modules/Lock/Resources.qml @@ -0,0 +1,80 @@ +import QtQuick +import QtQuick.Layouts +import qs.Modules +import qs.Components +import qs.Helpers +import qs.Config + +GridLayout { + id: root + + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.large + + rowSpacing: Appearance.spacing.large + columnSpacing: Appearance.spacing.large + rows: 1 + columns: 2 + + Ref { + service: SystemUsage + } + + Resource { + Layout.bottomMargin: Appearance.padding.large + Layout.topMargin: Appearance.padding.large + icon: "memory" + value: SystemUsage.cpuPerc + colour: DynamicColors.palette.m3primary + } + + Resource { + Layout.bottomMargin: Appearance.padding.large + Layout.topMargin: Appearance.padding.large + icon: "memory_alt" + value: SystemUsage.memPerc + colour: DynamicColors.palette.m3secondary + } + + component Resource: CustomRect { + id: res + + required property string icon + required property real value + required property color colour + + Layout.fillWidth: true + implicitHeight: width + + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + radius: Appearance.rounding.large + + CircularProgress { + id: circ + + anchors.fill: parent + value: res.value + padding: Appearance.padding.large * 3 + fgColour: res.colour + bgColour: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 3) + strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: res.icon + color: res.colour + font.pointSize: (circ.arcRadius * 0.7) || 1 + font.weight: 600 + } + + Behavior on value { + Anim { + duration: Appearance.anim.durations.large + } + } + } +} diff --git a/Modules/Lock/WeatherInfo.qml b/Modules/Lock/WeatherInfo.qml new file mode 100644 index 0000000..b7c8e77 --- /dev/null +++ b/Modules/Lock/WeatherInfo.qml @@ -0,0 +1,175 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Helpers +import qs.Config + +ColumnLayout { + id: root + + required property int rootHeight + + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Appearance.padding.large * 2 + + spacing: Appearance.spacing.small + + Loader { + Layout.topMargin: Appearance.padding.large * 2 + Layout.bottomMargin: -Appearance.padding.large + Layout.alignment: Qt.AlignHCenter + + active: root.rootHeight > 610 + visible: active + + sourceComponent: CustomText { + text: qsTr("Weather") + color: DynamicColors.palette.m3primary + font.pointSize: Appearance.font.size.extraLarge + font.weight: 500 + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.large + + MaterialIcon { + animate: true + text: Weather.icon + color: DynamicColors.palette.m3secondary + font.pointSize: Appearance.font.size.extraLarge * 2.5 + } + + ColumnLayout { + spacing: Appearance.spacing.small + + CustomText { + Layout.fillWidth: true + + animate: true + text: Weather.description + color: DynamicColors.palette.m3secondary + font.pointSize: Appearance.font.size.large + font.weight: 500 + elide: Text.ElideRight + } + + CustomText { + Layout.fillWidth: true + + animate: true + text: qsTr("Humidity: %1%").arg(Weather.humidity) + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + } + + Loader { + Layout.rightMargin: Appearance.padding.smaller + active: root.width > 400 + visible: active + + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.small + + CustomText { + Layout.fillWidth: true + + animate: true + text: Weather.temp + color: DynamicColors.palette.m3primary + horizontalAlignment: Text.AlignRight + font.pointSize: Appearance.font.size.extraLarge + font.weight: 500 + elide: Text.ElideLeft + } + + CustomText { + Layout.fillWidth: true + + animate: true + text: qsTr("Feels like: %1").arg(Weather.feelsLike) + color: DynamicColors.palette.m3outline + horizontalAlignment: Text.AlignRight + font.pointSize: Appearance.font.size.smaller + elide: Text.ElideLeft + } + } + } + } + + Loader { + id: forecastLoader + + Layout.topMargin: Appearance.spacing.smaller + Layout.bottomMargin: Appearance.padding.large * 2 + Layout.fillWidth: true + + active: root.rootHeight > 820 + visible: active + + sourceComponent: RowLayout { + spacing: Appearance.spacing.large + + Repeater { + model: { + const forecast = Weather.hourlyForecast; + const count = root.width < 320 ? 3 : root.width < 400 ? 4 : 5; + if (!forecast) + return Array.from({ + length: count + }, () => null); + + return forecast.slice(0, count); + } + + ColumnLayout { + id: forecastHour + + required property var modelData + + Layout.fillWidth: true + spacing: Appearance.spacing.small + + CustomText { + Layout.fillWidth: true + text: { + const hour = forecastHour.modelData?.hour ?? 0; + return hour > 12 ? `${(hour - 12).toString().padStart(2, "0")} PM` : `${hour.toString().padStart(2, "0")} AM`; + } + color: DynamicColors.palette.m3outline + horizontalAlignment: Text.AlignHCenter + font.pointSize: Appearance.font.size.larger + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: forecastHour.modelData?.icon ?? "cloud_alert" + font.pointSize: Appearance.font.size.extraLarge * 1.5 + font.weight: 500 + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: Config.services.useFahrenheit ? `${forecastHour.modelData?.tempF ?? 0}°F` : `${forecastHour.modelData?.tempC ?? 0}°C` + color: DynamicColors.palette.m3secondary + font.pointSize: Appearance.font.size.larger + } + } + } + } + } + + Timer { + running: true + triggeredOnStart: true + repeat: true + interval: 900000 // 15 minutes + onTriggered: Weather.reload() + } +} diff --git a/Modules/Osd/Background.qml b/Modules/Osd/Background.qml index e74647b..90f7711 100644 --- a/Modules/Osd/Background.qml +++ b/Modules/Osd/Background.qml @@ -9,7 +9,7 @@ ShapePath { id: root required property Wrapper wrapper - readonly property real rounding: 8 + readonly property real rounding: 10 readonly property bool flatten: wrapper.width < rounding * 2 readonly property real roundingX: flatten ? wrapper.width / 2 : rounding diff --git a/Modules/Time.qml b/Modules/Time.qml index 876a034..230f413 100644 --- a/Modules/Time.qml +++ b/Modules/Time.qml @@ -5,17 +5,22 @@ import QtQuick Singleton { id: root - readonly property string time: { - Qt.formatDateTime(clock.date, "ddd d MMM - hh:mm:ss") - } + property alias enabled: clock.enabled + readonly property date date: clock.date + readonly property int hours: clock.hours + readonly property int minutes: clock.minutes + readonly property int seconds: clock.seconds - readonly property string shortTime: { - Qt.formatDateTime(clock.date, "hh:mm") - } + readonly property string timeStr: format("hh:mm:ss") + readonly property string dateStr: format("ddd d MMM - hh:mm:ss") + readonly property list timeComponents: timeStr.split(":") + readonly property string hourStr: timeComponents[0] ?? "" + readonly property string minuteStr: timeComponents[1] ?? "" + readonly property string secondStr: timeComponents[2] ?? "" - readonly property string longTime: { - Qt.formatDateTime(clock.date, "hh:mm:ss") - } + function format(fmt: string): string { + return Qt.formatDateTime(clock.date, fmt); + } SystemClock { id: clock diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 46c9786..1883d73 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -4,9 +4,12 @@ qml_module(ZShell-internal hyprextras.hpp hyprextras.cpp hyprdevices.hpp hyprdevices.cpp cachingimagemanager.hpp cachingimagemanager.cpp + circularindicatormanager.hpp circularindicatormanager.cpp LIBRARIES Qt::Gui Qt::Quick Qt::Concurrent Qt::Core + Qt::Network + Qt::DBus ) diff --git a/Plugins/ZShell/Internal/circularindicatormanager.cpp b/Plugins/ZShell/Internal/circularindicatormanager.cpp new file mode 100644 index 0000000..da09c7c --- /dev/null +++ b/Plugins/ZShell/Internal/circularindicatormanager.cpp @@ -0,0 +1,211 @@ +#include "circularindicatormanager.hpp" +#include +#include + +namespace { + +namespace advance { + +constexpr qint32 TOTAL_CYCLES = 4; +constexpr qint32 TOTAL_DURATION_IN_MS = 5400; +constexpr qint32 DURATION_TO_EXPAND_IN_MS = 667; +constexpr qint32 DURATION_TO_COLLAPSE_IN_MS = 667; +constexpr qint32 DURATION_TO_COMPLETE_END_IN_MS = 333; +constexpr qint32 TAIL_DEGREES_OFFSET = -20; +constexpr qint32 EXTRA_DEGREES_PER_CYCLE = 250; +constexpr qint32 CONSTANT_ROTATION_DEGREES = 1520; + +constexpr std::array DELAY_TO_EXPAND_IN_MS = { 0, 1350, 2700, 4050 }; +constexpr std::array DELAY_TO_COLLAPSE_IN_MS = { 667, 2017, 3367, 4717 }; + +} // namespace advance + +namespace retreat { + +constexpr qint32 TOTAL_DURATION_IN_MS = 6000; +constexpr qint32 DURATION_SPIN_IN_MS = 500; +constexpr qint32 DURATION_GROW_ACTIVE_IN_MS = 3000; +constexpr qint32 DURATION_SHRINK_ACTIVE_IN_MS = 3000; +constexpr std::array DELAY_SPINS_IN_MS = { 0, 1500, 3000, 4500 }; +constexpr qint32 DELAY_GROW_ACTIVE_IN_MS = 0; +constexpr qint32 DELAY_SHRINK_ACTIVE_IN_MS = 3000; +constexpr qint32 DURATION_TO_COMPLETE_END_IN_MS = 500; + +// Constants for animation values. + +// The total degrees that a constant rotation goes by. +constexpr qint32 CONSTANT_ROTATION_DEGREES = 1080; +// Despite of the constant rotation, there are also 5 extra rotations the entire animation. The +// total degrees that each extra rotation goes by. +constexpr qint32 SPIN_ROTATION_DEGREES = 90; +constexpr std::array END_FRACTION_RANGE = { 0.10, 0.87 }; + +} // namespace retreat + +inline qreal getFractionInRange(qreal playtime, qreal start, qreal duration) { + const auto fraction = (playtime - start) / duration; + return std::clamp(fraction, 0.0, 1.0); +} + +} // namespace + +namespace ZShell::internal { + +CircularIndicatorManager::CircularIndicatorManager(QObject* parent) + : QObject(parent) + , m_type(IndeterminateAnimationType::Advance) + , m_curve(QEasingCurve(QEasingCurve::BezierSpline)) + , m_progress(0) + , m_startFraction(0) + , m_endFraction(0) + , m_rotation(0) + , m_completeEndProgress(0) { + // Fast out slow in + m_curve.addCubicBezierSegment({ 0.4, 0.0 }, { 0.2, 1.0 }, { 1.0, 1.0 }); +} + +qreal CircularIndicatorManager::startFraction() const { + return m_startFraction; +} + +qreal CircularIndicatorManager::endFraction() const { + return m_endFraction; +} + +qreal CircularIndicatorManager::rotation() const { + return m_rotation; +} + +qreal CircularIndicatorManager::progress() const { + return m_progress; +} + +void CircularIndicatorManager::setProgress(qreal progress) { + update(progress); +} + +qreal CircularIndicatorManager::duration() const { + if (m_type == IndeterminateAnimationType::Advance) { + return advance::TOTAL_DURATION_IN_MS; + } else { + return retreat::TOTAL_DURATION_IN_MS; + } +} + +qreal CircularIndicatorManager::completeEndDuration() const { + if (m_type == IndeterminateAnimationType::Advance) { + return advance::DURATION_TO_COMPLETE_END_IN_MS; + } else { + return retreat::DURATION_TO_COMPLETE_END_IN_MS; + } +} + +CircularIndicatorManager::IndeterminateAnimationType CircularIndicatorManager::indeterminateAnimationType() const { + return m_type; +} + +void CircularIndicatorManager::setIndeterminateAnimationType(IndeterminateAnimationType t) { + if (m_type != t) { + m_type = t; + emit indeterminateAnimationTypeChanged(); + } +} + +qreal CircularIndicatorManager::completeEndProgress() const { + return m_completeEndProgress; +} + +void CircularIndicatorManager::setCompleteEndProgress(qreal progress) { + if (qFuzzyCompare(m_completeEndProgress + 1.0, progress + 1.0)) { + return; + } + + m_completeEndProgress = progress; + emit completeEndProgressChanged(); + + update(m_progress); +} + +void CircularIndicatorManager::update(qreal progress) { + if (qFuzzyCompare(m_progress + 1.0, progress + 1.0)) { + return; + } + + if (m_type == IndeterminateAnimationType::Advance) { + updateAdvance(progress); + } else { + updateRetreat(progress); + } + + m_progress = progress; + emit progressChanged(); +} + +void CircularIndicatorManager::updateRetreat(qreal progress) { + using namespace retreat; + const auto playtime = progress * TOTAL_DURATION_IN_MS; + + // Constant rotation. + const qreal constantRotation = CONSTANT_ROTATION_DEGREES * progress; + // Extra rotation for the faster spinning. + qreal spinRotation = 0; + for (const int spinDelay : DELAY_SPINS_IN_MS) { + spinRotation += m_curve.valueForProgress(getFractionInRange(playtime, spinDelay, DURATION_SPIN_IN_MS)) * + SPIN_ROTATION_DEGREES; + } + m_rotation = constantRotation + spinRotation; + emit rotationChanged(); + + // Grow active indicator. + qreal fraction = + m_curve.valueForProgress(getFractionInRange(playtime, DELAY_GROW_ACTIVE_IN_MS, DURATION_GROW_ACTIVE_IN_MS)); + fraction -= + m_curve.valueForProgress(getFractionInRange(playtime, DELAY_SHRINK_ACTIVE_IN_MS, DURATION_SHRINK_ACTIVE_IN_MS)); + + if (!qFuzzyIsNull(m_startFraction)) { + m_startFraction = 0.0; + emit startFractionChanged(); + } + const auto oldEndFrac = m_endFraction; + m_endFraction = std::lerp(END_FRACTION_RANGE[0], END_FRACTION_RANGE[1], fraction); + + // Completing animation. + if (m_completeEndProgress > 0) { + m_endFraction *= 1 - m_completeEndProgress; + } + + if (!qFuzzyCompare(m_endFraction + 1.0, oldEndFrac + 1.0)) { + emit endFractionChanged(); + } +} + +void CircularIndicatorManager::updateAdvance(qreal progress) { + using namespace advance; + const auto playtime = progress * TOTAL_DURATION_IN_MS; + + // Adds constant rotation to segment positions. + m_startFraction = CONSTANT_ROTATION_DEGREES * progress + TAIL_DEGREES_OFFSET; + m_endFraction = CONSTANT_ROTATION_DEGREES * progress; + + // Adds cycle specific rotation to segment positions. + for (size_t cycleIndex = 0; cycleIndex < TOTAL_CYCLES; ++cycleIndex) { + // While expanding. + qreal fraction = getFractionInRange(playtime, DELAY_TO_EXPAND_IN_MS[cycleIndex], DURATION_TO_EXPAND_IN_MS); + m_endFraction += m_curve.valueForProgress(fraction) * EXTRA_DEGREES_PER_CYCLE; + + // While collapsing. + fraction = getFractionInRange(playtime, DELAY_TO_COLLAPSE_IN_MS[cycleIndex], DURATION_TO_COLLAPSE_IN_MS); + m_startFraction += m_curve.valueForProgress(fraction) * EXTRA_DEGREES_PER_CYCLE; + } + + // Closes the gap between head and tail for complete end. + m_startFraction += (m_endFraction - m_startFraction) * m_completeEndProgress; + + m_startFraction /= 360.0; + m_endFraction /= 360.0; + + emit startFractionChanged(); + emit endFractionChanged(); +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/circularindicatormanager.hpp b/Plugins/ZShell/Internal/circularindicatormanager.hpp new file mode 100644 index 0000000..c119c53 --- /dev/null +++ b/Plugins/ZShell/Internal/circularindicatormanager.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::internal { + +class CircularIndicatorManager : public QObject { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY(qreal startFraction READ startFraction NOTIFY startFractionChanged) +Q_PROPERTY(qreal endFraction READ endFraction NOTIFY endFractionChanged) +Q_PROPERTY(qreal rotation READ rotation NOTIFY rotationChanged) +Q_PROPERTY(qreal progress READ progress WRITE setProgress NOTIFY progressChanged) +Q_PROPERTY(qreal completeEndProgress READ completeEndProgress WRITE setCompleteEndProgress NOTIFY + completeEndProgressChanged) +Q_PROPERTY(qreal duration READ duration NOTIFY indeterminateAnimationTypeChanged) +Q_PROPERTY(qreal completeEndDuration READ completeEndDuration NOTIFY indeterminateAnimationTypeChanged) +Q_PROPERTY(IndeterminateAnimationType indeterminateAnimationType READ indeterminateAnimationType WRITE + setIndeterminateAnimationType NOTIFY indeterminateAnimationTypeChanged) + +public: +explicit CircularIndicatorManager(QObject* parent = nullptr); + +enum IndeterminateAnimationType { + Advance = 0, + Retreat +}; +Q_ENUM(IndeterminateAnimationType) + +[[nodiscard]] qreal startFraction() const; +[[nodiscard]] qreal endFraction() const; +[[nodiscard]] qreal rotation() const; + +[[nodiscard]] qreal progress() const; +void setProgress(qreal progress); + +[[nodiscard]] qreal completeEndProgress() const; +void setCompleteEndProgress(qreal progress); + +[[nodiscard]] qreal duration() const; +[[nodiscard]] qreal completeEndDuration() const; + +[[nodiscard]] IndeterminateAnimationType indeterminateAnimationType() const; +void setIndeterminateAnimationType(IndeterminateAnimationType t); + +signals: +void startFractionChanged(); +void endFractionChanged(); +void rotationChanged(); +void progressChanged(); +void completeEndProgressChanged(); +void indeterminateAnimationTypeChanged(); + +private: +IndeterminateAnimationType m_type; +QEasingCurve m_curve; + +qreal m_progress; +qreal m_startFraction; +qreal m_endFraction; +qreal m_rotation; +qreal m_completeEndProgress; + +void update(qreal progress); +void updateAdvance(qreal progress); +void updateRetreat(qreal progress); +}; + +} // namespace ZShell::internal