diff --git a/CMakeLists.txt b/CMakeLists.txt index cbc124a..d519974 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,7 @@ set(ENABLE_MODULES "plugin;shell" CACHE STRING "Modules to build/install") set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir") set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir") set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") +set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir") add_compile_options( -Wall -Wextra -Wpedantic -Wshadow -Wconversion @@ -31,4 +32,5 @@ if("shell" IN_LIST ENABLE_MODULES) install(DIRECTORY ${dir} DESTINATION "${INSTALL_QSCONFDIR}") endforeach() install(FILES shell.qml DESTINATION "${INSTALL_QSCONFDIR}") + install(DIRECTORY Greeter DESTINATION "${INSTALL_GREETERCONFDIR}") endif() diff --git a/Greeter/Center.qml b/Greeter/Center.qml new file mode 100644 index 0000000..d76dc8d --- /dev/null +++ b/Greeter/Center.qml @@ -0,0 +1,327 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Paths +import qs.Components +import qs.Helpers +import qs.Config + +ColumnLayout { + id: root + + readonly property real centerScale: Math.min(1, screenHeight / 1440) + readonly property int centerWidth: Config.lock.sizes.centerWidth * centerScale + required property var greeter + required property real screenHeight + + Layout.fillHeight: true + Layout.fillWidth: false + Layout.preferredWidth: centerWidth + spacing: Appearance.spacing.large * 2 + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Appearance.spacing.small + + CustomText { + Layout.alignment: Qt.AlignVCenter + color: DynamicColors.palette.m3secondary + font.bold: true + font.family: Appearance.font.family.clock + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) + text: Time.hourStr + } + + CustomText { + Layout.alignment: Qt.AlignVCenter + color: DynamicColors.palette.m3primary + font.bold: true + font.family: Appearance.font.family.clock + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) + text: ":" + } + + CustomText { + Layout.alignment: Qt.AlignVCenter + color: DynamicColors.palette.m3secondary + font.bold: true + font.family: Appearance.font.family.clock + font.pointSize: Math.floor(Appearance.font.size.extraLarge * 3 * root.centerScale) + text: Time.minuteStr + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: -Appearance.padding.large * 2 + color: DynamicColors.palette.m3tertiary + font.bold: true + font.family: Appearance.font.family.mono + font.pointSize: Math.floor(Appearance.font.size.extraLarge * root.centerScale) + text: Time.format("dddd, d MMMM yyyy") + } + + CustomClippingRect { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.spacing.large * 2 + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: root.centerWidth / 2 + implicitWidth: root.centerWidth / 2 + radius: Appearance.rounding.full + + MaterialIcon { + anchors.centerIn: parent + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Math.floor(root.centerWidth / 4) + text: "person" + } + + CachingImage { + id: pfp + + anchors.fill: parent + path: `${Paths.home}/.face` + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurfaceVariant + font.family: Appearance.font.family.mono + font.pointSize: Appearance.font.size.normal + font.weight: 600 + text: root.greeter.username + visible: text.length > 0 + } + + CustomRect { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.tPalette.m3surfaceContainer + focus: true + implicitHeight: input.implicitHeight + Appearance.padding.small * 2 + implicitWidth: root.centerWidth * 0.8 + radius: Appearance.rounding.full + + Keys.onPressed: event => { + if (root.greeter.launching) + return; + + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) + inputField.placeholder.animate = false; + + root.greeter.handleKey(event); + } + onActiveFocusChanged: { + if (!activeFocus) + forceActiveFocus(); + } + + StateLayer { + function onClicked(): void { + parent.forceActiveFocus(); + } + + cursorShape: Qt.IBeamCursor + hoverEnabled: false + } + + RowLayout { + id: input + + anchors.fill: parent + anchors.margins: Appearance.padding.small + spacing: Appearance.spacing.normal + + Item { + implicitHeight: statusIcon.implicitHeight + Appearance.padding.small * 2 + implicitWidth: implicitHeight + + MaterialIcon { + id: statusIcon + + anchors.centerIn: parent + animate: true + color: root.greeter.errorMessage ? DynamicColors.palette.m3error : DynamicColors.palette.m3onSurface + opacity: root.greeter.launching ? 0 : 1 + text: { + if (root.greeter.errorMessage) + return "error"; + if (root.greeter.awaitingResponse) + return root.greeter.echoResponse ? "person" : "lock"; + if (root.greeter.buffer.length > 0) + return "password"; + return "login"; + } + + Behavior on opacity { + Anim { + } + } + } + + CircularIndicator { + anchors.fill: parent + running: root.greeter.launching + } + } + + InputField { + id: inputField + + greeter: root.greeter + } + + CustomRect { + color: root.greeter.buffer && !root.greeter.launching ? DynamicColors.palette.m3primary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + implicitHeight: enterIcon.implicitHeight + Appearance.padding.small * 2 + implicitWidth: implicitHeight + radius: Appearance.rounding.full + + StateLayer { + function onClicked(): void { + root.greeter.submit(); + } + + color: root.greeter.buffer && !root.greeter.launching ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + } + + MaterialIcon { + id: enterIcon + + anchors.centerIn: parent + color: root.greeter.buffer && !root.greeter.launching ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + font.weight: 500 + text: root.greeter.launching ? "hourglass_top" : "arrow_forward" + } + } + } + } + + Item { + Layout.fillWidth: true + Layout.topMargin: -Appearance.spacing.large + implicitHeight: message.implicitHeight + + Behavior on implicitHeight { + Anim { + } + } + + CustomText { + id: message + + readonly property bool isError: !!root.greeter.errorMessage + readonly property string msg: { + if (root.greeter.errorMessage) + return root.greeter.errorMessage; + + if (root.greeter.launching) { + if (root.greeter.selectedSession && root.greeter.selectedSession.name) + return qsTr("Starting %1...").arg(root.greeter.selectedSession.name); + return qsTr("Starting session..."); + } + + if (root.greeter.awaitingResponse && root.greeter.promptMessage) + return root.greeter.promptMessage; + + return ""; + } + + anchors.left: parent.left + anchors.right: parent.right + color: isError ? DynamicColors.palette.m3error : DynamicColors.palette.m3primary + font.family: Appearance.font.family.mono + font.pointSize: Appearance.font.size.small + horizontalAlignment: Qt.AlignHCenter + opacity: 0 + scale: 0.7 + 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 { + function onFlashMsg(): void { + exitAnim.stop(); + if (message.scale < 1) + appearAnim.restart(); + else + flashAnim.restart(); + } + + target: root.greeter + } + + Anim { + id: appearAnim + + properties: "scale,opacity" + target: message + to: 1 + + onFinished: flashAnim.restart() + } + + SequentialAnimation { + id: flashAnim + + loops: 2 + + FlashAnim { + to: 0.3 + } + + FlashAnim { + to: 1 + } + } + + ParallelAnimation { + id: exitAnim + + Anim { + duration: Appearance.anim.durations.large + property: "scale" + target: message + to: 0.7 + } + + Anim { + duration: Appearance.anim.durations.large + property: "opacity" + target: message + to: 0 + } + } + } + } + + component FlashAnim: NumberAnimation { + duration: Appearance.anim.durations.small + easing.type: Easing.Linear + property: "opacity" + target: message + } +} diff --git a/Greeter/Components/Anim.qml b/Greeter/Components/Anim.qml new file mode 100644 index 0000000..242354f --- /dev/null +++ b/Greeter/Components/Anim.qml @@ -0,0 +1,8 @@ +import QtQuick +import qs.Config + +NumberAnimation { + duration: MaterialEasing.standardTime + easing.bezierCurve: MaterialEasing.standard + easing.type: Easing.BezierSpline +} diff --git a/Greeter/Components/AnimatedTabIndexPair.qml b/Greeter/Components/AnimatedTabIndexPair.qml new file mode 100644 index 0000000..d8fb656 --- /dev/null +++ b/Greeter/Components/AnimatedTabIndexPair.qml @@ -0,0 +1,24 @@ +import QtQuick + +QtObject { + id: root + + property real idx1: index + property int idx1Duration: 100 + property real idx2: index + property int idx2Duration: 300 + required property int index + + Behavior on idx1 { + NumberAnimation { + duration: root.idx1Duration + easing.type: Easing.OutSine + } + } + Behavior on idx2 { + NumberAnimation { + duration: root.idx2Duration + easing.type: Easing.OutSine + } + } +} diff --git a/Greeter/Components/BaseStyledSlider.qml b/Greeter/Components/BaseStyledSlider.qml new file mode 100644 index 0000000..48c8f33 --- /dev/null +++ b/Greeter/Components/BaseStyledSlider.qml @@ -0,0 +1,165 @@ +import QtQuick +import QtQuick.Templates +import qs.Config + +Slider { + id: root + + property color color: DynamicColors.palette.m3secondary + required property string icon + property bool initialized: false + readonly property bool isHorizontal: orientation === Qt.Horizontal + readonly property bool isVertical: orientation === Qt.Vertical + property real multiplier: 100 + property real oldValue + + // Wrapper components can inject their own track visuals here. + property Component trackContent + + // Keep current behavior for existing usages. + orientation: Qt.Vertical + + background: CustomRect { + id: groove + + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainer, 2) + height: root.availableHeight + radius: Appearance.rounding.full + width: root.availableWidth + x: root.leftPadding + y: root.topPadding + + Loader { + id: trackLoader + + anchors.fill: parent + sourceComponent: root.trackContent + + onLoaded: { + if (!item) + return; + + item.rootSlider = root; + item.groove = groove; + item.handleItem = handle; + } + } + } + handle: Item { + id: handle + + property alias moving: icon.moving + + implicitHeight: Math.min(root.width, root.height) + implicitWidth: Math.min(root.width, root.height) + x: root.isHorizontal ? root.leftPadding + root.visualPosition * (root.availableWidth - width) : root.leftPadding + (root.availableWidth - width) / 2 + y: root.isVertical ? root.topPadding + root.visualPosition * (root.availableHeight - height) : root.topPadding + (root.availableHeight - height) / 2 + + Elevation { + anchors.fill: parent + level: handleInteraction.containsMouse ? 2 : 1 + radius: rect.radius + } + + CustomRect { + id: rect + + anchors.fill: parent + color: DynamicColors.palette.m3inverseSurface + radius: Appearance.rounding.full + + MouseArea { + id: handleInteraction + + acceptedButtons: Qt.NoButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + } + + MaterialIcon { + id: icon + + property bool moving + + function update(): void { + animate = !moving; + binding.when = moving; + font.pointSize = moving ? Appearance.font.size.small : Appearance.font.size.larger; + font.family = moving ? Appearance.font.family.sans : Appearance.font.family.material; + } + + anchors.centerIn: parent + color: DynamicColors.palette.m3inverseOnSurface + text: root.icon + + onMovingChanged: anim.restart() + + Binding { + id: binding + + property: "text" + target: icon + value: Math.round(root.value * root.multiplier) + when: false + } + + SequentialAnimation { + id: anim + + Anim { + duration: Appearance.anim.durations.normal / 2 + easing.bezierCurve: Appearance.anim.curves.standardAccel + property: "scale" + target: icon + to: 0 + } + + ScriptAction { + script: icon.update() + } + + Anim { + duration: Appearance.anim.durations.normal / 2 + easing.bezierCurve: Appearance.anim.curves.standardDecel + property: "scale" + target: icon + to: 1 + } + } + } + } + } + Behavior on value { + Anim { + duration: Appearance.anim.durations.large + } + } + + onPressedChanged: handle.moving = pressed + onValueChanged: { + if (!initialized) { + initialized = true; + oldValue = value; + return; + } + + if (Math.abs(value - oldValue) < 0.01) + return; + + oldValue = value; + handle.moving = true; + stateChangeDelay.restart(); + } + + Timer { + id: stateChangeDelay + + interval: 500 + + onTriggered: { + if (!root.pressed) + handle.moving = false; + } + } +} diff --git a/Greeter/Components/CAnim.qml b/Greeter/Components/CAnim.qml new file mode 100644 index 0000000..d5be385 --- /dev/null +++ b/Greeter/Components/CAnim.qml @@ -0,0 +1,8 @@ +import QtQuick +import qs.Config + +ColorAnimation { + duration: MaterialEasing.standardTime + easing.bezierCurve: MaterialEasing.standard + easing.type: Easing.BezierSpline +} diff --git a/Greeter/Components/CircularIndicator.qml b/Greeter/Components/CircularIndicator.qml new file mode 100644 index 0000000..662c571 --- /dev/null +++ b/Greeter/Components/CircularIndicator.qml @@ -0,0 +1,102 @@ +import qs.Config +import ZShell.Internal +import QtQuick +import QtQuick.Templates + +BusyIndicator { + id: root + + enum AnimState { + Stopped, + Running, + Completing + } + enum AnimType { + Advance = 0, + Retreat + } + + property int animState + property color bgColour: DynamicColors.palette.m3secondaryContainer + property color fgColour: DynamicColors.palette.m3primary + property real implicitSize: Appearance.font.size.normal * 3 + property real internalStrokeWidth: strokeWidth + readonly property alias progress: manager.progress + property real strokeWidth: Appearance.padding.small * 0.8 + property alias type: manager.indeterminateAnimationType + + implicitHeight: implicitSize + implicitWidth: implicitSize + padding: 0 + + contentItem: CircularProgress { + anchors.fill: parent + bgColour: root.bgColour + fgColour: root.fgColour + padding: root.padding + rotation: manager.rotation + startAngle: manager.startFraction * 360 + strokeWidth: root.internalStrokeWidth + value: manager.endFraction - manager.startFraction + } + states: State { + name: "stopped" + when: !root.running + + PropertyChanges { + root.internalStrokeWidth: root.strokeWidth / 3 + root.opacity: 0 + } + } + transitions: Transition { + Anim { + duration: manager.completeEndDuration * Appearance.anim.durations.scale + properties: "opacity,internalStrokeWidth" + } + } + + 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; + } + } + + CircularIndicatorManager { + id: manager + + } + + NumberAnimation { + duration: manager.duration * Appearance.anim.durations.scale + from: 0 + loops: Animation.Infinite + property: "progress" + running: root.animState !== CircularIndicator.Stopped + target: manager + to: 1 + } + + NumberAnimation { + duration: manager.completeEndDuration * Appearance.anim.durations.scale + from: 0 + property: "completeEndProgress" + running: root.animState === CircularIndicator.Completing + target: manager + to: 1 + + onFinished: { + if (root.animState === CircularIndicator.Completing) + root.animState = CircularIndicator.Stopped; + } + } +} diff --git a/Greeter/Components/CircularProgress.qml b/Greeter/Components/CircularProgress.qml new file mode 100644 index 0000000..fa1011c --- /dev/null +++ b/Greeter/Components/CircularProgress.qml @@ -0,0 +1,66 @@ +import QtQuick +import QtQuick.Shapes +import qs.Config + +Shape { + id: root + + readonly property real arcRadius: (size - padding - strokeWidth) / 2 + property color bgColour: DynamicColors.palette.m3secondaryContainer + property color fgColour: DynamicColors.palette.m3primary + readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI) + property int padding: 0 + readonly property real size: Math.min(width, height) + property int spacing: Appearance.spacing.small + property int startAngle: -90 + property int strokeWidth: Appearance.padding.smaller + readonly property real vValue: value || 1 / 360 + property real value + + asynchronous: true + preferredRendererType: Shape.CurveRenderer + + ShapePath { + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + fillColor: "transparent" + strokeColor: root.bgColour + strokeWidth: root.strokeWidth + + Behavior on strokeColor { + CAnim { + duration: Appearance.anim.durations.large + } + } + + PathAngleArc { + centerX: root.size / 2 + centerY: root.size / 2 + radiusX: root.arcRadius + radiusY: root.arcRadius + startAngle: root.startAngle + 360 * root.vValue + root.gapAngle + sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2) + } + } + + ShapePath { + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + fillColor: "transparent" + strokeColor: root.fgColour + strokeWidth: root.strokeWidth + + Behavior on strokeColor { + CAnim { + duration: Appearance.anim.durations.large + } + } + + PathAngleArc { + centerX: root.size / 2 + centerY: root.size / 2 + radiusX: root.arcRadius + radiusY: root.arcRadius + startAngle: root.startAngle + sweepAngle: 360 * root.vValue + } + } +} diff --git a/Greeter/Components/CollapsibleSection.qml b/Greeter/Components/CollapsibleSection.qml new file mode 100644 index 0000000..6d6fc3e --- /dev/null +++ b/Greeter/Components/CollapsibleSection.qml @@ -0,0 +1,135 @@ +import QtQuick +import QtQuick.Layouts +import qs.Config + +ColumnLayout { + id: root + + default property alias content: contentColumn.data + property string description: "" + property bool expanded: false + property bool nested: false + property bool showBackground: false + required property string title + + signal toggleRequested + + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Item { + id: sectionHeaderItem + + Layout.fillWidth: true + Layout.preferredHeight: Math.max(titleRow.implicitHeight + Appearance.padding.normal * 2, 48) + + RowLayout { + id: titleRow + + anchors.left: parent.left + anchors.leftMargin: Appearance.padding.normal + anchors.right: parent.right + anchors.rightMargin: Appearance.padding.normal + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.normal + + CustomText { + font.pointSize: Appearance.font.size.larger + font.weight: 500 + text: root.title + } + + Item { + Layout.fillWidth: true + } + + MaterialIcon { + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + rotation: root.expanded ? 180 : 0 + text: "expand_more" + + Behavior on rotation { + Anim { + duration: Appearance.anim.durations.small + easing.bezierCurve: Appearance.anim.curves.standard + } + } + } + } + + StateLayer { + function onClicked(): void { + root.toggleRequested(); + root.expanded = !root.expanded; + } + + anchors.fill: parent + color: DynamicColors.palette.m3onSurface + radius: Appearance.rounding.normal + showHoverBackground: false + } + } + + Item { + id: contentWrapper + + Layout.fillWidth: true + Layout.preferredHeight: root.expanded ? (contentColumn.implicitHeight + Appearance.spacing.small * 2) : 0 + clip: true + + Behavior on Layout.preferredHeight { + Anim { + easing.bezierCurve: Appearance.anim.curves.standard + } + } + + CustomRect { + id: backgroundRect + + anchors.fill: parent + color: DynamicColors.transparency.enabled ? DynamicColors.layer(DynamicColors.palette.m3surfaceContainer, root.nested ? 3 : 2) : (root.nested ? DynamicColors.palette.m3surfaceContainerHigh : DynamicColors.palette.m3surfaceContainer) + opacity: root.showBackground && root.expanded ? 1.0 : 0.0 + radius: Appearance.rounding.normal + visible: root.showBackground + + Behavior on opacity { + Anim { + easing.bezierCurve: Appearance.anim.curves.standard + } + } + } + + ColumnLayout { + id: contentColumn + + anchors.bottomMargin: Appearance.spacing.small + anchors.left: parent.left + anchors.leftMargin: Appearance.padding.normal + anchors.right: parent.right + anchors.rightMargin: Appearance.padding.normal + opacity: root.expanded ? 1.0 : 0.0 + spacing: Appearance.spacing.small + y: Appearance.spacing.small + + Behavior on opacity { + Anim { + easing.bezierCurve: Appearance.anim.curves.standard + } + } + + CustomText { + id: descriptionText + + Layout.bottomMargin: root.description !== "" ? Appearance.spacing.small : 0 + Layout.fillWidth: true + Layout.topMargin: root.description !== "" ? Appearance.spacing.smaller : 0 + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: root.description + visible: root.description !== "" + wrapMode: Text.Wrap + } + } + } +} diff --git a/Greeter/Components/ColorArcPicker.qml b/Greeter/Components/ColorArcPicker.qml new file mode 100644 index 0000000..c909511 --- /dev/null +++ b/Greeter/Components/ColorArcPicker.qml @@ -0,0 +1,208 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Config + +Item { + id: root + + readonly property real arcStartAngle: 0.75 * Math.PI + readonly property real arcSweep: 1.5 * Math.PI + property real currentHue: 0 + property bool dragActive: false + required property var drawing + readonly property real handleAngle: hueToAngle(currentHue) + readonly property real handleCenterX: width / 2 + radius * Math.cos(handleAngle) + readonly property real handleCenterY: height / 2 + radius * Math.sin(handleAngle) + property real handleSize: 32 + property real lastChromaticHue: 0 + readonly property real radius: (Math.min(width, height) - handleSize) / 2 + readonly property int segmentCount: 240 + readonly property color thumbColor: DynamicColors.palette.m3inverseSurface + readonly property color thumbContentColor: DynamicColors.palette.m3inverseOnSurface + readonly property color trackColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainer, 2) + + function hueToAngle(hue) { + return arcStartAngle + arcSweep * hue; + } + + function normalizeAngle(angle) { + const tau = Math.PI * 2; + let a = angle % tau; + if (a < 0) + a += tau; + return a; + } + + function pointIsOnTrack(x, y) { + const cx = width / 2; + const cy = height / 2; + const dx = x - cx; + const dy = y - cy; + const distance = Math.sqrt(dx * dx + dy * dy); + + return distance >= radius - handleSize / 2 && distance <= radius + handleSize / 2; + } + + function syncFromPenColor() { + if (!drawing) + return; + + const c = drawing.penColor; + + if (c.hsvSaturation > 0) { + currentHue = c.hsvHue; + lastChromaticHue = c.hsvHue; + } else { + currentHue = lastChromaticHue; + } + + canvas.requestPaint(); + } + + function updateHueFromPoint(x, y, force = false) { + const cx = width / 2; + const cy = height / 2; + const dx = x - cx; + const dy = y - cy; + + const distance = Math.sqrt(dx * dx + dy * dy); + + if (!force && (distance < radius - handleSize / 2 || distance > radius + handleSize / 2)) + return; + + const angle = normalizeAngle(Math.atan2(dy, dx)); + const start = normalizeAngle(arcStartAngle); + + let relative = angle - start; + if (relative < 0) + relative += Math.PI * 2; + + if (relative > arcSweep) { + const gap = Math.PI * 2 - arcSweep; + relative = relative < arcSweep + gap / 2 ? arcSweep : 0; + } + + currentHue = relative / arcSweep; + lastChromaticHue = currentHue; + drawing.penColor = Qt.hsva(currentHue, drawing.penColor.hsvSaturation, drawing.penColor.hsvValue, drawing.penColor.a); + } + + implicitHeight: 180 + implicitWidth: 220 + + Component.onCompleted: syncFromPenColor() + onCurrentHueChanged: canvas.requestPaint() + onDrawingChanged: syncFromPenColor() + onHandleSizeChanged: canvas.requestPaint() + onHeightChanged: canvas.requestPaint() + onWidthChanged: canvas.requestPaint() + + Connections { + function onPenColorChanged() { + root.syncFromPenColor(); + } + + target: root.drawing + } + + Canvas { + id: canvas + + anchors.fill: parent + renderStrategy: Canvas.Threaded + renderTarget: Canvas.Image + + Component.onCompleted: requestPaint() + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + ctx.clearRect(0, 0, width, height); + + const cx = width / 2; + const cy = height / 2; + const radius = root.radius; + const trackWidth = root.handleSize; + + // Background track: always show the full hue spectrum + for (let i = 0; i < root.segmentCount; ++i) { + const t1 = i / root.segmentCount; + const t2 = (i + 1) / root.segmentCount; + const a1 = root.arcStartAngle + root.arcSweep * t1; + const a2 = root.arcStartAngle + root.arcSweep * t2; + + ctx.beginPath(); + ctx.arc(cx, cy, radius, a1, a2); + ctx.lineWidth = trackWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = Qt.hsla(t1, 1.0, 0.5, 1.0); + ctx.stroke(); + } + } + } + + Item { + id: handle + + height: root.handleSize + width: root.handleSize + x: root.handleCenterX - width / 2 + y: root.handleCenterY - height / 2 + z: 1 + + Elevation { + anchors.fill: parent + level: handleHover.containsMouse ? 2 : 1 + radius: rect.radius + } + + Rectangle { + id: rect + + anchors.fill: parent + color: root.thumbColor + radius: width / 2 + + MouseArea { + id: handleHover + + acceptedButtons: Qt.NoButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + } + + Rectangle { + anchors.centerIn: parent + color: root.drawing ? root.drawing.penColor : Qt.hsla(root.currentHue, 1.0, 0.5, 1.0) + height: width + radius: width / 2 + width: parent.width - 12 + } + } + } + + MouseArea { + id: dragArea + + acceptedButtons: Qt.LeftButton + anchors.fill: parent + hoverEnabled: true + + onCanceled: { + root.dragActive = false; + } + onPositionChanged: mouse => { + if ((mouse.buttons & Qt.LeftButton) && root.dragActive) + root.updateHueFromPoint(mouse.x, mouse.y, true); + } + onPressed: mouse => { + root.dragActive = root.pointIsOnTrack(mouse.x, mouse.y); + if (root.dragActive) + root.updateHueFromPoint(mouse.x, mouse.y); + } + onReleased: { + root.dragActive = false; + } + } +} diff --git a/Greeter/Components/ColoredIcon.qml b/Greeter/Components/ColoredIcon.qml new file mode 100644 index 0000000..a4711c6 --- /dev/null +++ b/Greeter/Components/ColoredIcon.qml @@ -0,0 +1,34 @@ +pragma ComponentBehavior: Bound + +import ZShell +import Quickshell.Widgets +import QtQuick + +IconImage { + id: root + + required property color color + + asynchronous: true + layer.enabled: true + + layer.effect: Coloriser { + colorizationColor: root.color + sourceColor: analyser.dominantColour + } + + layer.onEnabledChanged: { + if (layer.enabled && status === Image.Ready) + analyser.requestUpdate(); + } + onStatusChanged: { + if (layer.enabled && status === Image.Ready) + analyser.requestUpdate(); + } + + ImageAnalyser { + id: analyser + + sourceItem: root + } +} diff --git a/Greeter/Components/Coloriser.qml b/Greeter/Components/Coloriser.qml new file mode 100644 index 0000000..77bdf8d --- /dev/null +++ b/Greeter/Components/Coloriser.qml @@ -0,0 +1,14 @@ +import QtQuick +import QtQuick.Effects + +MultiEffect { + property color sourceColor: "black" + + brightness: 1 - sourceColor.hslLightness + colorization: 1 + + Behavior on colorizationColor { + CAnim { + } + } +} diff --git a/Greeter/Components/CustomAudioSlider.qml b/Greeter/Components/CustomAudioSlider.qml new file mode 100644 index 0000000..f7d35af --- /dev/null +++ b/Greeter/Components/CustomAudioSlider.qml @@ -0,0 +1,70 @@ +import QtQuick +import QtQuick.Templates +import qs.Config + +Slider { + id: root + + property color nonPeakColor: DynamicColors.tPalette.m3primary + required property real peak + property color peakColor: DynamicColors.palette.m3primary + + background: Item { + CustomRect { + anchors.bottom: parent.bottom + anchors.bottomMargin: root.implicitHeight / 3 + anchors.left: parent.left + anchors.top: parent.top + anchors.topMargin: root.implicitHeight / 3 + bottomRightRadius: root.implicitHeight / 15 + color: root.nonPeakColor + implicitWidth: root.handle.x - root.implicitHeight + radius: 1000 + topRightRadius: root.implicitHeight / 15 + + CustomRect { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.top: parent.top + bottomRightRadius: root.implicitHeight / 15 + color: root.peakColor + implicitWidth: parent.width * root.peak + radius: 1000 + topRightRadius: root.implicitHeight / 15 + + Behavior on implicitWidth { + Anim { + duration: 50 + } + } + } + } + + CustomRect { + anchors.bottom: parent.bottom + anchors.bottomMargin: root.implicitHeight / 3 + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: root.implicitHeight / 3 + bottomLeftRadius: root.implicitHeight / 15 + color: DynamicColors.tPalette.m3surfaceContainer + implicitWidth: root.implicitWidth - root.handle.x - root.handle.implicitWidth - root.implicitHeight + radius: 1000 + topLeftRadius: root.implicitHeight / 15 + } + } + handle: CustomRect { + anchors.verticalCenter: parent.verticalCenter + color: DynamicColors.palette.m3primary + implicitHeight: 15 + implicitWidth: 5 + radius: 1000 + x: root.visualPosition * root.availableWidth - implicitWidth / 2 + + MouseArea { + acceptedButtons: Qt.NoButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + } + } +} diff --git a/Greeter/Components/CustomBusyIndicator.qml b/Greeter/Components/CustomBusyIndicator.qml new file mode 100644 index 0000000..877db30 --- /dev/null +++ b/Greeter/Components/CustomBusyIndicator.qml @@ -0,0 +1,69 @@ +import QtQuick +import QtQuick.Controls.Basic + +BusyIndicator { + id: control + + property int busySize: 64 + property color color: delegate.color + + contentItem: Item { + implicitHeight: control.busySize + implicitWidth: control.busySize + + Item { + id: item + + height: control.busySize + opacity: control.running ? 1 : 0 + width: control.busySize + x: parent.width / 2 - (control.busySize / 2) + y: parent.height / 2 - (control.busySize / 2) + + Behavior on opacity { + OpacityAnimator { + duration: 250 + } + } + + RotationAnimator { + duration: 1250 + from: 0 + loops: Animation.Infinite + running: control.visible && control.running + target: item + to: 360 + } + + Repeater { + id: repeater + + model: 6 + + CustomRect { + id: delegate + + required property int index + + color: control.color + implicitHeight: 10 + implicitWidth: 10 + radius: 5 + x: item.width / 2 - width / 2 + y: item.height / 2 - height / 2 + + transform: [ + Translate { + y: -Math.min(item.width, item.height) * 0.5 + 5 + }, + Rotation { + angle: delegate.index / repeater.count * 360 + origin.x: 5 + origin.y: 5 + } + ] + } + } + } + } +} diff --git a/Greeter/Components/CustomButton.qml b/Greeter/Components/CustomButton.qml new file mode 100644 index 0000000..adc521e --- /dev/null +++ b/Greeter/Components/CustomButton.qml @@ -0,0 +1,32 @@ +import QtQuick +import QtQuick.Controls +import qs.Config + +Button { + id: control + + property color bgColor: DynamicColors.palette.m3primary + property int radius: 4 + property color textColor: DynamicColors.palette.m3onPrimary + + background: CustomRect { + color: control.bgColor + opacity: control.enabled ? 1.0 : 0.5 + radius: control.radius + } + contentItem: CustomText { + color: control.textColor + horizontalAlignment: Text.AlignHCenter + opacity: control.enabled ? 1.0 : 0.5 + text: control.text + verticalAlignment: Text.AlignVCenter + } + + StateLayer { + function onClicked(): void { + control.clicked(); + } + + radius: control.radius + } +} diff --git a/Greeter/Components/CustomCheckbox.qml b/Greeter/Components/CustomCheckbox.qml new file mode 100644 index 0000000..55a728f --- /dev/null +++ b/Greeter/Components/CustomCheckbox.qml @@ -0,0 +1,37 @@ +import QtQuick +import QtQuick.Controls +import qs.Config + +CheckBox { + id: control + + property int checkHeight: 20 + property int checkWidth: 20 + + contentItem: CustomText { + anchors.left: parent.left + anchors.leftMargin: control.checkWidth + control.leftPadding + 8 + anchors.verticalCenter: parent.verticalCenter + font.pointSize: control.font.pointSize + text: control.text + } + indicator: CustomRect { + // x: control.leftPadding + // y: parent.implicitHeight / 2 - implicitHeight / 2 + border.color: control.checked ? DynamicColors.palette.m3primary : "transparent" + color: DynamicColors.palette.m3surfaceVariant + implicitHeight: control.checkHeight + implicitWidth: control.checkWidth + radius: 4 + + CustomRect { + color: DynamicColors.palette.m3primary + implicitHeight: control.checkHeight - (y * 2) + implicitWidth: control.checkWidth - (x * 2) + radius: 3 + visible: control.checked + x: 4 + y: 4 + } + } +} diff --git a/Greeter/Components/CustomClippingRect.qml b/Greeter/Components/CustomClippingRect.qml new file mode 100644 index 0000000..9498a06 --- /dev/null +++ b/Greeter/Components/CustomClippingRect.qml @@ -0,0 +1,13 @@ +import Quickshell.Widgets +import QtQuick + +ClippingRectangle { + id: root + + color: "transparent" + + Behavior on color { + CAnim { + } + } +} diff --git a/Greeter/Components/CustomComboBox.qml b/Greeter/Components/CustomComboBox.qml new file mode 100644 index 0000000..2a0186f --- /dev/null +++ b/Greeter/Components/CustomComboBox.qml @@ -0,0 +1,169 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import qs.Config + +ComboBox { + id: root + + property int cornerRadius: Appearance.rounding.normal + property int fieldHeight: 42 + property bool filled: true + property real focusRingOpacity: 0.70 + property int hPadding: 16 + property int menuCornerRadius: 16 + property int menuRowHeight: 46 + property int menuVisibleRows: 7 + property bool preferPopupWindow: false + + hoverEnabled: true + implicitHeight: fieldHeight + implicitWidth: 240 + spacing: 8 + + // ---------- Field background (filled/outlined + state layers + focus ring) ---------- + background: Item { + anchors.fill: parent + + CustomRect { + id: container + + anchors.fill: parent + color: DynamicColors.palette.m3surfaceVariant + radius: root.cornerRadius + + StateLayer { + } + } + } + + // ---------- Content ---------- + contentItem: RowLayout { + anchors.fill: parent + anchors.leftMargin: root.hPadding + anchors.rightMargin: root.hPadding + spacing: 12 + + // Display text + CustomText { + Layout.fillWidth: true + color: root.enabled ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSurfaceVariant + elide: Text.ElideRight + font.pixelSize: 16 + font.weight: Font.Medium + text: root.currentText + verticalAlignment: Text.AlignVCenter + } + + // Indicator chevron (simple, replace with your icon system) + CustomText { + color: root.enabled ? DynamicColors.palette.m3onSurfaceVariant : DynamicColors.palette.m3onSurfaceVariant + rotation: root.popup.visible ? 180 : 0 + text: "▾" + transformOrigin: Item.Center + verticalAlignment: Text.AlignVCenter + + Behavior on rotation { + NumberAnimation { + duration: 140 + easing.type: Easing.OutCubic + } + } + } + } + popup: Popup { + id: p + + implicitHeight: list.contentItem.height + Appearance.padding.small * 2 + implicitWidth: root.width + modal: true + popupType: root.preferPopupWindow ? Popup.Window : Popup.Item + y: -list.currentIndex * (root.menuRowHeight + Appearance.spacing.small) - Appearance.padding.small + + background: CustomRect { + color: DynamicColors.palette.m3surface + radius: root.menuCornerRadius + } + contentItem: ListView { + id: list + + anchors.bottomMargin: Appearance.padding.small + anchors.fill: parent + anchors.topMargin: Appearance.padding.small + clip: true + currentIndex: root.currentIndex + model: root.delegateModel + spacing: Appearance.spacing.small + + delegate: CustomRect { + required property int index + required property var modelData + + anchors.horizontalCenter: parent.horizontalCenter + color: (index === root.currentIndex) ? DynamicColors.palette.m3primary : "transparent" + implicitHeight: root.menuRowHeight + implicitWidth: p.implicitWidth - Appearance.padding.small * 2 + radius: Appearance.rounding.normal - Appearance.padding.small + + RowLayout { + anchors.fill: parent + spacing: 10 + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3onSurface + elide: Text.ElideRight + font.pixelSize: 15 + text: modelData + verticalAlignment: Text.AlignVCenter + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + text: "✓" + verticalAlignment: Text.AlignVCenter + visible: index === root.currentIndex + } + } + + StateLayer { + onClicked: { + root.currentIndex = index; + p.close(); + } + } + } + } + + // Expressive-ish open/close motion: subtle scale+fade (tune to taste). :contentReference[oaicite:5]{index=5} + enter: Transition { + Anim { + from: 0 + property: "opacity" + to: 1 + } + + Anim { + from: 0.98 + property: "scale" + to: 1.0 + } + } + exit: Transition { + Anim { + from: 1 + property: "opacity" + to: 0 + } + } + + Elevation { + anchors.fill: parent + level: 2 + radius: root.menuCornerRadius + z: -1 + } + } +} diff --git a/Greeter/Components/CustomFlickable.qml b/Greeter/Components/CustomFlickable.qml new file mode 100644 index 0000000..01a9652 --- /dev/null +++ b/Greeter/Components/CustomFlickable.qml @@ -0,0 +1,13 @@ +import QtQuick + +Flickable { + id: root + + maximumFlickVelocity: 3000 + + rebound: Transition { + Anim { + properties: "x,y" + } + } +} diff --git a/Greeter/Components/CustomIcon.qml b/Greeter/Components/CustomIcon.qml new file mode 100644 index 0000000..8201305 --- /dev/null +++ b/Greeter/Components/CustomIcon.qml @@ -0,0 +1,10 @@ +pragma ComponentBehavior: Bound + +import Quickshell.Widgets +import QtQuick + +IconImage { + id: root + + asynchronous: true +} diff --git a/Greeter/Components/CustomListView.qml b/Greeter/Components/CustomListView.qml new file mode 100644 index 0000000..51c7110 --- /dev/null +++ b/Greeter/Components/CustomListView.qml @@ -0,0 +1,13 @@ +import QtQuick + +ListView { + id: root + + maximumFlickVelocity: 3000 + + rebound: Transition { + Anim { + properties: "x,y" + } + } +} diff --git a/Greeter/Components/CustomMouseArea.qml b/Greeter/Components/CustomMouseArea.qml new file mode 100644 index 0000000..6d52d16 --- /dev/null +++ b/Greeter/Components/CustomMouseArea.qml @@ -0,0 +1,19 @@ +import QtQuick + +MouseArea { + property int scrollAccumulatedY: 0 + + function onWheel(event: WheelEvent): void { + } + + onWheel: event => { + if (Math.sign(event.angleDelta.y) !== Math.sign(scrollAccumulatedY)) + scrollAccumulatedY = 0; + scrollAccumulatedY += event.angleDelta.y; + + if (Math.abs(scrollAccumulatedY) >= 120) { + onWheel(event); + scrollAccumulatedY = 0; + } + } +} diff --git a/Greeter/Components/CustomRadioButton.qml b/Greeter/Components/CustomRadioButton.qml new file mode 100644 index 0000000..7e743cb --- /dev/null +++ b/Greeter/Components/CustomRadioButton.qml @@ -0,0 +1,53 @@ +import QtQuick +import QtQuick.Templates +import qs.Config + +RadioButton { + id: root + + font.pointSize: Appearance.font.size.normal + implicitHeight: Math.max(implicitIndicatorHeight, implicitContentHeight) + implicitWidth: implicitIndicatorWidth + implicitContentWidth + contentItem.anchors.leftMargin + + contentItem: CustomText { + anchors.left: outerCircle.right + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + font.pointSize: root.font.pointSize + text: root.text + } + indicator: Rectangle { + id: outerCircle + + anchors.verticalCenter: parent.verticalCenter + border.color: root.checked ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurfaceVariant + border.width: 2 + color: "transparent" + implicitHeight: 16 + implicitWidth: 16 + radius: 1000 + + Behavior on border.color { + CAnim { + } + } + + StateLayer { + function onClicked(): void { + root.click(); + } + + anchors.margins: -7 + color: root.checked ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3primary + z: -1 + } + + CustomRect { + anchors.centerIn: parent + color: Qt.alpha(DynamicColors.palette.m3primary, root.checked ? 1 : 0) + implicitHeight: 8 + implicitWidth: 8 + radius: 1000 + } + } +} diff --git a/Greeter/Components/CustomRect.qml b/Greeter/Components/CustomRect.qml new file mode 100644 index 0000000..2f5da64 --- /dev/null +++ b/Greeter/Components/CustomRect.qml @@ -0,0 +1,12 @@ +import QtQuick + +Rectangle { + id: root + + color: "transparent" + + Behavior on color { + CAnim { + } + } +} diff --git a/Greeter/Components/CustomScrollBar.qml b/Greeter/Components/CustomScrollBar.qml new file mode 100644 index 0000000..1f0acbc --- /dev/null +++ b/Greeter/Components/CustomScrollBar.qml @@ -0,0 +1,189 @@ +import qs.Config +import QtQuick +import QtQuick.Templates + +ScrollBar { + id: root + + property bool _updatingFromFlickable: false + property bool _updatingFromUser: false + property bool animating + required property Flickable flickable + property real nonAnimPosition + property bool shouldBeActive + + implicitWidth: 8 + + contentItem: CustomRect { + anchors.left: parent.left + anchors.right: parent.right + color: DynamicColors.palette.m3secondary + opacity: { + if (root.size === 1) + return 0; + if (fullMouse.pressed) + return 1; + if (mouse.containsMouse) + return 0.8; + if (root.policy === ScrollBar.AlwaysOn || root.shouldBeActive) + return 0.6; + return 0; + } + radius: 1000 + + Behavior on opacity { + Anim { + } + } + + MouseArea { + id: mouse + + acceptedButtons: Qt.NoButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + } + } + Behavior on position { + enabled: !fullMouse.pressed + + Anim { + } + } + + Component.onCompleted: { + if (flickable) { + const contentHeight = flickable.contentHeight; + const height = flickable.height; + if (contentHeight > height) { + nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height))); + } + } + } + onHoveredChanged: { + if (hovered) + shouldBeActive = true; + else + shouldBeActive = flickable.moving; + } + + // Sync nonAnimPosition with Qt's automatic position binding + onPositionChanged: { + if (_updatingFromUser) { + _updatingFromUser = false; + return; + } + if (position === nonAnimPosition) { + animating = false; + return; + } + if (!animating && !_updatingFromFlickable && !fullMouse.pressed) { + nonAnimPosition = position; + } + } + + // Sync nonAnimPosition with flickable when not animating + Connections { + function onContentYChanged() { + if (!animating && !fullMouse.pressed) { + _updatingFromFlickable = true; + const contentHeight = flickable.contentHeight; + const height = flickable.height; + if (contentHeight > height) { + nonAnimPosition = Math.max(0, Math.min(1, flickable.contentY / (contentHeight - height))); + } else { + nonAnimPosition = 0; + } + _updatingFromFlickable = false; + } + } + + target: flickable + } + + Connections { + function onMovingChanged(): void { + if (root.flickable.moving) + root.shouldBeActive = true; + else + hideDelay.restart(); + } + + target: root.flickable + } + + Timer { + id: hideDelay + + interval: 600 + + onTriggered: root.shouldBeActive = root.flickable.moving || root.hovered + } + + CustomMouseArea { + id: fullMouse + + function onWheel(event: WheelEvent): void { + root.animating = true; + root._updatingFromUser = true; + let newPos = root.nonAnimPosition; + if (event.angleDelta.y > 0) + newPos = Math.max(0, root.nonAnimPosition - 0.1); + else if (event.angleDelta.y < 0) + newPos = Math.min(1 - root.size, root.nonAnimPosition + 0.1); + root.nonAnimPosition = newPos; + // Update flickable position + // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] + if (root.flickable) { + const contentHeight = root.flickable.contentHeight; + const height = root.flickable.height; + if (contentHeight > height) { + const maxContentY = contentHeight - height; + const maxPos = 1 - root.size; + const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; + root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); + } + } + } + + anchors.fill: parent + preventStealing: true + + onPositionChanged: event => { + root._updatingFromUser = true; + const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); + root.nonAnimPosition = newPos; + // Update flickable position + // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] + if (root.flickable) { + const contentHeight = root.flickable.contentHeight; + const height = root.flickable.height; + if (contentHeight > height) { + const maxContentY = contentHeight - height; + const maxPos = 1 - root.size; + const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; + root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); + } + } + } + onPressed: event => { + root.animating = true; + root._updatingFromUser = true; + const newPos = Math.max(0, Math.min(1 - root.size, event.y / root.height - root.size / 2)); + root.nonAnimPosition = newPos; + // Update flickable position + // Map scrollbar position [0, 1-size] to contentY [0, maxContentY] + if (root.flickable) { + const contentHeight = root.flickable.contentHeight; + const height = root.flickable.height; + if (contentHeight > height) { + const maxContentY = contentHeight - height; + const maxPos = 1 - root.size; + const contentY = maxPos > 0 ? (newPos / maxPos) * maxContentY : 0; + root.flickable.contentY = Math.max(0, Math.min(maxContentY, contentY)); + } + } + } + } +} diff --git a/Greeter/Components/CustomShortcut.qml b/Greeter/Components/CustomShortcut.qml new file mode 100644 index 0000000..c60cbef --- /dev/null +++ b/Greeter/Components/CustomShortcut.qml @@ -0,0 +1,5 @@ +import Quickshell.Hyprland + +GlobalShortcut { + appid: "zshell" +} diff --git a/Greeter/Components/CustomSlider.qml b/Greeter/Components/CustomSlider.qml new file mode 100644 index 0000000..8a375aa --- /dev/null +++ b/Greeter/Components/CustomSlider.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Templates +import qs.Config + +Slider { + id: root + + background: Item { + CustomRect { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.top: parent.top + bottomRightRadius: root.implicitHeight / 6 + color: DynamicColors.palette.m3primary + implicitWidth: root.handle.x - root.implicitHeight / 2 + radius: 1000 + topRightRadius: root.implicitHeight / 6 + } + + CustomRect { + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.top: parent.top + bottomLeftRadius: root.implicitHeight / 6 + color: DynamicColors.tPalette.m3surfaceContainer + implicitWidth: parent.width - root.handle.x - root.handle.implicitWidth - root.implicitHeight / 2 + radius: 1000 + topLeftRadius: root.implicitHeight / 6 + } + } + handle: CustomRect { + anchors.verticalCenter: parent.verticalCenter + color: DynamicColors.palette.m3primary + implicitHeight: 15 + implicitWidth: 5 + radius: 1000 + x: root.visualPosition * root.availableWidth - implicitWidth / 2 + + MouseArea { + acceptedButtons: Qt.NoButton + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + } + } +} diff --git a/Greeter/Components/CustomSpinBox.qml b/Greeter/Components/CustomSpinBox.qml new file mode 100644 index 0000000..5a4245b --- /dev/null +++ b/Greeter/Components/CustomSpinBox.qml @@ -0,0 +1,169 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Config + +RowLayout { + id: root + + property string displayText: root.value.toString() + property bool isEditing: false + property real max: Infinity + property real min: -Infinity + property alias repeatRate: timer.interval + property real step: 1 + property real value + + signal valueModified(value: real) + + spacing: Appearance.spacing.small + + onValueChanged: { + if (!root.isEditing) { + root.displayText = root.value.toString(); + } + } + + CustomTextField { + id: textField + + implicitHeight: upButton.implicitHeight + inputMethodHints: Qt.ImhFormattedNumbersOnly + leftPadding: Appearance.padding.normal + padding: Appearance.padding.small + rightPadding: Appearance.padding.normal + text: root.isEditing ? text : root.displayText + + background: CustomRect { + color: DynamicColors.tPalette.m3surfaceContainerHigh + implicitWidth: 100 + radius: Appearance.rounding.full + } + validator: DoubleValidator { + bottom: root.min + decimals: root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0 + top: root.max + } + + onAccepted: { + const numValue = parseFloat(text); + if (!isNaN(numValue)) { + const clampedValue = Math.max(root.min, Math.min(root.max, numValue)); + root.value = clampedValue; + root.displayText = clampedValue.toString(); + root.valueModified(clampedValue); + } else { + text = root.displayText; + } + root.isEditing = false; + } + onActiveFocusChanged: { + if (activeFocus) { + root.isEditing = true; + } else { + root.isEditing = false; + root.displayText = root.value.toString(); + } + } + onEditingFinished: { + if (text !== root.displayText) { + const numValue = parseFloat(text); + if (!isNaN(numValue)) { + const clampedValue = Math.max(root.min, Math.min(root.max, numValue)); + root.value = clampedValue; + root.displayText = clampedValue.toString(); + root.valueModified(clampedValue); + } else { + text = root.displayText; + } + } + root.isEditing = false; + } + } + + CustomRect { + id: upButton + + color: DynamicColors.palette.m3primary + implicitHeight: upIcon.implicitHeight + Appearance.padding.small * 2 + implicitWidth: implicitHeight + radius: Appearance.rounding.full + + StateLayer { + id: upState + + function onClicked(): void { + let newValue = Math.min(root.max, root.value + root.step); + // Round to avoid floating point precision errors + const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0; + newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals); + root.value = newValue; + root.displayText = newValue.toString(); + root.valueModified(newValue); + } + + color: DynamicColors.palette.m3onPrimary + + onPressAndHold: timer.start() + onReleased: timer.stop() + } + + MaterialIcon { + id: upIcon + + anchors.centerIn: parent + color: DynamicColors.palette.m3onPrimary + text: "keyboard_arrow_up" + } + } + + CustomRect { + color: DynamicColors.palette.m3primary + implicitHeight: downIcon.implicitHeight + Appearance.padding.small * 2 + implicitWidth: implicitHeight + radius: Appearance.rounding.full + + StateLayer { + id: downState + + function onClicked(): void { + let newValue = Math.max(root.min, root.value - root.step); + // Round to avoid floating point precision errors + const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0; + newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals); + root.value = newValue; + root.displayText = newValue.toString(); + root.valueModified(newValue); + } + + color: DynamicColors.palette.m3onPrimary + + onPressAndHold: timer.start() + onReleased: timer.stop() + } + + MaterialIcon { + id: downIcon + + anchors.centerIn: parent + color: DynamicColors.palette.m3onPrimary + text: "keyboard_arrow_down" + } + } + + Timer { + id: timer + + interval: 100 + repeat: true + triggeredOnStart: true + + onTriggered: { + if (upState.pressed) + upState.onClicked(); + else if (downState.pressed) + downState.onClicked(); + } + } +} diff --git a/Greeter/Components/CustomSplitButton.qml b/Greeter/Components/CustomSplitButton.qml new file mode 100644 index 0000000..6f9ad95 --- /dev/null +++ b/Greeter/Components/CustomSplitButton.qml @@ -0,0 +1,181 @@ +import QtQuick +import QtQuick.Layouts +import qs.Config +import qs.Helpers + +Row { + id: root + + enum Type { + Filled, + Tonal + } + + property alias active: menu.active + property color color: type == CustomSplitButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondaryContainer + property bool disabled + property color disabledColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) + property color disabledTextColor: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) + property alias expanded: menu.expanded + property string fallbackIcon + property string fallbackText + property real horizontalPadding: Appearance.padding.normal + property alias iconLabel: iconLabel + property alias label: label + property alias menu: menu + property alias menuItems: menu.items + property bool menuOnTop + property alias stateLayer: stateLayer + property color textColor: type == CustomSplitButton.Filled ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSecondaryContainer + property int type: CustomSplitButton.Filled + property real verticalPadding: Appearance.padding.smaller + + function closeDropdown(): void { + SettingsDropdowns.close(menu); + } + + function openDropdown(): void { + if (root.disabled) + return; + SettingsDropdowns.open(menu, root); + } + + function toggleDropdown(): void { + if (root.disabled) + return; + SettingsDropdowns.toggle(menu, root); + } + + spacing: Math.floor(Appearance.spacing.small / 2) + + onExpandedChanged: { + if (!expanded) + SettingsDropdowns.forget(menu); + } + + CustomRect { + bottomRightRadius: Appearance.rounding.small / 2 + color: root.disabled ? root.disabledColor : root.color + implicitHeight: expandBtn.implicitHeight + implicitWidth: textRow.implicitWidth + root.horizontalPadding * 2 + radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + topRightRadius: Appearance.rounding.small / 2 + + StateLayer { + id: stateLayer + + function onClicked(): void { + root.active?.clicked(); + } + + color: root.textColor + disabled: root.disabled + rect.bottomRightRadius: parent.bottomRightRadius + rect.topRightRadius: parent.topRightRadius + } + + RowLayout { + id: textRow + + anchors.centerIn: parent + anchors.horizontalCenterOffset: Math.floor(root.verticalPadding / 4) + spacing: Appearance.spacing.small + + MaterialIcon { + id: iconLabel + + Layout.alignment: Qt.AlignVCenter + animate: true + color: root.disabled ? root.disabledTextColor : root.textColor + fill: 1 + text: root.active?.activeIcon ?? root.fallbackIcon + } + + CustomText { + id: label + + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: implicitWidth + animate: true + clip: true + color: root.disabled ? root.disabledTextColor : root.textColor + text: root.active?.activeText ?? root.fallbackText + + Behavior on Layout.preferredWidth { + Anim { + easing.bezierCurve: Appearance.anim.curves.emphasized + } + } + } + } + } + + CustomRect { + id: expandBtn + + property real rad: root.expanded ? implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) : Appearance.rounding.small / 2 + + bottomLeftRadius: rad + color: root.disabled ? root.disabledColor : root.color + implicitHeight: expandIcon.implicitHeight + root.verticalPadding * 2 + implicitWidth: implicitHeight + radius: implicitHeight / 2 * Math.min(1, Appearance.rounding.scale) + topLeftRadius: rad + + Behavior on rad { + Anim { + } + } + + StateLayer { + id: expandStateLayer + + function onClicked(): void { + root.toggleDropdown(); + } + + color: root.textColor + disabled: root.disabled + rect.bottomLeftRadius: parent.bottomLeftRadius + rect.topLeftRadius: parent.topLeftRadius + } + + MaterialIcon { + id: expandIcon + + anchors.centerIn: parent + anchors.horizontalCenterOffset: root.expanded ? 0 : -Math.floor(root.verticalPadding / 4) + color: root.disabled ? root.disabledTextColor : root.textColor + rotation: root.expanded ? 180 : 0 + text: "expand_more" + + Behavior on anchors.horizontalCenterOffset { + Anim { + } + } + Behavior on rotation { + Anim { + } + } + } + + Menu { + id: menu + + anchors.bottomMargin: Appearance.spacing.small + anchors.right: parent.right + anchors.top: parent.bottom + anchors.topMargin: Appearance.spacing.small + + states: State { + when: root.menuOnTop + + AnchorChanges { + anchors.bottom: expandBtn.top + anchors.top: undefined + target: menu + } + } + } + } +} diff --git a/Greeter/Components/CustomSplitButtonRow.qml b/Greeter/Components/CustomSplitButtonRow.qml new file mode 100644 index 0000000..491a8ed --- /dev/null +++ b/Greeter/Components/CustomSplitButtonRow.qml @@ -0,0 +1,58 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Config + +Item { + id: root + + property alias active: splitButton.active + property bool enabled: true + property alias expanded: splitButton.expanded + property int expandedZ: 100 + required property string label + property alias menuItems: splitButton.menuItems + property alias type: splitButton.type + + signal selected(item: MenuItem) + + Layout.fillWidth: true + Layout.preferredHeight: row.implicitHeight + Appearance.padding.smaller * 2 + clip: false + z: root.expanded ? expandedZ : -1 + + RowLayout { + id: row + + anchors.left: parent.left + anchors.margins: Appearance.padding.small + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.normal + + CustomText { + Layout.fillWidth: true + color: root.enabled ? DynamicColors.palette.m3onSurface : DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.larger + text: root.label + z: root.expanded ? root.expandedZ : -1 + } + + CustomSplitButton { + id: splitButton + + enabled: root.enabled + type: CustomSplitButton.Filled + z: root.expanded ? root.expandedZ : -1 + + menu.onItemSelected: item => { + root.selected(item); + splitButton.closeDropdown(); + } + stateLayer.onClicked: { + splitButton.toggleDropdown(); + } + } + } +} diff --git a/Greeter/Components/CustomSwitch.qml b/Greeter/Components/CustomSwitch.qml new file mode 100644 index 0000000..15a54c2 --- /dev/null +++ b/Greeter/Components/CustomSwitch.qml @@ -0,0 +1,155 @@ +import QtQuick +import QtQuick.Templates +import QtQuick.Shapes +import qs.Config + +Switch { + id: root + + property int cLayer: 1 + + implicitHeight: implicitIndicatorHeight + implicitWidth: implicitIndicatorWidth + + indicator: CustomRect { + color: root.checked ? DynamicColors.palette.m3primary : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, root.cLayer) + implicitHeight: 13 + 7 * 2 + implicitWidth: implicitHeight * 1.7 + radius: 1000 + + CustomRect { + readonly property real nonAnimWidth: root.pressed ? implicitHeight * 1.3 : implicitHeight + + anchors.verticalCenter: parent.verticalCenter + color: root.checked ? DynamicColors.palette.m3onPrimary : DynamicColors.layer(DynamicColors.palette.m3outline, root.cLayer + 1) + implicitHeight: parent.implicitHeight - 10 + implicitWidth: nonAnimWidth + radius: 1000 + x: root.checked ? parent.implicitWidth - nonAnimWidth - 10 / 2 : 10 / 2 + + Behavior on implicitWidth { + Anim { + } + } + Behavior on x { + Anim { + } + } + + CustomRect { + anchors.fill: parent + color: root.checked ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface + opacity: root.pressed ? 0.1 : root.hovered ? 0.08 : 0 + radius: parent.radius + + Behavior on opacity { + Anim { + } + } + } + + Shape { + id: icon + + property point end1: { + if (root.pressed) { + if (root.checked) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.8, height / 2); + } + if (root.checked) + return Qt.point(width * 0.4, height * 0.7); + return Qt.point(width * 0.85, height * 0.85); + } + property point end2: { + if (root.pressed) + return Qt.point(width, height / 2); + if (root.checked) + return Qt.point(width * 0.85, height * 0.2); + return Qt.point(width * 0.85, height * 0.15); + } + property point start1: { + if (root.pressed) + return Qt.point(width * 0.1, height / 2); + if (root.checked) + return Qt.point(width * 0.15, height / 2); + return Qt.point(width * 0.15, height * 0.15); + } + property point start2: { + if (root.pressed) { + if (root.checked) + return Qt.point(width * 0.4, height / 2); + return Qt.point(width * 0.2, height / 2); + } + if (root.checked) + return Qt.point(width * 0.4, height * 0.7); + return Qt.point(width * 0.15, height * 0.85); + } + + anchors.centerIn: parent + asynchronous: true + height: parent.implicitHeight - Appearance.padding.small * 2 + preferredRendererType: Shape.CurveRenderer + width: height + + Behavior on end1 { + PropAnim { + } + } + Behavior on end2 { + PropAnim { + } + } + Behavior on start1 { + PropAnim { + } + } + Behavior on start2 { + PropAnim { + } + } + + ShapePath { + capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap + fillColor: "transparent" + startX: icon.start1.x + startY: icon.start1.y + strokeColor: root.checked ? DynamicColors.palette.m3primary : DynamicColors.palette.m3surfaceContainerHighest + strokeWidth: Appearance.font.size.larger * 0.15 + + Behavior on strokeColor { + CAnim { + } + } + + PathLine { + x: icon.end1.x + y: icon.end1.y + } + + PathMove { + x: icon.start2.x + y: icon.start2.y + } + + PathLine { + x: icon.end2.x + y: icon.end2.y + } + } + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + enabled: false + } + + component PropAnim: PropertyAnimation { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + easing.type: Easing.BezierSpline + } +} diff --git a/Greeter/Components/CustomText.qml b/Greeter/Components/CustomText.qml new file mode 100644 index 0000000..cff8b2d --- /dev/null +++ b/Greeter/Components/CustomText.qml @@ -0,0 +1,51 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Config + +Text { + id: root + + property bool animate: false + property int animateDuration: 400 + property real animateFrom: 0 + property string animateProp: "scale" + property real animateTo: 1 + + color: DynamicColors.palette.m3onSurface + font.family: Appearance.font.family.sans + font.pointSize: Appearance.font.size.normal + renderType: Text.NativeRendering + textFormat: Text.PlainText + + Behavior on color { + CAnim { + } + } + Behavior on text { + enabled: root.animate + + SequentialAnimation { + Anim { + easing.bezierCurve: MaterialEasing.standardAccel + to: root.animateFrom + } + + PropertyAction { + } + + Anim { + easing.bezierCurve: MaterialEasing.standardDecel + to: root.animateTo + } + } + } + + component Anim: NumberAnimation { + duration: root.animateDuration / 2 + easing.type: Easing.BezierSpline + properties: root.animateProp.split(",").length > 1 ? root.animateProp : "" + property: root.animateProp.split(",").length === 1 ? root.animateProp : "" + target: root + } +} diff --git a/Greeter/Components/CustomTextField.qml b/Greeter/Components/CustomTextField.qml new file mode 100644 index 0000000..16c39a6 --- /dev/null +++ b/Greeter/Components/CustomTextField.qml @@ -0,0 +1,75 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import qs.Config + +TextField { + id: root + + background: null + color: DynamicColors.palette.m3onSurface + cursorVisible: !readOnly + font.family: Appearance.font.family.sans + font.pointSize: Appearance.font.size.smaller + placeholderTextColor: DynamicColors.palette.m3outline + renderType: echoMode === TextField.Password ? TextField.QtRendering : TextField.NativeRendering + + Behavior on color { + CAnim { + } + } + cursorDelegate: CustomRect { + id: cursor + + property bool disableBlink + + color: DynamicColors.palette.m3primary + implicitWidth: 2 + radius: Appearance.rounding.normal + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.small + } + } + + Connections { + function onCursorPositionChanged(): void { + if (root.activeFocus && root.cursorVisible) { + cursor.opacity = 1; + cursor.disableBlink = true; + enableBlink.restart(); + } + } + + target: root + } + + Timer { + id: enableBlink + + interval: 100 + + onTriggered: cursor.disableBlink = false + } + + Timer { + interval: 500 + repeat: true + running: root.activeFocus && root.cursorVisible && !cursor.disableBlink + triggeredOnStart: true + + onTriggered: parent.opacity = parent.opacity === 1 ? 0 : 1 + } + + Binding { + cursor.opacity: 0 + when: !root.activeFocus || !root.cursorVisible + } + } + Behavior on placeholderTextColor { + CAnim { + } + } +} diff --git a/Greeter/Components/CustomTextInput.qml b/Greeter/Components/CustomTextInput.qml new file mode 100644 index 0000000..25b1eed --- /dev/null +++ b/Greeter/Components/CustomTextInput.qml @@ -0,0 +1,15 @@ +import QtQuick +import QtQuick.Controls +import qs.Config + +TextInput { + renderType: Text.NativeRendering + selectedTextColor: DynamicColors.palette.m3onSecondaryContainer + selectionColor: DynamicColors.tPalette.colSecondaryContainer + + font { + family: Appearance?.font.family.sans ?? "sans-serif" + hintingPreference: Font.PreferFullHinting + pixelSize: Appearance?.font.size.normal ?? 15 + } +} diff --git a/Greeter/Components/CustomTooltip.qml b/Greeter/Components/CustomTooltip.qml new file mode 100644 index 0000000..59cb1ad --- /dev/null +++ b/Greeter/Components/CustomTooltip.qml @@ -0,0 +1,25 @@ +import QtQuick +import QtQuick.Controls +import qs.Components + +ToolTip { + id: root + + property bool alternativeVisibleCondition: false + property bool extraVisibleCondition: true + readonly property bool internalVisibleCondition: (extraVisibleCondition && (parent.hovered === undefined || parent?.hovered)) || alternativeVisibleCondition + + background: null + horizontalPadding: 10 + verticalPadding: 5 + visible: internalVisibleCondition + + contentItem: CustomTooltipContent { + id: contentItem + + horizontalPadding: root.horizontalPadding + shown: root.internalVisibleCondition + text: root.text + verticalPadding: root.verticalPadding + } +} diff --git a/Greeter/Components/CustomTooltipContent.qml b/Greeter/Components/CustomTooltipContent.qml new file mode 100644 index 0000000..8c70d19 --- /dev/null +++ b/Greeter/Components/CustomTooltipContent.qml @@ -0,0 +1,54 @@ +import QtQuick +import qs.Components +import qs.Config + +Item { + id: root + + property real horizontalPadding: 10 + property bool isVisible: backgroundRectangle.implicitHeight > 0 + property bool shown: false + required property string text + property real verticalPadding: 5 + + implicitHeight: tooltipTextObject.implicitHeight + 2 * root.verticalPadding + implicitWidth: tooltipTextObject.implicitWidth + 2 * root.horizontalPadding + + Rectangle { + id: backgroundRectangle + + clip: true + color: DynamicColors.tPalette.m3inverseSurface ?? "#3C4043" + implicitHeight: shown ? (tooltipTextObject.implicitHeight + 2 * root.verticalPadding) : 0 + implicitWidth: shown ? (tooltipTextObject.implicitWidth + 2 * root.horizontalPadding) : 0 + opacity: shown ? 1 : 0 + radius: 8 + + Behavior on implicitHeight { + Anim { + } + } + Behavior on implicitWidth { + Anim { + } + } + Behavior on opacity { + Anim { + } + } + + anchors { + bottom: root.bottom + horizontalCenter: root.horizontalCenter + } + + CustomText { + id: tooltipTextObject + + anchors.centerIn: parent + color: DynamicColors.palette.m3inverseOnSurface ?? "#FFFFFF" + text: root.text + wrapMode: Text.Wrap + } + } +} diff --git a/Greeter/Components/CustomWindow.qml b/Greeter/Components/CustomWindow.qml new file mode 100644 index 0000000..7e5bf2c --- /dev/null +++ b/Greeter/Components/CustomWindow.qml @@ -0,0 +1,9 @@ +import Quickshell +import Quickshell.Wayland + +PanelWindow { + required property string name + + WlrLayershell.namespace: `ZShell-${name}` + color: "transparent" +} diff --git a/Greeter/Components/Elevation.qml b/Greeter/Components/Elevation.qml new file mode 100644 index 0000000..26b8fe6 --- /dev/null +++ b/Greeter/Components/Elevation.qml @@ -0,0 +1,18 @@ +import qs.Config +import QtQuick +import QtQuick.Effects + +RectangularShadow { + property real dp: [0, 1, 3, 6, 8, 12][level] + property int level + + blur: (dp * 5) ** 0.7 + color: Qt.alpha(DynamicColors.palette.m3shadow, 0.7) + offset.y: dp / 2 + spread: -dp * 0.3 + (dp * 0.1) ** 2 + + Behavior on dp { + Anim { + } + } +} diff --git a/Greeter/Components/ExtraIndicator.qml b/Greeter/Components/ExtraIndicator.qml new file mode 100644 index 0000000..da0302c --- /dev/null +++ b/Greeter/Components/ExtraIndicator.qml @@ -0,0 +1,44 @@ +import qs.Config +import QtQuick + +CustomRect { + required property int extra + + anchors.margins: 8 + anchors.right: parent.right + color: DynamicColors.palette.m3tertiary + implicitHeight: count.implicitHeight + 4 * 2 + implicitWidth: count.implicitWidth + 8 * 2 + opacity: extra > 0 ? 1 : 0 + radius: 8 + scale: extra > 0 ? 1 : 0.5 + + Behavior on opacity { + Anim { + duration: MaterialEasing.expressiveEffectsTime + } + } + Behavior on scale { + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } + + Elevation { + anchors.fill: parent + level: 2 + opacity: parent.opacity + radius: parent.radius + z: -1 + } + + CustomText { + id: count + + anchors.centerIn: parent + animate: parent.opacity > 0 + color: DynamicColors.palette.m3onTertiary + text: qsTr("+%1").arg(parent.extra) + } +} diff --git a/Greeter/Components/FilledSlider.qml b/Greeter/Components/FilledSlider.qml new file mode 100644 index 0000000..d38d1cb --- /dev/null +++ b/Greeter/Components/FilledSlider.qml @@ -0,0 +1,29 @@ +import QtQuick +import qs.Config + +BaseStyledSlider { + id: root + + trackContent: Component { + Item { + property var groove + readonly property real handleHeight: handleItem ? handleItem.height : 0 + property var handleItem + readonly property real handleWidth: handleItem ? handleItem.width : 0 + + // Set by BaseStyledSlider's Loader + property var rootSlider + + anchors.fill: parent + + CustomRect { + color: rootSlider?.color + height: rootSlider?.isVertical ? handleHeight + (1 - rootSlider?.visualPosition) * (groove?.height - handleHeight) : groove?.height + radius: groove?.radius + width: rootSlider?.isHorizontal ? handleWidth + rootSlider?.visualPosition * (groove?.width - handleWidth) : groove?.width + x: rootSlider?.isHorizontal ? (rootSlider?.mirrored ? groove?.width - width : 0) : 0 + y: rootSlider?.isVertical ? groove?.height - height : 0 + } + } + } +} diff --git a/Greeter/Components/GradientSlider.qml b/Greeter/Components/GradientSlider.qml new file mode 100644 index 0000000..c19f161 --- /dev/null +++ b/Greeter/Components/GradientSlider.qml @@ -0,0 +1,47 @@ +import QtQuick +import qs.Config + +BaseStyledSlider { + id: root + + property real alpha: 1.0 + property real brightness: 1.0 + property string channel: "saturation" + readonly property color currentColor: Qt.hsva(hue, channel === "saturation" ? value : saturation, channel === "brightness" ? value : brightness, alpha) + property real hue: 0.0 + property real saturation: 1.0 + + from: 0 + to: 1 + + trackContent: Component { + Item { + property var groove + property var handleItem + property var rootSlider + + anchors.fill: parent + + Rectangle { + anchors.fill: parent + antialiasing: true + color: "transparent" + radius: groove?.radius ?? 0 + + gradient: Gradient { + orientation: rootSlider?.isHorizontal ? Gradient.Horizontal : Gradient.Vertical + + GradientStop { + color: root.channel === "saturation" ? Qt.hsva(root.hue, 0.0, root.brightness, root.alpha) : Qt.hsva(root.hue, root.saturation, 0.0, root.alpha) + position: 0.0 + } + + GradientStop { + color: root.channel === "saturation" ? Qt.hsva(root.hue, 1.0, root.brightness, root.alpha) : Qt.hsva(root.hue, root.saturation, 1.0, root.alpha) + position: 1.0 + } + } + } + } + } +} diff --git a/Greeter/Components/IconButton.qml b/Greeter/Components/IconButton.qml new file mode 100644 index 0000000..e55289a --- /dev/null +++ b/Greeter/Components/IconButton.qml @@ -0,0 +1,80 @@ +import qs.Config +import QtQuick + +CustomRect { + id: root + + enum Type { + Filled, + Tonal, + Text + } + + property color activeColour: type === IconButton.Filled ? DynamicColors.palette.m3primary : DynamicColors.palette.m3secondary + property color activeOnColour: type === IconButton.Filled ? DynamicColors.palette.m3onPrimary : type === IconButton.Tonal ? DynamicColors.palette.m3onSecondary : DynamicColors.palette.m3primary + property bool checked + property bool disabled + property color disabledColour: Qt.alpha(DynamicColors.palette.m3onSurface, 0.1) + property color disabledOnColour: Qt.alpha(DynamicColors.palette.m3onSurface, 0.38) + property alias font: label.font + property alias icon: label.text + property color inactiveColour: { + if (!toggle && type === IconButton.Filled) + return DynamicColors.palette.m3primary; + return type === IconButton.Filled ? DynamicColors.tPalette.m3surfaceContainer : DynamicColors.palette.m3secondaryContainer; + } + property color inactiveOnColour: { + if (!toggle && type === IconButton.Filled) + return DynamicColors.palette.m3onPrimary; + return type === IconButton.Tonal ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurfaceVariant; + } + property bool internalChecked + property alias label: label + property real padding: type === IconButton.Text ? 10 / 2 : 7 + property alias radiusAnim: radiusAnim + property alias stateLayer: stateLayer + property bool toggle + property int type: IconButton.Filled + + signal clicked + + color: type === IconButton.Text ? "transparent" : disabled ? disabledColour : internalChecked ? activeColour : inactiveColour + implicitHeight: label.implicitHeight + padding * 2 + implicitWidth: implicitHeight + radius: internalChecked ? 6 : implicitHeight / 2 * Math.min(1, 1) + + Behavior on radius { + Anim { + id: radiusAnim + + } + } + + onCheckedChanged: internalChecked = checked + + StateLayer { + id: stateLayer + + function onClicked(): void { + if (root.toggle) + root.internalChecked = !root.internalChecked; + root.clicked(); + } + + color: root.internalChecked ? root.activeOnColour : root.inactiveOnColour + disabled: root.disabled + } + + MaterialIcon { + id: label + + anchors.centerIn: parent + color: root.disabled ? root.disabledOnColour : root.internalChecked ? root.activeOnColour : root.inactiveOnColour + fill: !root.toggle || root.internalChecked ? 1 : 0 + + Behavior on fill { + Anim { + } + } + } +} diff --git a/Greeter/Components/MarqueeText.qml b/Greeter/Components/MarqueeText.qml new file mode 100644 index 0000000..925b409 --- /dev/null +++ b/Greeter/Components/MarqueeText.qml @@ -0,0 +1,221 @@ +import QtQuick +import qs.Config + +Item { + id: root + + property alias anim: marqueeAnim + property bool animate: false + property color color: DynamicColors.palette.m3onSurface + property int fadeStrengthAnimMs: 180 + property real fadeStrengthIdle: 0.0 + property real fadeStrengthMoving: 1.0 + property alias font: elideText.font + property int gap: 40 + property alias horizontalAlignment: elideText.horizontalAlignment + property bool leftFadeEnabled: false + property real leftFadeStrength: overflowing && leftFadeEnabled ? fadeStrengthMoving : fadeStrengthIdle + property int leftFadeWidth: 28 + property bool marqueeEnabled: true + readonly property bool overflowing: metrics.width > root.width + property int pauseMs: 1200 + property real pixelsPerSecond: 40 + property real rightFadeStrength: overflowing ? fadeStrengthMoving : fadeStrengthIdle + property int rightFadeWidth: 28 + property bool sliding: false + property alias text: elideText.text + + function durationForDistance(px): int { + return Math.max(1, Math.round(Math.abs(px) / root.pixelsPerSecond * 1000)); + } + + function resetMarquee() { + marqueeAnim.stop(); + strip.x = 0; + root.sliding = false; + root.leftFadeEnabled = false; + + if (root.marqueeEnabled && root.overflowing && root.visible) { + marqueeAnim.restart(); + } + } + + clip: true + implicitHeight: elideText.implicitHeight + + Behavior on leftFadeStrength { + Anim { + } + } + Behavior on rightFadeStrength { + Anim { + } + } + + onTextChanged: resetMarquee() + onVisibleChanged: if (!visible) + resetMarquee() + onWidthChanged: resetMarquee() + + TextMetrics { + id: metrics + + font: elideText.font + text: elideText.text + } + + CustomText { + id: elideText + + anchors.verticalCenter: parent.verticalCenter + animate: root.animate + animateProp: "scale,opacity" + color: root.color + elide: Text.ElideNone + visible: !root.overflowing + width: root.width + } + + Item { + id: marqueeViewport + + anchors.fill: parent + clip: true + layer.enabled: true + visible: root.overflowing + + layer.effect: OpacityMask { + maskSource: rightFadeMask + } + + Item { + id: strip + + anchors.verticalCenter: parent.verticalCenter + height: t1.implicitHeight + width: t1.width + root.gap + t2.width + x: 0 + + CustomText { + id: t1 + + animate: root.animate + animateProp: "opacity" + color: root.color + text: elideText.text + } + + CustomText { + id: t2 + + animate: root.animate + animateProp: "opacity" + color: root.color + text: t1.text + x: t1.width + root.gap + } + } + + SequentialAnimation { + id: marqueeAnim + + running: false + + onFinished: pauseTimer.restart() + + ScriptAction { + script: { + root.sliding = true; + root.leftFadeEnabled = true; + } + } + + Anim { + duration: root.durationForDistance(t1.width) + easing.bezierCurve: Easing.Linear + easing.type: Easing.Linear + from: 0 + property: "x" + target: strip + to: -t1.width + } + + ScriptAction { + script: { + root.leftFadeEnabled = false; + } + } + + Anim { + duration: root.durationForDistance(root.gap) + easing.bezierCurve: Easing.Linear + easing.type: Easing.Linear + from: -t1.width + property: "x" + target: strip + to: -(t1.width + root.gap) + } + + ScriptAction { + script: { + root.sliding = false; + strip.x = 0; + } + } + } + + Timer { + id: pauseTimer + + interval: root.pauseMs + repeat: false + running: true + + onTriggered: { + if (root.marqueeEnabled) + marqueeAnim.start(); + } + } + } + + Rectangle { + id: rightFadeMask + + readonly property real fadeStartPos: { + const w = Math.max(1, width); + return Math.max(0, Math.min(1, (w - root.rightFadeWidth) / w)); + } + readonly property real leftFadeEndPos: { + const w = Math.max(1, width); + return Math.max(0, Math.min(1, root.leftFadeWidth / w)); + } + + anchors.fill: marqueeViewport + layer.enabled: true + visible: false + + gradient: Gradient { + orientation: Gradient.Horizontal + + GradientStop { + color: Qt.rgba(1, 1, 1, 1.0 - root.leftFadeStrength) + position: 0.0 + } + + GradientStop { + color: Qt.rgba(1, 1, 1, 1.0) + position: rightFadeMask.leftFadeEndPos + } + + GradientStop { + color: Qt.rgba(1, 1, 1, 1.0) + position: rightFadeMask.fadeStartPos + } + + GradientStop { + color: Qt.rgba(1, 1, 1, 1.0 - root.rightFadeStrength) + position: 1.0 + } + } + } +} diff --git a/Greeter/Components/MaterialIcon.qml b/Greeter/Components/MaterialIcon.qml new file mode 100644 index 0000000..69da4e3 --- /dev/null +++ b/Greeter/Components/MaterialIcon.qml @@ -0,0 +1,15 @@ +import qs.Config + +CustomText { + property real fill + property int grade: DynamicColors.light ? 0 : -25 + + font.family: "Material Symbols Rounded" + font.pointSize: Appearance.font.size.larger + font.variableAxes: ({ + FILL: fill.toFixed(1), + GRAD: grade, + opsz: fontInfo.pixelSize, + wght: fontInfo.weight + }) +} diff --git a/Greeter/Components/Menu.qml b/Greeter/Components/Menu.qml new file mode 100644 index 0000000..8222359 --- /dev/null +++ b/Greeter/Components/Menu.qml @@ -0,0 +1,115 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Config + +Elevation { + id: root + + property MenuItem active: items[0] ?? null + property bool expanded + property list items + + signal itemSelected(item: MenuItem) + + implicitHeight: root.expanded ? column.implicitHeight + Appearance.padding.small * 2 : 0 + implicitWidth: Math.max(200, column.implicitWidth) + level: 2 + opacity: root.expanded ? 1 : 0 + radius: Appearance.rounding.normal + + Behavior on implicitHeight { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + } + } + + CustomClippingRect { + anchors.fill: parent + color: DynamicColors.palette.m3surfaceContainer + radius: parent.radius + + ColumnLayout { + id: column + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 5 + + Repeater { + model: root.items + + CustomRect { + id: item + + readonly property bool active: modelData === root.active + required property int index + required property MenuItem modelData + + Layout.fillWidth: true + implicitHeight: menuOptionRow.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: menuOptionRow.implicitWidth + Appearance.padding.normal * 2 + + CustomRect { + anchors.fill: parent + anchors.leftMargin: Appearance.padding.small + anchors.rightMargin: Appearance.padding.small + color: Qt.alpha(DynamicColors.palette.m3secondaryContainer, active ? 1 : 0) + radius: Appearance.rounding.normal - Appearance.padding.small + + StateLayer { + function onClicked(): void { + root.itemSelected(item.modelData); + root.active = item.modelData; + root.expanded = false; + } + + color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface + disabled: !root.expanded + } + } + + RowLayout { + id: menuOptionRow + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurfaceVariant + text: item.modelData.icon + } + + CustomText { + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface + text: item.modelData.text + } + + Loader { + Layout.alignment: Qt.AlignVCenter + active: item.modelData.trailingIcon.length > 0 + visible: active + + sourceComponent: MaterialIcon { + color: item.active ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface + text: item.modelData.trailingIcon + } + } + } + } + } + } + } +} diff --git a/Greeter/Components/MenuItem.qml b/Greeter/Components/MenuItem.qml new file mode 100644 index 0000000..2378dd9 --- /dev/null +++ b/Greeter/Components/MenuItem.qml @@ -0,0 +1,12 @@ +import QtQuick + +QtObject { + property string activeIcon: icon + property string activeText: text + property string icon + required property string text + property string trailingIcon + property var value + + signal clicked +} diff --git a/Greeter/Components/OpacityMask.qml b/Greeter/Components/OpacityMask.qml new file mode 100644 index 0000000..8203f9f --- /dev/null +++ b/Greeter/Components/OpacityMask.qml @@ -0,0 +1,9 @@ +import Quickshell +import QtQuick + +ShaderEffect { + required property Item maskSource + required property Item source + + fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/opacitymask.frag.qsb`) +} diff --git a/Greeter/Components/PathMenu.qml b/Greeter/Components/PathMenu.qml new file mode 100644 index 0000000..0e2d10d --- /dev/null +++ b/Greeter/Components/PathMenu.qml @@ -0,0 +1,76 @@ +import QtQuick + +Path { + id: root + + required property real viewHeight + required property real viewWidth + + startX: root.viewWidth / 2 + startY: 0 + + PathAttribute { + name: "itemOpacity" + value: 0.25 + } + + PathLine { + x: root.viewWidth / 2 + y: root.viewHeight * (1 / 6) + } + + PathAttribute { + name: "itemOpacity" + value: 0.45 + } + + PathLine { + x: root.viewWidth / 2 + y: root.viewHeight * (2 / 6) + } + + PathAttribute { + name: "itemOpacity" + value: 0.70 + } + + PathLine { + x: root.viewWidth / 2 + y: root.viewHeight * (3 / 6) + } + + PathAttribute { + name: "itemOpacity" + value: 1.00 + } + + PathLine { + x: root.viewWidth / 2 + y: root.viewHeight * (4 / 6) + } + + PathAttribute { + name: "itemOpacity" + value: 0.70 + } + + PathLine { + x: root.viewWidth / 2 + y: root.viewHeight * (5 / 6) + } + + PathAttribute { + name: "itemOpacity" + value: 0.45 + } + + PathLine { + x: root.viewWidth / 2 + y: root.viewHeight + } + + PathAttribute { + name: "itemOpacity" + value: 0.25 + } +} diff --git a/Greeter/Components/PathViewMenu.qml b/Greeter/Components/PathViewMenu.qml new file mode 100644 index 0000000..898dd4b --- /dev/null +++ b/Greeter/Components/PathViewMenu.qml @@ -0,0 +1,178 @@ +import QtQuick +import QtQuick.Effects +import qs.Config + +Elevation { + id: root + + required property int currentIndex + property bool expanded + required property int from + property color insideTextColor: DynamicColors.palette.m3onPrimary + property int itemHeight + property int listHeight: 200 + property color outsideTextColor: DynamicColors.palette.m3onSurfaceVariant + readonly property var spinnerModel: root.range(root.from, root.to) + required property int to + property Item triggerItem + + signal itemSelected(item: int) + + function range(first, last) { + let out = []; + for (let i = first; i <= last; ++i) + out.push(i); + return out; + } + + implicitHeight: root.expanded ? view.implicitHeight : 0 + level: root.expanded ? 2 : 0 + radius: itemHeight / 2 + visible: implicitHeight > 0 + + Behavior on implicitHeight { + Anim { + } + } + + onExpandedChanged: { + if (!root.expanded) + root.itemSelected(view.currentIndex + 1); + } + + Component { + id: spinnerDelegate + + Item { + id: wrapper + + readonly property color delegateTextColor: wrapper.PathView.view ? wrapper.PathView.view.delegateTextColor : "white" + required property var modelData + + height: root.itemHeight + opacity: wrapper.PathView.itemOpacity + visible: wrapper.PathView.onPath + width: wrapper.PathView.view ? wrapper.PathView.view.width : 0 + z: wrapper.PathView.isCurrentItem ? 100 : Math.round(wrapper.PathView.itemScale * 100) + + CustomText { + anchors.centerIn: parent + color: wrapper.delegateTextColor + font.pointSize: Appearance.font.size.large + text: wrapper.modelData + } + } + } + + CustomClippingRect { + anchors.fill: parent + color: DynamicColors.palette.m3surfaceContainer + radius: parent.radius + + // Main visible spinner: normal/outside text color + PathView { + id: view + + property color delegateTextColor: root.outsideTextColor + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + currentIndex: root.currentIndex - 1 + delegate: spinnerDelegate + dragMargin: width + highlightRangeMode: PathView.StrictlyEnforceRange + implicitHeight: root.listHeight + model: root.spinnerModel + pathItemCount: 7 + preferredHighlightBegin: 0.5 + preferredHighlightEnd: 0.5 + snapMode: PathView.SnapToItem + + path: PathMenu { + viewHeight: view.height + viewWidth: view.width + } + } + + // The selection rectangle itself + CustomRect { + id: selectionRect + + anchors.verticalCenter: parent.verticalCenter + color: DynamicColors.palette.m3primary + height: root.itemHeight + radius: root.itemHeight / 2 + width: parent.width + z: 2 + } + + // Hidden source: same PathView, but with the "inside selection" text color + Item { + id: selectedTextSource + + anchors.fill: parent + layer.enabled: true + visible: false + + PathView { + id: selectedTextView + + property color delegateTextColor: root.insideTextColor + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + clip: true + currentIndex: view.currentIndex + delegate: spinnerDelegate + dragMargin: view.dragMargin + highlightRangeMode: view.highlightRangeMode + implicitHeight: root.listHeight + interactive: false + model: view.model + + // Keep this PathView visually locked to the real one + offset: view.offset + pathItemCount: view.pathItemCount + preferredHighlightBegin: view.preferredHighlightBegin + preferredHighlightEnd: view.preferredHighlightEnd + snapMode: view.snapMode + + path: PathMenu { + viewHeight: selectedTextView.height + viewWidth: selectedTextView.width + } + } + } + + // Mask matching the selection rectangle + Item { + id: selectionMask + + anchors.fill: parent + layer.enabled: true + visible: false + + CustomRect { + color: "white" + height: selectionRect.height + radius: selectionRect.radius + width: selectionRect.width + x: selectionRect.x + y: selectionRect.y + } + } + + // Only show the "inside selection" text where the mask exists + MultiEffect { + anchors.fill: selectedTextSource + maskEnabled: true + maskInverted: false + maskSource: selectionMask + source: selectedTextSource + z: 3 + } + } +} diff --git a/Greeter/Components/Ref.qml b/Greeter/Components/Ref.qml new file mode 100644 index 0000000..14f03d6 --- /dev/null +++ b/Greeter/Components/Ref.qml @@ -0,0 +1,8 @@ +import QtQuick + +QtObject { + required property var service + + Component.onCompleted: service.refCount++ + Component.onDestruction: service.refCount-- +} diff --git a/Greeter/Components/SpinBoxRow.qml b/Greeter/Components/SpinBoxRow.qml new file mode 100644 index 0000000..6686ec7 --- /dev/null +++ b/Greeter/Components/SpinBoxRow.qml @@ -0,0 +1,50 @@ +import QtQuick +import QtQuick.Layouts +import qs.Config + +CustomRect { + id: root + + required property string label + required property real max + required property real min + property var onValueModified: function (value) {} + property real step: 1 + required property real value + + Layout.fillWidth: true + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainer, 2) + implicitHeight: row.implicitHeight + Appearance.padding.large * 2 + radius: Appearance.rounding.normal + + Behavior on implicitHeight { + Anim { + } + } + + RowLayout { + id: row + + anchors.left: parent.left + anchors.margins: Appearance.padding.large + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.normal + + CustomText { + Layout.fillWidth: true + text: root.label + } + + CustomSpinBox { + max: root.max + min: root.min + step: root.step + value: root.value + + onValueModified: value => { + root.onValueModified(value); + } + } + } +} diff --git a/Greeter/Components/StateLayer.qml b/Greeter/Components/StateLayer.qml new file mode 100644 index 0000000..bde8ce3 --- /dev/null +++ b/Greeter/Components/StateLayer.qml @@ -0,0 +1,96 @@ +import qs.Config +import QtQuick + +MouseArea { + id: root + + property color color: DynamicColors.palette.m3onSurface + property bool disabled + property real radius: parent?.radius ?? 0 + property alias rect: hoverLayer + + function onClicked(): void { + } + + anchors.fill: parent + cursorShape: disabled ? undefined : Qt.PointingHandCursor + enabled: !disabled + hoverEnabled: true + + onClicked: event => !disabled && onClicked(event) + onPressed: event => { + if (disabled) + return; + + rippleAnim.x = event.x; + rippleAnim.y = event.y; + + const dist = (ox, oy) => ox * ox + oy * oy; + rippleAnim.radius = Math.sqrt(Math.max(dist(event.x, event.y), dist(event.x, height - event.y), dist(width - event.x, event.y), dist(width - event.x, height - event.y))); + + rippleAnim.restart(); + } + + SequentialAnimation { + id: rippleAnim + + property real radius + property real x + property real y + + PropertyAction { + property: "x" + target: ripple + value: rippleAnim.x + } + + PropertyAction { + property: "y" + target: ripple + value: rippleAnim.y + } + + PropertyAction { + property: "opacity" + target: ripple + value: 0.08 + } + + Anim { + easing.bezierCurve: MaterialEasing.standardDecel + from: 0 + properties: "implicitWidth,implicitHeight" + target: ripple + to: rippleAnim.radius * 2 + } + + Anim { + property: "opacity" + target: ripple + to: 0 + } + } + + CustomClippingRect { + id: hoverLayer + + anchors.fill: parent + border.pixelAligned: false + color: Qt.alpha(root.color, root.disabled ? 0 : root.pressed ? 0.1 : root.containsMouse ? 0.08 : 0) + radius: root.radius + + CustomRect { + id: ripple + + border.pixelAligned: false + color: root.color + opacity: 0 + radius: 1000 + + transform: Translate { + x: -ripple.width / 2 + y: -ripple.height / 2 + } + } + } +} diff --git a/Greeter/Components/Toast/ToastItem.qml b/Greeter/Components/Toast/ToastItem.qml new file mode 100644 index 0000000..a726087 --- /dev/null +++ b/Greeter/Components/Toast/ToastItem.qml @@ -0,0 +1,131 @@ +import ZShell +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Config + +CustomRect { + id: root + + required property Toast modelData + + anchors.left: parent.left + anchors.right: parent.right + border.color: { + let colour = DynamicColors.palette.m3outlineVariant; + if (root.modelData.type === Toast.Success) + colour = DynamicColors.palette.m3success; + if (root.modelData.type === Toast.Warning) + colour = DynamicColors.palette.m3secondaryContainer; + if (root.modelData.type === Toast.Error) + colour = DynamicColors.palette.m3error; + return Qt.alpha(colour, 0.3); + } + border.width: 1 + color: { + if (root.modelData.type === Toast.Success) + return DynamicColors.palette.m3successContainer; + if (root.modelData.type === Toast.Warning) + return DynamicColors.palette.m3secondary; + if (root.modelData.type === Toast.Error) + return DynamicColors.palette.m3errorContainer; + return DynamicColors.palette.m3surface; + } + implicitHeight: layout.implicitHeight + Appearance.padding.smaller * 2 + radius: Appearance.rounding.normal + + Behavior on border.color { + CAnim { + } + } + + Elevation { + anchors.fill: parent + level: 3 + opacity: parent.opacity + radius: parent.radius + z: -1 + } + + RowLayout { + id: layout + + anchors.fill: parent + anchors.leftMargin: Appearance.padding.normal + anchors.margins: Appearance.padding.smaller + anchors.rightMargin: Appearance.padding.normal + spacing: Appearance.spacing.normal + + CustomRect { + color: { + if (root.modelData.type === Toast.Success) + return DynamicColors.palette.m3success; + if (root.modelData.type === Toast.Warning) + return DynamicColors.palette.m3secondaryContainer; + if (root.modelData.type === Toast.Error) + return DynamicColors.palette.m3error; + return DynamicColors.palette.m3surfaceContainerHigh; + } + implicitHeight: icon.implicitHeight + Appearance.padding.smaller * 2 + implicitWidth: implicitHeight + radius: Appearance.rounding.normal + + MaterialIcon { + id: icon + + anchors.centerIn: parent + color: { + if (root.modelData.type === Toast.Success) + return DynamicColors.palette.m3onSuccess; + if (root.modelData.type === Toast.Warning) + return DynamicColors.palette.m3onSecondaryContainer; + if (root.modelData.type === Toast.Error) + return DynamicColors.palette.m3onError; + return DynamicColors.palette.m3onSurfaceVariant; + } + font.pointSize: Math.round(Appearance.font.size.large * 1.2) + text: root.modelData.icon + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + CustomText { + id: title + + Layout.fillWidth: true + color: { + if (root.modelData.type === Toast.Success) + return DynamicColors.palette.m3onSuccessContainer; + if (root.modelData.type === Toast.Warning) + return DynamicColors.palette.m3onSecondary; + if (root.modelData.type === Toast.Error) + return DynamicColors.palette.m3onErrorContainer; + return DynamicColors.palette.m3onSurface; + } + elide: Text.ElideRight + font.pointSize: Appearance.font.size.normal + text: root.modelData.title + } + + CustomText { + Layout.fillWidth: true + color: { + if (root.modelData.type === Toast.Success) + return DynamicColors.palette.m3onSuccessContainer; + if (root.modelData.type === Toast.Warning) + return DynamicColors.palette.m3onSecondary; + if (root.modelData.type === Toast.Error) + return DynamicColors.palette.m3onErrorContainer; + return DynamicColors.palette.m3onSurface; + } + elide: Text.ElideRight + opacity: 0.8 + text: root.modelData.message + textFormat: Text.StyledText + } + } + } +} diff --git a/Greeter/Components/Toast/Toasts.qml b/Greeter/Components/Toast/Toasts.qml new file mode 100644 index 0000000..e2fbde6 --- /dev/null +++ b/Greeter/Components/Toast/Toasts.qml @@ -0,0 +1,142 @@ +pragma ComponentBehavior: Bound + +import ZShell +import Quickshell +import QtQuick +import qs.Components +import qs.Config + +Item { + id: root + + property bool flag + readonly property int spacing: Appearance.spacing.small + + implicitHeight: { + let h = -spacing; + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i) as ToastWrapper; + if (!item.modelData.closed && !item.previewHidden) + h += item.implicitHeight + spacing; + } + return h; + } + implicitWidth: Config.utilities.sizes.toastWidth - Appearance.padding.normal * 2 + + Repeater { + id: repeater + + model: ScriptModel { + values: { + const toasts = []; + let count = 0; + for (const toast of Toaster.toasts) { + toasts.push(toast); + if (!toast.closed) { + count++; + if (count > Config.utilities.maxToasts) + break; + } + } + return toasts; + } + + onValuesChanged: root.flagChanged() + } + + ToastWrapper { + } + } + + component ToastWrapper: MouseArea { + id: toast + + required property int index + required property Toast modelData + readonly property bool previewHidden: { + let extraHidden = 0; + for (let i = 0; i < index; i++) + if (Toaster.toasts[i].closed) + extraHidden++; + return index >= Config.utilities.maxToasts + extraHidden; + } + + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + anchors.bottom: parent.bottom + anchors.bottomMargin: { + root.flag; // Force update + let y = 0; + for (let i = 0; i < index; i++) { + const item = repeater.itemAt(i) as ToastWrapper; + if (item && !item.modelData.closed && !item.previewHidden) + y += item.implicitHeight + root.spacing; + } + return y; + } + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: toastInner.implicitHeight + opacity: modelData.closed || previewHidden ? 0 : 1 + scale: modelData.closed || previewHidden ? 0.7 : 1 + + Behavior on anchors.bottomMargin { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + } + } + + Component.onCompleted: modelData.lock(this) + onClicked: modelData.close() + onPreviewHiddenChanged: { + if (initAnim.running && previewHidden) + initAnim.stop(); + } + + Anim { + id: initAnim + + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + from: 0 + properties: "opacity,scale" + target: toast + to: 1 + + Component.onCompleted: running = !toast.previewHidden + } + + ParallelAnimation { + running: toast.modelData.closed + + onFinished: toast.modelData.unlock(toast) + onStarted: toast.anchors.bottomMargin = toast.anchors.bottomMargin + + Anim { + property: "opacity" + target: toast + to: 0 + } + + Anim { + property: "scale" + target: toast + to: 0.7 + } + } + + ToastItem { + id: toastInner + + modelData: toast.modelData + } + } +} diff --git a/Greeter/Config/AccentColor.qml b/Greeter/Config/AccentColor.qml new file mode 100644 index 0000000..82a0157 --- /dev/null +++ b/Greeter/Config/AccentColor.qml @@ -0,0 +1,13 @@ +import Quickshell.Io + +JsonObject { + property Accents accents: Accents { + } + + component Accents: JsonObject { + property string primary: "#4080ff" + property string primaryAlt: "#60a0ff" + property string warning: "#ff6b6b" + property string warningAlt: "#ff8787" + } +} diff --git a/Greeter/Config/Appearance.qml b/Greeter/Config/Appearance.qml new file mode 100644 index 0000000..d2bf19e --- /dev/null +++ b/Greeter/Config/Appearance.qml @@ -0,0 +1,14 @@ +pragma Singleton + +import Quickshell + +Singleton { + readonly property AppearanceConf.Anim anim: Config.appearance.anim + readonly property AppearanceConf.FontStuff font: Config.appearance.font + readonly property AppearanceConf.Padding padding: Config.appearance.padding + // Literally just here to shorten accessing stuff :woe: + // Also kinda so I can keep accessing it with `Appearance.xxx` instead of `Conf.appearance.xxx` + readonly property AppearanceConf.Rounding rounding: Config.appearance.rounding + readonly property AppearanceConf.Spacing spacing: Config.appearance.spacing + readonly property AppearanceConf.Transparency transparency: Config.appearance.transparency +} diff --git a/Greeter/Config/AppearanceConf.qml b/Greeter/Config/AppearanceConf.qml new file mode 100644 index 0000000..60c648c --- /dev/null +++ b/Greeter/Config/AppearanceConf.qml @@ -0,0 +1,97 @@ +import Quickshell.Io + +JsonObject { + property Anim anim: Anim { + } + property FontStuff font: FontStuff { + } + property Padding padding: Padding { + } + property Rounding rounding: Rounding { + } + property Spacing spacing: Spacing { + } + property Transparency transparency: Transparency { + } + + component Anim: JsonObject { + property AnimCurves curves: AnimCurves { + } + property AnimDurations durations: AnimDurations { + } + property real mediaGifSpeedAdjustment: 300 + property real sessionGifSpeed: 0.7 + } + component AnimCurves: JsonObject { + property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] + property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] + property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1, 1, 1] + property list expressiveEffects: [0.34, 0.8, 0.34, 1, 1, 1] + property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.9, 1, 1] + property list standard: [0.2, 0, 0, 1, 1, 1] + property list standardAccel: [0.3, 0, 1, 1, 1, 1] + property list standardDecel: [0, 0, 0, 1, 1, 1] + } + component AnimDurations: JsonObject { + property int expressiveDefaultSpatial: 500 * scale + property int expressiveEffects: 200 * scale + property int expressiveFastSpatial: 350 * scale + property int extraLarge: 1000 * scale + property int large: 600 * scale + property int normal: 400 * scale + property real scale: 1 + property int small: 200 * scale + } + component FontFamily: JsonObject { + property string clock: "Rubik" + property string material: "Material Symbols Rounded" + property string mono: "CaskaydiaCove NF" + property string sans: "Segoe UI Variable Text" + } + component FontSize: JsonObject { + property int extraLarge: 28 * scale + property int large: 18 * scale + property int larger: 15 * scale + property int normal: 13 * scale + property real scale: 1 + property int small: 11 * scale + property int smaller: 12 * scale + } + component FontStuff: JsonObject { + property FontFamily family: FontFamily { + } + property FontSize size: FontSize { + } + } + component Padding: JsonObject { + property int large: 15 * scale + property int larger: 12 * scale + property int normal: 10 * scale + property real scale: 1 + property int small: 5 * scale + property int smaller: 7 * scale + property int smallest: 2 * scale + } + component Rounding: JsonObject { + property int full: 1000 * scale + property int large: 25 * scale + property int normal: 17 * scale + property real scale: 1 + property int small: 12 * scale + property int smallest: 8 * scale + } + component Spacing: JsonObject { + property int large: 20 * scale + property int larger: 15 * scale + property int normal: 12 * scale + property real scale: 1 + property int small: 7 * scale + property int smaller: 10 * scale + } + component Transparency: JsonObject { + property real base: 0.85 + property bool enabled: false + property real layers: 0.4 + } +} diff --git a/Greeter/Config/BackgroundConfig.qml b/Greeter/Config/BackgroundConfig.qml new file mode 100644 index 0000000..bcae021 --- /dev/null +++ b/Greeter/Config/BackgroundConfig.qml @@ -0,0 +1,7 @@ +import Quickshell.Io +import qs.Config + +JsonObject { + property bool enabled: true + property int wallFadeDuration: MaterialEasing.standardTime +} diff --git a/Greeter/Config/BarConfig.qml b/Greeter/Config/BarConfig.qml new file mode 100644 index 0000000..7b39915 --- /dev/null +++ b/Greeter/Config/BarConfig.qml @@ -0,0 +1,78 @@ +import Quickshell.Io + +JsonObject { + property bool autoHide: false + property int border: 8 + property list entries: [ + { + id: "workspaces", + enabled: true + }, + { + id: "audio", + enabled: true + }, + { + id: "media", + enabled: true + }, + { + id: "resources", + enabled: true + }, + { + id: "updates", + enabled: true + }, + { + id: "dash", + enabled: true + }, + { + id: "spacer", + enabled: true + }, + { + id: "activeWindow", + enabled: true + }, + { + id: "spacer", + enabled: true + }, + { + id: "tray", + enabled: true + }, + { + id: "upower", + enabled: false + }, + { + id: "network", + enabled: false + }, + { + id: "clock", + enabled: true + }, + { + id: "notifBell", + enabled: true + }, + ] + property int height: 34 + property Popouts popouts: Popouts { + } + property int rounding: 8 + + component Popouts: JsonObject { + property bool activeWindow: true + property bool audio: true + property bool clock: true + property bool network: true + property bool resources: true + property bool tray: true + property bool upower: true + } +} diff --git a/Greeter/Config/Colors.qml b/Greeter/Config/Colors.qml new file mode 100644 index 0000000..287ea02 --- /dev/null +++ b/Greeter/Config/Colors.qml @@ -0,0 +1,5 @@ +import Quickshell.Io + +JsonObject { + property string schemeType: "vibrant" +} diff --git a/Greeter/Config/Config.qml b/Greeter/Config/Config.qml new file mode 100644 index 0000000..78eee49 --- /dev/null +++ b/Greeter/Config/Config.qml @@ -0,0 +1,426 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import ZShell +import QtQuick +import qs.Helpers +import qs.Paths + +Singleton { + id: root + + property alias appearance: adapter.appearance + property alias background: adapter.background + property alias barConfig: adapter.barConfig + property alias colors: adapter.colors + property alias dashboard: adapter.dashboard + property alias dock: adapter.dock + property alias general: adapter.general + property alias launcher: adapter.launcher + property alias lock: adapter.lock + property alias notifs: adapter.notifs + property alias osd: adapter.osd + property alias overview: adapter.overview + property bool recentlySaved: false + property alias services: adapter.services + property alias sidebar: adapter.sidebar + property alias utilities: adapter.utilities + + function save(): void { + saveTimer.restart(); + recentlySaved = true; + recentSaveCooldown.restart(); + } + + function saveNoToast(): void { + saveTimer.restart(); + } + + function serializeAppearance(): var { + return { + rounding: { + scale: appearance.rounding.scale + }, + spacing: { + scale: appearance.spacing.scale + }, + padding: { + scale: appearance.padding.scale + }, + font: { + family: { + sans: appearance.font.family.sans, + mono: appearance.font.family.mono, + material: appearance.font.family.material, + clock: appearance.font.family.clock + }, + size: { + scale: appearance.font.size.scale + } + }, + anim: { + mediaGifSpeedAdjustment: appearance.anim.mediaGifSpeedAdjustment, + sessionGifSpeed: appearance.anim.sessionGifSpeed, + durations: { + scale: appearance.anim.durations.scale + } + }, + transparency: { + enabled: appearance.transparency.enabled, + base: appearance.transparency.base, + layers: appearance.transparency.layers + } + }; + } + + function serializeBackground(): var { + return { + wallFadeDuration: background.wallFadeDuration, + enabled: background.enabled + }; + } + + function serializeBar(): var { + return { + autoHide: barConfig.autoHide, + rounding: barConfig.rounding, + border: barConfig.border, + height: barConfig.height, + popouts: { + tray: barConfig.popouts.tray, + audio: barConfig.popouts.audio, + activeWindow: barConfig.popouts.activeWindow, + resources: barConfig.popouts.resources, + clock: barConfig.popouts.clock, + network: barConfig.popouts.network, + upower: barConfig.popouts.upower + }, + entries: barConfig.entries + }; + } + + function serializeColors(): var { + return { + schemeType: colors.schemeType + }; + } + + function serializeConfig(): var { + return { + barConfig: serializeBar(), + lock: serializeLock(), + general: serializeGeneral(), + services: serializeServices(), + notifs: serializeNotifs(), + sidebar: serializeSidebar(), + utilities: serializeUtilities(), + dashboard: serializeDashboard(), + appearance: serializeAppearance(), + osd: serializeOsd(), + background: serializeBackground(), + launcher: serializeLauncher(), + colors: serializeColors(), + dock: serializeDock() + }; + } + + function serializeDashboard(): var { + return { + enabled: dashboard.enabled, + mediaUpdateInterval: dashboard.mediaUpdateInterval, + resourceUpdateInterval: dashboard.resourceUpdateInterval, + dragThreshold: dashboard.dragThreshold, + performance: { + showBattery: dashboard.performance.showBattery, + showGpu: dashboard.performance.showGpu, + showCpu: dashboard.performance.showCpu, + showMemory: dashboard.performance.showMemory, + showStorage: dashboard.performance.showStorage, + showNetwork: dashboard.performance.showNetwork + }, + sizes: { + tabIndicatorHeight: dashboard.sizes.tabIndicatorHeight, + tabIndicatorSpacing: dashboard.sizes.tabIndicatorSpacing, + infoWidth: dashboard.sizes.infoWidth, + infoIconSize: dashboard.sizes.infoIconSize, + dateTimeWidth: dashboard.sizes.dateTimeWidth, + mediaWidth: dashboard.sizes.mediaWidth, + mediaProgressSweep: dashboard.sizes.mediaProgressSweep, + mediaProgressThickness: dashboard.sizes.mediaProgressThickness, + resourceProgessThickness: dashboard.sizes.resourceProgessThickness, + weatherWidth: dashboard.sizes.weatherWidth, + mediaCoverArtSize: dashboard.sizes.mediaCoverArtSize, + mediaVisualiserSize: dashboard.sizes.mediaVisualiserSize, + resourceSize: dashboard.sizes.resourceSize + } + }; + } + + function serializeDock(): var { + return { + enable: dock.enable, + height: dock.height, + hoverToReveal: dock.hoverToReveal, + pinnedApps: dock.pinnedApps, + pinnedOnStartup: dock.pinnedOnStartup, + ignoredAppRegexes: dock.ignoredAppRegexes + }; + } + + function serializeGeneral(): var { + return { + logo: general.logo, + wallpaperPath: general.wallpaperPath, + desktopIcons: general.desktopIcons, + color: { + wallust: general.color.wallust, + mode: general.color.mode, + smart: general.color.smart, + schemeGeneration: general.color.schemeGeneration, + scheduleDarkStart: general.color.scheduleDarkStart, + scheduleDarkEnd: general.color.scheduleDarkEnd, + neovimColors: general.color.neovimColors + }, + apps: { + terminal: general.apps.terminal, + audio: general.apps.audio, + playback: general.apps.playback, + explorer: general.apps.explorer + }, + idle: { + timeouts: general.idle.timeouts + } + }; + } + + function serializeLauncher(): var { + return { + maxAppsShown: launcher.maxAppsShown, + maxWallpapers: launcher.maxWallpapers, + actionPrefix: launcher.actionPrefix, + specialPrefix: launcher.specialPrefix, + useFuzzy: { + apps: launcher.useFuzzy.apps, + actions: launcher.useFuzzy.actions, + schemes: launcher.useFuzzy.schemes, + variants: launcher.useFuzzy.variants, + wallpapers: launcher.useFuzzy.wallpapers + }, + sizes: { + itemWidth: launcher.sizes.itemWidth, + itemHeight: launcher.sizes.itemHeight, + wallpaperWidth: launcher.sizes.wallpaperWidth, + wallpaperHeight: launcher.sizes.wallpaperHeight + }, + actions: launcher.actions + }; + } + + function serializeLock(): var { + return { + recolorLogo: lock.recolorLogo, + enableFprint: lock.enableFprint, + maxFprintTries: lock.maxFprintTries, + blurAmount: lock.blurAmount, + sizes: { + heightMult: lock.sizes.heightMult, + ratio: lock.sizes.ratio, + centerWidth: lock.sizes.centerWidth + } + }; + } + + function serializeNotifs(): var { + return { + expire: notifs.expire, + defaultExpireTimeout: notifs.defaultExpireTimeout, + appNotifCooldown: notifs.appNotifCooldown, + clearThreshold: notifs.clearThreshold, + expandThreshold: notifs.expandThreshold, + actionOnClick: notifs.actionOnClick, + groupPreviewNum: notifs.groupPreviewNum, + sizes: { + width: notifs.sizes.width, + image: notifs.sizes.image, + badge: notifs.sizes.badge + } + }; + } + + function serializeOsd(): var { + return { + enabled: osd.enabled, + hideDelay: osd.hideDelay, + enableBrightness: osd.enableBrightness, + enableMicrophone: osd.enableMicrophone, + allMonBrightness: osd.allMonBrightness, + sizes: { + sliderWidth: osd.sizes.sliderWidth, + sliderHeight: osd.sizes.sliderHeight + } + }; + } + + function serializeServices(): var { + return { + weatherLocation: services.weatherLocation, + useFahrenheit: services.useFahrenheit, + ddcutilService: services.ddcutilService, + useTwelveHourClock: services.useTwelveHourClock, + gpuType: services.gpuType, + audioIncrement: services.audioIncrement, + brightnessIncrement: services.brightnessIncrement, + maxVolume: services.maxVolume, + defaultPlayer: services.defaultPlayer, + playerAliases: services.playerAliases, + visualizerBars: services.visualizerBars + }; + } + + function serializeSidebar(): var { + return { + enabled: sidebar.enabled, + sizes: { + width: sidebar.sizes.width + } + }; + } + + function serializeUtilities(): var { + return { + enabled: utilities.enabled, + maxToasts: utilities.maxToasts, + sizes: { + width: utilities.sizes.width, + toastWidth: utilities.sizes.toastWidth + }, + toasts: { + configLoaded: utilities.toasts.configLoaded, + chargingChanged: utilities.toasts.chargingChanged, + gameModeChanged: utilities.toasts.gameModeChanged, + dndChanged: utilities.toasts.dndChanged, + audioOutputChanged: utilities.toasts.audioOutputChanged, + audioInputChanged: utilities.toasts.audioInputChanged, + capsLockChanged: utilities.toasts.capsLockChanged, + numLockChanged: utilities.toasts.numLockChanged, + kbLayoutChanged: utilities.toasts.kbLayoutChanged, + vpnChanged: utilities.toasts.vpnChanged, + nowPlaying: utilities.toasts.nowPlaying + }, + vpn: { + enabled: utilities.vpn.enabled, + provider: utilities.vpn.provider + } + }; + } + + ElapsedTimer { + id: timer + + } + + Timer { + id: saveTimer + + interval: 500 + + onTriggered: { + timer.restart(); + try { + let config = {}; + try { + config = JSON.parse(fileView.text()); + } catch (e) { + config = {}; + } + + config = root.serializeConfig(); + + fileView.setText(JSON.stringify(config, null, 4)); + } catch (e) { + Toaster.toast(qsTr("Failed to serialize config"), e.message, "settings_alert", Toast.Error); + } + } + } + + Timer { + id: recentSaveCooldown + + interval: 2000 + + onTriggered: { + root.recentlySaved = false; + } + } + + FileView { + id: fileView + + path: `${Paths.config}/config.json` + watchChanges: true + + onFileChanged: { + if (!root.recentlySaved) { + timer.restart(); + reload(); + } else { + reload(); + } + } + onLoadFailed: err => { + if (err !== FileViewError.FileNotFound) + Toaster.toast(qsTr("Failed to read config"), FileViewError.toString(err), "settings_alert", Toast.Warning); + } + onLoaded: { + try { + JSON.parse(text()); + const elapsed = timer.elapsedMs(); + + if (adapter.utilities.toasts.configLoaded && !root.recentlySaved) { + Toaster.toast(qsTr("Config loaded"), qsTr("Config loaded in %1ms").arg(elapsed), "rule_settings"); + } else if (adapter.utilities.toasts.configLoaded && root.recentlySaved) { + Toaster.toast(qsTr("Config saved"), qsTr("Config reloaded in %1ms").arg(elapsed), "settings_alert"); + } + } catch (e) { + Toaster.toast(qsTr("Failed to load config"), e.message, "settings_alert", Toast.Error); + } + } + onSaveFailed: err => Toaster.toast(qsTr("Failed to save config"), FileViewError.toString(err), "settings_alert", Toast.Error) + + JsonAdapter { + id: adapter + + property AppearanceConf appearance: AppearanceConf { + } + property BackgroundConfig background: BackgroundConfig { + } + property BarConfig barConfig: BarConfig { + } + property Colors colors: Colors { + } + property DashboardConfig dashboard: DashboardConfig { + } + property DockConfig dock: DockConfig { + } + property General general: General { + } + property Launcher launcher: Launcher { + } + property LockConf lock: LockConf { + } + property NotifConfig notifs: NotifConfig { + } + property Osd osd: Osd { + } + property Overview overview: Overview { + } + property Services services: Services { + } + property SidebarConfig sidebar: SidebarConfig { + } + property UtilConfig utilities: UtilConfig { + } + } + } +} diff --git a/Greeter/Config/DashboardConfig.qml b/Greeter/Config/DashboardConfig.qml new file mode 100644 index 0000000..0b6a610 --- /dev/null +++ b/Greeter/Config/DashboardConfig.qml @@ -0,0 +1,36 @@ +import Quickshell.Io + +JsonObject { + property int dragThreshold: 50 + property bool enabled: true + property int mediaUpdateInterval: 500 + property Performance performance: Performance { + } + property int resourceUpdateInterval: 1000 + property Sizes sizes: Sizes { + } + + component Performance: JsonObject { + property bool showBattery: true + property bool showCpu: true + property bool showGpu: true + property bool showMemory: true + property bool showNetwork: true + property bool showStorage: true + } + component Sizes: JsonObject { + readonly property int dateTimeWidth: 110 + readonly property int infoIconSize: 25 + readonly property int infoWidth: 200 + readonly property int mediaCoverArtSize: 150 + readonly property int mediaProgressSweep: 180 + readonly property int mediaProgressThickness: 8 + readonly property int mediaVisualiserSize: 80 + readonly property int mediaWidth: 200 + readonly property int resourceProgessThickness: 10 + readonly property int resourceSize: 200 + readonly property int tabIndicatorHeight: 3 + readonly property int tabIndicatorSpacing: 5 + readonly property int weatherWidth: 250 + } +} diff --git a/Greeter/Config/DockConfig.qml b/Greeter/Config/DockConfig.qml new file mode 100644 index 0000000..eeaf1de --- /dev/null +++ b/Greeter/Config/DockConfig.qml @@ -0,0 +1,10 @@ +import Quickshell.Io + +JsonObject { + property bool enable: false + property real height: 60 + property bool hoverToReveal: true + property list ignoredAppRegexes: [] + property list pinnedApps: ["org.kde.dolphin", "kitty",] + property bool pinnedOnStartup: false +} diff --git a/Greeter/Config/DynamicColors.qml b/Greeter/Config/DynamicColors.qml new file mode 100644 index 0000000..ce13971 --- /dev/null +++ b/Greeter/Config/DynamicColors.qml @@ -0,0 +1,286 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick +import ZShell +import qs.Helpers +import qs.Paths + +Singleton { + id: root + + readonly property M3Palette current: M3Palette { + } + property bool currentLight + property string flavour + readonly property bool light: showPreview ? previewLight : currentLight + readonly property M3Palette palette: showPreview ? preview : current + readonly property M3Palette preview: M3Palette { + } + property bool previewLight + property string scheme + property bool showPreview + readonly property M3TPalette tPalette: M3TPalette { + } + readonly property Transparency transparency: Transparency { + } + readonly property alias wallLuminance: analyser.luminance + + function alterColor(c: color, a: real, layer: int): color { + const luminance = getLuminance(c); + + const offset = (!light || layer == 1 ? 1 : -layer / 2) * (light ? 0.2 : 0.3) * (1 - transparency.base) * (1 + wallLuminance * (light ? (layer == 1 ? 3 : 1) : 2.5)); + const scale = (luminance + offset) / luminance; + const r = Math.max(0, Math.min(1, c.r * scale)); + const g = Math.max(0, Math.min(1, c.g * scale)); + const b = Math.max(0, Math.min(1, c.b * scale)); + + return Qt.rgba(r, g, b, a); + } + + function getLuminance(c: color): real { + if (c.r == 0 && c.g == 0 && c.b == 0) + return 0; + return Math.sqrt(0.299 * (c.r ** 2) + 0.587 * (c.g ** 2) + 0.114 * (c.b ** 2)); + } + + function layer(c: color, layer: var): color { + if (!transparency.enabled) + return c; + + return layer === 0 ? Qt.alpha(c, transparency.base) : alterColor(c, transparency.layers, layer ?? 1); + } + + function load(data: string, isPreview: bool): void { + const colors = isPreview ? preview : current; + const scheme = JSON.parse(data); + + if (!isPreview) { + root.scheme = scheme.name; + flavour = scheme.flavor; + currentLight = scheme.mode === "light"; + } else { + previewLight = scheme.mode === "light"; + } + + for (const [name, color] of Object.entries(scheme.colors)) { + const propName = name.startsWith("term") ? name : `m3${name}`; + if (colors.hasOwnProperty(propName)) + colors[propName] = `${color}`; + } + } + + function on(c: color): color { + if (c.hslLightness < 0.5) + return Qt.hsla(c.hslHue, c.hslSaturation, 0.9, 1); + return Qt.hsla(c.hslHue, c.hslSaturation, 0.1, 1); + } + + function setMode(mode: string): void { + Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); + Config.general.color.mode = mode; + Config.save(); + } + + FileView { + path: `${Paths.state}/scheme.json` + watchChanges: true + + onFileChanged: reload() + onLoaded: root.load(text(), false) + } + + ImageAnalyser { + id: analyser + + source: WallpaperPath.currentWallpaperPath + } + + component M3MaccchiatoPalette: QtObject { + property color m3background: "#131317" + property color m3error: "#ffb4ab" + property color m3errorContainer: "#93000a" + property color m3inverseOnSurface: "#303034" + property color m3inversePrimary: "#525b92" + property color m3inverseSurface: "#e4e1e7" + property color m3neutral_paletteKeyColor: "#77767b" + property color m3neutral_variant_paletteKeyColor: "#767680" + property color m3onBackground: "#e4e1e7" + property color m3onError: "#690005" + property color m3onErrorContainer: "#ffdad6" + property color m3onPrimary: "#232c60" + property color m3onPrimaryContainer: "#ffffff" + property color m3onPrimaryFixed: "#0b154b" + property color m3onPrimaryFixedVariant: "#3a4378" + property color m3onSecondary: "#2c2f44" + property color m3onSecondaryContainer: "#b1b3ce" + property color m3onSecondaryFixed: "#171a2e" + property color m3onSecondaryFixedVariant: "#42455c" + property color m3onSuccess: "#213528" + property color m3onSuccessContainer: "#D1E9D6" + property color m3onSurface: "#e4e1e7" + property color m3onSurfaceVariant: "#c6c5d1" + property color m3onTertiary: "#4c1f48" + property color m3onTertiaryContainer: "#000000" + property color m3onTertiaryFixed: "#340831" + property color m3onTertiaryFixedVariant: "#66365f" + property color m3outline: "#90909a" + property color m3outlineVariant: "#46464f" + property color m3primary: "#bac3ff" + property color m3primaryContainer: "#6a73ac" + property color m3primaryFixed: "#dee0ff" + property color m3primaryFixedDim: "#bac3ff" + property color m3primary_paletteKeyColor: "#6a73ac" + property color m3scrim: "#000000" + property color m3secondary: "#c3c5e0" + property color m3secondaryContainer: "#42455c" + property color m3secondaryFixed: "#dfe1fd" + property color m3secondaryFixedDim: "#c3c5e0" + property color m3secondary_paletteKeyColor: "#72758e" + property color m3shadow: "#000000" + property color m3success: "#B5CCBA" + property color m3successContainer: "#374B3E" + property color m3surface: "#131317" + property color m3surfaceBright: "#39393d" + property color m3surfaceContainer: "#1f1f23" + property color m3surfaceContainerHigh: "#2a2a2e" + property color m3surfaceContainerHighest: "#353438" + property color m3surfaceContainerLow: "#1b1b1f" + property color m3surfaceContainerLowest: "#0e0e12" + property color m3surfaceDim: "#131317" + property color m3surfaceTint: "#bac3ff" + property color m3surfaceVariant: "#46464f" + property color m3tertiary: "#f1b3e5" + property color m3tertiaryContainer: "#b77ead" + property color m3tertiaryFixed: "#ffd7f4" + property color m3tertiaryFixedDim: "#f1b3e5" + property color m3tertiary_paletteKeyColor: "#9b6592" + } + component M3Palette: QtObject { + property color m3background: "#191114" + property color m3error: "#ffb4ab" + property color m3errorContainer: "#93000a" + property color m3inverseOnSurface: "#372e30" + property color m3inversePrimary: "#8b4a62" + property color m3inverseSurface: "#efdfe2" + property color m3neutral_paletteKeyColor: "#807477" + property color m3neutral_variant_paletteKeyColor: "#837377" + property color m3onBackground: "#efdfe2" + property color m3onError: "#690005" + property color m3onErrorContainer: "#ffdad6" + property color m3onPrimary: "#541d34" + property color m3onPrimaryContainer: "#ffd9e3" + property color m3onPrimaryFixed: "#39071f" + property color m3onPrimaryFixedVariant: "#6f334a" + property color m3onSecondary: "#422932" + property color m3onSecondaryContainer: "#ffd9e3" + property color m3onSecondaryFixed: "#2b151d" + property color m3onSecondaryFixedVariant: "#5a3f48" + property color m3onSuccess: "#213528" + property color m3onSuccessContainer: "#D1E9D6" + property color m3onSurface: "#efdfe2" + property color m3onSurfaceVariant: "#d5c2c6" + property color m3onTertiary: "#48290c" + property color m3onTertiaryContainer: "#000000" + property color m3onTertiaryFixed: "#2f1500" + property color m3onTertiaryFixedVariant: "#623f21" + property color m3outline: "#9e8c91" + property color m3outlineVariant: "#514347" + property color m3primary: "#ffb0ca" + property color m3primaryContainer: "#6f334a" + property color m3primaryFixed: "#ffd9e3" + property color m3primaryFixedDim: "#ffb0ca" + property color m3primary_paletteKeyColor: "#a8627b" + property color m3scrim: "#000000" + property color m3secondary: "#e2bdc7" + property color m3secondaryContainer: "#5a3f48" + property color m3secondaryFixed: "#ffd9e3" + property color m3secondaryFixedDim: "#e2bdc7" + property color m3secondary_paletteKeyColor: "#8e6f78" + property color m3shadow: "#000000" + property color m3success: "#B5CCBA" + property color m3successContainer: "#374B3E" + property color m3surface: "#191114" + property color m3surfaceBright: "#403739" + property color m3surfaceContainer: "#261d20" + property color m3surfaceContainerHigh: "#31282a" + property color m3surfaceContainerHighest: "#3c3235" + property color m3surfaceContainerLow: "#22191c" + property color m3surfaceContainerLowest: "#130c0e" + property color m3surfaceDim: "#191114" + property color m3surfaceTint: "#ffb0ca" + property color m3surfaceVariant: "#514347" + property color m3tertiary: "#f0bc95" + property color m3tertiaryContainer: "#b58763" + property color m3tertiaryFixed: "#ffdcc3" + property color m3tertiaryFixedDim: "#f0bc95" + property color m3tertiary_paletteKeyColor: "#986e4c" + } + component M3TPalette: QtObject { + readonly property color m3background: root.layer(root.palette.m3background, 0) + readonly property color m3error: root.layer(root.palette.m3error) + readonly property color m3errorContainer: root.layer(root.palette.m3errorContainer) + readonly property color m3inverseOnSurface: root.layer(root.palette.m3inverseOnSurface) + readonly property color m3inversePrimary: root.layer(root.palette.m3inversePrimary) + readonly property color m3inverseSurface: root.layer(root.palette.m3inverseSurface, 0) + readonly property color m3neutral_paletteKeyColor: root.layer(root.palette.m3neutral_paletteKeyColor) + readonly property color m3neutral_variant_paletteKeyColor: root.layer(root.palette.m3neutral_variant_paletteKeyColor) + readonly property color m3onBackground: root.layer(root.palette.m3onBackground) + readonly property color m3onError: root.layer(root.palette.m3onError) + readonly property color m3onErrorContainer: root.layer(root.palette.m3onErrorContainer) + readonly property color m3onPrimary: root.layer(root.palette.m3onPrimary) + readonly property color m3onPrimaryContainer: root.layer(root.palette.m3onPrimaryContainer) + readonly property color m3onPrimaryFixed: root.layer(root.palette.m3onPrimaryFixed) + readonly property color m3onPrimaryFixedVariant: root.layer(root.palette.m3onPrimaryFixedVariant) + readonly property color m3onSecondary: root.layer(root.palette.m3onSecondary) + readonly property color m3onSecondaryContainer: root.layer(root.palette.m3onSecondaryContainer) + readonly property color m3onSecondaryFixed: root.layer(root.palette.m3onSecondaryFixed) + readonly property color m3onSecondaryFixedVariant: root.layer(root.palette.m3onSecondaryFixedVariant) + readonly property color m3onSuccess: root.layer(root.palette.m3onSuccess) + readonly property color m3onSuccessContainer: root.layer(root.palette.m3onSuccessContainer) + readonly property color m3onSurface: root.layer(root.palette.m3onSurface) + readonly property color m3onSurfaceVariant: root.layer(root.palette.m3onSurfaceVariant) + readonly property color m3onTertiary: root.layer(root.palette.m3onTertiary) + readonly property color m3onTertiaryContainer: root.layer(root.palette.m3onTertiaryContainer) + readonly property color m3onTertiaryFixed: root.layer(root.palette.m3onTertiaryFixed) + readonly property color m3onTertiaryFixedVariant: root.layer(root.palette.m3onTertiaryFixedVariant) + readonly property color m3outline: root.layer(root.palette.m3outline) + readonly property color m3outlineVariant: root.layer(root.palette.m3outlineVariant) + readonly property color m3primary: root.layer(root.palette.m3primary) + readonly property color m3primaryContainer: root.layer(root.palette.m3primaryContainer) + readonly property color m3primaryFixed: root.layer(root.palette.m3primaryFixed) + readonly property color m3primaryFixedDim: root.layer(root.palette.m3primaryFixedDim) + readonly property color m3primary_paletteKeyColor: root.layer(root.palette.m3primary_paletteKeyColor) + readonly property color m3scrim: root.layer(root.palette.m3scrim) + readonly property color m3secondary: root.layer(root.palette.m3secondary) + readonly property color m3secondaryContainer: root.layer(root.palette.m3secondaryContainer) + readonly property color m3secondaryFixed: root.layer(root.palette.m3secondaryFixed) + readonly property color m3secondaryFixedDim: root.layer(root.palette.m3secondaryFixedDim) + readonly property color m3secondary_paletteKeyColor: root.layer(root.palette.m3secondary_paletteKeyColor) + readonly property color m3shadow: root.layer(root.palette.m3shadow) + readonly property color m3success: root.layer(root.palette.m3success) + readonly property color m3successContainer: root.layer(root.palette.m3successContainer) + readonly property color m3surface: root.layer(root.palette.m3surface, 0) + readonly property color m3surfaceBright: root.layer(root.palette.m3surfaceBright, 0) + readonly property color m3surfaceContainer: root.layer(root.palette.m3surfaceContainer) + readonly property color m3surfaceContainerHigh: root.layer(root.palette.m3surfaceContainerHigh) + readonly property color m3surfaceContainerHighest: root.layer(root.palette.m3surfaceContainerHighest) + readonly property color m3surfaceContainerLow: root.layer(root.palette.m3surfaceContainerLow) + readonly property color m3surfaceContainerLowest: root.layer(root.palette.m3surfaceContainerLowest) + readonly property color m3surfaceDim: root.layer(root.palette.m3surfaceDim, 0) + readonly property color m3surfaceTint: root.layer(root.palette.m3surfaceTint) + readonly property color m3surfaceVariant: root.layer(root.palette.m3surfaceVariant, 0) + readonly property color m3tertiary: root.layer(root.palette.m3tertiary) + readonly property color m3tertiaryContainer: root.layer(root.palette.m3tertiaryContainer) + readonly property color m3tertiaryFixed: root.layer(root.palette.m3tertiaryFixed) + readonly property color m3tertiaryFixedDim: root.layer(root.palette.m3tertiaryFixedDim) + readonly property color m3tertiary_paletteKeyColor: root.layer(root.palette.m3tertiary_paletteKeyColor) + } + component Transparency: QtObject { + readonly property real base: Appearance.transparency.base - (root.light ? 0.1 : 0) + readonly property bool enabled: Appearance.transparency.enabled + readonly property real layers: Appearance.transparency.layers + } +} diff --git a/Greeter/Config/General.qml b/Greeter/Config/General.qml new file mode 100644 index 0000000..ce9d5f9 --- /dev/null +++ b/Greeter/Config/General.qml @@ -0,0 +1,45 @@ +import Quickshell.Io +import Quickshell + +JsonObject { + property Apps apps: Apps { + } + property Color color: Color { + } + property bool desktopIcons: false + property Idle idle: Idle { + } + property string logo: "" + property string wallpaperPath: Quickshell.env("HOME") + "/Pictures/Wallpapers" + + component Apps: JsonObject { + property list audio: ["pavucontrol"] + property list explorer: ["dolphin"] + property list playback: ["mpv"] + property list terminal: ["kitty"] + } + component Color: JsonObject { + property string mode: "dark" + property bool neovimColors: false + property int scheduleDarkEnd: 0 + property int scheduleDarkStart: 0 + property bool schemeGeneration: true + property bool smart: false + property bool wallust: false + } + component Idle: JsonObject { + property list timeouts: [ + { + name: "Lock", + timeout: 180, + idleAction: "lock" + }, + { + name: "Screen", + timeout: 300, + idleAction: "dpms off", + activeAction: "dpms on" + } + ] + } +} diff --git a/Greeter/Config/IdleTimeout.qml b/Greeter/Config/IdleTimeout.qml new file mode 100644 index 0000000..c1db937 --- /dev/null +++ b/Greeter/Config/IdleTimeout.qml @@ -0,0 +1,15 @@ +import Quickshell.Io + +JsonObject { + property list timeouts: [ + { + timeout: 180, + idleAction: "lock" + }, + { + timeout: 300, + idleAction: "dpms off", + activeAction: "dpms on" + } + ] +} diff --git a/Greeter/Config/Launcher.qml b/Greeter/Config/Launcher.qml new file mode 100644 index 0000000..a195868 --- /dev/null +++ b/Greeter/Config/Launcher.qml @@ -0,0 +1,108 @@ +import Quickshell.Io + +JsonObject { + property string actionPrefix: ">" + property list actions: [ + { + name: "Calculator", + icon: "calculate", + description: "Do simple math equations", + command: ["autocomplete", "calc"], + enabled: true, + dangerous: false + }, + { + name: "Light", + icon: "light_mode", + description: "Change to light mode", + command: ["setMode", "light"], + enabled: true, + dangerous: false + }, + { + name: "Dark", + icon: "dark_mode", + description: "Change to dark mode", + command: ["setMode", "dark"], + enabled: true, + dangerous: false + }, + { + name: "Wallpaper", + icon: "image", + description: "Change the current wallpaper", + command: ["autocomplete", "wallpaper"], + enabled: true, + dangerous: false + }, + { + name: "Variant", + icon: "colors", + description: "Change the current scheme variant", + command: ["autocomplete", "variant"], + enabled: true, + dangerous: false + }, + { + name: "Shutdown", + icon: "power_settings_new", + description: "Shutdown the system", + command: ["systemctl", "poweroff"], + enabled: true, + dangerous: true + }, + { + name: "Reboot", + icon: "cached", + description: "Reboot the system", + command: ["systemctl", "reboot"], + enabled: true, + dangerous: true + }, + { + name: "Logout", + icon: "logout", + description: "Log out of the current session", + command: ["loginctl", "terminate-user", ""], + enabled: true, + dangerous: true + }, + { + name: "Lock", + icon: "lock", + description: "Lock the current session", + command: ["loginctl", "lock-session"], + enabled: true, + dangerous: false + }, + { + name: "Sleep", + icon: "bedtime", + description: "Suspend then hibernate", + command: ["systemctl", "suspend-then-hibernate"], + enabled: true, + dangerous: false + }, + ] + property int maxAppsShown: 10 + property int maxWallpapers: 7 + property Sizes sizes: Sizes { + } + property string specialPrefix: "@" + property UseFuzzy useFuzzy: UseFuzzy { + } + + component Sizes: JsonObject { + property int itemHeight: 50 + property int itemWidth: 600 + property int wallpaperHeight: 200 + property int wallpaperWidth: 280 + } + component UseFuzzy: JsonObject { + property bool actions: false + property bool apps: false + property bool schemes: false + property bool variants: false + property bool wallpapers: false + } +} diff --git a/Greeter/Config/LockConf.qml b/Greeter/Config/LockConf.qml new file mode 100644 index 0000000..e377459 --- /dev/null +++ b/Greeter/Config/LockConf.qml @@ -0,0 +1,16 @@ +import Quickshell.Io + +JsonObject { + property int blurAmount: 40 + property bool enableFprint: true + property int maxFprintTries: 3 + property bool recolorLogo: false + property Sizes sizes: Sizes { + } + + component Sizes: JsonObject { + property int centerWidth: 600 + property real heightMult: 0.7 + property real ratio: 16 / 9 + } +} diff --git a/Greeter/Config/MaterialEasing.qml b/Greeter/Config/MaterialEasing.qml new file mode 100644 index 0000000..a43a8df --- /dev/null +++ b/Greeter/Config/MaterialEasing.qml @@ -0,0 +1,26 @@ +pragma Singleton +import Quickshell + +Singleton { + id: root + + readonly property list emphasized: [0.05, 0, 2 / 15, 0.06, 1 / 6, 0.4, 5 / 24, 0.82, 0.25, 1, 1, 1] + readonly property list emphasizedAccel: [0.3, 0, 0.8, 0.15, 1, 1] + readonly property int emphasizedAccelTime: 200 * scale + readonly property list emphasizedDecel: [0.05, 0.7, 0.1, 1, 1, 1] + readonly property int emphasizedDecelTime: 400 * scale + readonly property int emphasizedTime: 500 * scale + readonly property list expressiveDefaultSpatial: [0.38, 1.21, 0.22, 1.00, 1, 1] + readonly property int expressiveDefaultSpatialTime: 500 * scale + readonly property list expressiveEffects: [0.34, 0.80, 0.34, 1.00, 1, 1] + readonly property int expressiveEffectsTime: 200 * scale + readonly property list expressiveFastSpatial: [0.42, 1.67, 0.21, 0.90, 1, 1] + readonly property int expressiveFastSpatialTime: 350 * scale + property real scale: Appearance.anim.durations.scale + readonly property list standard: [0.2, 0, 0, 1, 1, 1] + readonly property list standardAccel: [0.3, 0, 1, 1, 1, 1] + readonly property int standardAccelTime: 200 * scale + readonly property list standardDecel: [0, 0, 0, 1, 1, 1] + readonly property int standardDecelTime: 250 * scale + readonly property int standardTime: 300 * scale +} diff --git a/Greeter/Config/NotifConfig.qml b/Greeter/Config/NotifConfig.qml new file mode 100644 index 0000000..0a11d46 --- /dev/null +++ b/Greeter/Config/NotifConfig.qml @@ -0,0 +1,20 @@ +import Quickshell.Io + +JsonObject { + property bool actionOnClick: false + property int appNotifCooldown: 0 + property real clearThreshold: 0.3 + property int defaultExpireTimeout: 5000 + property int expandThreshold: 20 + property bool expire: true + property int groupPreviewNum: 3 + property bool openExpanded: false + property Sizes sizes: Sizes { + } + + component Sizes: JsonObject { + property int badge: 20 + property int image: 41 + property int width: 400 + } +} diff --git a/Greeter/Config/Osd.qml b/Greeter/Config/Osd.qml new file mode 100644 index 0000000..79da85b --- /dev/null +++ b/Greeter/Config/Osd.qml @@ -0,0 +1,16 @@ +import Quickshell.Io + +JsonObject { + property bool allMonBrightness: false + property bool enableBrightness: true + property bool enableMicrophone: true + property bool enabled: true + property int hideDelay: 3000 + property Sizes sizes: Sizes { + } + + component Sizes: JsonObject { + property int sliderHeight: 150 + property int sliderWidth: 30 + } +} diff --git a/Greeter/Config/Overview.qml b/Greeter/Config/Overview.qml new file mode 100644 index 0000000..5e1a615 --- /dev/null +++ b/Greeter/Config/Overview.qml @@ -0,0 +1,8 @@ +import Quickshell.Io + +JsonObject { + property int columns: 5 + property bool enable: false + property int rows: 2 + property real scale: 0.16 +} diff --git a/Greeter/Config/Services.qml b/Greeter/Config/Services.qml new file mode 100644 index 0000000..e091fa2 --- /dev/null +++ b/Greeter/Config/Services.qml @@ -0,0 +1,21 @@ +import Quickshell.Io +import QtQuick + +JsonObject { + property real audioIncrement: 0.1 + property real brightnessIncrement: 0.1 + property bool ddcutilService: false + property string defaultPlayer: "Spotify" + property string gpuType: "" + property real maxVolume: 1.0 + property list playerAliases: [ + { + "from": "com.github.th_ch.youtube_music", + "to": "YT Music" + } + ] + property bool useFahrenheit: false + property bool useTwelveHourClock: Qt.locale().timeFormat(Locale.ShortFormat).toLowerCase().includes("a") + property int visualizerBars: 30 + property string weatherLocation: "" +} diff --git a/Greeter/Config/SidebarConfig.qml b/Greeter/Config/SidebarConfig.qml new file mode 100644 index 0000000..80800ce --- /dev/null +++ b/Greeter/Config/SidebarConfig.qml @@ -0,0 +1,11 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property Sizes sizes: Sizes { + } + + component Sizes: JsonObject { + property int width: 430 + } +} diff --git a/Greeter/Config/Transparency.qml b/Greeter/Config/Transparency.qml new file mode 100644 index 0000000..e687cfb --- /dev/null +++ b/Greeter/Config/Transparency.qml @@ -0,0 +1,7 @@ +import Quickshell.Io + +JsonObject { + property real base: 0.85 + property bool enabled: false + property real layers: 0.4 +} diff --git a/Greeter/Config/UtilConfig.qml b/Greeter/Config/UtilConfig.qml new file mode 100644 index 0000000..c4a2087 --- /dev/null +++ b/Greeter/Config/UtilConfig.qml @@ -0,0 +1,35 @@ +import Quickshell.Io + +JsonObject { + property bool enabled: true + property int maxToasts: 4 + property Sizes sizes: Sizes { + } + property Toasts toasts: Toasts { + } + property Vpn vpn: Vpn { + } + + component Sizes: JsonObject { + property int toastWidth: 430 + property int width: 430 + } + component Toasts: JsonObject { + property bool audioInputChanged: true + property bool audioOutputChanged: true + property bool capsLockChanged: true + property bool chargingChanged: true + property bool configLoaded: true + property bool dndChanged: true + property bool gameModeChanged: true + property bool kbLayoutChanged: true + property bool kbLimit: true + property bool nowPlaying: false + property bool numLockChanged: true + property bool vpnChanged: true + } + component Vpn: JsonObject { + property bool enabled: false + property list provider: ["netbird"] + } +} diff --git a/Greeter/Config/WorkspaceWidget.qml b/Greeter/Config/WorkspaceWidget.qml new file mode 100644 index 0000000..10a6969 --- /dev/null +++ b/Greeter/Config/WorkspaceWidget.qml @@ -0,0 +1,6 @@ +import Quickshell.Io + +JsonObject { + property string inactiveTextColor: "white" + property string textColor: "black" +} diff --git a/Greeter/Content.qml b/Greeter/Content.qml new file mode 100644 index 0000000..dde52f5 --- /dev/null +++ b/Greeter/Content.qml @@ -0,0 +1,76 @@ +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Helpers +import qs.Config + +RowLayout { + id: root + + required property var greeter + required property real screenHeight + + spacing: Appearance.spacing.large * 2 + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + CustomRect { + Layout.fillWidth: true + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: weather.implicitHeight + radius: Appearance.rounding.small + topLeftRadius: Appearance.rounding.large + + WeatherInfo { + id: weather + + rootHeight: root.height + } + } + + CustomRect { + Layout.fillWidth: true + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: resources.implicitHeight + radius: Appearance.rounding.small + + Resources { + id: resources + + } + } + + CustomClippingRect { + Layout.fillHeight: true + Layout.fillWidth: true + bottomLeftRadius: Appearance.rounding.large + color: DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.small + } + } + + Center { + greeter: root.greeter + screenHeight: root.screenHeight + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + CustomRect { + Layout.fillHeight: true + Layout.fillWidth: true + bottomRightRadius: Appearance.rounding.large + color: DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.small + topRightRadius: Appearance.rounding.large + + SessionDock { + greeter: root.greeter + } + } + } +} diff --git a/Greeter/GreeterState.qml b/Greeter/GreeterState.qml new file mode 100644 index 0000000..047c6e9 --- /dev/null +++ b/Greeter/GreeterState.qml @@ -0,0 +1,140 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Services.Greetd +import Quickshell.Io +import QtQuick + +Scope { + id: root + + property bool awaitingResponse: false + property string buffer: "" + property bool echoResponse: false + property string errorMessage: "" + property bool launching: false + property string promptMessage: "" + readonly property var selectedSession: sessionIndex >= 0 ? sessions[sessionIndex] : null + property int sessionIndex: sessions.length > 0 ? 0 : -1 + property var sessions: [] + required property string username + + signal flashMsg + + function handleKey(event: KeyEvent): void { + if (launching) + return; + + if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + submit(); + event.accepted = true; + return; + } + + if (event.key === Qt.Key_Backspace) { + if (event.modifiers & Qt.ControlModifier) + buffer = ""; + else + buffer = buffer.slice(0, -1); + + event.accepted = true; + return; + } + + if (event.text && !/[\r\n]/.test(event.text)) { + buffer += event.text; + event.accepted = true; + } + } + + function launchSelected(): void { + if (!selectedSession || !selectedSession.command || selectedSession.command.length === 0) { + errorMessage = qsTr("No session selected."); + flashMsg(); + launching = false; + return; + } + + launching = true; + Greetd.launch(selectedSession.command, [], true); + } + + function submit(): void { + errorMessage = ""; + + if (awaitingResponse) { + Greetd.respond(buffer); + buffer = ""; + awaitingResponse = false; + return; + } + + Greetd.createSession(username); + } + + Process { + id: sessionLister + + command: ["python3", Quickshell.shellDir + "/scripts/get-sessions"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + try { + root.sessions = JSON.parse(text); + + if (root.sessions.length > 0 && root.sessionIndex < 0) + root.sessionIndex = 0; + } catch (e) { + root.errorMessage = `Failed to parse sessions: ${e}`; + } + } + } + } + + Connections { + function onAuthFailure(message): void { + root.awaitingResponse = false; + root.launching = false; + root.buffer = ""; + root.errorMessage = message || qsTr("Authentication failed."); + root.flashMsg(); + } + + function onAuthMessage(message, error, responseRequired, echoResponse): void { + root.promptMessage = message; + root.echoResponse = echoResponse; + + if (error) { + root.errorMessage = message; + root.flashMsg(); + } + + if (responseRequired) { + // lets the existing “type password then press enter” UX still work + if (root.buffer.length > 0) { + Greetd.respond(root.buffer); + root.buffer = ""; + root.awaitingResponse = false; + } else { + root.awaitingResponse = true; + } + } else { + root.awaitingResponse = false; + } + } + + function onError(error): void { + root.awaitingResponse = false; + root.launching = false; + root.errorMessage = error; + root.flashMsg(); + } + + function onReadyToLaunch(): void { + root.launchSelected(); + } + + target: Greetd + } +} diff --git a/Greeter/GreeterSurface.qml b/Greeter/GreeterSurface.qml new file mode 100644 index 0000000..b33af50 --- /dev/null +++ b/Greeter/GreeterSurface.qml @@ -0,0 +1,143 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Wayland +import QtQuick +import QtQuick.Effects +import qs.Config +import qs.Helpers +import qs.Components + +CustomWindow { + id: root + + required property var greeter + + aboveWindows: true + focusable: true + + anchors { + bottom: true + left: true + right: true + top: true + } + + ParallelAnimation { + id: initAnim + + running: true + + SequentialAnimation { + ParallelAnimation { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + property: "scale" + target: lockContent + to: 1 + } + } + + ParallelAnimation { + Anim { + property: "opacity" + target: lockIcon + to: 0 + } + + Anim { + property: "opacity" + target: content + to: 1 + } + + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + property: "scale" + target: content + to: 1 + } + + Anim { + property: "radius" + target: lockBg + to: Appearance.rounding.large * 1.5 + } + + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + property: "implicitWidth" + target: lockContent + to: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult * Config.lock.sizes.ratio + } + + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + property: "implicitHeight" + target: lockContent + to: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult + } + } + } + } + + Image { + id: background + + anchors.fill: parent + source: WallpaperPath.lockscreenBg + } + + Item { + id: lockContent + + readonly property int radius: size / 4 * Appearance.rounding.scale + readonly property int size: lockIcon.implicitHeight + Appearance.padding.large * 4 + + anchors.centerIn: parent + implicitHeight: size + implicitWidth: size + scale: 0 + + CustomRect { + id: lockBg + + anchors.fill: parent + color: DynamicColors.palette.m3surface + layer.enabled: true + opacity: DynamicColors.transparency.enabled ? DynamicColors.transparency.base : 1 + radius: lockContent.radius + + layer.effect: MultiEffect { + blurMax: 15 + shadowColor: Qt.alpha(DynamicColors.palette.m3shadow, 0.7) + shadowEnabled: true + } + } + + MaterialIcon { + id: lockIcon + + anchors.centerIn: parent + font.bold: true + font.pointSize: Appearance.font.size.extraLarge * 4 + text: "lock" + } + + Content { + id: content + + anchors.centerIn: parent + greeter: root.greeter + height: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult - Appearance.padding.large * 2 + opacity: 0 + scale: 0 + screenHeight: root.screen?.height ?? 1440 + width: (root.screen?.height ?? 0) * Config.lock.sizes.heightMult * Config.lock.sizes.ratio - Appearance.padding.large * 2 + } + } +} diff --git a/Greeter/Helpers/Brightness.qml b/Greeter/Helpers/Brightness.qml new file mode 100644 index 0000000..5e2e7fb --- /dev/null +++ b/Greeter/Helpers/Brightness.qml @@ -0,0 +1,264 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.Config +import qs.Components + +Singleton { + id: root + + property bool appleDisplayPresent: false + property list ddcMonitors: [] + property list ddcServiceMon: [] + readonly property list monitors: variants.instances + + function decreaseBrightness(): void { + const monitor = getMonitor("active"); + if (monitor) + monitor.setBrightness(monitor.brightness - Config.services.brightnessIncrement); + } + + function getMonitor(query: string): var { + if (query === "active") { + return monitors.find(m => Hypr.monitorFor(m.modelData)?.focused); + } + + if (query.startsWith("model:")) { + const model = query.slice(6); + return monitors.find(m => m.modelData.model === model); + } + + if (query.startsWith("serial:")) { + const serial = query.slice(7); + return monitors.find(m => m.modelData.serialNumber === serial); + } + + if (query.startsWith("id:")) { + const id = parseInt(query.slice(3), 10); + return monitors.find(m => Hypr.monitorFor(m.modelData)?.id === id); + } + + return monitors.find(m => m.modelData.name === query); + } + + function getMonitorForScreen(screen: ShellScreen): var { + return monitors.find(m => m.modelData === screen); + } + + function increaseBrightness(): void { + const monitor = getMonitor("active"); + if (monitor) + monitor.setBrightness(monitor.brightness + Config.services.brightnessIncrement); + } + + onMonitorsChanged: { + ddcMonitors = []; + ddcServiceMon = []; + ddcServiceProc.running = true; + ddcProc.running = true; + } + + Variants { + id: variants + + model: Quickshell.screens + + Monitor { + } + } + + Process { + command: ["sh", "-c", "asdbctl get"] + running: true + + stdout: StdioCollector { + onStreamFinished: root.appleDisplayPresent = text.trim().length > 0 + } + } + + Process { + id: ddcProc + + command: ["ddcutil", "detect", "--brief"] + + stdout: StdioCollector { + onStreamFinished: root.ddcMonitors = text.trim().split("\n\n").filter(d => d.startsWith("Display ")).map(d => ({ + busNum: d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/)[1], + connector: d.match(/DRM connector:\s+(.*)/)[1].replace(/^card\d+-/, "") // strip "card1-" + })) + } + } + + Process { + id: ddcServiceProc + + command: ["ddcutil-client", "detect"] + + // running: true + + stdout: StdioCollector { + onStreamFinished: { + const t = text.replace(/\r\n/g, "\n").trim(); + + const output = ("\n" + t).split(/\n(?=display:\s*\d+\s*\n)/).filter(b => b.startsWith("display:")).map(b => ({ + display: Number(b.match(/^display:\s*(\d+)/m)?.[1] ?? -1), + name: (b.match(/^\s*product_name:\s*(.*)$/m)?.[1] ?? "").trim() + })).filter(d => d.display > 0); + root.ddcServiceMon = output; + } + } + } + + CustomShortcut { + description: "Increase brightness" + name: "brightnessUp" + + onPressed: root.increaseBrightness() + } + + CustomShortcut { + description: "Decrease brightness" + name: "brightnessDown" + + onPressed: root.decreaseBrightness() + } + + IpcHandler { + function get(): real { + return getFor("active"); + } + + // Allows searching by active/model/serial/id/name + function getFor(query: string): real { + return root.getMonitor(query)?.brightness ?? -1; + } + + function set(value: string): string { + return setFor("active", value); + } + + // Handles brightness value like brightnessctl: 0.1, +0.1, 0.1-, 10%, +10%, 10%- + function setFor(query: string, value: string): string { + const monitor = root.getMonitor(query); + if (!monitor) + return "Invalid monitor: " + query; + + let targetBrightness; + if (value.endsWith("%-")) { + const percent = parseFloat(value.slice(0, -2)); + targetBrightness = monitor.brightness - (percent / 100); + } else if (value.startsWith("+") && value.endsWith("%")) { + const percent = parseFloat(value.slice(1, -1)); + targetBrightness = monitor.brightness + (percent / 100); + } else if (value.endsWith("%")) { + const percent = parseFloat(value.slice(0, -1)); + targetBrightness = percent / 100; + } else if (value.startsWith("+")) { + const increment = parseFloat(value.slice(1)); + targetBrightness = monitor.brightness + increment; + } else if (value.endsWith("-")) { + const decrement = parseFloat(value.slice(0, -1)); + targetBrightness = monitor.brightness - decrement; + } else if (value.includes("%") || value.includes("-") || value.includes("+")) { + return `Invalid brightness format: ${value}\nExpected: 0.1, +0.1, 0.1-, 10%, +10%, 10%-`; + } else { + targetBrightness = parseFloat(value); + } + + if (isNaN(targetBrightness)) + return `Failed to parse value: ${value}\nExpected: 0.1, +0.1, 0.1-, 10%, +10%, 10%-`; + + monitor.setBrightness(targetBrightness); + + return `Set monitor ${monitor.modelData.name} brightness to ${+monitor.brightness.toFixed(2)}`; + } + + target: "brightness" + } + + component Monitor: QtObject { + id: monitor + + property real brightness + readonly property string busNum: root.ddcMonitors.find(m => m.connector === modelData.name)?.busNum ?? "" + readonly property string displayNum: root.ddcServiceMon.find(m => m.name === modelData.model)?.display ?? "" + readonly property Process initProc: Process { + stdout: StdioCollector { + onStreamFinished: { + if (monitor.isDdcService) { + const output = text.split("\n").filter(o => o.startsWith("vcp_current_value:"))[0].split(":")[1]; + const val = parseInt(output.trim()); + monitor.brightness = val / 100; + } else if (monitor.isAppleDisplay) { + const val = parseInt(text.trim()); + monitor.brightness = val / 101; + } else { + const [, , , cur, max] = text.split(" "); + monitor.brightness = parseInt(cur) / parseInt(max); + } + } + } + } + readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay") + readonly property bool isDdc: root.ddcMonitors.some(m => m.connector === modelData.name) + readonly property bool isDdcService: Config.services.ddcutilService + required property ShellScreen modelData + property real queuedBrightness: NaN + readonly property Timer timer: Timer { + interval: 500 + + onTriggered: { + if (!isNaN(monitor.queuedBrightness)) { + monitor.setBrightness(monitor.queuedBrightness); + monitor.queuedBrightness = NaN; + } + } + } + + function initBrightness(): void { + if (isDdcService) + initProc.command = ["ddcutil-client", "-d", displayNum, "getvcp", "10"]; + else if (isAppleDisplay) + initProc.command = ["asdbctl", "get"]; + else if (isDdc) + initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"]; + else + initProc.command = ["sh", "-c", "echo a b c $(brightnessctl g) $(brightnessctl m)"]; + + initProc.running = true; + } + + function setBrightness(value: real): void { + value = Math.max(0, Math.min(1, value)); + const rounded = Math.round(value * 100); + if (Math.round(brightness * 100) === rounded) + return; + + if ((isDdc || isDdcService) && timer.running) { + queuedBrightness = value; + return; + } + + brightness = value; + + if (isDdcService) + Quickshell.execDetached(["ddcutil-client", "-d", displayNum, "setvcp", "10", rounded]); + else if (isAppleDisplay) + Quickshell.execDetached(["asdbctl", "set", rounded]); + else if (isDdc) + Quickshell.execDetached(["ddcutil", "--disable-dynamic-sleep", "--sleep-multiplier", ".1", "--skip-ddc-checks", "-b", busNum, "setvcp", "10", rounded]); + else + Quickshell.execDetached(["brightnessctl", "s", `${rounded}%`]); + + if (isDdc || isDdcService) + timer.restart(); + } + + Component.onCompleted: initBrightness() + onBusNumChanged: initBrightness() + onDisplayNumChanged: initBrightness() + } +} diff --git a/Greeter/Helpers/CachingImage.qml b/Greeter/Helpers/CachingImage.qml new file mode 100644 index 0000000..649ec2c --- /dev/null +++ b/Greeter/Helpers/CachingImage.qml @@ -0,0 +1,28 @@ +import ZShell.Internal +import Quickshell +import QtQuick +import qs.Paths + +Image { + id: root + + property alias path: manager.path + + asynchronous: true + fillMode: Image.PreserveAspectCrop + + Connections { + function onDevicePixelRatioChanged(): void { + manager.updateSource(); + } + + target: QsWindow.window + } + + CachingImageManager { + id: manager + + cacheDir: Qt.resolvedUrl(Paths.imagecache) + item: root + } +} diff --git a/Greeter/Helpers/GetIcons.qml b/Greeter/Helpers/GetIcons.qml new file mode 100644 index 0000000..e957ad3 --- /dev/null +++ b/Greeter/Helpers/GetIcons.qml @@ -0,0 +1,17 @@ +pragma Singleton + +import Quickshell + +Singleton { + id: root + + function getTrayIcon(id: string, icon: string): string { + if (icon.includes("?path=")) { + const [name, path] = icon.split("?path="); + icon = Qt.resolvedUrl(`${path}/${name.slice(name.lastIndexOf("/") + 1)}`); + } else if (icon.includes("qspixmap") && id === "chrome_status_icon_1") { + icon = icon.replace("qspixmap", "icon/discord-tray"); + } + return icon; + } +} diff --git a/Greeter/Helpers/Icons.qml b/Greeter/Helpers/Icons.qml new file mode 100644 index 0000000..32df434 --- /dev/null +++ b/Greeter/Helpers/Icons.qml @@ -0,0 +1,186 @@ +pragma Singleton + +import qs.Config +import Quickshell +import Quickshell.Services.Notifications +import QtQuick + +Singleton { + id: root + + readonly property var categoryIcons: ({ + WebBrowser: "web", + Printing: "print", + Security: "security", + Network: "chat", + Archiving: "archive", + Compression: "archive", + Development: "code", + IDE: "code", + TextEditor: "edit_note", + Audio: "music_note", + Music: "music_note", + Player: "music_note", + Recorder: "mic", + Game: "sports_esports", + FileTools: "files", + FileManager: "files", + Filesystem: "files", + FileTransfer: "files", + Settings: "settings", + DesktopSettings: "settings", + HardwareSettings: "settings", + TerminalEmulator: "terminal", + ConsoleOnly: "terminal", + Utility: "build", + Monitor: "monitor_heart", + Midi: "graphic_eq", + Mixer: "graphic_eq", + AudioVideoEditing: "video_settings", + AudioVideo: "music_video", + Video: "videocam", + Building: "construction", + Graphics: "photo_library", + "2DGraphics": "photo_library", + RasterGraphics: "photo_library", + TV: "tv", + System: "host", + Office: "content_paste" + }) + readonly property var weatherIcons: ({ + "0": "clear_day", + "1": "clear_day", + "2": "partly_cloudy_day", + "3": "cloud", + "45": "foggy", + "48": "foggy", + "51": "rainy", + "53": "rainy", + "55": "rainy", + "56": "rainy", + "57": "rainy", + "61": "rainy", + "63": "rainy", + "65": "rainy", + "66": "rainy", + "67": "rainy", + "71": "cloudy_snowing", + "73": "cloudy_snowing", + "75": "snowing_heavy", + "77": "cloudy_snowing", + "80": "rainy", + "81": "rainy", + "82": "rainy", + "85": "cloudy_snowing", + "86": "snowing_heavy", + "95": "thunderstorm", + "96": "thunderstorm", + "99": "thunderstorm" + }) + + function getAppCategoryIcon(name: string, fallback: string): string { + const categories = DesktopEntries.heuristicLookup(name)?.categories; + + if (categories) + for (const [key, value] of Object.entries(categoryIcons)) + if (categories.includes(key)) + return value; + return fallback; + } + + function getAppIcon(name: string, fallback: string): string { + const icon = DesktopEntries.heuristicLookup(name)?.icon; + if (fallback !== "undefined") + return Quickshell.iconPath(icon, fallback); + return Quickshell.iconPath(icon); + } + + function getBluetoothIcon(icon: string): string { + if (icon.includes("headset") || icon.includes("headphones")) + return "headphones"; + if (icon.includes("audio")) + return "speaker"; + if (icon.includes("phone")) + return "smartphone"; + if (icon.includes("mouse")) + return "mouse"; + if (icon.includes("keyboard")) + return "keyboard"; + return "bluetooth"; + } + + function getMicVolumeIcon(volume: real, isMuted: bool): string { + if (!isMuted && volume > 0) + return "mic"; + return "mic_off"; + } + + function getNetworkIcon(strength: int, isSecure = false): string { + if (isSecure) { + if (strength >= 80) + return "network_wifi_locked"; + if (strength >= 60) + return "network_wifi_3_bar_locked"; + if (strength >= 40) + return "network_wifi_2_bar_locked"; + if (strength >= 20) + return "network_wifi_1_bar_locked"; + return "signal_wifi_0_bar"; + } else { + if (strength >= 80) + return "network_wifi"; + if (strength >= 60) + return "network_wifi_3_bar"; + if (strength >= 40) + return "network_wifi_2_bar"; + if (strength >= 20) + return "network_wifi_1_bar"; + return "signal_wifi_0_bar"; + } + } + + function getNotifIcon(summary: string, urgency: int): string { + summary = summary.toLowerCase(); + if (summary.includes("reboot")) + return "restart_alt"; + if (summary.includes("recording")) + return "screen_record"; + if (summary.includes("battery")) + return "power"; + if (summary.includes("screenshot")) + return "screenshot_monitor"; + if (summary.includes("welcome")) + return "waving_hand"; + if (summary.includes("time") || summary.includes("a break")) + return "schedule"; + if (summary.includes("installed")) + return "download"; + if (summary.includes("update")) + return "update"; + if (summary.includes("unable to")) + return "deployed_code_alert"; + if (summary.includes("profile")) + return "person"; + if (summary.includes("file")) + return "folder_copy"; + if (urgency === NotificationUrgency.Critical) + return "release_alert"; + return "chat"; + } + + function getVolumeIcon(volume: real, isMuted: bool): string { + if (isMuted) + return "no_sound"; + if (volume >= 0.5) + return "volume_up"; + if (volume > 0) + return "volume_down"; + return "volume_mute"; + } + + function getWeatherIcon(code: string): string { + if (weatherIcons.hasOwnProperty(code)) + return weatherIcons[code]; + return "air"; + } +} diff --git a/Greeter/Helpers/SettingsDropdowns.qml b/Greeter/Helpers/SettingsDropdowns.qml new file mode 100644 index 0000000..4a4ac27 --- /dev/null +++ b/Greeter/Helpers/SettingsDropdowns.qml @@ -0,0 +1,60 @@ +pragma Singleton +import QtQuick + +QtObject { + id: root + + property Item activeMenu: null + property Item activeTrigger: null + + function close(menu) { + if (!menu) + return; + + if (activeMenu === menu) { + activeMenu = null; + activeTrigger = null; + } + + menu.expanded = false; + } + + function closeActive() { + if (activeMenu) + activeMenu.expanded = false; + + activeMenu = null; + activeTrigger = null; + } + + function forget(menu) { + if (activeMenu === menu) { + activeMenu = null; + activeTrigger = null; + } + } + + function hit(item, scenePos) { + if (!item || !item.visible) + return false; + + const p = item.mapFromItem(null, scenePos.x, scenePos.y); + return item.contains(p); + } + + function open(menu, trigger) { + if (activeMenu && activeMenu !== menu) + activeMenu.expanded = false; + + activeMenu = menu; + activeTrigger = trigger || null; + menu.expanded = true; + } + + function toggle(menu, trigger) { + if (activeMenu === menu && menu.expanded) + close(menu); + else + open(menu, trigger); + } +} diff --git a/Greeter/Helpers/SystemInfo.qml b/Greeter/Helpers/SystemInfo.qml new file mode 100644 index 0000000..c4e3565 --- /dev/null +++ b/Greeter/Helpers/SystemInfo.qml @@ -0,0 +1,83 @@ +pragma Singleton + +import qs.Config +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + property bool isDefaultLogo: true + property string osId + property list osIdLike + property string osLogo + property string osName + property string osPrettyName + readonly property string shell: Quickshell.env("SHELL").split("/").pop() + property string uptime + readonly property string user: Quickshell.env("USER") + readonly property string wm: Quickshell.env("XDG_CURRENT_DESKTOP") || Quickshell.env("XDG_SESSION_DESKTOP") + + FileView { + id: osRelease + + path: "/etc/os-release" + + onLoaded: { + const lines = text().split("\n"); + + const fd = key => lines.find(l => l.startsWith(`${key}=`))?.split("=")[1].replace(/"/g, "") ?? ""; + + root.osName = fd("NAME"); + root.osPrettyName = fd("PRETTY_NAME"); + root.osId = fd("ID"); + root.osIdLike = fd("ID_LIKE").split(" "); + + const logo = Quickshell.iconPath(fd("LOGO"), true); + if (Config.general.logo) { + root.osLogo = Quickshell.iconPath(Config.general.logo, true) || "file://" + Paths.absolutePath(Config.general.logo); + root.isDefaultLogo = false; + } else if (logo) { + root.osLogo = logo; + root.isDefaultLogo = false; + } + } + } + + Connections { + function onLogoChanged(): void { + osRelease.reload(); + } + + target: Config.general + } + + Timer { + interval: 15000 + repeat: true + running: true + + onTriggered: fileUptime.reload() + } + + FileView { + id: fileUptime + + path: "/proc/uptime" + + onLoaded: { + const up = parseInt(text().split(" ")[0] ?? 0); + + const hours = Math.floor(up / 3600); + const minutes = Math.floor((up % 3600) / 60); + + let str = ""; + if (hours > 0) + str += `${hours} hour${hours === 1 ? "" : "s"}`; + if (minutes > 0 || !str) + str += `${str ? ", " : ""}${minutes} minute${minutes === 1 ? "" : "s"}`; + root.uptime = str; + } + } +} diff --git a/Greeter/Helpers/SystemUsage.qml b/Greeter/Helpers/SystemUsage.qml new file mode 100644 index 0000000..e613060 --- /dev/null +++ b/Greeter/Helpers/SystemUsage.qml @@ -0,0 +1,410 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.Config + +Singleton { + id: root + + property string autoGpuType: "NONE" + property string cpuName: "" + property real cpuPerc + property real cpuTemp + + // Individual disks: Array of { mount, used, total, free, perc } + property var disks: [] + property real gpuMemTotal: 0 + property real gpuMemUsed + property real gpuPerc + property real gpuTemp + readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType + property real lastCpuIdle + property real lastCpuTotal + readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0 + property real memTotal + property real memUsed + property int refCount + readonly property real storagePerc: { + let totalUsed = 0; + let totalSize = 0; + for (const disk of disks) { + totalUsed += disk.used; + totalSize += disk.total; + } + return totalSize > 0 ? totalUsed / totalSize : 0; + } + + function cleanCpuName(name: string): string { + return name.replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/CPU/gi, "").replace(/\d+th Gen /gi, "").replace(/\d+nd Gen /gi, "").replace(/\d+rd Gen /gi, "").replace(/\d+st Gen /gi, "").replace(/Core /gi, "").replace(/Processor/gi, "").replace(/\s+/g, " ").trim(); + } + + function cleanGpuName(name: string): string { + return name.replace(/NVIDIA GeForce /gi, "").replace(/NVIDIA /gi, "").replace(/AMD Radeon /gi, "").replace(/AMD /gi, "").replace(/Intel /gi, "").replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/Graphics/gi, "").replace(/\s+/g, " ").trim(); + } + + function formatKib(kib: real): var { + const mib = 1024; + const gib = 1024 ** 2; + const tib = 1024 ** 3; + + if (kib >= tib) + return { + value: kib / tib, + unit: "TiB" + }; + if (kib >= gib) + return { + value: kib / gib, + unit: "GiB" + }; + if (kib >= mib) + return { + value: kib / mib, + unit: "MiB" + }; + return { + value: kib, + unit: "KiB" + }; + } + + Timer { + interval: Config.dashboard.resourceUpdateInterval + repeat: true + running: root.refCount > 0 + triggeredOnStart: true + + onTriggered: { + stat.reload(); + meminfo.reload(); + if (root.gpuType === "GENERIC") + gpuUsage.running = true; + } + } + + Timer { + interval: 60000 * 120 + repeat: true + running: true + triggeredOnStart: true + + onTriggered: { + storage.running = true; + } + } + + Timer { + interval: Config.dashboard.resourceUpdateInterval * 5 + repeat: true + running: root.refCount > 0 + triggeredOnStart: true + + onTriggered: { + sensors.running = true; + } + } + + FileView { + id: cpuinfoInit + + path: "/proc/cpuinfo" + + onLoaded: { + const nameMatch = text().match(/model name\s*:\s*(.+)/); + if (nameMatch) + root.cpuName = root.cleanCpuName(nameMatch[1]); + } + } + + FileView { + id: stat + + path: "/proc/stat" + + onLoaded: { + const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/); + if (data) { + const stats = data.slice(1).map(n => parseInt(n, 10)); + const total = stats.reduce((a, b) => a + b, 0); + const idle = stats[3] + (stats[4] ?? 0); + + const totalDiff = total - root.lastCpuTotal; + const idleDiff = idle - root.lastCpuIdle; + const newCpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0; + + root.lastCpuTotal = total; + root.lastCpuIdle = idle; + + if (Math.abs(newCpuPerc - root.cpuPerc) >= 0.01) + root.cpuPerc = newCpuPerc; + } + } + } + + FileView { + id: meminfo + + path: "/proc/meminfo" + + onLoaded: { + const data = text(); + const total = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1; + const used = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0; + + if (root.memTotal !== total) + root.memTotal = total; + + if (Math.abs(used - root.memUsed) >= 16384) + root.memUsed = used; + } + } + + Process { + id: storage + + command: ["lsblk", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE", "-P"] + + stdout: StdioCollector { + onStreamFinished: { + const diskMap = {}; // Map disk name -> { name, totalSize, used, fsTotal } + const lines = text.trim().split("\n"); + + for (const line of lines) { + if (line.trim() === "") + continue; + const nameMatch = line.match(/NAME="([^"]+)"/); + const sizeMatch = line.match(/SIZE="([^"]+)"/); + const typeMatch = line.match(/TYPE="([^"]+)"/); + const fsusedMatch = line.match(/FSUSED="([^"]*)"/); + const fssizeMatch = line.match(/FSSIZE="([^"]*)"/); + + if (!nameMatch || !typeMatch) + continue; + + const name = nameMatch[1]; + const type = typeMatch[1]; + const size = parseInt(sizeMatch?.[1] || "0", 10); + const fsused = parseInt(fsusedMatch?.[1] || "0", 10); + const fssize = parseInt(fssizeMatch?.[1] || "0", 10); + + if (type === "disk") { + // Skip zram (swap) devices + if (name.startsWith("zram")) + continue; + + // Initialize disk entry + if (!diskMap[name]) { + diskMap[name] = { + name: name, + totalSize: size, + used: 0, + fsTotal: 0 + }; + } + } else if (type === "part") { + // Find parent disk (remove trailing numbers/p+numbers) + let parentDisk = name.replace(/p?\d+$/, ""); + // For nvme devices like nvme0n1p1, parent is nvme0n1 + if (name.match(/nvme\d+n\d+p\d+/)) + parentDisk = name.replace(/p\d+$/, ""); + + // Aggregate partition usage to parent disk + if (diskMap[parentDisk]) { + diskMap[parentDisk].used += fsused; + diskMap[parentDisk].fsTotal += fssize; + } + } + } + + const diskList = []; + let totalUsed = 0; + let totalSize = 0; + + for (const diskName of Object.keys(diskMap).sort()) { + const disk = diskMap[diskName]; + // Use filesystem total if available, otherwise use disk size + const total = disk.fsTotal > 0 ? disk.fsTotal : disk.totalSize; + const used = disk.used; + const perc = total > 0 ? used / total : 0; + + // Convert bytes to KiB for consistency with formatKib + diskList.push({ + mount: disk.name // Using 'mount' property for compatibility + , + used: used / 1024, + total: total / 1024, + free: (total - used) / 1024, + perc: perc + }); + + totalUsed += used; + totalSize += total; + } + + root.disks = diskList; + } + } + } + + Process { + id: gpuNameDetect + + command: ["sh", "-c", "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || lspci 2>/dev/null | grep -i 'vga\\|3d\\|display' | head -1"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + const output = text.trim(); + if (!output) + return; + + // Check if it's from nvidia-smi (clean GPU name) + if (output.toLowerCase().includes("nvidia") || output.toLowerCase().includes("geforce") || output.toLowerCase().includes("rtx") || output.toLowerCase().includes("gtx")) { + root.gpuName = root.cleanGpuName(output); + } else { + // Parse lspci output: extract name from brackets or after colon + const bracketMatch = output.match(/\[([^\]]+)\]/); + if (bracketMatch) { + root.gpuName = root.cleanGpuName(bracketMatch[1]); + } else { + const colonMatch = output.match(/:\s*(.+)/); + if (colonMatch) + root.gpuName = root.cleanGpuName(colonMatch[1]); + } + } + } + } + } + + Process { + id: gpuTypeCheck + + command: ["sh", "-c", "if command -v nvidia-smi &>/dev/null && nvidia-smi -L &>/dev/null; then echo NVIDIA; elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep -q .; then echo GENERIC; else echo NONE; fi"] + running: !Config.services.gpuType + + stdout: StdioCollector { + onStreamFinished: root.autoGpuType = text.trim() + } + } + + Process { + id: oneshotMem + + command: ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"] + running: root.gpuType === "NVIDIA" && root.gpuMemTotal === 0 + + stdout: StdioCollector { + onStreamFinished: { + root.gpuMemTotal = Number(this.text.trim()); + oneshotMem.running = false; + } + } + } + + Process { + id: gpuUsageNvidia + + command: ["/usr/bin/nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu,memory.used", "--format=csv,noheader,nounits", "-lms", "1000"] + running: root.refCount > 0 && root.gpuType === "NVIDIA" + + stdout: SplitParser { + onRead: data => { + const parts = String(data).trim().split(/\s*,\s*/); + if (parts.length < 3) + return; + + const usageRaw = parseInt(parts[0], 10); + const tempRaw = parseInt(parts[1], 10); + const memRaw = parseInt(parts[2], 10); + + if (!Number.isFinite(usageRaw) || !Number.isFinite(tempRaw) || !Number.isFinite(memRaw)) + return; + + const newGpuPerc = Math.max(0, Math.min(1, usageRaw / 100)); + const newGpuTemp = tempRaw; + const newGpuMemUsed = root.gpuMemTotal > 0 ? Math.max(0, Math.min(1, memRaw / root.gpuMemTotal)) : 0; + + // Only publish meaningful changes to avoid needless binding churn / repaints + if (Math.abs(root.gpuPerc - newGpuPerc) >= 0.01) + root.gpuPerc = newGpuPerc; + + if (Math.abs(root.gpuTemp - newGpuTemp) >= 1) + root.gpuTemp = newGpuTemp; + + if (Math.abs(root.gpuMemUsed - newGpuMemUsed) >= 0.01) + root.gpuMemUsed = newGpuMemUsed; + } + } + } + + Process { + id: gpuUsage + + command: root.gpuType === "GENERIC" ? ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent"] : ["echo"] + + stdout: StdioCollector { + onStreamFinished: { + console.log("this is running"); + if (root.gpuType === "GENERIC") { + const percs = text.trim().split("\n"); + const sum = percs.reduce((acc, d) => acc + parseInt(d, 10), 0); + root.gpuPerc = sum / percs.length / 100; + } else { + root.gpuPerc = 0; + root.gpuTemp = 0; + } + } + } + } + + Process { + id: sensors + + command: ["sensors"] + environment: ({ + LANG: "C.UTF-8", + LC_ALL: "C.UTF-8" + }) + + stdout: StdioCollector { + onStreamFinished: { + let cpuTemp = text.match(/(?:Package id [0-9]+|Tdie):\s+((\+|-)[0-9.]+)(°| )C/); + if (!cpuTemp) + // If AMD Tdie pattern failed, try fallback on Tctl + cpuTemp = text.match(/Tctl:\s+((\+|-)[0-9.]+)(°| )C/); + + if (cpuTemp && Math.abs(parseFloat(cpuTemp[1]) - root.cpuTemp) >= 0.5) + root.cpuTemp = parseFloat(cpuTemp[1]); + + if (root.gpuType !== "GENERIC") + return; + + let eligible = false; + let sum = 0; + let count = 0; + + for (const line of text.trim().split("\n")) { + if (line === "Adapter: PCI adapter") + eligible = true; + else if (line === "") + eligible = false; + else if (eligible) { + let match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); + if (!match) + // Fall back to junction/mem if GPU doesn't have edge temp (for AMD GPUs) + match = line.match(/^(junction|mem)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); + + if (match) { + sum += parseFloat(match[2]); + count++; + } + } + } + + root.gpuTemp = count > 0 ? sum / count : 0; + } + } + } +} diff --git a/Greeter/Helpers/TextRender.qml b/Greeter/Helpers/TextRender.qml new file mode 100644 index 0000000..bdc852c --- /dev/null +++ b/Greeter/Helpers/TextRender.qml @@ -0,0 +1,6 @@ +import QtQuick + +Text { + renderType: Text.NativeRendering + textFormat: Text.PlainText +} diff --git a/Greeter/Helpers/ThemeIcons.qml b/Greeter/Helpers/ThemeIcons.qml new file mode 100644 index 0000000..62a3e8a --- /dev/null +++ b/Greeter/Helpers/ThemeIcons.qml @@ -0,0 +1,288 @@ +pragma Singleton + +import QtQuick +import Quickshell + +Singleton { + id: root + + property list entryList: [] + property var preppedIcons: [] + property var preppedIds: [] + property var preppedNames: [] + + // Dynamic fixups + property var regexSubstitutions: [ + { + "regex": /^steam_app_(\d+)$/, + "replace": "steam_icon_$1" + }, + { + "regex": /Minecraft.*/, + "replace": "minecraft-launcher" + }, + { + "regex": /.*polkit.*/, + "replace": "system-lock-screen" + }, + { + "regex": /gcr.prompter/, + "replace": "system-lock-screen" + } + ] + property real scoreThreshold: 0.2 + + // Manual overrides for tricky apps + property var substitutions: ({ + "code-url-handler": "visual-studio-code", + "Code": "visual-studio-code", + "gnome-tweaks": "org.gnome.tweaks", + "pavucontrol-qt": "pavucontrol", + "wps": "wps-office2019-kprometheus", + "wpsoffice": "wps-office2019-kprometheus", + "footclient": "foot" + }) + + function checkCleanMatch(str) { + if (!str || str.length <= 3) + return null; + if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId) + return null; + + // Aggressive fallback: strip all separators + const cleanStr = str.toLowerCase().replace(/[\.\-_]/g, ''); + const list = Array.from(entryList); + + for (let i = 0; i < list.length; i++) { + const entry = list[i]; + const cleanId = (entry.id || "").toLowerCase().replace(/[\.\-_]/g, ''); + if (cleanId.includes(cleanStr) || cleanStr.includes(cleanId)) { + return entry; + } + } + return null; + } + + function checkFuzzySearch(str) { + if (typeof FuzzySort === 'undefined') + return null; + + // Check filenames (IDs) first + if (preppedIds.length > 0) { + let results = fuzzyQuery(str, preppedIds); + if (results.length === 0) { + const underscored = str.replace(/-/g, '_').toLowerCase(); + if (underscored !== str) + results = fuzzyQuery(underscored, preppedIds); + } + if (results.length > 0) + return results[0]; + } + + // Then icons + if (preppedIcons.length > 0) { + const results = fuzzyQuery(str, preppedIcons); + if (results.length > 0) + return results[0]; + } + + // Then names + if (preppedNames.length > 0) { + const results = fuzzyQuery(str, preppedNames); + if (results.length > 0) + return results[0]; + } + + return null; + } + + // --- Lookup Helpers --- + + function checkHeuristic(str) { + if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) { + const entry = DesktopEntries.heuristicLookup(str); + if (entry) + return entry; + } + return null; + } + + function checkRegex(str) { + for (let i = 0; i < regexSubstitutions.length; i++) { + const sub = regexSubstitutions[i]; + const replaced = str.replace(sub.regex, sub.replace); + if (replaced !== str) { + return findAppEntry(replaced); + } + } + return null; + } + + function checkSimpleTransforms(str) { + if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId) + return null; + + const lower = str.toLowerCase(); + + const variants = [str, lower, getFromReverseDomain(str), getFromReverseDomain(str)?.toLowerCase(), normalizeWithHyphens(str), str.replace(/_/g, '-').toLowerCase(), str.replace(/-/g, '_').toLowerCase()]; + + for (let i = 0; i < variants.length; i++) { + const variant = variants[i]; + if (variant) { + const entry = DesktopEntries.byId(variant); + if (entry) + return entry; + } + } + return null; + } + + function checkSubstitutions(str) { + let effectiveStr = substitutions[str]; + if (!effectiveStr) + effectiveStr = substitutions[str.toLowerCase()]; + + if (effectiveStr && effectiveStr !== str) { + return findAppEntry(effectiveStr); + } + return null; + } + + function distroLogoPath() { + try { + return (typeof OSInfo !== 'undefined' && OSInfo.distroIconPath) ? OSInfo.distroIconPath : ""; + } catch (e) { + return ""; + } + } + + // Robust lookup strategy + function findAppEntry(str) { + if (!str || str.length === 0) + return null; + + let result = null; + + if (result = checkHeuristic(str)) + return result; + if (result = checkSubstitutions(str)) + return result; + if (result = checkRegex(str)) + return result; + if (result = checkSimpleTransforms(str)) + return result; + if (result = checkFuzzySearch(str)) + return result; + if (result = checkCleanMatch(str)) + return result; + + return null; + } + + function fuzzyQuery(search, preppedData) { + if (!search || !preppedData || preppedData.length === 0) + return []; + return FuzzySort.go(search, preppedData, { + all: true, + key: "name" + }).map(r => r.obj.entry); + } + + function getFromReverseDomain(str) { + if (!str) + return ""; + return str.split('.').slice(-1)[0]; + } + + // Deprecated shim + function guessIcon(str) { + const entry = findAppEntry(str); + return entry ? entry.icon : "image-missing"; + } + + function iconExists(iconName) { + if (!iconName || iconName.length === 0) + return false; + if (iconName.startsWith("/")) + return true; + + const path = Quickshell.iconPath(iconName, true); + return path && path.length > 0 && !path.includes("image-missing"); + } + + function iconForAppId(appId, fallbackName) { + const fallback = fallbackName || "application-x-executable"; + if (!appId) + return iconFromName(fallback, fallback); + + const entry = findAppEntry(appId); + if (entry) { + return iconFromName(entry.icon, fallback); + } + + return iconFromName(appId, fallback); + } + + function iconFromName(iconName, fallbackName) { + const fallback = fallbackName || "application-x-executable"; + try { + if (iconName && typeof Quickshell !== 'undefined' && Quickshell.iconPath) { + const p = Quickshell.iconPath(iconName, fallback); + if (p && p !== "") + return p; + } + } catch (e) {} + + try { + return Quickshell.iconPath ? (Quickshell.iconPath(fallback, true) || "") : ""; + } catch (e2) { + return ""; + } + } + + function normalizeWithHyphens(str) { + if (!str) + return ""; + return str.toLowerCase().replace(/\s+/g, "-"); + } + + function refreshEntries() { + if (typeof DesktopEntries === 'undefined') + return; + + const values = Array.from(DesktopEntries.applications.values); + if (values) { + entryList = values.sort((a, b) => a.name.localeCompare(b.name)); + updatePreppedData(); + } + } + + function updatePreppedData() { + if (typeof FuzzySort === 'undefined') + return; + + const list = Array.from(entryList); + preppedNames = list.map(a => ({ + name: FuzzySort.prepare(`${a.name} `), + entry: a + })); + preppedIcons = list.map(a => ({ + name: FuzzySort.prepare(`${a.icon} `), + entry: a + })); + preppedIds = list.map(a => ({ + name: FuzzySort.prepare(`${a.id} `), + entry: a + })); + } + + Component.onCompleted: refreshEntries() + + Connections { + function onValuesChanged() { + refreshEntries(); + } + + target: DesktopEntries.applications + } +} diff --git a/Greeter/Helpers/Time.qml b/Greeter/Helpers/Time.qml new file mode 100644 index 0000000..f9ef387 --- /dev/null +++ b/Greeter/Helpers/Time.qml @@ -0,0 +1,26 @@ +pragma Singleton + +import Quickshell + +Singleton { + readonly property string amPmStr: timeComponents[2] ?? "" + readonly property date date: clock.date + property alias enabled: clock.enabled + readonly property string hourStr: timeComponents[0] ?? "" + readonly property int hours: clock.hours + readonly property string minuteStr: timeComponents[1] ?? "" + readonly property int minutes: clock.minutes + readonly property int seconds: clock.seconds + readonly property list timeComponents: timeStr.split(":") + readonly property string timeStr: format("hh:mm") + + function format(fmt: string): string { + return Qt.formatDateTime(clock.date, fmt); + } + + SystemClock { + id: clock + + precision: SystemClock.Seconds + } +} diff --git a/Greeter/Helpers/WallpaperPath.qml b/Greeter/Helpers/WallpaperPath.qml new file mode 100644 index 0000000..89e9a06 --- /dev/null +++ b/Greeter/Helpers/WallpaperPath.qml @@ -0,0 +1,29 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import qs.Paths + +Singleton { + id: root + + property alias currentWallpaperPath: adapter.currentWallpaperPath + property alias lockscreenBg: adapter.lockscreenBg + + FileView { + id: fileView + + path: `${Paths.state}/wallpaper_path.json` + watchChanges: true + + onAdapterUpdated: writeAdapter() + onFileChanged: reload() + + JsonAdapter { + id: adapter + + property string currentWallpaperPath: "" + property string lockscreenBg: `${Paths.state}/lockscreen_bg.png` + } + } +} diff --git a/Greeter/Helpers/Weather.qml b/Greeter/Helpers/Weather.qml new file mode 100644 index 0000000..480c119 --- /dev/null +++ b/Greeter/Helpers/Weather.qml @@ -0,0 +1,205 @@ +pragma Singleton + +import Quickshell +import QtQuick +import ZShell +import qs.Config + +Singleton { + id: root + + readonly property var cachedCities: new Map() + property var cc + property string city + readonly property string description: cc?.weatherDesc ?? qsTr("No weather") + readonly property string feelsLike: `${cc?.feelsLikeC ?? 0}°C` + property list forecast + property list hourlyForecast + readonly property int humidity: cc?.humidity ?? 0 + readonly property string icon: cc ? Icons.getWeatherIcon(cc.weatherCode) : "cloud_alert" + property string loc + readonly property string sunrise: cc ? Qt.formatDateTime(new Date(cc.sunrise), "h:mm") : "--:--" + readonly property string sunset: cc ? Qt.formatDateTime(new Date(cc.sunset), "h:mm") : "--:--" + readonly property string temp: `${cc?.tempC ?? 0}°C` + readonly property real windSpeed: cc?.windSpeed ?? 0 + + function fetchCityFromCoords(coords: string): void { + if (cachedCities.has(coords)) { + city = cachedCities.get(coords); + return; + } + + const [lat, lon] = coords.split(","); + const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=geocodejson`; + Requests.get(url, text => { + const geo = JSON.parse(text).features?.[0]?.properties.geocoding; + if (geo) { + const geoCity = geo.type === "city" ? geo.name : geo.city; + city = geoCity; + cachedCities.set(coords, geoCity); + } else { + city = "Unknown City"; + } + }); + } + + function fetchCoordsFromCity(cityName: string): void { + const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=en&format=json`; + + Requests.get(url, text => { + const json = JSON.parse(text); + if (json.results && json.results.length > 0) { + const result = json.results[0]; + loc = result.latitude + "," + result.longitude; + city = result.name; + } else { + loc = ""; + reload(); + } + }); + } + + function fetchWeatherData(): void { + const url = getWeatherUrl(); + if (url === "") + return; + + Requests.get(url, text => { + const json = JSON.parse(text); + if (!json.current || !json.daily) + return; + + cc = { + weatherCode: json.current.weather_code, + weatherDesc: getWeatherCondition(json.current.weather_code), + tempC: Math.round(json.current.temperature_2m), + tempF: Math.round(toFahrenheit(json.current.temperature_2m)), + feelsLikeC: Math.round(json.current.apparent_temperature), + feelsLikeF: Math.round(toFahrenheit(json.current.apparent_temperature)), + humidity: json.current.relative_humidity_2m, + windSpeed: json.current.wind_speed_10m, + isDay: json.current.is_day, + sunrise: json.daily.sunrise[0], + sunset: json.daily.sunset[0] + }; + + const forecastList = []; + for (let i = 0; i < json.daily.time.length; i++) + forecastList.push({ + date: json.daily.time[i], + maxTempC: Math.round(json.daily.temperature_2m_max[i]), + maxTempF: Math.round(toFahrenheit(json.daily.temperature_2m_max[i])), + minTempC: Math.round(json.daily.temperature_2m_min[i]), + minTempF: Math.round(toFahrenheit(json.daily.temperature_2m_min[i])), + weatherCode: json.daily.weather_code[i], + icon: Icons.getWeatherIcon(json.daily.weather_code[i]) + }); + forecast = forecastList; + + const hourlyList = []; + const now = new Date(); + for (let i = 0; i < json.hourly.time.length; i++) { + const time = new Date(json.hourly.time[i]); + if (time < now) + continue; + + hourlyList.push({ + timestamp: json.hourly.time[i], + hour: time.getHours(), + tempC: Math.round(json.hourly.temperature_2m[i]), + tempF: Math.round(toFahrenheit(json.hourly.temperature_2m[i])), + weatherCode: json.hourly.weather_code[i], + icon: Icons.getWeatherIcon(json.hourly.weather_code[i]) + }); + } + hourlyForecast = hourlyList; + }); + } + + function getWeatherCondition(code: string): string { + const conditions = { + "0": "Clear", + "1": "Clear", + "2": "Partly cloudy", + "3": "Overcast", + "45": "Fog", + "48": "Fog", + "51": "Drizzle", + "53": "Drizzle", + "55": "Drizzle", + "56": "Freezing drizzle", + "57": "Freezing drizzle", + "61": "Light rain", + "63": "Rain", + "65": "Heavy rain", + "66": "Light rain", + "67": "Heavy rain", + "71": "Light snow", + "73": "Snow", + "75": "Heavy snow", + "77": "Snow", + "80": "Light rain", + "81": "Rain", + "82": "Heavy rain", + "85": "Light snow showers", + "86": "Heavy snow showers", + "95": "Thunderstorm", + "96": "Thunderstorm with hail", + "99": "Thunderstorm with hail" + }; + return conditions[code] || "Unknown"; + } + + function getWeatherUrl(): string { + if (!loc || loc.indexOf(",") === -1) + return ""; + + const [lat, lon] = loc.split(","); + const baseUrl = "https://api.open-meteo.com/v1/forecast"; + const params = ["latitude=" + lat, "longitude=" + lon, "hourly=weather_code,temperature_2m", "daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset", "current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,weather_code,wind_speed_10m", "timezone=auto", "forecast_days=7"]; + + return baseUrl + "?" + params.join("&"); + } + + function reload(): void { + const configLocation = Config.services.weatherLocation; + + if (configLocation) { + if (configLocation.indexOf(",") !== -1 && !isNaN(parseFloat(configLocation.split(",")[0]))) { + loc = configLocation; + fetchCityFromCoords(configLocation); + } else { + fetchCoordsFromCity(configLocation); + } + } else if (!loc || timer.elapsed() > 900) { + Requests.get("https://ipinfo.io/json", text => { + const response = JSON.parse(text); + if (response.loc) { + loc = response.loc; + city = response.city ?? ""; + timer.restart(); + } + }); + } + } + + function toFahrenheit(celcius: real): real { + return celcius * 9 / 5 + 32; + } + + onLocChanged: fetchWeatherData() + + // Refresh current location hourly + Timer { + interval: 3600000 // 1 hour + repeat: true + running: true + + onTriggered: fetchWeatherData() + } + + ElapsedTimer { + id: timer + + } +} diff --git a/Greeter/InputField.qml b/Greeter/InputField.qml new file mode 100644 index 0000000..9182b84 --- /dev/null +++ b/Greeter/InputField.qml @@ -0,0 +1,170 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Helpers +import qs.Config + +Item { + id: root + + property string buffer + required property var greeter + readonly property alias placeholder: placeholder + + Layout.fillHeight: true + Layout.fillWidth: true + clip: true + + Connections { + function onBufferChanged(): void { + if (root.greeter.buffer.length > root.buffer.length) { + charList.bindImWidth(); + } else if (root.greeter.buffer.length === 0) { + charList.implicitWidth = charList.implicitWidth; + placeholder.animate = true; + } + + root.buffer = root.greeter.buffer; + } + + target: root.greeter + } + + CustomText { + id: placeholder + + anchors.centerIn: parent + animate: true + color: root.greeter.launching ? DynamicColors.palette.m3secondary : DynamicColors.palette.m3outline + font.family: Appearance.font.family.mono + font.pointSize: Appearance.font.size.normal + opacity: root.buffer ? 0 : 1 + text: { + if (root.greeter.launching) + return qsTr("Starting session..."); + if (root.greeter.awaitingResponse && root.greeter.promptMessage) + return root.greeter.promptMessage; + return qsTr("Enter your password"); + } + + Behavior on opacity { + Anim { + } + } + } + + CustomText { + id: visibleBufferText + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + color: DynamicColors.palette.m3onSurface + elide: Text.ElideLeft + font.family: Appearance.font.family.mono + font.pointSize: Appearance.font.size.normal + horizontalAlignment: Qt.AlignHCenter + opacity: root.greeter.echoResponse && root.buffer ? 1 : 0 + text: root.buffer + + 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 + implicitHeight: Appearance.font.size.normal + implicitWidth: fullWidth + interactive: false + opacity: root.greeter.echoResponse ? 0 : 1 + orientation: Qt.Horizontal + spacing: Appearance.spacing.small / 2 + + delegate: CustomRect { + id: ch + + color: DynamicColors.palette.m3onSurface + implicitHeight: charList.implicitHeight + implicitWidth: implicitHeight + opacity: 0 + radius: Appearance.rounding.small / 2 + scale: 0 + + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + duration: Appearance.anim.durations.expressiveFastSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveFastSpatial + } + } + + Component.onCompleted: { + opacity = 1; + scale = 1; + } + ListView.onRemove: removeAnim.start() + + SequentialAnimation { + id: removeAnim + + PropertyAction { + property: "ListView.delayRemove" + target: ch + value: true + } + + ParallelAnimation { + Anim { + property: "opacity" + target: ch + to: 0 + } + + Anim { + property: "scale" + target: ch + to: 0.5 + } + } + + PropertyAction { + property: "ListView.delayRemove" + target: ch + value: false + } + } + } + Behavior on implicitWidth { + id: imWidthBehavior + + Anim { + } + } + model: ScriptModel { + values: root.buffer.split("") + } + Behavior on opacity { + Anim { + } + } + } +} diff --git a/Greeter/Paths/Paths.qml b/Greeter/Paths/Paths.qml new file mode 100644 index 0000000..27131f1 --- /dev/null +++ b/Greeter/Paths/Paths.qml @@ -0,0 +1,36 @@ +pragma Singleton + +import ZShell +import Quickshell +import qs.Config + +Singleton { + id: root + + readonly property string cache: `${Quickshell.env("XDG_CACHE_HOME") || `${home}/.cache`}/zshell` + readonly property string config: `${Quickshell.env("XDG_CONFIG_HOME") || `${home}/.config`}/zshell` + readonly property string data: `${Quickshell.env("XDG_DATA_HOME") || `${home}/.local/share`}/zshell` + readonly property string desktop: `${Quickshell.env("HOME")}/Desktop` + readonly property string home: Quickshell.env("HOME") + readonly property string imagecache: `${cache}/imagecache` + readonly property string libdir: Quickshell.env("ZSHELL_LIB_DIR") || "/usr/lib/zshell" + readonly property string notifimagecache: `${imagecache}/notifs` + readonly property string pictures: Quickshell.env("XDG_PICTURES_DIR") || `${home}/Pictures` + readonly property string recsdir: Quickshell.env("ZSHELL_RECORDINGS_DIR") || `${videos}/Recordings` + readonly property string state: `${Quickshell.env("XDG_STATE_HOME") || `${home}/.local/state`}/zshell` + readonly property string videos: Quickshell.env("XDG_VIDEOS_DIR") || `${home}/Videos` + readonly property string wallsdir: Quickshell.env("ZSHELL_WALLPAPERS_DIR") || absolutePath(Config.wallpaperPath) + + function absolutePath(path: string): string { + return toLocalFile(path.replace("~", home)); + } + + function shortenHome(path: string): string { + return path.replace(home, "~"); + } + + function toLocalFile(path: url): string { + path = Qt.resolvedUrl(path); + return path.toString() ? ZShellIo.toLocalFile(path) : ""; + } +} diff --git a/Greeter/Resources.qml b/Greeter/Resources.qml new file mode 100644 index 0000000..c2f5c62 --- /dev/null +++ b/Greeter/Resources.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Helpers +import qs.Config + +GridLayout { + id: root + + anchors.left: parent.left + anchors.margins: Appearance.padding.large + anchors.right: parent.right + columnSpacing: Appearance.spacing.large + columns: 2 + rowSpacing: Appearance.spacing.large + rows: 1 + + Ref { + service: SystemUsage + } + + Resource { + Layout.bottomMargin: Appearance.padding.large + Layout.topMargin: Appearance.padding.large + colour: DynamicColors.palette.m3primary + icon: "memory" + value: SystemUsage.cpuPerc + } + + Resource { + Layout.bottomMargin: Appearance.padding.large + Layout.topMargin: Appearance.padding.large + colour: DynamicColors.palette.m3secondary + icon: "memory_alt" + value: SystemUsage.memPerc + } + + component Resource: CustomRect { + id: res + + required property color colour + required property string icon + required property real value + + Layout.fillWidth: true + color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + implicitHeight: width + radius: Appearance.rounding.large + + Behavior on value { + Anim { + duration: Appearance.anim.durations.large + } + } + + CircularProgress { + id: circ + + anchors.fill: parent + bgColour: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 3) + fgColour: res.colour + padding: Appearance.padding.large * 3 + strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal + value: res.value + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + color: res.colour + font.pointSize: (circ.arcRadius * 0.7) || 1 + font.weight: 600 + text: res.icon + } + } +} diff --git a/Greeter/SessionDock.qml b/Greeter/SessionDock.qml new file mode 100644 index 0000000..af97f17 --- /dev/null +++ b/Greeter/SessionDock.qml @@ -0,0 +1,133 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Helpers +import qs.Config + +ColumnLayout { + id: root + + required property var greeter + + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.smaller + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font.family: Appearance.font.family.mono + font.weight: 500 + text: root.greeter.sessions.length > 0 ? qsTr("%1 session%2").arg(root.greeter.sessions.length).arg(root.greeter.sessions.length === 1 ? "" : "s") : qsTr("Sessions") + } + + CustomClippingRect { + Layout.fillHeight: true + Layout.fillWidth: true + color: "transparent" + radius: Appearance.rounding.small + + Loader { + active: opacity > 0 + anchors.centerIn: parent + opacity: root.greeter.sessions.length > 0 ? 0 : 1 + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.extraLarge + } + } + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.large + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3outlineVariant + font.pointSize: Appearance.font.size.extraLarge * 2 + text: "desktop_windows" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3outlineVariant + font.family: Appearance.font.family.mono + font.pointSize: Appearance.font.size.large + font.weight: 500 + text: qsTr("No Sessions Found") + } + } + } + + ListView { + anchors.fill: parent + clip: true + currentIndex: root.greeter.sessionIndex + model: root.greeter.sessions + spacing: Appearance.spacing.small + + delegate: CustomRect { + required property int index + required property var modelData + + anchors.left: parent?.left + anchors.right: parent?.right + color: ListView.isCurrentItem ? DynamicColors.palette.m3secondaryContainer : DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + implicitHeight: row.implicitHeight + Appearance.padding.normal * 2 + radius: Appearance.rounding.normal + + StateLayer { + function onClicked(): void { + root.greeter.sessionIndex = index; + } + + color: ListView.isCurrentItem ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface + } + + RowLayout { + id: row + + anchors.fill: parent + anchors.margins: Appearance.padding.normal + spacing: Appearance.spacing.normal + + MaterialIcon { + color: ListView.isCurrentItem ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurfaceVariant + text: modelData.kind === "x11" ? "tv" : "desktop_windows" + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small / 2 + + CustomText { + Layout.fillWidth: true + color: ListView.isCurrentItem ? DynamicColors.palette.m3onSecondaryContainer : DynamicColors.palette.m3onSurface + elide: Text.ElideRight + font.pointSize: Appearance.font.size.normal + font.weight: 600 + text: modelData.name + } + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3outline + elide: Text.ElideRight + font.family: Appearance.font.family.mono + font.pointSize: Appearance.font.size.small + text: modelData.kind + } + } + + MaterialIcon { + color: DynamicColors.palette.m3primary + opacity: ListView.isCurrentItem ? 1 : 0 + text: "check_circle" + } + } + } + } + } +} diff --git a/Greeter/UserImage.qml b/Greeter/UserImage.qml new file mode 100644 index 0000000..60fcfb0 --- /dev/null +++ b/Greeter/UserImage.qml @@ -0,0 +1,24 @@ +import Quickshell +import Quickshell.Widgets +import QtQuick +import qs.Paths + +Item { + id: root + + ClippingRectangle { + anchors.fill: parent + radius: 1000 + + Image { + id: userImage + + anchors.fill: parent + asynchronous: true + fillMode: Image.PreserveAspectCrop + source: `${Paths.home}/.face` + sourceSize.height: parent.height + sourceSize.width: parent.width + } + } +} diff --git a/Greeter/WeatherInfo.qml b/Greeter/WeatherInfo.qml new file mode 100644 index 0000000..56bcdb5 --- /dev/null +++ b/Greeter/WeatherInfo.qml @@ -0,0 +1,169 @@ +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.margins: Appearance.padding.large * 2 + anchors.right: parent.right + spacing: Appearance.spacing.small + + Loader { + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: -Appearance.padding.large + Layout.topMargin: Appearance.padding.large * 2 + active: root.rootHeight > 610 + visible: active + + sourceComponent: CustomText { + color: DynamicColors.palette.m3primary + font.pointSize: Appearance.font.size.extraLarge + font.weight: 500 + text: qsTr("Weather") + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.large + + MaterialIcon { + animate: true + color: DynamicColors.palette.m3secondary + font.pointSize: Appearance.font.size.extraLarge * 2.5 + text: Weather.icon + } + + ColumnLayout { + spacing: Appearance.spacing.small + + CustomText { + Layout.fillWidth: true + animate: true + color: DynamicColors.palette.m3secondary + elide: Text.ElideRight + font.pointSize: Appearance.font.size.large + font.weight: 500 + text: Weather.description + } + + CustomText { + Layout.fillWidth: true + animate: true + color: DynamicColors.palette.m3onSurfaceVariant + elide: Text.ElideRight + font.pointSize: Appearance.font.size.normal + text: qsTr("Humidity: %1%").arg(Weather.humidity) + } + } + + Loader { + Layout.rightMargin: Appearance.padding.smaller + active: root.width > 400 + visible: active + + sourceComponent: ColumnLayout { + spacing: Appearance.spacing.small + + CustomText { + Layout.fillWidth: true + animate: true + color: DynamicColors.palette.m3primary + elide: Text.ElideLeft + font.pointSize: Appearance.font.size.extraLarge + font.weight: 500 + horizontalAlignment: Text.AlignRight + text: Weather.temp + } + + CustomText { + Layout.fillWidth: true + animate: true + color: DynamicColors.palette.m3outline + elide: Text.ElideLeft + font.pointSize: Appearance.font.size.smaller + horizontalAlignment: Text.AlignRight + text: qsTr("Feels like: %1").arg(Weather.feelsLike) + } + } + } + } + + Loader { + id: forecastLoader + + Layout.bottomMargin: Appearance.padding.large * 2 + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.smaller + 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 + color: DynamicColors.palette.m3outline + font.pointSize: Appearance.font.size.larger + horizontalAlignment: Text.AlignHCenter + text: { + const hour = forecastHour.modelData?.hour ?? 0; + return hour > 12 ? `${(hour - 12).toString().padStart(2, "0")} PM` : `${hour.toString().padStart(2, "0")} AM`; + } + } + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + font.pointSize: Appearance.font.size.extraLarge * 1.5 + font.weight: 500 + text: forecastHour.modelData?.icon ?? "cloud_alert" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3secondary + font.pointSize: Appearance.font.size.larger + text: Config.services.useFahrenheit ? `${forecastHour.modelData?.tempF ?? 0}°F` : `${forecastHour.modelData?.tempC ?? 0}°C` + } + } + } + } + } + + Timer { + interval: 900000 // 15 minutes + repeat: true + running: true + triggeredOnStart: true + + onTriggered: Weather.reload() + } +} diff --git a/Greeter/scripts/get-sessions b/Greeter/scripts/get-sessions new file mode 100755 index 0000000..dc9dfa6 --- /dev/null +++ b/Greeter/scripts/get-sessions @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +import configparser +import json +import os +import pathlib +import shlex + +SEARCH_DIRS = [ + "/usr/share/wayland-sessions", + "/usr/local/share/wayland-sessions", + os.path.expanduser("~/.local/share/wayland-sessions"), + "/usr/share/xsessions", + "/usr/local/share/xsessions", + os.path.expanduser("~/.local/share/xsessions"), +] + +def clean_exec(exec_string: str): + parts = shlex.split(exec_string) + out = [] + + for part in parts: + if part == "%%": + out.append("%") + elif part.startswith("%"): + # ignore desktop-entry field codes for login sessions + continue + else: + out.append(part) + + return out + +def truthy(value: str) -> bool: + return value.strip().lower() in {"1", "true", "yes"} + +sessions = [] + +for directory in SEARCH_DIRS: + kind = "wayland" if "wayland-sessions" in directory else "x11" + base = pathlib.Path(directory) + + if not base.is_dir(): + continue + + for file in sorted(base.glob("*.desktop")): + parser = configparser.ConfigParser(interpolation=None, strict=False) + parser.read(file, encoding="utf-8") + + if "Desktop Entry" not in parser: + continue + + entry = parser["Desktop Entry"] + + if entry.get("Type", "Application") != "Application": + continue + if truthy(entry.get("Hidden", "false")): + continue + if truthy(entry.get("NoDisplay", "false")): + continue + + exec_string = entry.get("Exec", "").strip() + if not exec_string: + continue + + sessions.append({ + "id": file.stem, + "name": entry.get("Name", file.stem), + "comment": entry.get("Comment", ""), + "icon": entry.get("Icon", ""), + "kind": kind, + "desktopFile": str(file), + "command": clean_exec(exec_string), + }) + +print(json.dumps(sessions)) diff --git a/Greeter/scripts/start-zshell-greeter b/Greeter/scripts/start-zshell-greeter new file mode 100755 index 0000000..a4c125a --- /dev/null +++ b/Greeter/scripts/start-zshell-greeter @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -eu + +export XDG_SESSION_TYPE=wayland +export QT_QPA_PLATFORM=wayland +export QT_WAYLAND_DISABLE_WINDOWDECORATION=1 +export EGL_PLATFORM=gbm + +if command -v start-hyprland >/dev/null 2>&1; then + exec start-hyprland -- -c /etc/xdg/quickshell/zshell-greeter/scripts/zshell-hyprland.conf +else + exec Hyprland -c /etc/xdg/quickshell/zshell-greeter/scripts/zshell-hyprland.conf +fi diff --git a/Greeter/scripts/zshell-hyprland.conf b/Greeter/scripts/zshell-hyprland.conf new file mode 100644 index 0000000..f452209 --- /dev/null +++ b/Greeter/scripts/zshell-hyprland.conf @@ -0,0 +1,12 @@ +monitor = ,preferred,auto,1 + +env = XDG_SESSION_TYPE,wayland +env = QT_QPA_PLATFORM,wayland +env = QT_WAYLAND_DISABLE_WINDOWDECORATION,1 + +misc { + disable_hyprland_logo = true + disable_splash_rendering = true +} + +exec = sh -lc 'qs -c zshell-greeter; hyprctl dispatch exit' diff --git a/Greeter/shell.qml b/Greeter/shell.qml new file mode 100644 index 0000000..83ad3b6 --- /dev/null +++ b/Greeter/shell.qml @@ -0,0 +1,31 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import Quickshell.Wayland +import Quickshell.Io +import QtQuick +import qs.Components + +ShellRoot { + id: root + + GreeterState { + id: greeter + + username: "zach" + } + + GreeterSurface { + id: greeterSurface + + greeter: greeter + } + + Connections { + function onLastWindowClosed(): void { + Qt.quit(); + } + + target: Quickshell + } +} diff --git a/Modules/Lock/IdleInhibitor.qml b/Modules/Lock/IdleInhibitor.qml deleted file mode 100644 index e69de29..0000000 diff --git a/Modules/Lock/Lock.qml b/Modules/Lock/Lock.qml index 083d876..9c6f8df 100644 --- a/Modules/Lock/Lock.qml +++ b/Modules/Lock/Lock.qml @@ -2,12 +2,9 @@ pragma ComponentBehavior: Bound import Quickshell import Quickshell.Wayland -import Quickshell.Hyprland import Quickshell.Io import QtQuick -import QtQuick.Effects import qs.Components -import qs.Config Scope { id: root