commit 5f3b3573447b578cd74a2d31c5a6926e216dc908 Author: SomeElse Date: Fri Apr 24 02:14:35 2026 +0000 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..72093fb --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# Quickshell Dots + +**Warning:** I used AI to create the theme styling and pass many variables to `config.qml`. +Additionally, I used AI to fix several bugs, which suggests the code may not be very readable and could be broken in some aspects. +I am currently identifying and cleaning up bugs and bad code as I find them. (im not qml expert) + + +Just git clone this repo into .config/quickshell and it will work. diff --git a/bar/Bar.qml b/bar/Bar.qml new file mode 100644 index 0000000..90914b9 --- /dev/null +++ b/bar/Bar.qml @@ -0,0 +1,179 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland + +import "../config" as Cfg + +PanelWindow { + id: barWindow + + required property var modelData + property bool isTop + + readonly property var cfg: Cfg.Config + + color: "transparent" + anchors { + top: isTop + bottom: !isTop + left: true + right: true + } + implicitHeight: cfg.barHeight + + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "main-shell-bar" + WlrLayershell.exclusiveZone: cfg.barHeight + + Item { + id: animatedWrapper + anchors.fill: parent + + opacity: 0 + scale: 0.95 + y: barWindow.isTop ? -20 : 20 + + Component.onCompleted: barEntrance.start() + + ParallelAnimation { + id: barEntrance + + NumberAnimation { + target: animatedWrapper + property: "opacity" + from: 0; to: 1 + duration: 400 + easing.type: Easing.OutCubic + } + + NumberAnimation { + target: animatedWrapper + property: "scale" + from: 0.95; to: 1.0 + duration: 500 + easing.type: Easing.OutBack + } + + NumberAnimation { + target: animatedWrapper + property: "y" + to: 0 + duration: 600 + easing.type: Easing.OutExpo + } + } + + Item { + id: barContent + anchors.fill: parent + anchors.leftMargin: cfg.margin + anchors.rightMargin: cfg.margin + + function bindModule(item, side) { + item.height = Qt.binding(() => barContent.height - 12) + + const props = { + "panelRadius": cfg.radius / 2, + "borderWidth": cfg.panelBorderWidth, + "isTop": barWindow.isTop, + "targetScreen": barWindow.modelData, + "barHeight": cfg.barHeight, + "barMargin": cfg.margin, + "barSide": side, + "menuWindow": barWindow + } + for (let prop in props) { + if (prop in item) item[prop] = props[prop] + } + } + + readonly property real sideMaxWidth: Math.max(0, + (width - centerRow.implicitWidth - 2 * cfg.panelSpacing) / 2) + + Item { + id: leftContainer + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + height: parent.height + width: Math.max(0, Math.min(leftRow.implicitWidth, barContent.sideMaxWidth)) + clip: true + + Row { + id: leftRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: cfg.moduleSpacing + transformOrigin: Item.Left + scale: leftContainer.width / Math.max(leftContainer.width, implicitWidth) + + Repeater { + model: cfg.leftModules + Loader { + required property string modelData + source: Qt.resolvedUrl("modules/" + modelData + ".qml") + onLoaded: barContent.bindModule(item, "left") + } + } + } + } + + Item { + id: rightContainer + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: parent.height + width: Math.max(0, Math.min(rightRow.implicitWidth, barContent.sideMaxWidth)) + clip: true + + Row { + id: rightRow + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: cfg.moduleSpacing + transformOrigin: Item.Right + scale: rightContainer.width / Math.max(rightContainer.width, implicitWidth) + + Repeater { + model: cfg.rightModules + Loader { + required property string modelData + source: Qt.resolvedUrl("modules/" + modelData + ".qml") + onLoaded: barContent.bindModule(item, "right") + } + } + } + } + + Item { + id: centerContainer + anchors.verticalCenter: parent.verticalCenter + height: parent.height + width: Math.max(0, Math.min( + centerRow.implicitWidth, + barContent.width + - 2 * (Math.max(leftContainer.width, rightContainer.width) + + cfg.panelSpacing) + )) + x: (barContent.width - width) / 2 + clip: true + + Row { + id: centerRow + anchors.centerIn: parent + spacing: cfg.moduleSpacing + transformOrigin: Item.Center + scale: centerContainer.width / Math.max(centerContainer.width, implicitWidth) + + Repeater { + model: cfg.centerModules + Loader { + required property string modelData + source: Qt.resolvedUrl("modules/" + modelData + ".qml") + onLoaded: barContent.bindModule(item, "center") + } + } + } + } + } + } +} diff --git a/bar/modules/ArchLogo.qml b/bar/modules/ArchLogo.qml new file mode 100644 index 0000000..0957440 --- /dev/null +++ b/bar/modules/ArchLogo.qml @@ -0,0 +1,91 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell + +import "../../config" as Cfg + +Rectangle { + id: root + + property real panelRadius: Cfg.Config.radius / 2 + property int borderWidth: Cfg.Config.panelBorderWidth + + property bool isTop: true + property var targetScreen: null + property int barHeight: Cfg.Config.barHeight + property int barMargin: Cfg.Config.margin + + readonly property var t: Cfg.Config.theme + + property var launcher: null + + readonly property bool launcherActive: !!launcher && launcher.isOpen + readonly property bool isHovered: hoverHandler.hovered + + property real spinTarget: 0 + + onTargetScreenChanged: Qt.callLater(() => { + launcher = (targetScreen && targetScreen.launcher) ? targetScreen.launcher : null + }) + + onLauncherActiveChanged: { + spinTarget += launcherActive ? 360 : -360 + if (launcherActive && !clickAnim.running) + clickAnim.restart() + } + + width: Cfg.Config.logoIconSize * 2 + 4 + height: parent.height + + color: isHovered ? t.archLogoBgHover : t.archLogoBg + radius: panelRadius + + border.color: launcherActive ? t.archLogoBorderActive : t.archLogoBorder + border.width: borderWidth + + Behavior on color { ColorAnimation { duration: 250 } } + Behavior on border.color { ColorAnimation { duration: 250 } } + + HoverHandler { id: hoverHandler } + + TapHandler { + onTapped: { + clickAnim.restart() + if (root.targetScreen && root.targetScreen.launcher) { + root.targetScreen.launcher.toggle() + } + } + } + + SequentialAnimation { + id: clickAnim + NumberAnimation { target: logoText; property: "scale"; from: 1.0; to: 0.7; duration: 60; easing.type: Easing.OutQuad } + NumberAnimation { target: logoText; property: "scale"; from: 0.7; to: 1.2; duration: 150; easing.type: Easing.OutBack } + NumberAnimation { target: logoText; property: "scale"; to: 1.0; duration: 100 } + } + + Text { + id: logoText + anchors.centerIn: parent + text: Cfg.Config.logoIcon + + color: launcherActive ? t.archLogoIconActive : t.archLogoIcon + font.pixelSize: Cfg.Config.logoIconSize + + Behavior on color { ColorAnimation { duration: 250 } } + + scale: clickAnim.running ? logoText.scale : 1.0 + Behavior on scale { + enabled: !clickAnim.running + NumberAnimation { duration: 250; easing.type: Easing.OutBack } + } + + rotation: root.spinTarget + Behavior on rotation { + NumberAnimation { + duration: 600 + easing.type: Easing.InOutBack + } + } + } +} diff --git a/bar/modules/Clock.qml b/bar/modules/Clock.qml new file mode 100644 index 0000000..5a2acb1 --- /dev/null +++ b/bar/modules/Clock.qml @@ -0,0 +1,331 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland + +import "../../config" as Cfg + +Rectangle { + id: root + + property real panelRadius: Cfg.Config.radius / 2 + property int borderWidth: Cfg.Config.panelBorderWidth + + property bool isTop: true + property var targetScreen: null + property int barHeight: Cfg.Config.barHeight + property int barMargin: Cfg.Config.margin + + readonly property var t: Cfg.Config.theme + + readonly property int tzOffsetHours: -timeManager.now.getTimezoneOffset() / 60 + + width: clockLayout.implicitWidth + 24 + height: Cfg.Config.moduleHeight + radius: panelRadius + + color: hoverHandler.hovered ? t.clockBgHover : t.clockBg + border.color: hoverHandler.hovered ? t.clockBorderHover : t.clockBorder + border.width: borderWidth + + anchors.right: parent.right + + Behavior on width { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 150 } } + Behavior on border.color { ColorAnimation { duration: 150 } } + + HoverHandler { + id: hoverHandler + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: calendarPopup.toggle() + } + + Item { + id: timeManager + property date now: new Date() + Timer { + interval: 1000; running: true; repeat: true + onTriggered: timeManager.now = new Date() + } + } + + RowLayout { + id: clockLayout + anchors.centerIn: parent + spacing: 0 + + Text { + text: "󱑂" + color: root.t.clockIcon + font.pixelSize: 14 + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: 8 + } + + Text { + text: timeManager.now.toLocaleTimeString(Qt.locale(), "HH:mm") + color: root.t.clockTime + font.pixelSize: 13 + font.bold: true + verticalAlignment: Text.AlignVCenter + } + + Text { + id: secondsText + text: ":" + timeManager.now.toLocaleTimeString(Qt.locale(), "ss") + color: root.t.clockSeconds + font.pixelSize: 13 + font.bold: true + verticalAlignment: Text.AlignVCenter + + opacity: (hoverHandler.hovered || calendarPopup.popupOpen) ? 1.0 : 0.0 + visible: opacity > 0 + Layout.preferredWidth: (hoverHandler.hovered || calendarPopup.popupOpen) ? implicitWidth : 0 + clip: true + + Behavior on opacity { NumberAnimation { duration: 200 } } + Behavior on Layout.preferredWidth { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } + } + } + + PanelWindow { + id: calendarPopup + screen: root.targetScreen + visible: false + color: "transparent" + + property bool popupOpen: false + + function open() { hideTimer.stop(); visible = true; popupOpen = true } + function close() { popupOpen = false; hideTimer.restart() } + function toggle() { popupOpen ? close() : open() } + + Timer { + id: hideTimer + interval: Cfg.Config.popupAnimDuration + 70 + onTriggered: calendarPopup.visible = false + } + + anchors { top: true; bottom: true; left: true; right: true } + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "main-shell-calendar" + WlrLayershell.exclusiveZone: -1 + mask: popupOpen ? null : _noInput + Region { id: _noInput } + + TapHandler { onTapped: calendarPopup.close() } + + Item { + id: calClipContainer + readonly property int cardW: 284 + readonly property int screenPad: root.barMargin + 10 + + x: { + let centerX = root.mapToItem(null, 0, 0).x + root.width / 2 + let desiredX = centerX - cardW / 2 + return Math.max(screenPad, Math.min(desiredX, parent.width - cardW - screenPad)) + } + width: cardW + + anchors.top: root.isTop ? parent.top : undefined + anchors.bottom: root.isTop ? undefined : parent.bottom + anchors.topMargin: root.isTop ? (root.barHeight + 10) : 0 + anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + 10) + height: parent.height - root.barHeight - 10 + + clip: true + + Rectangle { + id: card + width: parent.width + height: cardCol.implicitHeight + 24 + + anchors.top: root.isTop ? parent.top : undefined + anchors.bottom: root.isTop ? undefined : parent.bottom + + transform: Translate { + y: calendarPopup.popupOpen ? 0 : (root.isTop ? -card.height : card.height) + + Behavior on y { + NumberAnimation { + duration: Cfg.Config.popupAnimDuration; + easing.type: Easing.OutExpo + } + enabled: calendarPopup.visible + } + } + + opacity: calendarPopup.popupOpen ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 200 } } + + color: root.t.calCardBg + radius: Cfg.Config.popupRadius + border.color: root.t.calCardBorder + border.width: Cfg.Config.popupBorderWidth + layer.enabled: true + + MouseArea { anchors.fill: parent; propagateComposedEvents: false } + + ColumnLayout { + id: cardCol + anchors { left: parent.left; right: parent.right; top: parent.top } + anchors.margins: 16 + anchors.topMargin: 18 + spacing: 14 + + ColumnLayout { + spacing: 2 + Layout.fillWidth: true + + Text { + text: timeManager.now.toLocaleDateString(Qt.locale(), "dddd") + color: root.t.clockPopupDim + font.pixelSize: 12 + font.capitalization: Font.AllUppercase + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + Text { + text: timeManager.now.toLocaleDateString(Qt.locale(), "d MMMM yyyy") + color: root.t.clockPopupHeader + font.pixelSize: 22 + font.bold: true + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: root.t.calSeparator } + + RowLayout { + Layout.fillWidth: true + + Text { + text: "‹"; color: root.t.clockPopupDim; font.pixelSize: 18; font.bold: true + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: { + cal.displayMonth -= 1 + if (cal.displayMonth < 0) { cal.displayMonth = 11; cal.displayYear -= 1 } + } + } + } + + Text { + text: new Date(cal.displayYear, cal.displayMonth, 1).toLocaleDateString(Qt.locale(), "MMMM yyyy") + color: root.t.clockPopupText + font.pixelSize: 13; font.bold: true + Layout.fillWidth: true; horizontalAlignment: Text.AlignHCenter + } + + Text { + text: "›"; color: root.t.clockPopupDim; font.pixelSize: 18; font.bold: true + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onClicked: { + cal.displayMonth += 1 + if (cal.displayMonth > 11) { cal.displayMonth = 0; cal.displayYear += 1 } + } + } + } + } + + Item { + id: cal + Layout.fillWidth: true + implicitHeight: gridCol.implicitHeight + property int displayYear: timeManager.now.getFullYear() + property int displayMonth: timeManager.now.getMonth() + + Connections { + target: calendarPopup + function onPopupOpenChanged() { + if (calendarPopup.popupOpen) { + cal.displayYear = timeManager.now.getFullYear() + cal.displayMonth = timeManager.now.getMonth() + } + } + } + + ColumnLayout { + id: gridCol + anchors.fill: parent + spacing: 2 + + Row { + spacing: 0 + Repeater { + model: ["Mo","Tu","We","Th","Fr","Sa","Su"] + Text { + width: (card.width - 32) / 7 + text: modelData; color: root.t.clockPopupDim + font.pixelSize: 11; horizontalAlignment: Text.AlignHCenter + } + } + } + + Repeater { + model: 6 + Row { + id: weekRow + property int weekIndex: index + Repeater { + model: 7 + delegate: Item { + width: (card.width - 32) / 7; height: 26 + readonly property int dayNum: (weekRow.weekIndex * 7 + index) - ((new Date(cal.displayYear, cal.displayMonth, 1).getDay() + 6) % 7) + 1 + readonly property bool inMonth: dayNum >= 1 && dayNum <= new Date(cal.displayYear, cal.displayMonth + 1, 0).getDate() + readonly property bool isToday: inMonth && dayNum === timeManager.now.getDate() && cal.displayYear === timeManager.now.getFullYear() && cal.displayMonth === timeManager.now.getMonth() + + Rectangle { + anchors.centerIn: parent; width: 22; height: 22; radius: 11 + color: isToday ? root.t.accent : "transparent" + visible: inMonth + } + + Text { + anchors.fill: parent + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + text: inMonth ? dayNum : "" + color: isToday ? root.t.todayText : (index >= 5 ? root.t.clockPopupDim : root.t.clockPopupText) + font.pixelSize: 12; font.bold: isToday + } + } + } + } + } + } + } + + Rectangle { Layout.fillWidth: true; height: 1; color: root.t.calSeparator } + + RowLayout { + Layout.fillWidth: true; spacing: 12 + Text { text: "󰣐"; color: root.t.clockIcon; font.pixelSize: 16 } + ColumnLayout { + spacing: 0; Layout.fillWidth: true + Text { + text: timeManager.now.toLocaleTimeString(Qt.locale(), "t") || "UTC" + color: root.t.clockPopupText; font.pixelSize: 12; font.bold: true + } + Text { + text: "UTC " + (root.tzOffsetHours >= 0 ? "+" : "") + root.tzOffsetHours + color: root.t.clockPopupDim; font.pixelSize: 10 + } + } + Text { + text: timeManager.now.toLocaleTimeString(Qt.locale(), "HH:mm:ss") + color: root.t.clockTime; font.pixelSize: 20; font.bold: true; font.family: "monospace" + } + } + + Item { height: 4 } + } + } + } + } +} diff --git a/bar/modules/Notifications.qml b/bar/modules/Notifications.qml new file mode 100644 index 0000000..b0d17af --- /dev/null +++ b/bar/modules/Notifications.qml @@ -0,0 +1,357 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Notifications + +import "../../config" as Cfg +import "../../notifications" as Notif + +Rectangle { + id: root + + property real panelRadius: Cfg.Config.radius / 2 + property int borderWidth: Cfg.Config.panelBorderWidth + property bool isTop: true + property var targetScreen: null + property int barHeight: Cfg.Config.barHeight + property int barMargin: Cfg.Config.margin + readonly property var t: Cfg.Config.theme + + property int unreadCount: 0 + property bool historyOpen: historyPopup.popupOpen + + width: 35 + height: 40 + radius: panelRadius + + color: hoverHandler.hovered ? t.notifBgHover : t.notifBg + border.color: (hoverHandler.hovered || historyOpen) ? t.notifBorderActive : t.notifBorder + border.width: borderWidth + + Behavior on color { ColorAnimation { duration: 150 } } + Behavior on border.color { ColorAnimation { duration: 150 } } + Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } + + HoverHandler { id: hoverHandler; cursorShape: Qt.PointingHandCursor } + TapHandler { + onTapped: { + if (historyPopup.popupOpen) { + historyPopup.close() + } else { + root.unreadCount = 0 + historyPopup.open() + } + } + } + + Item { + anchors.centerIn: parent + width: Cfg.Config.notifIconSize + 10 + height: Cfg.Config.notifIconSize + 10 + + Text { + id: bellIcon + anchors.centerIn: parent + text: "󰂚" + color: root.unreadCount > 0 ? t.notifIconActive : t.notifIcon + font.pixelSize: Cfg.Config.notifIconSize + Behavior on color { ColorAnimation { duration: 150 } } + } + + Rectangle { + visible: root.unreadCount > 0 + anchors.top: parent.top + anchors.right: parent.right + width: Math.max(14, badgeLabel.implicitWidth + 6) + height: 14 + radius: 7 + color: t.notifBadgeBg + + Behavior on width { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + + Text { + id: badgeLabel + anchors.centerIn: parent + text: root.unreadCount > 99 ? "99+" : root.unreadCount.toString() + color: t.notifBadgeText + font.pixelSize: 8 + font.bold: true + } + } + } + + ListModel { id: notifHistory } + ListModel { id: activeToasts } + + function removeToast(toastId) { + for (var i = 0; i < activeToasts.count; i++) { + if (activeToasts.get(i).toastId === toastId) { + activeToasts.remove(i) + return + } + } + } + + function urgencyColor(urgency) { + if (urgency === NotificationUrgency.Critical) return t.notifUrgencyCritical + if (urgency === NotificationUrgency.Normal) return t.notifUrgencyNormal + return t.notifUrgencyLow + } + + function urgencyIcon(urgency) { + if (urgency === NotificationUrgency.Critical) return "󰀪" + if (urgency === NotificationUrgency.Normal) return "󰋼" + return "󰵙" + } + + NotificationServer { + id: notifServer + keepOnReload: false + actionIconsSupported: true + bodyMarkupSupported: true + bodySupported: true + persistenceSupported: true + imageSupported: true + + onNotification: function(notif) { + notif.tracked = true + var timeStr = new Date().toLocaleTimeString(Qt.locale(), "HH:mm") + var toastId = notif.id + "_" + Date.now() + + notifHistory.insert(0, { + nid: notif.id, + appName: notif.appName || "Unknown", + summary: notif.summary || "", + body: notif.body || "", + urgency: notif.urgency, + timeStr: timeStr + }) + while (notifHistory.count > Cfg.Config.notificationHistorySize) + notifHistory.remove(notifHistory.count - 1) + + var cfgTimeout = notif.urgency === NotificationUrgency.Critical + ? Cfg.Config.notifTimeoutCritical + : (notif.urgency === NotificationUrgency.Low + ? Cfg.Config.notifTimeoutLow + : Cfg.Config.notifTimeoutNormal) + + var finalTimeout = (notif.expireTimeout > 0) ? notif.expireTimeout : cfgTimeout + + var toastEntry = { + toastId: toastId, + nid: notif.id, + appName: notif.appName || "Unknown", + summary: notif.summary || "", + body: notif.body || "", + urgency: notif.urgency, + timeStr: timeStr, + timeout: finalTimeout + } + if (toastLayer.atBottom) activeToasts.append(toastEntry) + else activeToasts.insert(0, toastEntry) + + if (!root.historyOpen) root.unreadCount++ + } + } + + PanelWindow { + id: toastLayer + screen: root.targetScreen + visible: activeToasts.count > 0 + color: "transparent" + + readonly property string pos: Cfg.Config.notificationPosition + readonly property bool atRight: pos === "topright" || pos === "bottomright" + readonly property bool atBottom: pos === "bottomleft" || pos === "bottomright" + + anchors { top: true; bottom: true; right: atRight; left: !atRight } + implicitWidth: 350 + root.barMargin + 4 + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "main-shell-notifications-toasts" + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + mask: Region { item: toastCol } + + readonly property bool shareEdge: (root.isTop && !atBottom) || (!root.isTop && atBottom) + readonly property int edgeGap: shareEdge ? root.barMargin + root.barHeight + 10 : root.barMargin + 10 + + Column { + id: toastCol + x: !toastLayer.atRight ? 4 : 4 + y: !toastLayer.atBottom ? toastLayer.edgeGap : parent.height - toastLayer.edgeGap - toastCol.height + spacing: 8 + width: 350 + + Repeater { + model: activeToasts + Notif.NotificationCard { + width: toastCol.width + toastId: model.toastId + appName: model.appName + summary: model.summary + body: model.body + urgency: model.urgency + timeStr: model.timeStr + timeout: model.timeout + t: root.t + urgencyColor: root.urgencyColor + urgencyIcon: root.urgencyIcon + onDismissed: (id) => root.removeToast(id) + } + } + } + } + + PanelWindow { + id: historyPopup + screen: root.targetScreen + visible: false + color: "transparent" + + property bool popupOpen: false + + function open() { hideTimer.stop(); visible = true; popupOpen = true } + function close() { popupOpen = false; hideTimer.restart() } + + Timer { + id: hideTimer + interval: Cfg.Config.popupAnimDuration + 70 + onTriggered: historyPopup.visible = false + } + + anchors { top: true; bottom: true; left: true; right: true } + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "main-shell-notifications-history" + WlrLayershell.exclusiveZone: -1 + mask: popupOpen ? null : _noInput + Region { id: _noInput } + + MouseArea { + anchors.fill: parent + onClicked: historyPopup.close() + } + + Item { + id: histClipContainer + readonly property int cardW: 340 + readonly property int screenPad: root.barMargin + 10 + + x: { + var cx = root.mapToItem(null, 0, 0).x + root.width / 2 + return Math.max(screenPad, Math.min(cx - cardW / 2, parent.width - cardW - screenPad)) + } + width: cardW + + anchors.top: root.isTop ? parent.top : undefined + anchors.bottom: root.isTop ? undefined : parent.bottom + anchors.topMargin: root.isTop ? (root.barHeight + 10) : 0 + anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + 10) + height: parent.height - root.barHeight - 10 + + clip: true + + Rectangle { + id: historyCard + width: parent.width + height: Math.min(histHeader.implicitHeight + 56 + (notifHistory.count > 0 ? notifHistory.count * 76 : 90), 520) + + anchors.top: root.isTop ? parent.top : undefined + anchors.bottom: root.isTop ? undefined : parent.bottom + + transform: Translate { + y: historyPopup.popupOpen ? 0 : (root.isTop ? -historyCard.height : historyCard.height) + + Behavior on y { + NumberAnimation { + duration: Cfg.Config.popupAnimDuration; + easing.type: Easing.OutExpo + } + enabled: historyPopup.visible + } + } + + opacity: historyPopup.popupOpen ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 200 } } + + color: t.notifHistoryBg + radius: Cfg.Config.popupRadius + border.color: t.notifHistoryBorder + border.width: Cfg.Config.popupBorderWidth + layer.enabled: true + + MouseArea { anchors.fill: parent; propagateComposedEvents: false } + + RowLayout { + id: histHeader + anchors { left: parent.left; right: parent.right; top: parent.top } + anchors.topMargin: 20; anchors.leftMargin: 20; anchors.rightMargin: 20 + spacing: 10 + + Text { text: "󰂚"; color: t.notifToastAppName; font.pixelSize: 18; Layout.alignment: Qt.AlignVCenter } + Text { + text: "Notifications"; color: t.notifToastAppName; font.pixelSize: 15; font.bold: true + Layout.fillWidth: true; Layout.alignment: Qt.AlignVCenter + } + + Rectangle { + visible: notifHistory.count > 0 + Layout.alignment: Qt.AlignVCenter + width: clearText.implicitWidth + 16 + height: 24 + radius: 12 + color: clearHover.hovered ? t.notifHistoryHover : "transparent" + Behavior on color { ColorAnimation { duration: 150 } } + + Text { id: clearText; anchors.centerIn: parent; text: "Clear all"; color: t.notifToastDim; font.pixelSize: 11 } + HoverHandler { id: clearHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: { notifHistory.clear(); root.unreadCount = 0 } } + } + } + + Rectangle { + id: histDivider + anchors { left: parent.left; right: parent.right; top: histHeader.bottom } + anchors.topMargin: 12; height: 1; color: t.notifSeparator + } + + Flickable { + id: histFlick + anchors { left: parent.left; right: parent.right; top: histDivider.bottom; bottom: parent.bottom } + anchors.leftMargin: 8; anchors.rightMargin: 8 + anchors.topMargin: 6; anchors.bottomMargin: 8; clip: true + contentHeight: histCol.implicitHeight + flickableDirection: Flickable.VerticalFlick + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + Column { + id: histCol + width: histFlick.width; spacing: 2; topPadding: 4; bottomPadding: 4; leftPadding: 8; rightPadding: 8 + + Item { + width: histCol.width - 16; height: 90; visible: notifHistory.count === 0 + ColumnLayout { + anchors.centerIn: parent; spacing: 8 + Text { text: "󰂚"; color: t.notifHistoryEmpty; font.pixelSize: 30; Layout.alignment: Qt.AlignHCenter } + Text { text: "No notifications"; color: t.notifHistoryEmpty; font.pixelSize: 12; Layout.alignment: Qt.AlignHCenter } + } + } + + Repeater { + model: notifHistory + Notif.HistoryCard { + width: histCol.width - 16; x: 8; itemIndex: index + appName: model.appName; summary: model.summary; body: model.body; urgency: model.urgency; timeStr: model.timeStr + t: root.t; urgencyColor: root.urgencyColor; urgencyIcon: root.urgencyIcon + onDismissed: (idx) => notifHistory.remove(idx) + } + } + } + } + } + } + } +} diff --git a/bar/modules/Stats.qml b/bar/modules/Stats.qml new file mode 100644 index 0000000..f6b7265 --- /dev/null +++ b/bar/modules/Stats.qml @@ -0,0 +1,181 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io + +import "../../config" as Cfg + +Rectangle { + id: root + + property real panelRadius: Cfg.Config.radius / 2 + property int borderWidth: Cfg.Config.panelBorderWidth + property int barHeight: Cfg.Config.barHeight + property int barMargin: Cfg.Config.margin + readonly property var t: Cfg.Config.theme + + width: statsLayout.implicitWidth + 20 + height: Cfg.Config.moduleHeight + color: t.statsBg + radius: panelRadius + border.color: t.statsBorder + border.width: borderWidth + + Behavior on width { + NumberAnimation { duration: 250; easing.type: Easing.OutCubic } + } + + HoverHandler { cursorShape: Qt.PointingHandCursor } + + property real cpuVal: 0 + property real memVal: 0 + property real diskVal: 0 + + Timer { + interval: 2000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: { + cpuProc.running = true + memProc.running = true + diskProc.running = true + } + } + + Process { + id: cpuProc + command: ["sh", "-c", "top -bn1 | awk '/^%Cpu/ {print 100-$8}'"] + stdout: SplitParser { + onRead: data => { + let val = parseFloat(data.trim()) + root.cpuVal = isNaN(val) ? 0 : val + } + } + } + + Process { + id: memProc + command: ["sh", "-c", "free | awk '/Mem:/ {print ($2-$7)/$2 * 100}'"] + stdout: SplitParser { + onRead: data => { + let val = parseFloat(data.trim()) + root.memVal = isNaN(val) ? 0 : val + } + } + } + + Process { + id: diskProc + command: ["sh", "-c", "df / --output=pcent | tail -1 | tr -dc '0-9'"] + stdout: SplitParser { + onRead: data => { + let val = parseFloat(data.trim()) + root.diskVal = isNaN(val) ? 0 : val + } + } + } + + component StatRing: Item { + id: ring + + property real value: 0.0 + property string label: "" + property color ringColor: Cfg.Config.theme.accent + property color trackColor: Cfg.Config.theme.statsTrackColor + property color labelColor: Cfg.Config.theme.textMain + + Behavior on value { + NumberAnimation { + duration: 800 + easing.type: Easing.OutCubic + } + } + + readonly property real fraction: Math.max(0, Math.min(value, 100)) / 100 + + width: Cfg.Config.statsRingSize + height: Cfg.Config.statsRingSize + + onFractionChanged: arc.requestPaint() + onRingColorChanged: arc.requestPaint() + + Canvas { + id: arc + anchors.fill: parent + antialiasing: true + + onPaint: { + var ctx = getContext("2d") + ctx.reset() + ctx.clearRect(0, 0, width, height) + + var cx = width / 2 + var cy = height / 2 + var lw = 2.8 + var r = Math.min(cx, cy) - lw / 2 - 0.5 + + ctx.beginPath() + ctx.arc(cx, cy, r, 0, Math.PI * 2) + ctx.strokeStyle = ring.trackColor + ctx.lineWidth = lw + ctx.stroke() + + if (ring.fraction > 0) { + var start = -Math.PI / 2 + var end = start + (ring.fraction * Math.PI * 2) + ctx.beginPath() + ctx.arc(cx, cy, r, start, end) + ctx.strokeStyle = ring.ringColor + ctx.lineWidth = lw + ctx.lineCap = "round" + ctx.stroke() + } + } + } + + HoverHandler { id: hov } + + Text { + anchors.centerIn: parent + text: Cfg.Config.statsShowPercentByDefault + ? (hov.hovered ? ring.label : Math.round(ring.value) + "%") + : (hov.hovered ? Math.round(ring.value) + "%" : ring.label) + color: ring.labelColor + font.pixelSize: Cfg.Config.statsRingFontSize + font.bold: true + font.family: "monospace" + } + } + + RowLayout { + id: statsLayout + anchors.centerIn: parent + spacing: 10 + + StatRing { + value: root.cpuVal + label: "cpu" + ringColor: root.t.statsCpuColor + trackColor: root.t.statsTrackColor + labelColor: root.t.statsText + } + + StatRing { + value: root.memVal + label: "ram" + ringColor: root.t.statsMemColor + trackColor: root.t.statsTrackColor + labelColor: root.t.statsText + } + + StatRing { + value: root.diskVal + label: "disk" + ringColor: root.t.statsDiskColor + trackColor: root.t.statsTrackColor + labelColor: root.t.statsText + } + } +} + diff --git a/bar/modules/Tray.qml b/bar/modules/Tray.qml new file mode 100644 index 0000000..15dea88 --- /dev/null +++ b/bar/modules/Tray.qml @@ -0,0 +1,161 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.SystemTray +import Quickshell.Widgets + +import "../../config" as Cfg + +Item { + id: root + + property real panelRadius: Cfg.Config.radius / 2 + property int borderWidth: Cfg.Config.panelBorderWidth + + property bool isTop: true + property var targetScreen: null + property string barSide: "right" + + readonly property var t: Cfg.Config.theme + + property bool expanded: false + property bool manuallyExpanded: false + property var menuWindow: null + + implicitWidth: bgRect.width + implicitHeight: bgRect.height + + Rectangle { + id: bgRect + anchors.verticalCenter: parent.verticalCenter + + height: Cfg.Config.trayHeight + width: mainRow.width + radius: root.panelRadius + + color: bgHover.hovered ? root.t.trayBgHover : root.t.trayBg + border.color: bgHover.hovered ? root.t.trayBorderHover : root.t.trayBorder + border.width: root.borderWidth + + Behavior on color { ColorAnimation { duration: 150 } } + Behavior on border.color { ColorAnimation { duration: 150 } } + + HoverHandler { + id: bgHover + onHoveredChanged: { + if (!root.manuallyExpanded) { + root.expanded = hovered; + } + } + } + + Row { + id: mainRow + anchors.verticalCenter: parent.verticalCenter + + leftPadding: root.expanded && root.barSide === "right" ? 8 : 0 + rightPadding: root.expanded && root.barSide === "left" ? 8 : 0 + + Behavior on leftPadding { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } + Behavior on rightPadding { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } + + layoutDirection: root.barSide === "left" ? Qt.LeftToRight : Qt.RightToLeft + spacing: root.expanded ? 4 : 0 + + Behavior on spacing { NumberAnimation { duration: 180; easing.type: Easing.OutCubic } } + + Item { + id: toggleArea + width: Cfg.Config.trayHeight + height: Cfg.Config.trayHeight + z: 10 + + TapHandler { + onTapped: { + root.manuallyExpanded = !root.manuallyExpanded; + root.expanded = root.manuallyExpanded; + } + } + + Text { + anchors.centerIn: parent + text: root.expanded + ? (root.barSide === "left" ? "󰅁" : "󰅂") + : (root.barSide === "left" ? "󰅂" : "󰅁") + color: root.t.trayIcon + font.pixelSize: Cfg.Config.trayHeight * 0.55 + } + } + + Row { + id: iconsRow + spacing: 6 + clip: true + anchors.verticalCenter: parent.verticalCenter + + width: root.expanded ? implicitWidth : 0 + opacity: root.expanded ? 1 : 0 + + Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 150 } } + + Repeater { + model: SystemTray.items + + delegate: MouseArea { + id: iconDelegate + required property SystemTrayItem modelData + property alias item: iconDelegate.modelData + + implicitWidth: Cfg.Config.trayIconSize + implicitHeight: Cfg.Config.trayIconSize + width: Cfg.Config.trayIconSize + height: Cfg.Config.trayIconSize + + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + hoverEnabled: true + + onClicked: function(mouse) { + if (mouse.button === Qt.LeftButton) { + item.activate() + } else if (mouse.button === Qt.MiddleButton) { + item.secondaryActivate() + } else if (mouse.button === Qt.RightButton) { + menuAnchor.open() + } + } + + onWheel: function(wheel) { + wheel.accepted = true + item.scroll(wheel.angleDelta.y / 120, false) + } + + IconImage { + anchors.fill: parent + source: item.icon + } + + QsMenuAnchor { + id: menuAnchor + menu: item.menu + anchor.window: root.menuWindow + anchor.adjustment: PopupAdjustment.Flip + anchor.onAnchoring: { + if (!root.menuWindow) return + const global = iconDelegate.mapToGlobal(0, iconDelegate.height) + const local = root.menuWindow.contentItem.mapFromGlobal( + global.x, global.y + ) + menuAnchor.anchor.rect = Qt.rect( + local.x, local.y, + iconDelegate.width, 0 + ) + } + } + } + } + } + } + } +} diff --git a/bar/modules/Volume.qml b/bar/modules/Volume.qml new file mode 100644 index 0000000..1ee3e3b --- /dev/null +++ b/bar/modules/Volume.qml @@ -0,0 +1,530 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import Quickshell.Services.Pipewire + +import "../../config" as Cfg + +Rectangle { + id: root + + property real panelRadius: Cfg.Config.radius / 2 + property int borderWidth: Cfg.Config.panelBorderWidth + property bool isTop: true + property var targetScreen: null + property int barHeight: Cfg.Config.barHeight + property int barMargin: Cfg.Config.margin + + readonly property var t: Cfg.Config.theme + + property int outputMaxVolume: Cfg.Config.volumeOutputMax + property int inputMaxVolume: Cfg.Config.volumeInputMax + + readonly property var sink: Pipewire.defaultAudioSink + readonly property var source: Pipewire.defaultAudioSource + + PwObjectTracker { + objects: root.sinkNodes.concat(root.sourceNodes) + } + + readonly property var sinkNodes: { + if (!Pipewire.nodes) return [] + return Pipewire.nodes.values.filter(n => !n.isStream && n.isSink) + } + readonly property var sourceNodes: { + if (!Pipewire.nodes) return [] + return Pipewire.nodes.values.filter(n => !n.isStream && !n.isSink) + } + + property int _outVol: 0 + property int _inVol: 0 + + readonly property int outputVolume: { + if (sink && sink.ready) { _outVol = Math.round(sink.audio.volume * 100); return _outVol } + return _outVol + } + readonly property int inputVolume: { + if (source && source.ready) { _inVol = Math.round(source.audio.volume * 100); return _inVol } + return _inVol + } + + readonly property bool outputMuted: sink && sink.ready ? sink.audio.muted : false + readonly property bool inputMuted: source && source.ready ? source.audio.muted : false + + readonly property string outputDeviceName: sink ? (sink.description || sink.nickName || sink.name) : "No device" + readonly property string inputDeviceName: source ? (source.description || source.nickName || source.name) : "No device" + + function setOutputVol(pct) { if (sink && sink.ready) sink.audio.volume = pct / 100 } + function setInputVol(pct) { if (source && source.ready) source.audio.volume = pct / 100 } + function toggleOutputMute() { if (sink && sink.ready) sink.audio.muted = !sink.audio.muted } + function toggleInputMute() { if (source && source.ready) source.audio.muted = !source.audio.muted } + + Process { + id: defaultSetter + running: false + property string nodeId: "0" + command: ["wpctl", "set-default", nodeId] + } + function setDefaultDevice(node) { + defaultSetter.nodeId = node.id.toString() + defaultSetter.running = true + } + + readonly property string iconSpeakerHigh: "󰕾" + readonly property string iconSpeakerMed: "󰖀" + readonly property string iconSpeakerLow: "󰕿" + readonly property string iconSpeakerMuted: "󰖁" + readonly property string iconMic: "󰍬" + readonly property string iconMicMuted: "󰍭" + + function speakerGlyph(muted, vol) { + if (muted || vol === 0) return iconSpeakerMuted + if (vol > 60) return iconSpeakerHigh + if (vol > 25) return iconSpeakerMed + return iconSpeakerLow + } + + width: chipLayout.implicitWidth + 15 + height: Cfg.Config.moduleHeight + radius: panelRadius + + color: hoverHandler.hovered ? t.volBgHover : t.volBg + border.color: (hoverHandler.hovered || volumePopup.popupOpen) ? t.volBorderHover : t.volBorder + border.width: borderWidth + + Behavior on color { ColorAnimation { duration: 150 } } + Behavior on border.color { ColorAnimation { duration: 150 } } + + HoverHandler { id: hoverHandler; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: volumePopup.toggle() } + + RowLayout { + id: chipLayout + anchors.centerIn: parent + spacing: 5 + + Text { + text: root.speakerGlyph(root.outputMuted, root.outputVolume) + color: root.outputMuted ? root.t.volIconMuted : root.t.volIcon + font.pixelSize: Cfg.Config.volumeIconSize + Layout.alignment: Qt.AlignVCenter + } + } + + PanelWindow { + id: volumePopup + screen: root.targetScreen + visible: false + color: "transparent" + + property bool popupOpen: false + + function open() { hideTimer.stop(); visible = true; popupOpen = true } + function close() { popupOpen = false; hideTimer.restart() } + function toggle() { popupOpen ? close() : open() } + + Timer { + id: hideTimer + interval: Cfg.Config.popupAnimDuration + 70 + onTriggered: volumePopup.visible = false + } + + anchors { top: true; bottom: true; left: true; right: true } + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "main-shell-volume" + WlrLayershell.exclusiveZone: -1 + mask: popupOpen ? null : _noInput + Region { id: _noInput } + + MouseArea { + anchors.fill: parent + onClicked: volumePopup.close() + } + + Item { + id: volClipContainer + readonly property int cardW: Cfg.Config.volumePopupWidth + readonly property int screenPad: root.barMargin + 10 + + x: { + let cx = root.mapToItem(null, 0, 0).x + root.width / 2 + return Math.max(screenPad, Math.min(cx - cardW / 2, parent.width - cardW - screenPad)) + } + width: cardW + + anchors.top: root.isTop ? parent.top : undefined + anchors.bottom: root.isTop ? undefined : parent.bottom + anchors.topMargin: root.isTop ? (root.barHeight + 10) : 0 + anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + 10) + height: parent.height - root.barHeight - 10 + + clip: true + + Rectangle { + id: volCard + width: parent.width + height: popupCol.implicitHeight + 32 + + anchors.top: root.isTop ? parent.top : undefined + anchors.bottom: root.isTop ? undefined : parent.bottom + + transform: Translate { + y: volumePopup.popupOpen ? 0 : (root.isTop ? -volCard.height : volCard.height) + + Behavior on y { + NumberAnimation { + duration: Cfg.Config.popupAnimDuration; + easing.type: Easing.OutExpo + } + enabled: volumePopup.visible + } + } + + opacity: volumePopup.popupOpen ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 200 } } + + radius: Cfg.Config.popupRadius + color: root.t.volPopupBg + border.color: root.t.volPopupBorder + border.width: Cfg.Config.popupBorderWidth + layer.enabled: true + + MouseArea { anchors.fill: parent; propagateComposedEvents: false } + + ColumnLayout { + id: popupCol + anchors { left: parent.left; right: parent.right; top: parent.top } + anchors.margins: 16 + anchors.topMargin: 18 + spacing: 14 + + VolumeSection { + id: outputSection + Layout.fillWidth: true + label: "Output" + icon: root.speakerGlyph(root.outputMuted, root.outputVolume) + iconColor: root.t.accent + volume: root.outputVolume + maxVolume: root.outputMaxVolume + muted: root.outputMuted + accentColor: root.t.volHandle + dimColor: root.t.textDim + textColor: root.t.textMain + trackColor: root.t.volTrack + deviceName: root.outputDeviceName + nodes: root.sinkNodes + currentNode: root.sink + onSliderMoved: vol => root.setOutputVol(vol) + onMuteToggled: root.toggleOutputMute() + onDeviceSelected: node => root.setDefaultDevice(node) + } + + Rectangle { Layout.fillWidth: true; height: 1; color: root.t.separator } + + VolumeSection { + id: inputSection + Layout.fillWidth: true + label: "Input" + icon: root.inputMuted ? root.iconMicMuted : root.iconMic + iconColor: root.t.statusInfo + volume: root.inputVolume + maxVolume: root.inputMaxVolume + muted: root.inputMuted + accentColor: root.t.statusInfo + dimColor: root.t.textDim + textColor: root.t.textMain + trackColor: root.t.volTrack + deviceName: root.inputDeviceName + nodes: root.sourceNodes + currentNode: root.source + onSliderMoved: vol => root.setInputVol(vol) + onMuteToggled: root.toggleInputMute() + onDeviceSelected: node => root.setDefaultDevice(node) + } + + Rectangle { Layout.fillWidth: true; height: 1; color: root.t.separator } + + ColumnLayout { + Layout.fillWidth: true + spacing: 10 + Text { + text: "Volume Limits" + color: root.t.textDim + font.pixelSize: 10 + font.capitalization: Font.AllUppercase + Layout.fillWidth: true + } + LimitRow { + Layout.fillWidth: true + icon: root.iconSpeakerHigh + iconColor: root.t.accent + labelText: "Output max" + value: root.outputMaxVolume + minValue: 10 + maxValue: 300 + textColor: root.t.textMain + dimColor: root.t.textDim + borderColor: root.t.volPopupBorder + onDecrement: root.outputMaxVolume = Math.max(10, root.outputMaxVolume - 10) + onIncrement: root.outputMaxVolume = Math.min(300, root.outputMaxVolume + 10) + } + LimitRow { + Layout.fillWidth: true + icon: root.iconMic + iconColor: root.t.statusInfo + labelText: "Input max" + value: root.inputMaxVolume + minValue: 10 + maxValue: 300 + textColor: root.t.textMain + dimColor: root.t.textDim + borderColor: root.t.volPopupBorder + onDecrement: root.inputMaxVolume = Math.max(10, root.inputMaxVolume - 10) + onIncrement: root.inputMaxVolume = Math.min(300, root.inputMaxVolume + 10) + } + } + Item { height: 2 } + } + } + } + } + + component VolumeSection: ColumnLayout { + id: vs + spacing: 8 + required property string label + required property string icon + required property color iconColor + required property int volume + required property int maxVolume + required property bool muted + required property color accentColor + required property color dimColor + required property color textColor + required property color trackColor + required property string deviceName + required property var nodes + required property var currentNode + + signal sliderMoved(int vol) + signal muteToggled() + signal deviceSelected(var node) + + property bool pickerOpen: false + + RowLayout { + Layout.fillWidth: true + spacing: 6 + Text { + text: vs.icon + color: vs.muted ? vs.dimColor : vs.iconColor + font.pixelSize: Cfg.Config.volumeIconSize + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: Cfg.Config.volumeIconSize + 4 + horizontalAlignment: Text.AlignHCenter + } + Text { + text: vs.label + color: vs.textColor + font.pixelSize: 13 + font.bold: true + Layout.fillWidth: true + } + Text { + text: vs.muted ? "muted" : vs.volume + "%" + color: vs.muted ? vs.dimColor : vs.textColor + font.pixelSize: 12 + font.bold: !vs.muted + horizontalAlignment: Text.AlignHCenter + Layout.preferredWidth: 44 + } + Rectangle { + width: 22; height: 22; radius: 6 + color: muteHover.hovered ? (vs.muted ? "#33ffffff" : "#22ffffff") : (vs.muted ? root.t.volMuteBg : "transparent") + border.color: vs.muted ? vs.accentColor : vs.dimColor + border.width: 1 + Text { + anchors.centerIn: parent + text: vs.muted ? "󰖁" : "󰕾" + color: vs.muted ? vs.accentColor : vs.dimColor + font.pixelSize: 11 + } + HoverHandler { id: muteHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: vs.muteToggled() } + } + } + + Item { + id: sliderItem + Layout.fillWidth: true + height: 22 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: parent.width; height: Cfg.Config.volumeSliderHeight; radius: 2 + color: vs.trackColor + Rectangle { + width: Math.min(parent.width, parent.width * (vs.volume / Math.max(1, vs.maxVolume))) + height: parent.height; radius: parent.radius + color: vs.muted ? vs.dimColor : vs.accentColor + } + } + Rectangle { + readonly property real ratio: vs.volume / Math.max(1, vs.maxVolume) + x: Math.min(sliderItem.width - width, Math.max(0, sliderItem.width * ratio - width / 2)) + anchors.verticalCenter: parent.verticalCenter + width: Cfg.Config.volumeHandleSize; height: Cfg.Config.volumeHandleSize; radius: width / 2 + color: vs.muted ? vs.dimColor : vs.accentColor + border.color: root.t.volHandleBorder + border.width: 1 + } + MouseArea { + anchors.fill: parent; cursorShape: Qt.PointingHandCursor + onPressed: pos => sliderItem.seek(pos.x) + onPositionChanged: pos => { if (pressed) sliderItem.seek(pos.x) } + } + function seek(x) { + let ratio = Math.max(0.0, Math.min(1.0, x / sliderItem.width)) + vs.sliderMoved(Math.round(ratio * vs.maxVolume)) + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + Text { text: "󰒓"; color: vs.dimColor; font.pixelSize: 11 } + Text { + text: vs.deviceName + color: vs.textColor + font.pixelSize: 11 + elide: Text.ElideRight + Layout.fillWidth: true + } + Rectangle { + width: 20; height: 20; radius: 5 + color: chevHover.hovered ? "#22ffffff" : "transparent" + border.color: vs.pickerOpen ? vs.accentColor : root.t.volPopupBorder + border.width: 1 + Text { + anchors.centerIn: parent + text: vs.pickerOpen ? "󰅃" : "󰅀" + color: vs.pickerOpen ? vs.accentColor : vs.dimColor + font.pixelSize: 10 + } + HoverHandler { id: chevHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: vs.pickerOpen = !vs.pickerOpen } + } + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: vs.pickerOpen ? Math.min(deviceFlickable.contentHeight, 120) : 0 + opacity: vs.pickerOpen ? 1.0 : 0.0 + clip: true + + Behavior on Layout.preferredHeight { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } + + Flickable { + id: deviceFlickable + anchors.fill: parent + contentWidth: width + contentHeight: deviceList.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: deviceList + width: deviceFlickable.width + spacing: 2 + Repeater { + model: vs.nodes + delegate: Rectangle { + required property var modelData + readonly property bool isActive: vs.currentNode !== null && modelData.id === vs.currentNode.id + Layout.fillWidth: true; height: 28; radius: 7 + color: isActive ? Qt.alpha(vs.accentColor, 0.18) : (rowHover.hovered ? root.t.volPickerHover : "transparent") + border.color: isActive ? Qt.alpha(vs.accentColor, 0.45) : "transparent" + border.width: 1 + RowLayout { + anchors { fill: parent; leftMargin: 8; rightMargin: 8 } + spacing: 8 + Rectangle { + width: 6; height: 6; radius: 3 + color: isActive ? vs.accentColor : "transparent" + border.color: isActive ? "transparent" : vs.dimColor + border.width: 1 + } + Text { + text: modelData.description || modelData.nickName || modelData.name + color: isActive ? vs.accentColor : vs.textColor + font.pixelSize: 11; font.bold: isActive + elide: Text.ElideRight; Layout.fillWidth: true + } + } + HoverHandler { id: rowHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: { vs.deviceSelected(modelData); vs.pickerOpen = false } } + } + } + } + } + } + } + + component LimitRow: RowLayout { + id: lr + spacing: 8 + required property string icon + required property color iconColor + required property string labelText + required property int value + required property int minValue + required property int maxValue + required property color textColor + required property color dimColor + required property color borderColor + + signal decrement() + signal increment() + + Text { text: lr.icon; color: lr.iconColor; font.pixelSize: 13 } + Text { + text: lr.labelText + color: lr.textColor + font.pixelSize: 11 + Layout.fillWidth: true + } + Rectangle { + width: 22; height: 22; radius: 5 + color: decHover.hovered ? "#22ffffff" : "transparent" + border.color: lr.borderColor; border.width: 1 + Text { + anchors.centerIn: parent; text: "−" + color: lr.value <= lr.minValue ? lr.dimColor : lr.textColor + font.pixelSize: 14 + } + HoverHandler { id: decHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: { if (lr.value > lr.minValue) lr.decrement() } } + } + Text { + text: lr.value + "%" + color: lr.textColor + font.pixelSize: 12; font.bold: true + horizontalAlignment: Text.AlignHCenter + Layout.preferredWidth: 44 + } + Rectangle { + width: 22; height: 22; radius: 5 + color: incHover.hovered ? "#22ffffff" : "transparent" + border.color: lr.borderColor; border.width: 1 + Text { + anchors.centerIn: parent; text: "+" + color: lr.value >= lr.maxValue ? lr.dimColor : lr.textColor + font.pixelSize: 14 + } + HoverHandler { id: incHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: { if (lr.value < lr.maxValue) lr.increment() } } + } + } +} diff --git a/bar/modules/Workspace.qml b/bar/modules/Workspace.qml new file mode 100644 index 0000000..6855199 --- /dev/null +++ b/bar/modules/Workspace.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Hyprland + +import "../../config" as Cfg + +Rectangle { + id: root + + property real panelRadius: Cfg.Config.radius / 2 + property int borderWidth: Cfg.Config.panelBorderWidth + + readonly property var t: Cfg.Config.theme + + width: workspaceLayout.implicitWidth + 24 + height: Cfg.Config.moduleHeight + color: t.wsPanelBg + radius: panelRadius + border.color: t.wsPanelBorder + border.width: borderWidth + + RowLayout { + id: workspaceLayout + anchors.centerIn: parent + spacing: 6 + + Repeater { + model: Hyprland.workspaces + + Rectangle { + Layout.preferredWidth: modelData.active ? 34 : 28 + Layout.preferredHeight: 22 + radius: 11 + + color: modelData.active + ? root.t.wsActiveBg + : (wsHover.containsMouse ? root.t.wsHoverBg : root.t.wsInactiveBg) + + Behavior on color { ColorAnimation { duration: 200 } } + Behavior on Layout.preferredWidth { NumberAnimation { duration: 200 } } + + Text { + anchors.centerIn: parent + text: modelData.name + font.pixelSize: 11 + font.bold: true + color: modelData.active + ? root.t.wsActiveText + : (wsHover.containsMouse ? root.t.wsHoverText : root.t.wsInactiveText) + } + + MouseArea { + id: wsHover + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: modelData.activate() + } + } + } + } +} diff --git a/config/config.qml b/config/config.qml new file mode 100644 index 0000000..61a3a88 --- /dev/null +++ b/config/config.qml @@ -0,0 +1,87 @@ +pragma Singleton +import QtQuick +import Quickshell +import "themes" + +QtObject { + id: root + + readonly property var theme: Everforest + + // ──────────────────────────────────────────────────────────────────────── + // Widgets + // ──────────────────────────────────────────────────────────────────────── + property bool enableBgDate: true + + // ──────────────────────────────────────────────────────────────────────── + // BAR + // ──────────────────────────────────────────────────────────────────────── + + // ── Position & Dimensions ───────────────────────────────────────────────── + property string barPosition: "bottom" + property int barHeight: 37 + property int margin: 20 + property int radius: 26 + + // ── Module Lists ────────────────────────────────────────────────────────── + property var leftModules: ["ArchLogo", "Stats"] + property var centerModules: ["Workspace"] + property var rightModules: ["Tray", "Notifications", "Volume", "Clock"] + + // ── Arch Logo ───────────────────────────────────────────────────────────── + property int logoIconSize: 22 // icon font size; container width scales with it + property string logoIcon: "󱄅" // Nerd Font glyph for the logo + + // ── Panel Styling ───────────────────────────────────────────────────────── + property int panelBorderWidth: 1 + property int moduleSpacing: 2 // space between modules within a panel + property int panelSpacing: 12 // gap between left / center / right panels + property int moduleHeight: 32 // default chip height for bar modules + + // ── Popup Styling ───────────────────────────────────────────────────────── + property int popupRadius: 18 + property int popupBorderWidth: 1 + property int popupAnimDuration: 500 + + // ── Tray Styling ───────────────────────────────────────────────────────── + property int trayHeight: 26 + property int trayIconSize: 20 + + // ── Stats Styling ───────────────────────────────────────────────────────── + property int statsRingSize: 25 + property int statsRingFontSize: 10 + property bool statsShowPercentByDefault: true + + // ── Volume Module ───────────────────────────────────────────────────────── + property int volumeIconSize: 20 + property int volumeSliderHeight: 4 + property int volumeHandleSize: 20 + property int volumePopupWidth: 280 + property int volumeOutputMax: 100 + property int volumeInputMax: 200 + + // ──────────────────────────────────────────────────────────────────────── + // FRAME + // ──────────────────────────────────────────────────────────────────────── + property int frameBorderWidth: 1 + + // ──────────────────────────────────────────────────────────────────────── + // NOTIFICATIONS + // ──────────────────────────────────────────────────────────────────────── + + property string notificationPosition: "topright" + property int notificationHistorySize: 50 + + // ── Toast timeouts (milliseconds) ───────────────────────────────────────── + property int notifTimeoutLow: 3500 + property int notifTimeoutNormal: 5000 + property int notifTimeoutCritical: 0 + + property int notifIconSize: 18 + + // ──────────────────────────────────────────────────────────────────────── + // LAUNCHER + // ──────────────────────────────────────────────────────────────────────── + property string launcherScript: Quickshell.env("HOME") + "/.config/quickshell/launcher/launcher.sh" + property string launcherCacheDir: "/tmp/qs_launcher" +} diff --git a/config/qmldir b/config/qmldir new file mode 100644 index 0000000..c2ac3bc --- /dev/null +++ b/config/qmldir @@ -0,0 +1,3 @@ +module config + +singleton Config 1.0 config.qml diff --git a/config/themes/everforest.qml b/config/themes/everforest.qml new file mode 100644 index 0000000..15696eb --- /dev/null +++ b/config/themes/everforest.qml @@ -0,0 +1,159 @@ +pragma Singleton +import QtQuick +import Quickshell + +QtObject { + id: theme + + // ── Wallpaper ────────────────────────────────────────────────────────────── + readonly property string wallpaper: Quickshell.env("HOME") + "/.config/quickshell/wallpaper.jpg" + + // ── Base Backgrounds ─────────────────────────────────────────────────────── + readonly property color bg: "#aa0a1410" + readonly property color bgPanel: "transparent" + readonly property color bgPopup: bg + readonly property color bgFrame: bg + + // ── Separators ──────────────────────────────────────────────────────────── + readonly property color separator: "#33ffffff" + + // ── Text ────────────────────────────────────────────────────────────────── + readonly property color textMain: "#ffdce3de" + readonly property color textDim: "#d97a9480" + readonly property color todayText: "#ffffffff" + + // ── Accent & Borders ────────────────────────────────────────────────────── + readonly property color accent: "#ff4a8c5c" + readonly property color border: "transparent" + readonly property color borderPopup: "#ff2d4032" + readonly property color borderToday: accent + + // ── ArchLogo Module ──────────────────────────────────────────────────────── + readonly property color archLogoBg: bgPanel + readonly property color archLogoBgHover: "#22ffffff" + readonly property color archLogoHover: accent + readonly property color archLogoBorder: border + readonly property color archLogoBorderActive: accent + readonly property color archLogoIcon: textMain + readonly property color archLogoIconActive: accent + + // ── Stats Module ─────────────────────────────────────────────────────────── + readonly property color statsBg: bgPanel + readonly property color statsBorder: border + readonly property color statsText: textMain + readonly property color statsRingColor: "#ff83c092" + readonly property color statsCpuColor: statsRingColor + readonly property color statsMemColor: statsRingColor + readonly property color statsDiskColor: statsRingColor + readonly property color statsGpuColor: statsRingColor + readonly property color statsTrackColor: "#28ffffff" + + // ── Volume Module ────────────────────────────────────────────────────────── + readonly property color volBg: bgPanel + readonly property color volBgHover: "#22ffffff" + readonly property color volBorder: border + readonly property color volBorderHover: accent + readonly property color volPopupBg: bgPopup + readonly property color volPopupBorder: borderPopup + readonly property color volIcon: textMain + readonly property color volIconMuted: textDim + readonly property color volTrack: "#22ffffff" + readonly property color volHandle: accent + readonly property color volHandleBorder: "#33ffffff" + readonly property color volMuteBg: "#22ffffff" + readonly property color volPickerHover: "#15ffffff" + + // ── Clock Module ─────────────────────────────────────────────────────────── + readonly property color clockBg: bgPanel + readonly property color clockBgHover: "#22ffffff" + readonly property color clockBorder: border + readonly property color clockBorderHover: accent + readonly property color clockTime: textMain + readonly property color clockSeconds: textMain + readonly property color clockIcon: textMain + readonly property color clockSeparator: separator + readonly property color clockText: textMain + readonly property color clockShadow: "#22000000" + + // ── Calendar Popup ───────────────────────────────────────────────────────── + readonly property color calCardBg: bgPopup + readonly property color calCardBorder: borderPopup + readonly property color calSeparator: separator + readonly property color calShadow: "#44000000" + readonly property color clockPopupHeader: accent + readonly property color clockPopupText: textMain + readonly property color clockPopupDim: textDim + + // ── Workspace Module ─────────────────────────────────────────────────────── + readonly property color wsPanelBg: bgPanel + readonly property color wsPanelBorder: "transparent" + readonly property color wsActiveBg: accent + readonly property color wsInactiveBg: "#22ffffff" + readonly property color wsHoverBg: borderPopup + readonly property color wsActiveText: "#ff0e1410" + readonly property color wsInactiveText: textMain + readonly property color wsHoverText: wsActiveText + + // ── Tray Module ──────────────────────────────────────────────────────────── + readonly property color trayBg: clockBg + readonly property color trayBgHover: clockBgHover + readonly property color trayBorder: clockBorder + readonly property color trayBorderHover: clockBorderHover + readonly property color trayIcon: clockIcon + + // ── Launcher ─────────────────────────────────────────────────────────────── + readonly property color launcherBg: bgPopup + readonly property color launcherBorder: "#22ffffff" + readonly property color launcherInput: "#330b1210" + readonly property color launcherInputBorderFocus: accent + readonly property color launcherPill: "#4d111a14" + readonly property color launcherText: textMain + readonly property color launcherDim: textDim + readonly property color launcherAccent: accent + readonly property color launcherHover: "#4d2d4032" + readonly property color launcherSelected: launcherHover + readonly property color launcherModeActiveText: todayText + readonly property color launcherModeActiveBg: accent + + // ── Status Colors ────────────────────────────────────────────────────────── + readonly property color statusOk: "#ffa7c080" + readonly property color statusWarn: "#ffdbbc7f" + readonly property color statusErr: "#ffe67e80" + readonly property color statusInfo: "#ff7fbbb3" + + // ── Notifications Module ─────────────────────────────────────────────────── + readonly property color notifBg: bgPanel + readonly property color notifBgHover: "#22ffffff" + readonly property color notifBorder: border + readonly property color notifBorderActive: accent + readonly property color notifIcon: textMain + readonly property color notifIconActive: accent + readonly property color notifBadgeBg: statusErr + readonly property color notifBadgeText: "#ff0e1410" + readonly property color notifSeparator: separator + + // ── Toast cards ──────────────────────────────────────────────────────────── + readonly property color notifToastBg: bgPopup + readonly property color notifToastBgHover: borderPopup + readonly property color notifToastBorder: borderPopup + readonly property color notifToastShadow: "#55000000" + readonly property color notifToastText: textMain + readonly property color notifToastDim: textDim + readonly property color notifToastAppName: accent + + // ── Urgency colours ──────────────────────────────────────────────────────── + readonly property color notifUrgencyCritical: statusErr + readonly property color notifUrgencyNormal: statusInfo + readonly property color notifUrgencyLow: textDim + + // ── Progress bar ─────────────────────────────────────────────────────────── + readonly property color notifProgressBg: "#22ffffff" + readonly property color notifProgressFill: accent + + // ── History popup ────────────────────────────────────────────────────────── + readonly property color notifHistoryBg: bgPopup + readonly property color notifHistoryBorder: borderPopup + readonly property color notifHistoryShadow: "#55000000" + readonly property color notifHistoryHover: "#22ffffff" + readonly property color notifHistoryEmpty: textDim +} diff --git a/config/themes/qmldir b/config/themes/qmldir new file mode 100644 index 0000000..c592283 --- /dev/null +++ b/config/themes/qmldir @@ -0,0 +1,3 @@ +module themes + +singleton Everforest 1.0 everforest.qml diff --git a/launcher/Launcher.qml b/launcher/Launcher.qml new file mode 100644 index 0000000..a28e8be --- /dev/null +++ b/launcher/Launcher.qml @@ -0,0 +1,393 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import Quickshell.Io +import Quickshell.Hyprland + +import "../config" as Cfg + +PanelWindow { + id: root + + property bool isTop: true + property int barHeight: Cfg.Config.barHeight + property int barMargin: Cfg.Config.margin + + readonly property var t: Cfg.Config.theme + + readonly property int launcherGap: 12 + + GlobalShortcut { + id: launcherShortcut + name: "toggle" + onPressed: root.toggle() + } + + property bool _isOpen: false + readonly property bool isOpen: _isOpen + property string mode: "drun" + property bool _keyboardNav: false + property var allItems: [] + property bool loading: false + property bool proxyMode: false + + readonly property var filteredItems: { + const q = searchInput.text.toLowerCase() + if (!q) return allItems.slice(0, 80) + return allItems.filter(i => i.name.toLowerCase().includes(q)) + } + + function toggle() { _isOpen ? close() : open() } + + function open() { + closeTimer.stop() + visible = true + _isOpen = true + searchInput.text = "" + resultsList.currentIndex = -1 + loadItems() + Qt.callLater(() => searchInput.forceActiveFocus()) + } + + function close() { + _isOpen = false + closeTimer.restart() + } + + Connections { + target: root + function onActiveChanged() { + if (!root.active && root._isOpen) root.close() + } + } + + function loadItems() { + loading = true + readCache() + updateProc.command = [Cfg.Config.launcherScript, "--update-only"] + updateProc.running = true + } + + function readCache() { + const path = Cfg.Config.launcherCacheDir + "/" + (mode === "drun" ? "drun.txt" : "run.txt") + dataProc.rawOutput = "" + dataProc.command = ["bash", "-c", "cat " + path] + dataProc.running = true + } + + function launch(item) { + if (!item || !item.exec) return + + const cleanExec = item.exec.replace(/%[fFuUik]/g, "").trim() + const finalExec = root.proxyMode ? "proxychains4 " + cleanExec : cleanExec + const logPath = "/tmp/qml_launch.log" + + launchProc.command = ["sh", "-c", finalExec + " > " + logPath + " 2>&1 &"] + launchProc.running = true + + console.log("Launching: " + finalExec) + if (typeof close === "function") close() + } + + onModeChanged: if (_isOpen) loadItems() + + visible: false + color: "transparent" + + anchors { top: true; bottom: true; left: true; right: true } + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "main-shell-launcher" + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: _isOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + mask: Region { item: clipContainer } + + Timer { + id: closeTimer + interval: Cfg.Config.popupAnimDuration + 70 + onTriggered: { + root.visible = false + root.mode = "drun" + root.allItems = [] + } + } + + Process { + id: updateProc + onRunningChanged: if (!running) root.readCache() + } + + Process { + id: dataProc + property string rawOutput: "" + stdout: SplitParser { onRead: data => dataProc.rawOutput += data + "\n" } + onRunningChanged: { + if (!running && rawOutput) { + const lines = rawOutput.trim().split("\n") + const items = [] + for (let i = 0; i < lines.length; i++) { + const p = lines[i].split("\t") + if (p.length >= 3) items.push({ name: p[0], exec: p[1], id: p[2], icon: p[3] || "" }) + } + root.allItems = items + root.loading = false + } + } + } + + Process { id: launchProc } + + Item { + id: clipContainer + anchors.horizontalCenter: parent.horizontalCenter + width: 460 + + anchors.top: root.isTop ? parent.top : undefined + anchors.bottom: root.isTop ? undefined : parent.bottom + anchors.topMargin: root.isTop ? (root.barHeight + root.launcherGap) : 0 + anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + root.launcherGap) + height: parent.height - root.barHeight - root.launcherGap + + clip: true + + Rectangle { + id: bubble + width: parent.width + property int targetHeight: Math.min(layout.implicitHeight + 28, 540) + height: targetHeight + + anchors.top: root.isTop ? parent.top : undefined + anchors.bottom: root.isTop ? undefined : parent.bottom + + transform: Translate { + y: root._isOpen ? 0 : (root.isTop ? -bubble.height : bubble.height) + + Behavior on y { + NumberAnimation { + duration: Cfg.Config.popupAnimDuration; + easing.type: Easing.OutExpo + } + enabled: root.visible + } + } + + opacity: root._isOpen ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 200 } } + + radius: Cfg.Config.popupRadius + color: t.launcherBg + border.color: t.launcherBorder + border.width: Cfg.Config.popupBorderWidth + + focus: root._isOpen + Keys.onEscapePressed: root.close() + + ColumnLayout { + id: layout + anchors.fill: parent + anchors.margins: 14 + spacing: 10 + + Rectangle { + Layout.fillWidth: true + height: 44 + radius: 10 + color: t.launcherInput + border.color: searchInput.activeFocus ? t.launcherInputBorderFocus : t.launcherBorder + + Behavior on color { NumberAnimation { duration: 180 } } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 8 + spacing: 10 + + TextInput { + id: searchInput + Layout.fillWidth: true + color: t.launcherText + font.pixelSize: 15 + verticalAlignment: TextInput.AlignVCenter + selectionColor: t.launcherAccent + + onTextChanged: resultsList.currentIndex = -1 + + Keys.onReturnPressed: { + if (root.filteredItems.length > 0) { + let idx = resultsList.currentIndex; + if (idx === -1) idx = 0; + root.launch(root.filteredItems[idx]); + } + } + + Keys.onDownPressed: { + root._keyboardNav = true + if (resultsList.currentIndex < 0) { + resultsList.currentIndex = 0; + } else { + resultsList.currentIndex = Math.min(resultsList.currentIndex + 1, resultsList.count - 1) + } + resultsList.positionViewAtIndex(resultsList.currentIndex, ListView.Contain) + } + + Keys.onUpPressed: { + root._keyboardNav = true + if (resultsList.currentIndex < 0) { + resultsList.currentIndex = resultsList.count - 1; + } else { + resultsList.currentIndex = Math.max(resultsList.currentIndex - 1, 0) + } + resultsList.positionViewAtIndex(resultsList.currentIndex, ListView.Contain) + } + + Keys.onTabPressed: root.mode = root.mode === "drun" ? "run" : "drun" + } + + Rectangle { + height: 28 + width: modeRow.implicitWidth + 10 + radius: 8 + color: t.launcherPill + border.color: t.launcherBorder + + RowLayout { + id: modeRow + anchors.centerIn: parent + spacing: 2 + Repeater { + model: ["drun", "run"] + delegate: Rectangle { + height: 22 + width: label.implicitWidth + 14 + radius: 6 + color: root.mode === modelData ? t.launcherModeActiveBg : "transparent" + Text { + id: label + anchors.centerIn: parent + text: modelData + font.pixelSize: 10 + font.bold: root.mode === modelData + color: root.mode === modelData ? t.launcherModeActiveText : t.launcherDim + } + MouseArea { + anchors.fill: parent + onClicked: root.mode = modelData + } + } + } + } + } + + Rectangle { + id: proxyBtn + height: 28 + width: 110 + radius: 8 + color: root.proxyMode ? Qt.rgba(1, 0.35, 0.35, 0.22) : t.launcherPill + border.color: root.proxyMode ? Qt.rgba(1, 0.35, 0.35, 0.65) : t.launcherBorder + border.width: 1 + Behavior on color { ColorAnimation { duration: 150 } } + Behavior on border.color { ColorAnimation { duration: 150 } } + + RowLayout { + id: proxyBtnRow + anchors.centerIn: parent + spacing: 5 + Text { + text: "󰒃" + font.pixelSize: 11 + color: root.proxyMode ? Qt.rgba(1, 0.45, 0.45, 1) : t.launcherDim + Behavior on color { ColorAnimation { duration: 150 } } + } + Text { + text: "proxychains4" + font.pixelSize: 10 + font.bold: root.proxyMode + color: root.proxyMode ? Qt.rgba(1, 0.45, 0.45, 1) : t.launcherDim + Behavior on color { ColorAnimation { duration: 150 } } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: root.proxyMode = !root.proxyMode + } + } + } + } + + ListView { + id: resultsList + Layout.fillWidth: true + implicitHeight: Math.min(contentHeight, 400) + clip: true + model: root.filteredItems + currentIndex: -1 + spacing: 2 + + onModelChanged: currentIndex = -1 + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + width: resultsList.width + height: 38 + radius: 8 + property bool hovered: false + + color: hovered ? t.launcherHover + : (resultsList.currentIndex === index && index !== -1 + ? t.launcherSelected : "transparent") + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 12 + + Item { + width: 22; height: 22 + Layout.alignment: Qt.AlignVCenter + + Image { + id: appIcon + anchors.fill: parent + source: modelData.icon ? "image://icon/" + modelData.icon : "" + fillMode: Image.PreserveAspectFit + smooth: true + visible: status === Image.Ready + } + + Text { + anchors.centerIn: parent + visible: !appIcon.visible + text: root.mode === "drun" ? "󰣆" : "󰆍" + color: resultsList.currentIndex === index && index !== -1 ? t.launcherAccent : t.launcherDim + font.pixelSize: 16 + } + } + + Text { + text: modelData.name + color: t.launcherText + font.pixelSize: 14 + Layout.fillWidth: true + elide: Text.ElideRight + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: parent.hovered = true + onExited: parent.hovered = false + onClicked: root.launch(modelData) + } + } + } + } + } + } +} diff --git a/launcher/launcher.sh b/launcher/launcher.sh new file mode 100755 index 0000000..504184d --- /dev/null +++ b/launcher/launcher.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash + +CACHE_DIR="/tmp/qs_launcher" + +DRUN_CACHE="$CACHE_DIR/drun.txt" +RUN_CACHE="$CACHE_DIR/run.txt" + +mkdir -p "$CACHE_DIR" + + +get_desktop_files() { + IFS=':' read -ra DIRS <<< \ + "${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" + + for dir in "${DIRS[@]}"; do + APP_DIR="$dir/applications" + + [ -d "$APP_DIR" ] || continue + + find "$APP_DIR" \ + -maxdepth 1 \ + -name "*.desktop" \ + 2>/dev/null + done | while read -r f; do + + name=$( + grep -m1 '^Name=' "$f" \ + | cut -d= -f2- + ) + + exec=$( + grep -m1 '^Exec=' "$f" \ + | cut -d= -f2- \ + | sed 's/%[fFuUik]//g' \ + | xargs + ) + + type=$( + grep -m1 '^Type=' "$f" \ + | cut -d= -f2- + ) + + nodisplay=$( + grep -m1 '^NoDisplay=' "$f" \ + | cut -d= -f2- \ + | tr '[:upper:]' '[:lower:]' + ) + + icon=$( + grep -m1 '^Icon=' "$f" \ + | cut -d= -f2- + ) + + [[ "$type" == "Application" ]] || continue + [[ "$nodisplay" != "true" ]] || continue + [[ -n "$name" ]] || continue + + printf "%s\t%s\t%s\t%s\n" \ + "$name" \ + "$exec" \ + "$(basename "$f" .desktop)" \ + "$icon" + done +} + + +get_binaries() { + compgen -c \ + | sort -u \ + | head -600 \ + | awk '{ + print $1 "\t" $1 "\t" $1 + }' +} + + +build_drun() { + get_desktop_files \ + | sort -f -t$'\t' -k1,1 \ + | awk -F'\t' '!seen[$1]++' +} + +build_run() { + { + get_desktop_files + get_binaries + } \ + | sort -f -t$'\t' -k1,1 \ + | awk -F'\t' '!seen[$1]++' +} + + +update_cache() { + build_drun > "$DRUN_CACHE.tmp" + build_run > "$RUN_CACHE.tmp" + + mv "$DRUN_CACHE.tmp" "$DRUN_CACHE" + mv "$RUN_CACHE.tmp" "$RUN_CACHE" +} + + +if [ "$1" == "--update-only" ]; then + update_cache + exit 0 +fi diff --git a/notifications/HistoryCard.qml b/notifications/HistoryCard.qml new file mode 100644 index 0000000..5fa807c --- /dev/null +++ b/notifications/HistoryCard.qml @@ -0,0 +1,83 @@ +import QtQuick +import QtQuick.Layouts + +Rectangle { + id: root + + property int itemIndex: 0 + property string appName: "" + property string summary: "" + property string body: "" + property int urgency: 0 + property string timeStr: "" + + property var t + property var urgencyColor: function(u) { return "#ffffff" } + property var urgencyIcon: function(u) { return "" } + + signal dismissed(int itemIndex) + + height: histItemContent.implicitHeight + 24 + radius: 12 + clip: true + color: histItemHover.hovered ? root.t.notifHistoryHover : "transparent" + Behavior on color { ColorAnimation { duration: 150; easing.type: Easing.OutCubic } } + + HoverHandler { id: histItemHover } + + SequentialAnimation { + id: dismissAnim + ParallelAnimation { + NumberAnimation { target: root; property: "opacity"; to: 0; duration: 150; easing.type: Easing.OutCubic } + NumberAnimation { target: root; property: "scale"; to: 0.95; duration: 150; easing.type: Easing.OutCubic } + } + NumberAnimation { target: root; property: "height"; to: 0; duration: 150; easing.type: Easing.InOutQuad } + ScriptAction { script: root.dismissed(root.itemIndex) } + } + + Rectangle { + width: 3 + height: Math.max(12, root.height - 24) + radius: 1.5 + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + color: root.urgencyColor(root.urgency) + } + + Rectangle { + anchors { right: parent.right; top: parent.top; margins: 8 } + width: 22; height: 22; radius: 11 + color: histCloseHover.hovered ? root.t.notifHistoryHover : "transparent" + opacity: histItemHover.hovered ? 1 : 0 + Behavior on opacity { NumberAnimation { duration: 150 } } + Behavior on color { ColorAnimation { duration: 150 } } + + Text { anchors.centerIn: parent; text: "✕"; color: root.t.notifToastText; font.pixelSize: 10 } + HoverHandler { id: histCloseHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: dismissAnim.start() } + } + + ColumnLayout { + id: histItemContent + anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 24; rightMargin: 36; topMargin: 12 } + spacing: 4 + + RowLayout { + Layout.fillWidth: true; spacing: 5 + Text { text: root.urgencyIcon(root.urgency); color: root.urgencyColor(root.urgency); font.pixelSize: 11; Layout.alignment: Qt.AlignVCenter } + Text { + text: root.appName; color: root.t.notifToastAppName; font.pixelSize: 11; font.bold: true; + font.capitalization: Font.AllUppercase; Layout.fillWidth: true; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter + } + Text { text: root.timeStr; color: root.t.notifToastDim; font.pixelSize: 11; verticalAlignment: Text.AlignVCenter } + } + + Text { + text: root.summary; color: root.t.notifToastText; font.pixelSize: 13; font.bold: true; + Layout.fillWidth: true; elide: Text.ElideRight; textFormat: Text.PlainText; visible: root.summary !== "" + } + Text { + text: root.body; color: root.t.notifToastDim; font.pixelSize: 12; Layout.fillWidth: true; + wrapMode: Text.WordWrap; maximumLineCount: 2; elide: Text.ElideRight; textFormat: Text.PlainText; visible: root.body !== ""; lineHeight: 1.15 + } + } +} diff --git a/notifications/NotificationCard.qml b/notifications/NotificationCard.qml new file mode 100644 index 0000000..0d718ce --- /dev/null +++ b/notifications/NotificationCard.qml @@ -0,0 +1,191 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Notifications + +Item { + id: root + + property string toastId: "" + property string appName: "" + property string summary: "" + property string body: "" + property int urgency: NotificationUrgency.Normal + property string timeStr: "" + property int timeout: 5000 + + property var t + property var urgencyColor: function(u) { return "#ffffff" } + property var urgencyIcon: function(u) { return "" } + + signal dismissed(string toastId) + + width: parent ? parent.width : 350 + height: toastCard.height + + property real progressVal: 1.0 + + Timer { + interval: root.timeout + running: root.timeout > 0 + repeat: false + onTriggered: root.dismissed(root.toastId) + } + + NumberAnimation on progressVal { + from: 1.0; to: 0.0 + duration: root.timeout + running: root.urgency === NotificationUrgency.Low && root.timeout > 0 + } + + opacity: 0 + scale: 0.90 + Component.onCompleted: { + entranceOp.start() + entranceSc.start() + } + NumberAnimation { + id: entranceOp; target: root + property: "opacity"; from: 0; to: 1 + duration: 240; easing.type: Easing.OutCubic + } + NumberAnimation { + id: entranceSc; target: root + property: "scale"; from: 0.90; to: 1.0 + duration: 240; easing.type: Easing.OutBack + } + + Rectangle { + id: toastCard + width: parent.width + height: toastContent.implicitHeight + (root.urgency === NotificationUrgency.Low ? 30 : 24) + radius: 16 + + color: cardHover.hovered ? root.t.notifToastBgHover : root.t.notifToastBg + border.color: root.urgency === NotificationUrgency.Critical + ? root.t.notifUrgencyCritical + : (cardHover.hovered ? root.t.notifBorderActive : root.t.notifToastBorder) + border.width: 1 + layer.enabled: true + + Behavior on color { ColorAnimation { duration: 150; easing.type: Easing.OutCubic } } + Behavior on border.color { ColorAnimation { duration: 150 } } + + HoverHandler { id: cardHover } + TapHandler { onTapped: root.dismissed(root.toastId) } + + Rectangle { + width: 4 + height: Math.max(16, toastCard.height - 24) + radius: 2 + anchors { + left: parent.left + leftMargin: 10 + verticalCenter: parent.verticalCenter + } + color: root.urgencyColor(root.urgency) + } + + ColumnLayout { + id: toastContent + anchors { + left: parent.left + right: parent.right + top: parent.top + leftMargin: 24 + rightMargin: 12 + topMargin: 12 + } + spacing: 4 + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + text: root.urgencyIcon(root.urgency) + color: root.urgencyColor(root.urgency) + font.pixelSize: 12 + Layout.alignment: Qt.AlignVCenter + } + Text { + text: root.appName + color: root.t.notifToastAppName + font.pixelSize: 11 + font.bold: true + font.capitalization: Font.AllUppercase + Layout.fillWidth: true + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + Text { + text: root.timeStr + color: root.t.notifToastDim + font.pixelSize: 10 + verticalAlignment: Text.AlignVCenter + } + + Rectangle { + Layout.alignment: Qt.AlignVCenter + width: 20; height: 20; radius: 10 + color: closeHover.hovered ? root.t.notifHistoryHover : "transparent" + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + anchors.centerIn: parent + text: "✕" + color: closeHover.hovered ? root.t.notifToastText : root.t.notifToastDim + font.pixelSize: 10 + } + HoverHandler { id: closeHover; cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: root.dismissed(root.toastId) } + } + } + + Text { + text: root.summary + color: root.t.notifToastText + font.pixelSize: 14 + font.bold: true + Layout.fillWidth: true + elide: Text.ElideRight + textFormat: Text.PlainText + visible: root.summary !== "" + } + + Text { + text: root.body + color: root.t.notifToastDim + font.pixelSize: 12 + Layout.fillWidth: true + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + textFormat: Text.PlainText + visible: root.body !== "" + Layout.bottomMargin: root.urgency === NotificationUrgency.Low ? 6 : 0 + lineHeight: 1.15 + } + } + + Item { + visible: root.urgency === NotificationUrgency.Low && root.timeout > 0 + anchors { + left: parent.left; right: parent.right; bottom: parent.bottom + margins: 10; bottomMargin: 10 + } + height: 4 + + Rectangle { + anchors.fill: parent + radius: 2 + color: root.t.notifProgressBg + } + Rectangle { + width: parent.width * root.progressVal + height: parent.height + radius: 2 + color: root.t.notifProgressFill + } + } + } +} diff --git a/shell.qml b/shell.qml new file mode 100644 index 0000000..6380421 --- /dev/null +++ b/shell.qml @@ -0,0 +1,247 @@ +//@ pragma UseQApplication +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Io + +import "bar" +import "launcher" +import "widgets/bgDate" +import "config" as Cfg + +ShellRoot { + id: root + + readonly property bool isTop: Cfg.Config.barPosition === "top" + + property color frameBackground: Cfg.Config.theme.bgFrame + property color frameBorderColor: Cfg.Config.theme.borderPopup + + // ── Spacing Gap: Top / Bottom ──────────────────────────────────────── + Variants { + model: Quickshell.screens + PanelWindow { + required property var modelData + screen: modelData + anchors { + top: !root.isTop + bottom: root.isTop + left: true + right: true + } + implicitHeight: Cfg.Config.margin + color: "transparent" + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.exclusiveZone: Cfg.Config.margin + mask: Region {} + } + } + + // ── Spacing Gap: Left ──────────────────────────────────────────────── + Variants { + model: Quickshell.screens + PanelWindow { + required property var modelData + screen: modelData + anchors { left: true; top: true; bottom: true } + implicitWidth: Cfg.Config.margin + color: "transparent" + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.exclusiveZone: Cfg.Config.margin + mask: Region {} + } + } + + // ── Spacing Gap: Right ─────────────────────────────────────────────── + Variants { + model: Quickshell.screens + PanelWindow { + required property var modelData + screen: modelData + anchors { right: true; top: true; bottom: true } + implicitWidth: Cfg.Config.margin + color: "transparent" + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.exclusiveZone: Cfg.Config.margin + mask: Region {} + } + } + + // ── Wallpaper ──────────────────────────────────────────────────────── + Variants { + model: Quickshell.screens + PanelWindow { + required property var modelData + screen: modelData + color: "transparent" + anchors { top: true; bottom: true; left: true; right: true } + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.namespace: "main-shell-wallpaper" + WlrLayershell.exclusiveZone: -1 + mask: Region {} + + Image { + anchors.fill: parent + source: { + var p = Cfg.Config.theme.wallpaper + return p.startsWith("file://") ? p : "file://" + p + } + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + } + } + + // ── Center Clock (bgDate Widget) ───────────────────────────────────── + Variants { + model: Cfg.Config.enableBgDate ? Quickshell.screens : [] + BgDate { + modelData: modelData + } + } + + // ── Frame ──────────────────────────────────────────────────────────── + Variants { + model: Quickshell.screens + PanelWindow { + required property var modelData + screen: modelData + color: "transparent" + anchors { top: true; bottom: true; left: true; right: true } + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.namespace: "main-shell-frame" + WlrLayershell.exclusiveZone: -1 + mask: Region {} + + Canvas { + id: frameCanvas + anchors.fill: parent + + // Animation Properties + opacity: 0 + scale: 0.98 + + Component.onCompleted: startupAnimation.start() + + ParallelAnimation { + id: startupAnimation + NumberAnimation { + target: frameCanvas; property: "opacity" + from: 0; to: 1; duration: 500 + easing.type: Easing.OutCubic + } + NumberAnimation { + target: frameCanvas; property: "scale" + from: 0.98; to: 1.0; duration: 600 + easing.type: Easing.OutBack + } + } + + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + + var mT = root.isTop ? Cfg.Config.barHeight : Cfg.Config.margin + var mB = root.isTop ? Cfg.Config.margin : Cfg.Config.barHeight + var r = Cfg.Config.radius + var cw = width - Cfg.Config.margin * 2 + var ch = height - mT - mB + var x = Cfg.Config.margin + var y = mT + + ctx.fillStyle = root.frameBackground + + ctx.fillRect(0, 0, width, mT) + ctx.fillRect(0, height - mB, width, mB) + ctx.fillRect(0, mT, x, ch) + ctx.fillRect(x + cw, mT, width - (x + cw), ch) + + ctx.beginPath() + ctx.moveTo(x, y) + ctx.lineTo(x + r, y) + ctx.arc(x + r, y + r, r, -Math.PI / 2, Math.PI, true) + ctx.closePath() + ctx.fill() + + ctx.beginPath() + ctx.moveTo(x + cw, y) + ctx.lineTo(x + cw - r, y) + ctx.arc(x + cw - r, y + r, r, -Math.PI / 2, 0, false) + ctx.closePath() + ctx.fill() + + ctx.beginPath() + ctx.moveTo(x + cw, y + ch) + ctx.lineTo(x + cw, y + ch - r) + ctx.arc(x + cw - r, y + ch - r, r, 0, Math.PI / 2, false) + ctx.closePath() + ctx.fill() + + ctx.beginPath() + ctx.moveTo(x, y + ch) + ctx.lineTo(x + r, y + ch) + ctx.arc(x + r, y + ch - r, r, Math.PI / 2, Math.PI, false) + ctx.closePath() + ctx.fill() + + ctx.lineWidth = Cfg.Config.frameBorderWidth + ctx.strokeStyle = root.frameBorderColor + + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.lineTo(x + cw - r, y) + ctx.arcTo(x + cw, y, x + cw, y + r, r) + ctx.stroke() + + ctx.beginPath() + ctx.moveTo(x + cw, y + r) + ctx.lineTo(x + cw, y + ch - r) + ctx.arcTo(x + cw, y + ch, x + cw - r, y + ch, r) + ctx.stroke() + + ctx.beginPath() + ctx.moveTo(x + cw - r, y + ch) + ctx.lineTo(x + r, y + ch) + ctx.arcTo(x, y + ch, x, y + ch - r, r) + ctx.stroke() + + ctx.beginPath() + ctx.moveTo(x, y + ch - r) + ctx.lineTo(x, y + r) + ctx.arcTo(x, y, x + r, y, r) + ctx.stroke() + } + + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + + Connections { + target: root + function onFrameBackgroundChanged() { frameCanvas.requestPaint() } + function onFrameBorderColorChanged() { frameCanvas.requestPaint() } + } + } + } + } + + // ── Launcher ───────────────────────────────────────────────────────── + Variants { + model: Quickshell.screens + Launcher { + required property var modelData + screen: modelData + isTop: root.isTop + Component.onCompleted: modelData.launcher = this + + } + } + + // ── Bar ────────────────────────────────────────────────────────────── + Variants { + model: Quickshell.screens + Bar { + screen: modelData + isTop: root.isTop + } + } +} diff --git a/wallpaper.jpg b/wallpaper.jpg new file mode 100644 index 0000000..70b7149 Binary files /dev/null and b/wallpaper.jpg differ diff --git a/widgets/bgDate/BgDate.qml b/widgets/bgDate/BgDate.qml new file mode 100644 index 0000000..2c27da2 --- /dev/null +++ b/widgets/bgDate/BgDate.qml @@ -0,0 +1,34 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import "../../config" as Cfg + +PanelWindow { + required property var modelData + screen: modelData + color: "transparent" + + anchors { top: true; bottom: true; left: true; right: true } + + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.exclusiveZone: -1 + mask: Region {} + + Text { + id: clockText + anchors.centerIn: parent + color: Cfg.Config.theme.clockText + styleColor: Cfg.Config.theme.clockShadow + font.pixelSize: 100 + font.weight: Font.Thin + style: Text.Outline + text: new Date().toLocaleTimeString(Qt.locale(), "HH:mm") + } + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: clockText.text = new Date().toLocaleTimeString(Qt.locale(), "HH:mm") + } +}