diff --git a/.gitea/workflows/python.yml b/.gitea/workflows/python.yml index 2baf3ed..011ba51 100644 --- a/.gitea/workflows/python.yml +++ b/.gitea/workflows/python.yml @@ -97,7 +97,7 @@ jobs: py3-pip python3 -m venv .venv . .venv/bin/activate - pip install --no-cache-dir basedpyright + pip install --no-cache-dir basedpyright typer pillow materialyoucolor jinja2 - name: Type check run: | diff --git a/Components/MarqueeText.qml b/Components/MarqueeText.qml index 5f1322c..7d3d12d 100644 --- a/Components/MarqueeText.qml +++ b/Components/MarqueeText.qml @@ -18,13 +18,14 @@ Item { property real leftFadeStrength: overflowing && leftFadeEnabled ? fadeStrengthMoving : fadeStrengthIdle property int leftFadeWidth: 28 property bool marqueeEnabled: true - readonly property bool overflowing: metrics.width > root.width + readonly property bool overflowing: metrics.width > content.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 + property bool vertical: false function durationForDistance(px): int { return Math.max(1, Math.round(Math.abs(px) / root.pixelsPerSecond * 1000)); @@ -41,8 +42,9 @@ Item { } } - clip: true - implicitHeight: elideText.implicitHeight + clip: false + implicitHeight: vertical ? elideText.implicitWidth : elideText.implicitHeight + implicitWidth: vertical ? elideText.implicitHeight : elideText.implicitWidth Behavior on leftFadeStrength { Anim { @@ -53,171 +55,187 @@ Item { } } + onHeightChanged: resetMarquee() onTextChanged: resetMarquee() + onVerticalChanged: 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 + id: content - anchors.fill: parent - clip: true - layer.enabled: true - visible: root.overflowing + anchors.centerIn: parent + height: root.vertical ? root.width : root.height + width: root.vertical ? root.height : root.width - layer.effect: OpacityMask { - maskSource: rightFadeMask + transform: Rotation { + angle: root.vertical ? -90 : 0 + origin.x: content.width / 2 + origin.y: content.height / 2 + } + + 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: content.width } Item { - id: strip + id: marqueeViewport - anchors.verticalCenter: parent.verticalCenter - height: t1.implicitHeight - width: t1.width + root.gap + t2.width - x: 0 + anchors.fill: parent + clip: false + layer.enabled: true + visible: root.overflowing - CustomText { - id: t1 - - animate: root.animate - animateProp: "opacity" - color: root.color - font.pointSize: elideText.font.pointSize - text: elideText.text + layer.effect: OpacityMask { + maskSource: rightFadeMask } - CustomText { - id: t2 + Item { + id: strip - animate: root.animate - animateProp: "opacity" - color: root.color - font.pointSize: elideText.font.pointSize - text: t1.text - x: t1.width + root.gap - } - } + anchors.verticalCenter: parent.verticalCenter + height: t1.implicitHeight + width: t1.width + root.gap + t2.width + x: 0 - SequentialAnimation { - id: marqueeAnim + CustomText { + id: t1 - running: false + animate: root.animate + animateProp: "opacity" + color: root.color + font.pointSize: elideText.font.pointSize + text: elideText.text + } - onFinished: pauseTimer.restart() + CustomText { + id: t2 - ScriptAction { - script: { - root.sliding = true; - root.leftFadeEnabled = true; + animate: root.animate + animateProp: "opacity" + color: root.color + font.pointSize: elideText.font.pointSize + text: t1.text + x: t1.width + root.gap } } - Anim { - duration: root.durationForDistance(t1.width) - easing.bezierCurve: Easing.Linear - easing.type: Easing.Linear - from: 0 - property: "x" - target: strip - to: -t1.width - } + SequentialAnimation { + id: marqueeAnim - ScriptAction { - script: { - root.leftFadeEnabled = false; + 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; + } } } - 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) - } + Timer { + id: pauseTimer - ScriptAction { - script: { - root.sliding = false; - strip.x = 0; + interval: root.pauseMs + repeat: false + running: true + + onTriggered: { + if (root.marqueeEnabled) + marqueeAnim.start(); } } } - Timer { - id: pauseTimer + Rectangle { + id: rightFadeMask - interval: root.pauseMs - repeat: false - running: true - - onTriggered: { - if (root.marqueeEnabled) - marqueeAnim.start(); + readonly property real fadeStartPos: { + const w = Math.max(1, width); + return Math.max(0, Math.min(1, (w - root.rightFadeWidth) / w)); } - } - } - - 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 + readonly property real leftFadeEndPos: { + const w = Math.max(1, width); + return Math.max(0, Math.min(1, root.leftFadeWidth / w)); } - GradientStop { - color: Qt.rgba(1, 1, 1, 1.0) - position: rightFadeMask.leftFadeEndPos - } + anchors.fill: marqueeViewport + layer.enabled: true + visible: false - GradientStop { - color: Qt.rgba(1, 1, 1, 1.0) - position: rightFadeMask.fadeStartPos - } + gradient: Gradient { + orientation: Gradient.Horizontal - GradientStop { - color: Qt.rgba(1, 1, 1, 1.0 - root.rightFadeStrength) - position: 1.0 + 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/Components/Menu.qml b/Components/Menu.qml index 449e6de..b8eaad1 100644 --- a/Components/Menu.qml +++ b/Components/Menu.qml @@ -126,7 +126,7 @@ MouseArea { CustomRect { id: item - readonly property bool active: modelData === root.active + readonly property bool active: modelData === root?.active required property int index required property MenuItem modelData @@ -163,19 +163,19 @@ MouseArea { MaterialIcon { Layout.alignment: Qt.AlignVCenter color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurfaceVariant - text: item.modelData.icon + text: item.modelData?.icon ?? "" } CustomText { Layout.alignment: Qt.AlignVCenter Layout.fillWidth: true color: item.active ? Colors.palette.m3onTertiaryContainer : Colors.palette.m3onSurface - text: item.modelData.text + text: item.modelData?.text ?? "" } Loader { Layout.alignment: Qt.AlignVCenter - active: item.modelData.trailingIcon.length > 0 + active: item.modelData?.trailingIcon.length > 0 asynchronous: true visible: active diff --git a/Drawers/Drawers.qml b/Drawers/Drawers.qml index b026775..7a38a43 100644 --- a/Drawers/Drawers.qml +++ b/Drawers/Drawers.qml @@ -11,15 +11,46 @@ Variants { required property ShellScreen modelData + function rebuild(): void { + loader.active = false; + loader.active = true; + } + + LazyLoader { + id: loader + + active: true + + Drawer { + screen: scope.modelData + } + } + + Connections { + function onConfigPositionChanged(): void { + Qt.callLater(scope.rebuild); + } + + target: (loader.item as Drawer)?.geometry ?? null + } + } + + component Drawer: Scope { + id: drawer + + readonly property alias geometry: content.geometry + required property ShellScreen screen + Exclusions { bar: content.bar - screen: scope.modelData + geometry: content.geometry + screen: drawer.screen } Windows { id: content - screen: scope.modelData + screen: drawer.screen } } } diff --git a/Drawers/EdgeGeometry.qml b/Drawers/EdgeGeometry.qml new file mode 100644 index 0000000..e10b45b --- /dev/null +++ b/Drawers/EdgeGeometry.qml @@ -0,0 +1,55 @@ +import QtQuick +import qs.Modules.Bar as Bar + +QtObject { + id: root + + required property Bar.BarLoader bar + readonly property real barClamped: bar.clampedExtent + readonly property real barExtent: bar.extent + readonly property bool barOnBottom: position === "bottom" + readonly property bool barOnLeft: position === "left" + readonly property bool barOnTop: position === "top" + required property string configPosition + readonly property bool horizontal: position !== "left" + readonly property string position: configPosition === "top" || configPosition === "bottom" ? configPosition : "left" + required property var win + + function axisPos(x: real, y: real): real { + return horizontal ? x : y; + } + + function barContains(x: real, y: real, clamped = false): bool { + const extent = clamped ? barClamped : barExtent; + if (barOnTop) + return y < extent; + if (barOnBottom) + return y > win.height - extent; + return x < extent; + } + + function insetBottom(border: real, clamped = false): real { + return barOnBottom ? (clamped ? barClamped : barExtent) : border; + } + + function insetLeft(border: real, clamped = false): real { + return barOnLeft ? (clamped ? barClamped : barExtent) : border; + } + + function insetTop(border: real, clamped = false): real { + return barOnTop ? (clamped ? barClamped : barExtent) : border; + } + + function inwardDrag(dragX: real, dragY: real): real { + if (barOnTop) + return dragY; + if (barOnBottom) + return -dragY; + return dragX; + } + + onConfigPositionChanged: { + if (!["left", "top", "bottom"].includes(configPosition)) + console.warn(`Invalid bar position '${configPosition}', falling back to left`); + } +} diff --git a/Drawers/Exclusions.qml b/Drawers/Exclusions.qml index c1b3190..99ad14d 100644 --- a/Drawers/Exclusions.qml +++ b/Drawers/Exclusions.qml @@ -9,19 +9,21 @@ Scope { id: root required property Item bar + required property EdgeGeometry geometry required property ShellScreen screen ExclusionZone { id: top anchors.top: true - exclusiveZone: root.bar.exclusiveZone + hasBar: root.geometry.barOnTop } ExclusionZone { id: left anchors.left: true + hasBar: root.geometry.barOnLeft } ExclusionZone { @@ -34,6 +36,7 @@ Scope { id: bottom anchors.bottom: true + hasBar: root.geometry.barOnBottom } Timer { @@ -44,7 +47,9 @@ Scope { } component ExclusionZone: CustomWindow { - exclusiveZone: Config.bar.border + property bool hasBar + + exclusiveZone: hasBar ? root.bar.exclusiveZone : Config.bar.border implicitHeight: 1 implicitWidth: 1 name: "Bar-Exclusion" diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index f62438e..6f70a2c 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -3,7 +3,7 @@ import QtQuick import qs.Components import ZShell.Config import qs.Helpers -import qs.Modules as BarPopouts +import qs.Modules.Bar.Popouts Item { id: root @@ -12,43 +12,47 @@ Item { required property real borderThickness property bool dashboardShortcutActive required property Drawing drawing + required property EdgeGeometry geometry property bool osdShortcutActive required property Panels panels - required property BarPopouts.Wrapper popouts + required property Wrapper popouts required property ShellScreen screen property bool singleGestureTriggered: false property bool utilitiesShortcutActive required property PersistentProperties visibilities function inBottomPanel(panel: Item, x: real, y: real): bool { - return y > root.height - panel.height - Config.bar.border && withinPanelWidth(panel, x, y); + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); + return y > root.height - (panelHeight + geometry.insetBottom(borderThickness)) && withinPanelWidth(panel, x, y); } function inLeftPanel(panel: Item, x: real, y: real): bool { - return x < panel.x + panel.width + Config.bar.border && withinPanelHeight(panel, x, y); + return x < panel.x + panel.width + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); + } + + function inPopoutArea(x: real, y: real): bool { + const panel = panels.popoutsWrapper; + if (!geometry.horizontal) + return inLeftPanel(panel, x, y); + return withinPanelWidth(panel, x, y) && withinPanelHeight(panel, x, y); } function inRightPanel(panel: Item, x: real, y: real): bool { - return x > panel.x - Config.bar.border && withinPanelHeight(panel, x, y); + return x > panel.x + geometry.insetLeft(borderThickness) && withinPanelHeight(panel, x, y); } function inTopPanel(panel: Item, x: real, y: real): bool { - return y < bar.implicitHeight + panel.height && withinPanelWidth(panel, x, y); - } - - function onWheel(event: WheelEvent): void { - if (event.x < bar.implicitWidth) { - bar.handleWheel(event.y, event.angleDelta); - } + const panelHeight = panel.height * (1 - (panel.offsetScale ?? 0)); + return y < geometry.insetTop(borderThickness) + panelHeight && withinPanelWidth(panel, x, y); } function withinPanelHeight(panel: Item, x: real, y: real): bool { - const panelY = panel.y + bar.implicitHeight; + const panelY = panel.y + geometry.insetTop(borderThickness); return y >= panelY && y <= panelY + panel.height; } function withinPanelWidth(panel: Item, x: real, y: real): bool { - const panelX = panel.x + root.borderThickness; + const panelX = panel.x + geometry.insetLeft(borderThickness); return x >= panelX && x <= panelX + panel.width; } @@ -81,7 +85,7 @@ Item { if (root.singleGestureTriggered) return; - if (centroid.pressPosition.y < root.bar.implicitHeight) { + if (root.geometry.insetTop(root.borderThickness) > centroid.pressPosition.y) { if (dragY > 20) { root.visibilities.settings = true; root.singleGestureTriggered = true; @@ -91,10 +95,10 @@ Item { } } - if (centroid.pressPosition.y > root.screen.height - Config.bar.border && centroid.pressPosition.x < root.screen.width / 5 && dragY < -50) + if (centroid.pressPosition.y > root.screen.height - root.geometry.insetBottom(root.borderThickness) && centroid.pressPosition.x < root.screen.width / 5 && dragY < -50) root.visibilities.clipboard = true; - if (!Config.dock.hoverToReveal && centroid.pressPosition.y > root.screen.height - root.bar.implicitHeight && centroid.pressPosition.x > root.screen.width / 5 && !root.visibilities.launcher) + if (!Config.dock.hoverToReveal && centroid.pressPosition.y > root.screen.height - root.geometry.insetTop(root.borderThickness) && centroid.pressPosition.x > root.screen.width / 5 && !root.visibilities.launcher) if (dragY < -10) { root.visibilities.dock = true; root.singleGestureTriggered = true; @@ -193,7 +197,7 @@ Item { return; } - if (!root.visibilities.bar && Config.bar.autoHide && y < root.bar.implicitHeight) + if (!root.visibilities.bar && Config.bar.autoHide && root.geometry.barContains(x, y, true)) root.bar.isHovered = true; if (root.panels.sidebar.offsetScale === 1) { @@ -219,8 +223,8 @@ Item { if (Config.dock.enable && Config.dock.hoverToReveal && !root.visibilities.dock && !root.visibilities.launcher && root.inBottomPanel(root.panels.dock, x, y)) root.visibilities.dock = true; - if (y < root.bar.implicitHeight) - root.bar.checkPopout(x); + if (root.geometry.barContains(x, y)) + root.bar.checkPopout(root.geometry.axisPos(x, y)); } } @@ -298,8 +302,6 @@ Item { root.visibilities.sidebar = false; root.panels.popouts.hasCurrent = false; root.visibilities.launcher = false; - } else { - Config.save(); } } diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 1a24491..66827ad 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -1,7 +1,7 @@ import Quickshell import QtQuick import qs.Components -import qs.Modules as Modules +import qs.Modules.Bar.Popouts as Popouts import qs.Modules.Notifications as Notifications import qs.Modules.Notifications.Sidebar as Sidebar import qs.Modules.Notifications.Sidebar.Utils as Utils @@ -27,6 +27,7 @@ Item { readonly property alias dock: dock readonly property alias drawing: drawing required property var drawingItem + required property EdgeGeometry geometry readonly property alias launcher: launcher readonly property alias notifications: notifications readonly property alias osd: osd @@ -43,24 +44,26 @@ Item { readonly property alias utilities: utilities required property PersistentProperties visibilities + anchors.bottomMargin: geometry.insetBottom(borderThickness) anchors.fill: parent + anchors.leftMargin: geometry.insetLeft(borderThickness) anchors.margins: borderThickness - anchors.topMargin: bar.implicitHeight + anchors.topMargin: geometry.insetTop(borderThickness) Item { id: resourcesWrapper + anchors.bottom: root.geometry.position === "bottom" ? parent.bottom : undefined anchors.left: parent.left - anchors.top: parent.top + anchors.top: root.geometry.position !== "bottom" ? parent.top : undefined clip: true - implicitHeight: resources.implicitHeight * (1 - resources.offsetScale) - implicitWidth: resources.implicitWidth + implicitHeight: root.geometry.horizontal ? resources.implicitHeight * (1 - resources.offsetScale) : resources.implicitHeight + implicitWidth: root.geometry.horizontal ? resources.implicitWidth : resources.implicitWidth * (1 - resources.offsetScale) Resources.Wrapper { id: resources - anchors.left: parent.left - anchors.top: parent.top + position: root.geometry.position visibilities: root.visibilities } } @@ -96,11 +99,11 @@ Item { visibilities: root.visibilities } - Modules.ClipWrapper { + Popouts.ClipWrapper { id: popouts - anchors.top: parent.top borderThickness: root.borderThickness + position: root.geometry.position screen: root.screen } @@ -147,11 +150,13 @@ Item { property real offsetScale: dashboard.shouldBeActive ? 0 : 1 - anchors.right: parent.right - anchors.top: parent.top + anchors.bottom: root.geometry.barOnLeft || root.geometry.barOnBottom ? parent.bottom : undefined + anchors.left: root.geometry.barOnLeft ? parent.left : undefined + anchors.right: root.geometry.barOnLeft ? undefined : parent.right + anchors.top: root.geometry.barOnBottom || root.geometry.barOnLeft ? undefined : parent.top clip: true - implicitHeight: dashboard.implicitHeight * (1 - offsetScale) - implicitWidth: dashboard.implicitWidth + implicitHeight: root.geometry.horizontal ? dashboard.implicitHeight * (1 - offsetScale) : dashboard.implicitHeight + implicitWidth: root.geometry.horizontal ? dashboard.implicitWidth : dashboard.implicitWidth * (1 - offsetScale) Behavior on offsetScale { Anim { @@ -163,10 +168,8 @@ Item { Dashboard.Wrapper { id: dashboard - anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: (-implicitHeight - 5) * offsetScale offsetScale: dashboardWrapper.offsetScale + position: root.geometry.position visibilities: root.visibilities } } diff --git a/Drawers/Regions.qml b/Drawers/Regions.qml index e5c3757..590f92f 100644 --- a/Drawers/Regions.qml +++ b/Drawers/Regions.qml @@ -7,17 +7,18 @@ import qs.Modules.Bar as Bar Region { id: root - required property Bar.BarLoader bar + // required property Bar.BarLoader bar readonly property real borderThickness: win.borderThickness + required property EdgeGeometry geometry readonly property alias menuPopoutRegion: menuPopoutRegion required property Panels panels required property var win - height: win.height - bar.implicitHeight - win.borderThickness - win.dragMaskPadding * 2 + height: win.height - geometry.insetTop(borderThickness, true) - geometry.insetBottom(borderThickness, true) - win.dragMaskPadding * 2 intersection: Intersection.Xor - width: win.width - win.borderThickness * 2 - win.dragMaskPadding * 2 - x: win.borderThickness + win.dragMaskPadding - y: bar.implicitHeight + win.dragMaskPadding + width: win.width - geometry.insetLeft(borderThickness, true) * 2 - win.dragMaskPadding * 2 + x: geometry.insetLeft(borderThickness, true) + win.dragMaskPadding + y: geometry.insetTop(borderThickness, true) + win.dragMaskPadding R { panel: root.panels.dashboardWrapper @@ -41,17 +42,21 @@ Region { } R { + height: panel.height + root.geometry.insetTop(root.borderThickness) panel: root.panels.notifications + y: 0 } R { - height: panel.height * (1 - root.panels.utilities.offsetScale) + root.borderThickness + height: panel.height * (1 - root.panels.utilities.offsetScale) + root.geometry.insetBottom(root.borderThickness) panel: root.panels.utilities y: root.win.height - height } R { + height: root.geometry.horizontal ? panel.height * (1 - root.panels.popoutsWrapper.offsetScale) : panel.height panel: root.panels.popoutsWrapper + width: root.geometry.horizontal ? panel.width : panel.width * (1 - root.panels.popoutsWrapper.offsetScale) } R { @@ -82,7 +87,7 @@ Region { height: panel.height intersection: Intersection.Subtract width: panel.width - x: panel.x + root.borderThickness - y: panel.y + root.bar.implicitHeight + x: panel.x + root.geometry.insetLeft(root.borderThickness) + y: panel.y + root.geometry.insetTop(root.borderThickness) } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index ea7a9d4..dc118cc 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -33,6 +33,7 @@ CustomWindow { return 100; } property real fsTransitionProg: hasFullscreen ? 1 : 0 + readonly property alias geometry: geometry readonly property bool hasFullscreen: { if (hasSpecialWorkspace) { const specialName = monitor?.lastIpcObject.specialWorkspace?.name; @@ -95,13 +96,21 @@ CustomWindow { panels.popouts.hasCurrent = false; } + EdgeGeometry { + id: geometry + + bar: bar + configPosition: Config.bar.position + win: root + } + Region { id: emptyRegion height: panels.notifications.height width: panels.notifications.width - x: panels.notifications.x + root.borderThickness - y: panels.notifications.y + bar.implicitHeight + x: panels.notifications.x + geometry.insetLeft(root.borderThickness) + y: panels.notifications.y + geometry.insetTop(root.borderThickness) Region { height: panels.osd.height @@ -114,7 +123,7 @@ CustomWindow { Regions { id: regions - bar: bar + geometry: geometry panels: panels win: root } @@ -193,10 +202,10 @@ CustomWindow { BlobInvertedRect { anchors.fill: parent anchors.margins: -50 - borderBottom: root.borderThickness - anchors.margins - root.sdfBorderOffset - borderLeft: root.borderThickness - anchors.margins - root.sdfBorderOffset + borderBottom: geometry.insetBottom(root.borderThickness) - anchors.margins - root.sdfBorderOffset + borderLeft: geometry.insetLeft(root.borderThickness) - anchors.margins - root.sdfBorderOffset borderRight: root.borderThickness - anchors.margins - root.sdfBorderOffset - borderTop: bar.implicitHeight - anchors.margins - root.sdfBorderOffset + borderTop: geometry.insetTop(root.borderThickness) - anchors.margins - root.sdfBorderOffset group: blobGroup radius: root.borderRounding } @@ -204,15 +213,15 @@ CustomWindow { PanelBg { id: dashBg - property real extraHeight: 0.2 + property real extraExtent: 0.2 deformAmount: 0.06 - implicitHeight: panels.dashboard.height * (1 + extraHeight) - implicitWidth: panels.dashboard.width + implicitHeight: panels.dashboard.height * (geometry.horizontal ? 1 + extraExtent : 1) + implicitWidth: panels.dashboard.width * (geometry.horizontal ? 1 : 1 + extraExtent) panel: panels.dashboardWrapper radius: Tokens.rounding.normal - x: panels.dashboardWrapper.x + panels.dashboard.x + root.borderThickness - y: panels.dashboardWrapper.y + panels.dashboard.y + bar.implicitHeight - panels.dashboard.height * extraHeight + x: panels.dashboardWrapper.x + panels.dashboard.x + geometry.insetLeft(root.borderThickness) - (geometry.horizontal ? 0 : panels.dashboard.width * extraExtent) + y: panels.dashboardWrapper.y + panels.dashboard.y + geometry.insetTop(root.borderThickness) - (geometry.horizontal && geometry.position !== "bottom" ? panels.dashboard.height * extraExtent : 0) } PanelBg { @@ -224,7 +233,7 @@ CustomWindow { implicitHeight: panels.launcher.height * (1 + extraHeight) panel: panels.launcher radius: Tokens.rounding.smallest + 5 - y: panels.launcher.y + bar.implicitHeight + y: panels.launcher.y + geometry.insetTop(root.borderThickness) } PanelBg { @@ -245,8 +254,7 @@ CustomWindow { implicitWidth: panels.osd.width panel: panels.osdWrapper radius: 20 - x: panels.osdWrapper.x + panels.osd.x + root.borderThickness - y: panels.osdWrapper.y + panels.osd.y + bar.implicitHeight + x: panels.osdWrapper.x + panels.osd.x + geometry.insetLeft(root.borderThickness) } PanelBg { @@ -268,17 +276,17 @@ CustomWindow { PanelBg { id: popoutBg - property real extraHeight: 0.2 + property real extraExtent: 0.2 deformAmount: panels.popouts.currentName.startsWith("traymenu") ? 0.15 : 0.08 - implicitHeight: panels.popouts.height * (1 + extraHeight) - implicitWidth: panels.popouts.width + implicitHeight: panels.popouts.height * (geometry.horizontal ? 1 + extraExtent : 1) + implicitWidth: panels.popouts.width * (geometry.horizontal ? 1 : 1 + extraExtent) panel: panels.popoutsWrapper radius: panels.popouts.current?.panelRadius ?? Tokens.rounding.normal - x: panels.popoutsWrapper.x + panels.popouts.x + root.borderThickness - y: panels.popoutsWrapper.y + panels.popouts.y + bar.implicitHeight - panels.popouts.height * extraHeight + x: panels.popoutsWrapper.x + panels.popouts.x + geometry.insetLeft(root.borderThickness) - (geometry.horizontal ? 0 : panels.popouts.width * extraExtent) + y: panels.popoutsWrapper.y + panels.popouts.y + geometry.insetTop(root.borderThickness) - (geometry.barOnTop ? panels.popouts.height * extraExtent : 0) - Behavior on extraHeight { + Behavior on extraExtent { Anim { } } @@ -292,8 +300,8 @@ CustomWindow { implicitWidth: panels.resources.width panel: panels.resourcesWrapper radius: Tokens.rounding.large - x: panels.resourcesWrapper.x + panels.resources.x + root.borderThickness - y: panels.resourcesWrapper.y + panels.resources.y + bar.implicitHeight + x: panels.resourcesWrapper.x + panels.resources.x + geometry.insetLeft(root.borderThickness) + y: panels.resourcesWrapper.y + panels.resources.y + geometry.insetTop(root.borderThickness) } PanelBg { @@ -371,6 +379,7 @@ CustomWindow { borderThickness: root.borderLayoutThickness drawing: drawing enabled: true + geometry: geometry panels: panels popouts: panels.popouts screen: root.screen @@ -382,6 +391,7 @@ CustomWindow { bar: bar borderThickness: root.borderThickness drawingItem: drawing + geometry: geometry screen: root.screen visibilities: visibilities @@ -423,14 +433,18 @@ CustomWindow { BarLoader { id: bar + anchors.bottom: geometry.barOnBottom ? parent.bottom : undefined anchors.left: parent.left - anchors.right: parent.right + anchors.top: geometry.barOnBottom ? undefined : parent.top enabled: !visibilities.isDrawing fullscreen: root.hasFullscreen + height: geometry.horizontal ? implicitHeight : parent.height popouts: panels.popouts popoutsWrapper: panels.popoutsWrapper + position: geometry.position screen: root.screen visibilities: visibilities + width: geometry.horizontal ? parent.width : implicitWidth } } @@ -443,7 +457,7 @@ CustomWindow { implicitHeight: panel.height implicitWidth: panel.width radius: Tokens.rounding.smallest - x: panel.x + root.borderThickness - y: panel.y + bar.implicitHeight + x: panel.x + geometry.insetLeft(root.borderThickness) + y: panel.y + geometry.insetTop(root.borderThickness) } } diff --git a/Helpers/Icons.qml b/Helpers/Icons.qml index 2599449..03be431 100644 --- a/Helpers/Icons.qml +++ b/Helpers/Icons.qml @@ -1,9 +1,8 @@ pragma Singleton -import ZShell.Config +import QtQuick import Quickshell import Quickshell.Services.Notifications -import QtQuick Singleton { id: root @@ -95,6 +94,15 @@ Singleton { return Quickshell.iconPath(icon); } + function getBatteryIcon(percentage: real, charging = false): string { + if (percentage === 1) + return charging ? "battery_charging_full" : "battery_full"; + let level = Math.floor(percentage * 7); + if (charging && (level === 4 || level === 1)) + level--; + return charging ? `battery_charging_${(level + 3) * 10}` : `battery_${level}_bar`; + } + function getBluetoothIcon(icon: string): string { if (icon.includes("headset") || icon.includes("headphones")) return "headphones"; @@ -168,6 +176,32 @@ Singleton { return "chat"; } + function getSpecialWsIcon(name: string): string { + name = name.toLowerCase().slice("special:".length); + + if (name === "special") + return "star"; + if (name === "communication") + return "forum"; + if (name === "music") + return "music_cast"; + if (name === "todo") + return "checklist"; + if (name === "sysmon") + return "monitor_heart"; + return name[0].toUpperCase(); + } + + function getTrayIcon(id: string, icon: string): string { + if (icon.includes("?path=")) { + const [name, path] = icon.split("?path="); + const file = name.slice(name.lastIndexOf("/") + 1); + const themed = Quickshell.iconPath(file, true); + icon = themed ? themed : Qt.resolvedUrl(`${path}/${file}`); + } + return icon; + } + function getVolumeIcon(volume: real, isMuted: bool): string { if (isMuted) return "no_sound"; @@ -183,4 +217,19 @@ Singleton { return weatherIcons[code]; return "air"; } + + function matchIconConfig(name: string, iconConfig: var): bool { + if (!iconConfig.icon) + return false; + + if (iconConfig.regex) { + const re = new RegExp(iconConfig.regex, iconConfig.flags ?? ""); + if (re.test(name)) + return true; + } else if (iconConfig.name === name) { + return true; + } + + return false; + } } diff --git a/Helpers/TaskbarApps.qml b/Helpers/TaskbarApps.qml index 829ed15..5e2cf59 100644 --- a/Helpers/TaskbarApps.qml +++ b/Helpers/TaskbarApps.qml @@ -73,7 +73,6 @@ Singleton { Config.dock.pinnedApps = pinnedApps; root.unpinnedOrder = visibleUnpinned.concat(root.unpinnedOrder.map(normalizeId).filter(id => !pinnedApps.includes(id) && !visibleUnpinned.includes(id))); - Config.saveNoToast(); } function isPinned(appId) { diff --git a/Helpers/Updates.qml b/Helpers/Updates.qml index 9281ec7..cc1eb02 100644 --- a/Helpers/Updates.qml +++ b/Helpers/Updates.qml @@ -11,8 +11,8 @@ Singleton { id: root property int availableUpdates: 0 - property string cmd: "" property bool commandReady + property bool hasHelper property bool loaded property double now: Date.now() property var updates: ({}) @@ -38,18 +38,12 @@ Singleton { } function performPackageUpdate(pkg: string): void { - if (root.cmd === "pacman") - pkgUpdateProc.command = ["pkexec", root.cmd, "--noconfirm", "-Sy", pkg]; - else - pkgUpdateProc.command = [root.cmd, "--noconfirm", "--sudo", "pkexec", "-Sy", pkg]; + pkgUpdateProc.command = ["pkexec", "pacman", "--noconfirm", "-Sy", pkg]; pkgUpdateProc.running = true; } function performSystemUpdate(): void { - if (root.cmd === "pacman") - sysUpdateProc.command = ["pkexec", root.cmd, "--noconfirm", "-Syu"]; - else - sysUpdateProc.command = [root.cmd, "--noconfirm", "--sudo", "pkexec", "-Syu"]; + sysUpdateProc.command = ["pkexec", "pacman", "--noconfirm", "-Syu"]; sysUpdateProc.running = true; } @@ -87,7 +81,7 @@ Singleton { Process { id: cmdDetect - command: ["sh", "-c", "command -v checkupdates || command -v yay || command -v paru"] + command: ["sh", "-c", "command -v checkupdates"] running: true stdout: StdioCollector { @@ -96,47 +90,20 @@ Singleton { let helper; if (cmd.length > 0) { - helper = cmd.split("/").pop(); + helper = true; } else { - helper = "pacman"; - } - - if (helper === "checkupdates") { - updatesProc.command = [helper]; - } else { - updatesProc.command = [helper, "-Qu"]; + helper = false; } + root.hasHelper = helper; root.commandReady = true; } } } - Process { - id: updateCmdDetect - - command: ["sh", "-c", "command -v yay || command -v paru"] - running: true - - stdout: StdioCollector { - onStreamFinished: { - const cmd = this.text.trim(); - let helper; - - if (cmd.length > 0) { - helper = cmd.split("/").pop(); - } else { - helper = "pacman"; - } - - root.cmd = helper; - } - } - } - Process { id: updatesProc - command: [] + command: root.hasHelper ? ["checkupdates"] : [] running: false stdout: StdioCollector { diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 949c706..d396f8f 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -3,24 +3,28 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import QtQuick.Layouts -import qs.Modules import ZShell.Config -import qs.Modules.SysTray -import qs.Modules.Network -import qs.Modules.Updates +import qs.Modules.Bar.Components +import qs.Modules.Bar.Popouts -RowLayout { +GridLayout { id: root required property bool fullscreen + required property bool horizontal required property Wrapper popouts required property ClipWrapper popoutsWrapper required property ShellScreen screen readonly property int vPadding: 6 required property PersistentProperties visibilities - function checkPopout(x: real): void { - const ch = childAt(x, height / 2) as EntryWrapper; + function axisCenterOf(item: Item): real { + const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2); + return horizontal ? c.x : c.y; + } + + function checkPopout(pos: real): void { + const ch = (horizontal ? childAt(pos, height / 2) : childAt(width / 2, pos)) as EntryWrapper; const id = ch?.entryId; if (!ch || id === undefined) { @@ -34,26 +38,21 @@ RowLayout { if (id === "statusIcons" && Config.bar.popouts.statusIcons) { const items = (ch.item as StatusIcons).items; - const localX = mapToItem(items, x, 0).x; - const icon = items.childAt(localX, items.height / 2); + const icon = horizontal ? items.childAt(mapToItem(items, pos, 0).x, items.height / 2) : items.childAt(items.width / 2, mapToItem(items, 0, pos).y); if (icon) { popouts.currentName = icon.name; - popouts.currentCenter = Qt.binding(() => icon.mapToItem(root, icon.implicitWidth / 2, 0).x); + popouts.currentCenter = Qt.binding(() => axisCenterOf(icon)); popouts.hasCurrent = true; } } else if (id === "tray" && Config.bar.popouts.tray && Config.bar.tray.showOnHover) { const tray = ch.item as TrayIcons; const layout = tray.layout; - const localX = mapToItem(layout, x, 0).x; - const trayItem = layout.childAt(localX, layout.height / 2); + const trayItem = horizontal ? layout.childAt(mapToItem(layout, pos, 0).x, layout.height / 2) : layout.childAt(tray.width / 2, mapToItem(layout, 0, pos).y); if (trayItem) { const idx = trayItem.index; popouts.currentName = `traymenu${idx}`; - popouts.currentCenter = Qt.binding(() => { - const it = tray.items.itemAt(idx); - return it ? it.mapToItem(root, it.implicitWidth / 2, 0).x : popouts.currentCenter; - }); + popouts.currentCenter = Qt.binding(() => axisCenterOf(trayItem)); popouts.hasCurrent = true; } @@ -63,12 +62,19 @@ RowLayout { if (id === "updates") { popouts.currentName = "updates"; - popouts.currentCenter = Qt.binding(() => ch.item.mapToItem(root, (ch.item as Item).implicitWidth / 2, 0).x); + popouts.currentCenter = Qt.binding(() => axisCenterOf(ch.item as Item)); popouts.hasCurrent = true; } } - spacing: Tokens.spacing.small + function entryAt(pos: real): string { + const ch = (horizontal ? childAt(pos, height / 2) : childAt(width / 2, pos)) as EntryWrapper; + return ch?.entryId ?? ""; + } + + columnSpacing: Tokens.spacing.small + columns: horizontal ? -1 : 1 + rowSpacing: Tokens.spacing.small Repeater { id: repeater @@ -85,6 +91,7 @@ RowLayout { delegate: EntryWrapper { HyprsunsetWidget { + horizontal: root.horizontal visible: !root.fullscreen } } @@ -94,7 +101,8 @@ RowLayout { roleValue: "spacer" delegate: EntryWrapper { - Layout.fillWidth: true + Layout.fillHeight: !root.horizontal + Layout.fillWidth: root.horizontal } } @@ -103,6 +111,7 @@ RowLayout { delegate: EntryWrapper { Workspaces { + horizontal: root.horizontal screen: root.screen visible: !root.fullscreen } @@ -114,6 +123,7 @@ RowLayout { delegate: EntryWrapper { TrayIcons { + horizontal: root.horizontal loader: root popouts: root.popouts visible: !root.fullscreen @@ -126,6 +136,7 @@ RowLayout { delegate: EntryWrapper { StatusIcons { + horizontal: root.horizontal } } } @@ -135,6 +146,7 @@ RowLayout { delegate: EntryWrapper { Resources { + horizontal: root.horizontal visibilities: root.visibilities visible: !root.fullscreen } @@ -146,6 +158,7 @@ RowLayout { delegate: EntryWrapper { UpdatesWidget { + horizontal: root.horizontal visible: !root.fullscreen } } @@ -156,6 +169,7 @@ RowLayout { delegate: EntryWrapper { NotifBell { + horizontal: root.horizontal popouts: root.popouts visibilities: root.visibilities visible: !root.fullscreen @@ -168,6 +182,7 @@ RowLayout { delegate: EntryWrapper { Clock { + horizontal: root.horizontal loader: root popouts: root.popouts visibilities: root.visibilities @@ -182,6 +197,7 @@ RowLayout { delegate: EntryWrapper { WindowTitle { bar: root + horizontal: root.horizontal visible: !root.fullscreen } } @@ -201,6 +217,7 @@ RowLayout { delegate: EntryWrapper { MediaWidget { + horizontal: root.horizontal visible: !root.fullscreen } } @@ -214,9 +231,11 @@ RowLayout { default property Item item required property var modelData - Layout.alignment: Qt.AlignVCenter - Layout.leftMargin: index === 0 ? root.vPadding : (entryId === "statusIcons") ? -root.spacing + Tokens.spacing.extraSmall : 0 - Layout.rightMargin: index === repeater.count - 1 ? root.vPadding : 0 + Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter + Layout.bottomMargin: !root.horizontal && index === repeater.count - 1 ? root.vPadding : 0 + Layout.leftMargin: root.horizontal && index === 0 ? root.vPadding : (entryId === "statusIcons" && root.horizontal) ? -root.rowSpacing + Tokens.spacing.extraSmall : 0 + Layout.rightMargin: root.horizontal && index === repeater.count - 1 ? root.vPadding : 0 + Layout.topMargin: !root.horizontal && index === 0 ? root.vPadding : (entryId === "statusIcons" && !root.horizontal) ? -root.columnSpacing + Tokens.spacing.extraSmall : 0 children: item implicitHeight: item?.implicitHeight ?? 0 implicitWidth: item?.implicitWidth ?? 0 diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 3da2463..2243ad7 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -1,45 +1,48 @@ pragma ComponentBehavior: Bound import Quickshell -import Quickshell.Hyprland import QtQuick -import QtQuick.Layouts -import qs.Components -import qs.Modules import ZShell.Config -import qs.Helpers -import qs.Modules.SysTray -import qs.Modules.SysTray.Widgets -import qs.Modules.Network +import qs.Modules.Bar.Popouts +import qs.Components Item { id: root - readonly property int contentHeight: Config.bar.height + padding * 2 - readonly property int exclusiveZone: Config.bar.autoHide ? Config.bar.border : contentHeight + readonly property int clampedExtent: Math.max(Config.bar.border, extent) + readonly property int contentThickness: Math.floor(Math.max(Config.bar.height, Tokens.bar.innerSize * (horizontal ? 1 : 1.5))) + padding * 2 + readonly property int exclusiveZone: Config.bar.autoHide ? Config.bar.border : contentThickness + property real extent: fullscreen ? 0 : Config.bar.border required property bool fullscreen + readonly property bool horizontal: position !== "left" property bool isHovered readonly property int padding: Math.max(Tokens.padding.smaller, Config.bar.border) required property Wrapper popouts required property ClipWrapper popoutsWrapper + required property string position required property ShellScreen screen readonly property bool shouldBeVisible: !fullscreen && (!Config.bar.autoHide || visibilities.bar || isHovered) readonly property int vPadding: 6 required property PersistentProperties visibilities - function checkPopout(x: real): void { - content.item?.checkPopout(x); + function checkPopout(pos: real): void { + (content.item as Bar)?.checkPopout(pos); } - implicitHeight: fullscreen ? 0 : Config.bar.border - visible: height > Config.bar.border + function entryAt(pos: real): string { + return (content.item as Bar)?.entryAt(pos) ?? ""; + } + + implicitHeight: extent + implicitWidth: extent + visible: extent > Config.bar.border states: State { name: "visible" when: root.shouldBeVisible PropertyChanges { - root.implicitHeight: root.contentHeight + root.extent: root.contentThickness } } transitions: [ @@ -48,7 +51,7 @@ Item { to: "visible" Anim { - property: "implicitHeight" + property: "extent" target: root } }, @@ -57,7 +60,7 @@ Item { to: "" Anim { - property: "implicitHeight" + property: "extent" target: root } } @@ -67,13 +70,16 @@ Item { id: content active: root.shouldBeVisible || root.visible - anchors.bottom: parent.bottom - anchors.left: parent.left + anchors.bottom: root.position !== "bottom" ? parent.bottom : undefined + anchors.left: root.horizontal ? parent.left : undefined anchors.right: parent.right + anchors.top: root.position !== "top" ? parent.top : undefined + height: root.contentThickness + width: root.contentThickness sourceComponent: Bar { fullscreen: root.fullscreen - height: root.contentHeight + horizontal: root.horizontal popouts: root.popouts popoutsWrapper: root.popoutsWrapper screen: root.screen diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml new file mode 100644 index 0000000..0125670 --- /dev/null +++ b/Modules/Bar/Components/Clock.qml @@ -0,0 +1,157 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Modules.Bar.Popouts +import qs.Modules.Bar.Components.Common +import qs.Helpers +import qs.Components +import qs.Services + +WidgetBase { + id: root + + readonly property color contentColor: visibilities.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + readonly property string effectiveFormat: { + let fmt = Config.general.dateFormat; + if (!root.horizontal) + fmt = fmt.replace("dddd", "ddd"); + return fmt; + } + readonly property var formatParts: { + const segments = root.effectiveFormat.split(" - "); + let datePart = ""; + let timePart = ""; + + if (segments.length > 1) { + datePart = segments[0]; + timePart = segments[1]; + } else if (/[hms]/.test(segments[0])) { + timePart = segments[0]; + } else { + datePart = segments[0]; + } + + const dateTokens = datePart.trim().length ? datePart.trim().split(/\s+/) : []; + const timeTokens = timePart.trim().length ? timePart.trim().split(":") : []; + + return { + dateTokens: dateTokens, + timeTokens: timeTokens + }; + } + required property GridLayout loader + required property Wrapper popouts + readonly property real size: horizontal ? timeText.contentWidth + Tokens.padding.normal * 2 : verticalColumn.implicitHeight + Tokens.padding.larger * 2 + required property PersistentProperties visibilities + + color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer + implicitHeight: horizontal ? baseSize : size + implicitWidth: horizontal ? size : baseSize + radius: Tokens.rounding.full + + CustomText { + id: timeText + + anchors.centerIn: parent + color: root.contentColor + font: Config.appearance.font.family.mono // qmllint disable incompatible-type + height: implicitHeight + text: Time.dateStr + visible: root.horizontal + + Behavior on color { + CAnim { + } + } + } + + ColumnLayout { + id: verticalColumn + + anchors.centerIn: parent + spacing: Tokens.padding.small ?? 2 + visible: !root.horizontal + width: root.baseSize + + Repeater { + model: root.formatParts.dateTokens + + CustomText { + required property string modelData + + Layout.alignment: Qt.AlignHCenter + color: root.contentColor + font: Config.appearance.font.family.mono // qmllint disable incompatible-type + text: Qt.formatDateTime(Time.date, modelData) + + Behavior on color { + CAnim { + } + } + } + } + + Rectangle { + Layout.alignment: Qt.AlignHCenter + Layout.bottomMargin: Tokens.padding.small ?? 2 + Layout.topMargin: Tokens.padding.small ?? 2 + color: root.contentColor + height: 1 + implicitWidth: root.baseSize * 0.5 + opacity: 0.4 + radius: 1 + visible: root.formatParts.dateTokens.length > 0 && root.formatParts.timeTokens.length > 0 + } + + Repeater { + model: root.formatParts.timeTokens + + CustomText { + required property string modelData + + Layout.alignment: Qt.AlignHCenter + color: root.contentColor + font: Config.appearance.font.family.mono // qmllint disable incompatible-type + text: { + if (modelData.includes("h")) + return Time.hourStr; + if (modelData.includes("m")) + return Time.minuteStr; + if (modelData.includes("s")) + return Time.secondStr; + return ""; + } + + Behavior on color { + CAnim { + } + } + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: root.contentColor + font: Config.appearance.font.family.mono // qmllint disable incompatible-type + text: Qt.formatDateTime(Time.date, "AP") + visible: Config.services.useTwelveHourClock && root.formatParts.timeTokens.length > 0 + + Behavior on color { + CAnim { + } + } + } + } + + StateLayer { + acceptedButtons: Qt.LeftButton + color: root.contentColor + + onClicked: { + root.visibilities.dashboard = !root.visibilities.dashboard; + } + } +} diff --git a/Modules/Bar/Components/Common/WidgetBase.qml b/Modules/Bar/Components/Common/WidgetBase.qml new file mode 100644 index 0000000..2a0fb95 --- /dev/null +++ b/Modules/Bar/Components/Common/WidgetBase.qml @@ -0,0 +1,16 @@ +import QtQuick +import ZShell.Config +import qs.Components +import qs.Services + +CustomRect { + id: root + + readonly property int baseSize: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : Math.max(Config.bar.height, Math.floor(Tokens.bar.innerSize * 1.5)) + required property bool horizontal + + color: Colors.tPalette.m3surfaceContainer + implicitHeight: horizontal ? baseSize : width + implicitWidth: horizontal ? height : baseSize + radius: Tokens.rounding.full +} diff --git a/Modules/HyprsunsetWidget.qml b/Modules/Bar/Components/HyprsunsetWidget.qml similarity index 85% rename from Modules/HyprsunsetWidget.qml rename to Modules/Bar/Components/HyprsunsetWidget.qml index d4daee3..8afd545 100644 --- a/Modules/HyprsunsetWidget.qml +++ b/Modules/Bar/Components/HyprsunsetWidget.qml @@ -1,17 +1,16 @@ import QtQuick -import qs.Components -import qs.Helpers import ZShell.Config +import qs.Components +import qs.Modules.Bar.Components.Common +import qs.Helpers import qs.Services -CustomRect { +WidgetBase { id: root property bool tempEnabled: Hyprsunset.enabled color: root.tempEnabled ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Tokens.padding.smallest * 2 - implicitWidth: implicitHeight radius: Tokens.rounding.full StateLayer { @@ -26,6 +25,7 @@ CustomRect { animate: true color: root.tempEnabled ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: root.tempEnabled ? 1 : 0 + font.pointSize: Tokens.font.size.larger text: root.tempEnabled ? "lightbulb" : "light_off" Behavior on fill { diff --git a/Modules/Bar/Components/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml new file mode 100644 index 0000000..0342550 --- /dev/null +++ b/Modules/Bar/Components/MediaWidget.qml @@ -0,0 +1,92 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Modules.Bar.Components.Common +import qs.Helpers +import qs.Services + +WidgetBase { + id: root + + readonly property string currentMedia: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") + readonly property int textWidth: Math.min(metrics.width, 200) + + implicitHeight: horizontal ? baseSize : layout.implicitHeight + Tokens.padding.normal * 2 + implicitWidth: horizontal ? layout.implicitWidth + Tokens.padding.normal * 2 : baseSize + radius: Tokens.rounding.full + + Behavior on implicitHeight { + enabled: !root.horizontal + + Anim { + } + } + Behavior on implicitWidth { + enabled: root.horizontal + + Anim { + } + } + + TextMetrics { + id: metrics + + font: mediatext.font + text: mediatext.text + } + + GridLayout { + id: layout + + anchors.bottomMargin: root.horizontal ? 0 : Tokens.padding.normal + anchors.fill: parent + anchors.leftMargin: root.horizontal ? Tokens.padding.normal : 0 + anchors.rightMargin: root.horizontal ? Tokens.padding.normal : 0 + anchors.topMargin: root.horizontal ? 0 : Tokens.padding.normal + columns: root.horizontal ? -1 : 1 + + Behavior on implicitWidth { + Anim { + } + } + + MaterialIcon { + Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter + animate: true + color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface + font.pointSize: Tokens.font.size.larger + text: Players.active?.isPlaying ? "music_note" : "music_off" + } + + MarqueeText { + id: mediatext + + Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter + animate: true + color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface + font.pointSize: Tokens.font.size.normal + horizontalAlignment: Text.AlignHCenter + implicitHeight: root.horizontal ? root.implicitHeight : root.textWidth + implicitWidth: root.horizontal ? root.textWidth : root.implicitWidth + marqueeEnabled: false + pauseMs: 4000 + text: root.currentMedia + vertical: !root.horizontal + } + } + + CustomMouseArea { + anchors.fill: parent + hoverEnabled: true + + onContainsMouseChanged: { + if (!containsMouse) { + mediatext.marqueeEnabled = false; + } else { + mediatext.marqueeEnabled = true; + mediatext.anim.start(); + } + } + } +} diff --git a/Modules/Network/NetworkWidget.qml b/Modules/Bar/Components/NetworkWidget.qml similarity index 100% rename from Modules/Network/NetworkWidget.qml rename to Modules/Bar/Components/NetworkWidget.qml diff --git a/Modules/NotifBell.qml b/Modules/Bar/Components/NotifBell.qml similarity index 85% rename from Modules/NotifBell.qml rename to Modules/Bar/Components/NotifBell.qml index a5543e8..c255deb 100644 --- a/Modules/NotifBell.qml +++ b/Modules/Bar/Components/NotifBell.qml @@ -1,19 +1,19 @@ import Quickshell import QtQuick import ZShell.Config +import qs.Modules.Bar.Components.Common +import qs.Modules.Bar.Popouts import qs.Daemons import qs.Components import qs.Services -CustomRect { +WidgetBase { id: root required property Wrapper popouts required property PersistentProperties visibilities color: visibilities.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Tokens.padding.smallest * 2 - implicitWidth: implicitHeight radius: Tokens.rounding.full MaterialIcon { @@ -22,7 +22,6 @@ CustomRect { anchors.centerIn: parent color: root.visibilities.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: root.visibilities.sidebar ? 1 : 0 - font.family: "Material Symbols Rounded" font.pointSize: Tokens.font.size.larger text: NotifServer.list.length ? "\uf4fe" : "\ue7f4" diff --git a/Modules/Resource.qml b/Modules/Bar/Components/Resource.qml similarity index 71% rename from Modules/Resource.qml rename to Modules/Bar/Components/Resource.qml index 4da447e..0608268 100644 --- a/Modules/Resource.qml +++ b/Modules/Bar/Components/Resource.qml @@ -5,13 +5,14 @@ import qs.Components import ZShell.Config import qs.Services -RowLayout { +GridLayout { id: root property color accentColor: warning ? Colors.palette.m3error : mainColor property real animatedPercentage: 0 readonly property real arcStartAngle: 0.75 * Math.PI readonly property real arcSweep: 1.5 * Math.PI + required property bool horizontal property string icon required property color iconColor required property color mainColor @@ -21,8 +22,9 @@ RowLayout { property bool warning: percentage * 100 >= warningThreshold property int warningThreshold: 80 + columnSpacing: Tokens.spacing.smaller + columns: horizontal ? -1 : 1 percentage: 0 - spacing: Tokens.spacing.smaller Behavior on animatedPercentage { Anim { @@ -47,8 +49,8 @@ RowLayout { } CustomClippingRect { - Layout.preferredHeight: root.height - Tokens.padding.small - Layout.preferredWidth: 4 + Layout.preferredHeight: root.horizontal ? icon.implicitHeight - Tokens.padding.small : 4 + Layout.preferredWidth: root.horizontal ? 4 : icon.implicitWidth color: Colors.layer(Colors.palette.m3surfaceContainerHigh, 2) radius: Tokens.rounding.full @@ -57,9 +59,11 @@ RowLayout { anchors.bottom: parent.bottom anchors.left: parent.left - anchors.right: parent.right + anchors.right: root.horizontal ? parent.right : undefined + anchors.top: root.horizontal ? undefined : parent.top color: root.mainColor - implicitHeight: Math.ceil(root.percentage * parent.height) + implicitHeight: root.horizontal ? Math.ceil(root.percentage * parent.height) : 0 + implicitWidth: root.horizontal ? 0 : Math.ceil(root.percentage * parent.width) // Behavior on implicitHeight { // Anim { diff --git a/Modules/Resources.qml b/Modules/Bar/Components/Resources.qml similarity index 67% rename from Modules/Resources.qml rename to Modules/Bar/Components/Resources.qml index e2d82ac..5759d54 100644 --- a/Modules/Resources.qml +++ b/Modules/Bar/Components/Resources.qml @@ -6,18 +6,18 @@ import QtQuick.Layouts import ZShell.Config import ZShell.Services import qs.Services -import qs.Modules +import qs.Modules.Bar.Components.Common import qs.Components -CustomRect { +WidgetBase { id: root required property PersistentProperties visibilities clip: true color: visibilities.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Tokens.padding.smallest * 2 - implicitWidth: rowLayout.implicitWidth + Tokens.padding.larger * 2 + implicitHeight: horizontal ? baseSize : gridLayout.implicitHeight + Tokens.padding.normal * 2 + implicitWidth: horizontal ? gridLayout.implicitWidth + Tokens.padding.larger * 2 : baseSize radius: Tokens.rounding.full StateLayer { @@ -26,13 +26,14 @@ CustomRect { onClicked: root.visibilities.resources = !root.visibilities.resources } - RowLayout { - id: rowLayout + GridLayout { + id: gridLayout anchors.centerIn: parent - anchors.horizontalCenterOffset: -2 - implicitHeight: root.implicitHeight - spacing: Tokens.spacing.smaller + anchors.horizontalCenterOffset: root.horizontal ? 0 : 1 + anchors.verticalCenterOffset: root.horizontal ? 0 : -3 + columnSpacing: Tokens.spacing.smaller + columns: root.horizontal ? -1 : 1 ServiceRef { service: Gpu @@ -48,7 +49,9 @@ CustomRect { Resource { Layout.alignment: Qt.AlignVCenter - Layout.fillHeight: true + Layout.fillHeight: root.horizontal + Layout.fillWidth: !root.horizontal + horizontal: root.horizontal icon: "memory" iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary @@ -57,7 +60,9 @@ CustomRect { } Resource { - Layout.fillHeight: true + Layout.fillHeight: root.horizontal + Layout.fillWidth: !root.horizontal + horizontal: root.horizontal icon: "memory_alt" iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3secondary @@ -66,7 +71,9 @@ CustomRect { } Resource { - Layout.fillHeight: true + Layout.fillHeight: root.horizontal + Layout.fillWidth: !root.horizontal + horizontal: root.horizontal icon: "gamepad" iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3tertiary @@ -74,7 +81,9 @@ CustomRect { } Resource { - Layout.fillHeight: true + Layout.fillHeight: root.horizontal + Layout.fillWidth: !root.horizontal + horizontal: root.horizontal icon: "developer_board" iconColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface mainColor: root.visibilities.resources ? Colors.palette.m3onPrimary : Colors.palette.m3primary diff --git a/Modules/SysTray/StatusIcons.qml b/Modules/Bar/Components/StatusIcons.qml similarity index 63% rename from Modules/SysTray/StatusIcons.qml rename to Modules/Bar/Components/StatusIcons.qml index cb85c92..66f6158 100644 --- a/Modules/SysTray/StatusIcons.qml +++ b/Modules/Bar/Components/StatusIcons.qml @@ -3,13 +3,14 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import QtQuick.Layouts -import qs.Helpers -import qs.Modules.SysTray.Widgets import ZShell.Config +import qs.Helpers +import qs.Modules.Bar.Components.StatusIcons +import qs.Modules.Bar.Components.Common import qs.Components import qs.Services -CustomClippingRect { +WidgetBase { id: root readonly property int firstPresent: { @@ -21,7 +22,7 @@ CustomClippingRect { } // Index of the first/last entry that isn't collapsed, for edge margin gating - readonly property alias items: row + readonly property alias items: grid readonly property int lastPresent: { const values = model.values; for (let i = values.length - 1; i >= 0; i--) @@ -29,6 +30,7 @@ CustomClippingRect { return i; return -1; } + readonly property real size: horizontal ? grid.implicitWidth + Tokens.padding.small * 2 : grid.implicitHeight + Tokens.padding.small * 2 readonly property int spacing: Tokens.spacing.normal / 2 // Entries that can shrink to nothing, spacing included @@ -38,19 +40,36 @@ CustomClippingRect { return false; } - bottomLeftRadius: Tokens.rounding.smallest / 2 + bottomLeftRadius: horizontal ? Tokens.rounding.smallest / 2 : Tokens.rounding.full color: Colors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Tokens.padding.smallest * 2 - implicitWidth: row.implicitWidth + Tokens.padding.small * 2 + implicitHeight: horizontal ? baseSize : size + implicitWidth: horizontal ? size : baseSize radius: Tokens.rounding.full topLeftRadius: Tokens.rounding.smallest / 2 + topRightRadius: horizontal ? Tokens.rounding.full : Tokens.rounding.smallest / 2 - RowLayout { - id: row + Behavior on implicitHeight { + enabled: !root.horizontal + Anim { + } + } + Behavior on implicitWidth { + enabled: root.horizontal + + Anim { + } + } + + GridLayout { + id: grid + + anchors.bottomMargin: root.horizontal ? 0 : Tokens.padding.small anchors.fill: parent - anchors.leftMargin: Tokens.padding.small - anchors.rightMargin: Tokens.padding.small + anchors.leftMargin: root.horizontal ? Tokens.padding.small : 0 + anchors.rightMargin: root.horizontal ? Tokens.padding.small : 0 + anchors.topMargin: root.horizontal ? 0 : Tokens.padding.small + columns: root.horizontal ? -1 : 1 Repeater { model: ScriptModel { @@ -69,6 +88,7 @@ CustomClippingRect { name: "audio" AudioWidget { + horizontal: root.horizontal } } } @@ -80,6 +100,7 @@ CustomClippingRect { name: "audio" MicWidget { + horizontal: root.horizontal } } } @@ -91,6 +112,7 @@ CustomClippingRect { name: "upower" UPowerWidget { + horizontal: root.horizontal } } } @@ -109,8 +131,10 @@ CustomClippingRect { property real rightGap: present && index !== root.firstPresent ? margin : 0 Layout.alignment: Qt.AlignHCenter - Layout.leftMargin: Math.round(leftGap) - Layout.rightMargin: Math.round(rightGap) + Layout.bottomMargin: root.horizontal ? 0 : Math.round(rightGap) + Layout.leftMargin: root.horizontal ? Math.round(leftGap) : 0 + Layout.rightMargin: root.horizontal ? Math.round(rightGap) : 0 + Layout.topMargin: root.horizontal ? 0 : Math.round(leftGap) children: item implicitHeight: item?.implicitHeight ?? 0 implicitWidth: item?.implicitWidth ?? 0 diff --git a/Modules/SysTray/Widgets/AudioWidget.qml b/Modules/Bar/Components/StatusIcons/AudioWidget.qml similarity index 86% rename from Modules/SysTray/Widgets/AudioWidget.qml rename to Modules/Bar/Components/StatusIcons/AudioWidget.qml index 6f6661b..93c4752 100644 --- a/Modules/SysTray/Widgets/AudioWidget.qml +++ b/Modules/Bar/Components/StatusIcons/AudioWidget.qml @@ -1,16 +1,15 @@ import QtQuick import QtQuick.Layouts -import Quickshell.Io -import Quickshell.Services.Pipewire -import qs.Daemons -import qs.Modules import ZShell.Config +import qs.Daemons import qs.Components import qs.Services MaterialIcon { id: speaker + required property bool horizontal + animate: true color: Audio.muted ? Colors.palette.m3error : Colors.palette.m3onSurface fill: 1 diff --git a/Modules/SysTray/Widgets/MicWidget.qml b/Modules/Bar/Components/StatusIcons/MicWidget.qml similarity index 86% rename from Modules/SysTray/Widgets/MicWidget.qml rename to Modules/Bar/Components/StatusIcons/MicWidget.qml index 07492e0..8f58616 100644 --- a/Modules/SysTray/Widgets/MicWidget.qml +++ b/Modules/Bar/Components/StatusIcons/MicWidget.qml @@ -1,16 +1,15 @@ import QtQuick import QtQuick.Layouts -import Quickshell.Io -import Quickshell.Services.Pipewire -import qs.Daemons -import qs.Modules import ZShell.Config +import qs.Daemons import qs.Components import qs.Services MaterialIcon { id: mic + required property bool horizontal + animate: true color: (Audio.sourceMuted ?? false) ? Colors.palette.m3error : Colors.palette.m3onSurface fill: 1 diff --git a/Modules/SysTray/Widgets/UPowerWidget.qml b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml similarity index 97% rename from Modules/SysTray/Widgets/UPowerWidget.qml rename to Modules/Bar/Components/StatusIcons/UPowerWidget.qml index 6cb2345..380b912 100644 --- a/Modules/SysTray/Widgets/UPowerWidget.qml +++ b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml @@ -1,13 +1,15 @@ import Quickshell.Services.UPower import QtQuick import QtQuick.Layouts -import qs.Components import ZShell.Config +import qs.Components import qs.Helpers Item { id: root + required property bool horizontal + implicitHeight: Battery.isLaptop ? batteryIconLoader.item.implicitHeight : upowerIconLoader.item.implicitHeight implicitWidth: Battery.isLaptop ? batteryIconLoader.item.implicitWidth : upowerIconLoader.item.implicitWidth @@ -123,6 +125,7 @@ Item { Layout.alignment: Qt.AlignVCenter animate: true fill: 1 + font.pointSize: Tokens.font.size.larger text: { if (PowerProfiles.profile === PowerProfile.PowerSaver) return "energy_savings_leaf"; diff --git a/Modules/SysTray/TrayItem.qml b/Modules/Bar/Components/Tray/TrayItem.qml similarity index 95% rename from Modules/SysTray/TrayItem.qml rename to Modules/Bar/Components/Tray/TrayItem.qml index 8c74abc..d7b8fc1 100644 --- a/Modules/SysTray/TrayItem.qml +++ b/Modules/Bar/Components/Tray/TrayItem.qml @@ -1,10 +1,9 @@ import QtQuick.Layouts import QtQuick -import QtQuick.VectorImage import Quickshell import Quickshell.Services.SystemTray import qs.Helpers -import qs.Modules +import qs.Modules.Bar.Popouts import qs.Components import ZShell.Config import qs.Services @@ -13,10 +12,10 @@ Item { id: root property bool current: popouts.currentName.startsWith(`traymenu${ind}`) && popouts.hasCurrent - readonly property real dpr: Hypr.monitorFor(loader.screen).scale + readonly property real dpr: Hypr.monitorFor(loader?.screen).scale required property int ind required property SystemTrayItem item - required property RowLayout loader + required property GridLayout loader required property Wrapper popouts function resolveIcon(app: string, icon: string): string { diff --git a/Modules/SysTray/TrayIcons.qml b/Modules/Bar/Components/TrayIcons.qml similarity index 53% rename from Modules/SysTray/TrayIcons.qml rename to Modules/Bar/Components/TrayIcons.qml index c9e05e2..568ee28 100644 --- a/Modules/SysTray/TrayIcons.qml +++ b/Modules/Bar/Components/TrayIcons.qml @@ -3,33 +3,37 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import Quickshell.Services.SystemTray -import qs.Components import ZShell.Config -import qs.Modules.SysTray.Widgets -import qs.Modules +import qs.Components +import qs.Modules.Bar.Components.Common +import qs.Modules.Bar.Components.Tray +import qs.Modules.Bar.Popouts import qs.Services -CustomClippingRect { +WidgetBase { id: root readonly property alias items: repeater - readonly property alias layout: sysRow - required property RowLayout loader + readonly property alias layout: sysGrid + required property GridLayout loader readonly property int padding: Tokens.padding.small required property Wrapper popouts + readonly property real size: horizontal ? sysGrid.implicitWidth + Tokens.padding.small : sysGrid.implicitHeight + Tokens.padding.small + bottomLeftRadius: horizontal ? Tokens.rounding.full : Tokens.rounding.smallest / 2 bottomRightRadius: Tokens.rounding.smallest / 2 color: Colors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Tokens.padding.smallest * 2 - implicitWidth: sysRow.implicitWidth + Tokens.padding.small * 2 + implicitHeight: horizontal ? baseSize : size + implicitWidth: horizontal ? size : baseSize radius: Tokens.rounding.full - topRightRadius: Tokens.rounding.smallest / 2 + topRightRadius: horizontal ? Tokens.rounding.smallest / 2 : Tokens.rounding.full - RowLayout { - id: sysRow + GridLayout { + id: sysGrid anchors.centerIn: parent - spacing: 0 + columns: root.horizontal ? -1 : 1 + rowSpacing: -2 Repeater { id: repeater diff --git a/Modules/Bar/Components/UpdatesWidget.qml b/Modules/Bar/Components/UpdatesWidget.qml new file mode 100644 index 0000000..a525040 --- /dev/null +++ b/Modules/Bar/Components/UpdatesWidget.qml @@ -0,0 +1,41 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Components +import qs.Modules.Bar.Components.Common +import qs.Helpers +import qs.Services + +WidgetBase { + id: root + + property int countUpdates: Updates.availableUpdates + property color textColor: Colors.palette.m3onSurface + + color: Colors.tPalette.m3surfaceContainer + implicitHeight: horizontal ? baseSize : content.implicitHeight + Tokens.spacing.small + implicitWidth: horizontal ? content.implicitWidth + Tokens.spacing.small * 2 : baseSize + radius: Tokens.rounding.full + + GridLayout { + id: content + + anchors.centerIn: parent + columnSpacing: Tokens.spacing.small + columns: root.horizontal ? -1 : 1 + rowSpacing: -Tokens.spacing.extraSmall / 2 + + MaterialIcon { + Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter + font.pointSize: root.horizontal ? Tokens.font.size.larger : Tokens.font.size.large + text: "package_2" + } + + CustomText { + Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter + color: root.textColor + font.pointSize: Tokens.font.size.normal + text: root.countUpdates + } + } +} diff --git a/Modules/Bar/Components/WindowTitle.qml b/Modules/Bar/Components/WindowTitle.qml new file mode 100644 index 0000000..c213072 --- /dev/null +++ b/Modules/Bar/Components/WindowTitle.qml @@ -0,0 +1,98 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Services + +Item { + id: root + + required property var bar + property color color: Colors.palette.m3primary + property Title current: text1 + required property bool horizontal + // readonly property int maxWidth: 300 + readonly property int maxSize: { + const otherModules = bar.children.filter(c => c.enabled && c.entryId && c.item !== this && c.entryId !== "spacer"); + const otherSize = otherModules.reduce((acc, curr) => acc + (horizontal ? (curr.item?.nonAnimWidth ?? curr.width ?? 0) : (curr.item.nonAnimHeight ?? curr.height ?? 0)), 0); + return horizontal ? (bar.width - otherSize - bar.columnSpacing * (bar.children.length - 1) - bar.vPadding * 2) : (bar.height - otherSize - bar.rowSpacing * (bar.children.length - 1) - bar.vPadding * 4); + } + + clip: true + implicitHeight: horizontal ? current.implicitHeight : current.implicitWidth + current.anchors.topMargin + implicitWidth: horizontal ? current.implicitWidth + current.anchors.leftMargin : current.implicitHeight + current.anchors.topMargin + + Behavior on implicitHeight { + enabled: !root.horizontal + + Anim { + type: Anim.DefaultEffects + } + } + Behavior on implicitWidth { + enabled: root.horizontal + + Anim { + type: Anim.DefaultEffects + } + } + + Title { + id: text1 + } + + Title { + id: text2 + } + + TextMetrics { + id: metrics + + elide: Qt.ElideRight + elideWidth: root.maxSize - (root.horizontal ? root.current.anchors.leftMargin : 0) + font.family: "Rubik" + font.pointSize: Tokens.font.size.normal + text: Hypr.activeToplevel?.title ?? qsTr("Desktop") + + onElideWidthChanged: root.current.text = elidedText + onTextChanged: { + const next = root.current === text1 ? text2 : text1; + next.text = elidedText; + root.current = next; + } + } + + component Title: CustomText { + id: text + + anchors.horizontalCenter: root.horizontal ? undefined : parent.horizontalCenter + anchors.left: root.horizontal ? parent.left : undefined + anchors.leftMargin: root.horizontal ? Tokens.spacing.small : 0 + anchors.top: root.horizontal ? undefined : parent.top + anchors.topMargin: root.horizontal ? 0 : Tokens.spacing.small + anchors.verticalCenter: root.horizontal ? parent.verticalCenter : undefined + color: root.color + font.family: metrics.font.family + font.pointSize: metrics.font.pointSize + height: root.horizontal ? implicitHeight : implicitWidth + opacity: root.current === this ? 1 : 0 + width: root.horizontal ? implicitWidth : implicitHeight + + Behavior on opacity { + Anim { + } + } + transform: [ + Translate { + x: !root.horizontal ? -text.implicitWidth + text.implicitHeight : 0 + }, + Rotation { + angle: root.horizontal ? 0 : 270 + origin.x: text.implicitHeight / 2 + origin.y: text.implicitHeight / 2 + } + ] + } +} diff --git a/Modules/Workspaces.qml b/Modules/Bar/Components/Workspaces.qml similarity index 58% rename from Modules/Workspaces.qml rename to Modules/Bar/Components/Workspaces.qml index 98c3ee9..091ff2f 100644 --- a/Modules/Workspaces.qml +++ b/Modules/Bar/Components/Workspaces.qml @@ -2,10 +2,10 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Controls -import QtQuick.Effects import Quickshell import Quickshell.Hyprland import ZShell.Config +import qs.Modules.Bar.Components.Workspaces import qs.Components import qs.Services @@ -14,41 +14,57 @@ Item { property real activeWorkspaceMargin: Math.ceil(Tokens.padding.small / 2) readonly property int effectiveActiveWorkspaceId: monitor?.activeWorkspace?.id ?? 1 + required property bool horizontal readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) required property ShellScreen screen - property int workspaceButtonWidth: bgRect.implicitHeight - root.activeWorkspaceMargin * 2 + readonly property real shortSize: Math.max(Config.bar.height, Tokens.bar.innerSize) + readonly property real size: (workspaceButtonWidth * workspacesShown) + activeWorkspaceMargin * 2 + property int workspaceButtonWidth: (horizontal ? bgRect.implicitHeight : bgRect.implicitWidth) - root.activeWorkspaceMargin * 2 property int workspaceIndexInGroup: (effectiveActiveWorkspaceId - 1) % root.workspacesShown readonly property list workspaces: Hyprland.workspaces.values.filter(w => w.monitor === root.monitor) readonly property int workspacesShown: workspaces.length - height: implicitHeight - implicitHeight: Config.bar.height + Tokens.padding.smaller * 2 - implicitWidth: (root.workspaceButtonWidth * root.workspacesShown) + root.activeWorkspaceMargin * 2 + implicitHeight: horizontal ? shortSize : size + implicitWidth: horizontal ? size : shortSize + Behavior on implicitHeight { + enabled: !root.horizontal + + Anim { + } + } Behavior on implicitWidth { + enabled: root.horizontal + Anim { } } CustomRect { + // qmllint disable Quick.anchor-combinations id: bgRect - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter + anchors.bottom: root.horizontal ? undefined : parent.bottom + anchors.horizontalCenter: root.horizontal ? undefined : parent.horizontalCenter + anchors.left: root.horizontal ? parent.left : undefined + anchors.right: root.horizontal ? parent.right : undefined + anchors.top: root.horizontal ? undefined : parent.top + anchors.verticalCenter: root.horizontal ? parent.verticalCenter : undefined color: Colors.tPalette.m3surfaceContainer - implicitHeight: root.implicitHeight - ((Tokens.padding.small - 1) * 2) + implicitHeight: root.horizontal ? root.implicitHeight : 0 + implicitWidth: root.horizontal ? 0 : root.implicitWidth radius: height / 2 + // qmllint enable Quick.anchor-combinations Grid { id: grid anchors.fill: parent anchors.margins: root.activeWorkspaceMargin columnSpacing: 0 - columns: root.workspacesShown + columns: root.horizontal ? root.workspacesShown : 1 rowSpacing: 0 - rows: 1 + rows: root.horizontal ? 1 : root.workspacesShown Repeater { model: root.workspaces @@ -74,7 +90,7 @@ Item { color: Colors.palette.m3onSecondaryContainer elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter - text: button.modelData.name + text: button.modelData?.name ?? "" verticalAlignment: Text.AlignVCenter } } @@ -94,13 +110,15 @@ Item { property real indicatorPosition: Math.min(idxPair.idx1, idxPair.idx2) * root.workspaceButtonWidth + root.activeWorkspaceMargin property real indicatorThickness: root.workspaceButtonWidth - anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: root.horizontal ? undefined : parent.horizontalCenter + anchors.verticalCenter: root.horizontal ? parent.verticalCenter : undefined clip: true color: Colors.palette.m3primary - implicitHeight: indicatorThickness - implicitWidth: indicatorLength + implicitHeight: root.horizontal ? indicatorThickness : indicatorLength + implicitWidth: root.horizontal ? indicatorLength : indicatorThickness radius: Tokens.rounding.full - x: indicatorPosition + x: root.horizontal ? indicatorPosition : 0 + y: root.horizontal ? 0 : indicatorPosition AnimatedTabIndexPair { id: idxPair @@ -114,7 +132,8 @@ Item { implicitWidth: grid.width source: grid sourceColor: Colors.palette.m3onSurface - x: -indicator.x + 3 + x: root.horizontal ? -indicator.x + 3 : 0 + y: root.horizontal ? 0 : -indicator.y + 3 } } } diff --git a/Components/AnimatedTabIndexPair.qml b/Modules/Bar/Components/Workspaces/AnimatedTabIndexPair.qml similarity index 100% rename from Components/AnimatedTabIndexPair.qml rename to Modules/Bar/Components/Workspaces/AnimatedTabIndexPair.qml diff --git a/Modules/SysTray/Popouts/AudioPopout.qml b/Modules/Bar/Popouts/AudioPopout.qml similarity index 100% rename from Modules/SysTray/Popouts/AudioPopout.qml rename to Modules/Bar/Popouts/AudioPopout.qml diff --git a/Modules/Bar/Popouts/ClipWrapper.qml b/Modules/Bar/Popouts/ClipWrapper.qml new file mode 100644 index 0000000..a732c60 --- /dev/null +++ b/Modules/Bar/Popouts/ClipWrapper.qml @@ -0,0 +1,87 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import qs.Components +import ZShell.Config + +Item { + id: root + + required property real borderThickness + readonly property alias content: content + readonly property bool horizontal: position !== "left" + // property real offsetScale: y > 0 || content.hasCurrent ? 0 : 1 + property real offsetScale: { + if (content.hasCurrent) + return 0; + if (position === "top") + return y > 0 ? 0 : 1; + if (position === "bottom") + return 1; + return x > 0 ? 0 : 1; + } + required property string position + required property ShellScreen screen + + anchors.bottom: position === "bottom" ? parent.bottom : undefined + clip: true + implicitHeight: horizontal ? content.implicitHeight * (1 - offsetScale) : content.implicitHeight + implicitWidth: horizontal ? content.implicitWidth : content.implicitWidth * (1 - offsetScale) + visible: width > 0 && height > 0 + x: { + if (!horizontal) + return 0; + const off = content.currentCenter - borderThickness - content.nonAnimWidth / 2; + const diff = parent.width - Math.floor(off + content.nonAnimWidth); + if (diff < 0) + return off + diff; + return Math.floor(Math.max(off, 0)); + } + y: { + if (horizontal) + return 0; + + const off = content.currentCenter - borderThickness - content.nonAnimHeight / 2; + const diff = parent.height - Math.floor(off + content.nonAnimHeight); + if (diff < 0) + return off + diff; + return Math.max(off, 0); + } + + Behavior on offsetScale { + Anim { + } + } + Behavior on x { + enabled: !root.horizontal || root.offsetScale < 1 + + Anim { + duration: content.animLength + easing.bezierCurve: content.animCurve + } + } + Behavior on y { + enabled: root.horizontal || root.offsetScale < 1 + + Anim { + duration: content.animLength + easing.bezierCurve: content.animCurve + } + } + + Wrapper { + id: content + + anchors.bottom: root.position === "bottom" ? parent.bottom : undefined + anchors.bottomMargin: (-implicitHeight - 5) * root.offsetScale + anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined + anchors.left: root.horizontal ? undefined : parent.left + anchors.leftMargin: (-implicitWidth - 5) * root.offsetScale + anchors.top: root.position === "top" ? parent.top : undefined + anchors.topMargin: (-implicitHeight - 5) * root.offsetScale + anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter + offsetScale: root.offsetScale + screen: root.screen + } +} diff --git a/Modules/Content.qml b/Modules/Bar/Popouts/Content.qml similarity index 95% rename from Modules/Content.qml rename to Modules/Bar/Popouts/Content.qml index a2ec2d1..555b9d9 100644 --- a/Modules/Content.qml +++ b/Modules/Bar/Popouts/Content.qml @@ -5,10 +5,6 @@ import Quickshell.Services.SystemTray import QtQuick import ZShell.Config import qs.Components -import qs.Modules.WSOverview -import qs.Modules.Network -import qs.Modules.SysTray.Popouts -import qs.Modules.Updates Item { id: root diff --git a/Modules/Network/NetworkPopout.qml b/Modules/Bar/Popouts/NetworkPopout.qml similarity index 100% rename from Modules/Network/NetworkPopout.qml rename to Modules/Bar/Popouts/NetworkPopout.qml diff --git a/Modules/PopoutState.qml b/Modules/Bar/Popouts/PopoutState.qml similarity index 100% rename from Modules/PopoutState.qml rename to Modules/Bar/Popouts/PopoutState.qml diff --git a/Modules/SysTray/Popouts/TrayMenuPopout.qml b/Modules/Bar/Popouts/TrayMenuPopout.qml similarity index 100% rename from Modules/SysTray/Popouts/TrayMenuPopout.qml rename to Modules/Bar/Popouts/TrayMenuPopout.qml diff --git a/Modules/SysTray/Popouts/UPowerPopout.qml b/Modules/Bar/Popouts/UPowerPopout.qml similarity index 100% rename from Modules/SysTray/Popouts/UPowerPopout.qml rename to Modules/Bar/Popouts/UPowerPopout.qml diff --git a/Modules/Updates/UpdatesPopout.qml b/Modules/Bar/Popouts/UpdatesPopout.qml similarity index 98% rename from Modules/Updates/UpdatesPopout.qml rename to Modules/Bar/Popouts/UpdatesPopout.qml index a7aef14..b88b107 100644 --- a/Modules/Updates/UpdatesPopout.qml +++ b/Modules/Bar/Popouts/UpdatesPopout.qml @@ -78,8 +78,6 @@ CustomClippingRect { required property var modelData readonly property list sections: modelData.update.split(" ") - // anchors.left: parent.left - // anchors.right: parent.right color: Colors.tPalette.m3surfaceContainer implicitHeight: root.itemHeight implicitWidth: 600 diff --git a/Modules/Wrapper.qml b/Modules/Bar/Popouts/Wrapper.qml similarity index 100% rename from Modules/Wrapper.qml rename to Modules/Bar/Popouts/Wrapper.qml diff --git a/Modules/ClipWrapper.qml b/Modules/ClipWrapper.qml deleted file mode 100644 index cf6f057..0000000 --- a/Modules/ClipWrapper.qml +++ /dev/null @@ -1,56 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import Quickshell -import qs.Components -import ZShell.Config - -Item { - id: root - - required property real borderThickness - readonly property alias content: content - property real offsetScale: y > 0 || content.hasCurrent ? 0 : 1 - required property ShellScreen screen - - clip: true - implicitHeight: content.implicitHeight * (1 - offsetScale) - implicitWidth: content.implicitWidth - visible: width > 0 && height > 0 - x: { - const off = content.currentCenter - borderThickness - content.nonAnimWidth / 2; - const diff = parent.width - Math.floor(off + content.nonAnimWidth); - if (diff < 0) - return off + diff; - return Math.floor(Math.max(off, 0)); - } - - Behavior on offsetScale { - Anim { - } - } - Behavior on x { - enabled: root.offsetScale < 1 - - Anim { - duration: content.animLength - easing.bezierCurve: content.animCurve - } - } - Behavior on y { - Anim { - duration: content.animLength - easing.bezierCurve: content.animCurve - } - } - - Wrapper { - id: content - - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - anchors.topMargin: (-implicitHeight - 5) * root.offsetScale - offsetScale: root.offsetScale - screen: root.screen - } -} diff --git a/Modules/Clock.qml b/Modules/Clock.qml deleted file mode 100644 index f96fe16..0000000 --- a/Modules/Clock.qml +++ /dev/null @@ -1,44 +0,0 @@ -import Quickshell -import QtQuick -import QtQuick.Layouts -import ZShell.Config -import qs.Modules -import qs.Helpers -import qs.Components -import qs.Services - -CustomRect { - id: root - - required property RowLayout loader - required property Wrapper popouts - required property PersistentProperties visibilities - - color: visibilities.dashboard ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Tokens.padding.smallest * 2 - implicitWidth: timeText.contentWidth + Tokens.padding.normal * 2 - radius: Tokens.rounding.full - - CustomText { - id: timeText - - anchors.centerIn: parent - color: root.visibilities.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - font: Config.appearance.font.family.mono - text: Time.dateStr - - Behavior on color { - CAnim { - } - } - } - - StateLayer { - acceptedButtons: Qt.LeftButton - color: root.visibilities.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface - - onClicked: { - root.visibilities.dashboard = !root.visibilities.dashboard; - } - } -} diff --git a/Modules/Dashboard/Wrapper.qml b/Modules/Dashboard/Wrapper.qml index 9b401ba..c78a97f 100644 --- a/Modules/Dashboard/Wrapper.qml +++ b/Modules/Dashboard/Wrapper.qml @@ -16,9 +16,16 @@ Item { } readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0 required property real offsetScale + required property string position readonly property bool shouldBeActive: root.visibilities.dashboard && Config.dashboard.enabled required property PersistentProperties visibilities + anchors.bottom: position === "bottom" ? parent.bottom : undefined + anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0 + anchors.left: position === "left" ? parent.left : undefined + anchors.leftMargin: position === "left" ? (-implicitWidth - 5) * offsetScale : 0 + anchors.top: position === "top" ? parent.top : undefined + anchors.topMargin: position === "top" ? (-implicitHeight - 5) * offsetScale : 0 implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth || 854 opacity: 1 - offsetScale @@ -28,8 +35,11 @@ Item { id: content active: root.shouldBeActive || root.visible - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: root.position === "top" ? parent.bottom : undefined + anchors.horizontalCenter: root.position !== "left" ? parent.horizontalCenter : undefined + anchors.right: root.position !== "left" ? undefined : parent.right + anchors.top: root.position === "bottom" ? parent.top : undefined + anchors.verticalCenter: root.position === "left" ? undefined : parent.verticalCenter sourceComponent: Content { dashState: root.dashState diff --git a/Modules/Launcher/Services/SchemeVariants.qml b/Modules/Launcher/Services/SchemeVariants.qml index 27ba36d..08a754f 100644 --- a/Modules/Launcher/Services/SchemeVariants.qml +++ b/Modules/Launcher/Services/SchemeVariants.qml @@ -82,7 +82,6 @@ Searcher { list.visibilities.launcher = false; Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--scheme", variant]); Config.colors.schemeType = variant; - Config.save(); } } } diff --git a/Modules/MediaWidget.qml b/Modules/MediaWidget.qml deleted file mode 100644 index f7de773..0000000 --- a/Modules/MediaWidget.qml +++ /dev/null @@ -1,77 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import qs.Components -import qs.Daemons -import ZShell.Config -import qs.Helpers -import qs.Services - -CustomRect { - id: root - - readonly property string currentMedia: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") - readonly property int textWidth: Math.min(metrics.width, 200) - - color: Colors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Tokens.padding.smallest * 2 - implicitWidth: layout.implicitWidth + Tokens.padding.normal * 2 - radius: Tokens.rounding.full - - Behavior on implicitWidth { - Anim { - } - } - - TextMetrics { - id: metrics - - font: mediatext.font - text: mediatext.text - } - - RowLayout { - id: layout - - anchors.centerIn: parent - - Behavior on implicitWidth { - Anim { - } - } - - MaterialIcon { - animate: true - color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.larger - text: Players.active?.isPlaying ? "music_note" : "music_off" - } - - MarqueeText { - id: mediatext - - Layout.preferredWidth: root.textWidth - animate: true - color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface - font.pointSize: Tokens.font.size.normal - horizontalAlignment: Text.AlignHCenter - marqueeEnabled: false - pauseMs: 4000 - text: root.currentMedia - width: root.textWidth - - CustomMouseArea { - anchors.fill: parent - hoverEnabled: true - - onContainsMouseChanged: { - if (!containsMouse) { - mediatext.marqueeEnabled = false; - } else { - mediatext.marqueeEnabled = true; - mediatext.anim.start(); - } - } - } - } - } -} diff --git a/Modules/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml index a0a9806..52f4c1b 100644 --- a/Modules/Resources/Wrapper.qml +++ b/Modules/Resources/Wrapper.qml @@ -10,10 +10,16 @@ Item { readonly property real nonAnimHeight: content.item?.nonAnimHeight ?? 0 property real offsetScale: shouldBeActive ? 0 : 1 + required property string position readonly property bool shouldBeActive: root.visibilities.resources required property PersistentProperties visibilities - anchors.topMargin: (-implicitHeight - 5) * offsetScale + anchors.bottom: position === "bottom" ? parent.bottom : undefined + anchors.bottomMargin: position === "bottom" ? (-implicitHeight - 5) * offsetScale : 0 + anchors.left: parent.left + anchors.leftMargin: position === "left" ? (-implicitWidth - 5) * offsetScale : 0 + anchors.top: position !== "bottom" ? parent.top : undefined + anchors.topMargin: position === "top" ? (-implicitHeight - 5) * offsetScale : 0 clip: true implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth || 854 // Hard coded fallback for first open @@ -31,7 +37,11 @@ Item { id: content active: root.shouldBeActive || root.visible - anchors.centerIn: parent + anchors.bottom: root.position === "top" ? parent.bottom : undefined + anchors.horizontalCenter: root.position !== "left" ? parent.horizontalCenter : undefined + anchors.right: root.position !== "left" ? undefined : parent.right + anchors.top: root.position === "bottom" ? parent.top : undefined + anchors.verticalCenter: root.position === "left" ? undefined : parent.verticalCenter sourceComponent: Content { wrapper: root diff --git a/Modules/Settings/Pages/Panels/BarPanel.qml b/Modules/Settings/Pages/Panels/BarPanel.qml index 7c78975..31e613a 100644 --- a/Modules/Settings/Pages/Panels/BarPanel.qml +++ b/Modules/Settings/Pages/Panels/BarPanel.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick.Layouts import ZShell.Config +import qs.Components import qs.Modules.Settings.Common PageBase { @@ -25,7 +26,6 @@ PageBase { ToggleRow { checked: Config.bar.autoHide first: true - last: true settingAnchor: "bar-autohide" subtext: qsTr("Hide the bar, reveal on hover") text: qsTr("Auto hide") @@ -33,6 +33,34 @@ PageBase { onToggled: Config.bar.autoHide = checked } + SelectRow { + active: Config.bar.position === "top" ? menuItems[0] : Config.bar.position === "left" ? menuItems[1] : menuItems[2] + last: true + settingAnchor: "bar-position" + subtext: qsTr("Change which edge the bar appears on") + text: qsTr("Position") + + menuItems: [ + MenuItem { + icon: "build" + text: qsTr("Top") + value: "top" + }, + MenuItem { + icon: "rotate_auto" + text: qsTr("Left") + value: "left" + }, + MenuItem { + icon: "rotate_auto" + text: qsTr("Bottom") + value: "bottom" + } + ] + + onSelected: item => Config.bar.position = item.value + } + // Components SectionHeader { text: qsTr("Components") diff --git a/Modules/Settings/Pages/ScreenshotPage.qml b/Modules/Settings/Pages/ScreenshotPage.qml index 28f6180..2a08a2d 100644 --- a/Modules/Settings/Pages/ScreenshotPage.qml +++ b/Modules/Settings/Pages/ScreenshotPage.qml @@ -70,10 +70,7 @@ PageBase { } ] - onSelected: item => { - Config.screenshot.mode = item.value; - Config.save(); - } + onSelected: item => Config.screenshot.mode = item.value } SectionHeader { diff --git a/Modules/Settings/Pages/WallpaperPage.qml b/Modules/Settings/Pages/WallpaperPage.qml index 52e688c..6f3e14a 100644 --- a/Modules/Settings/Pages/WallpaperPage.qml +++ b/Modules/Settings/Pages/WallpaperPage.qml @@ -104,7 +104,6 @@ PageBase { onApplySettings: (start, end) => { Config.general.color.scheduleDarkStart = start; Config.general.color.scheduleDarkEnd = end; - Config.save(); ModeScheduler.checkStartup(); PopupManager.requestClose(); } @@ -146,7 +145,6 @@ PageBase { onApplySettings: (start, end) => { Config.general.color.scheduleHyprsunsetStart = start; Config.general.color.scheduleHyprsunsetEnd = end; - Config.save(); Hyprsunset.checkStartup(); PopupManager.requestClose(); } diff --git a/Modules/SysTray/TrayWidget.qml b/Modules/SysTray/TrayWidget.qml deleted file mode 100644 index 8e94948..0000000 --- a/Modules/SysTray/TrayWidget.qml +++ /dev/null @@ -1,144 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import QtQuick.Layouts -import Quickshell.Services.SystemTray -import qs.Components -import ZShell.Config -import qs.Modules.SysTray.Widgets -import qs.Modules -import qs.Services - -RowLayout { - id: root - - readonly property alias items: repeater - required property RowLayout loader - required property Wrapper popouts - - function closestRowChild(row, x) { - let child = row.childAt(x, row.height / 2); - if (child) - return child; - - let closest = null; - let closestDistance = Infinity; - - for (let i = 0; i < row.children.length; ++i) { - let c = row.children[i]; - - if (!c.visible || c.width <= 0) - continue; - - let centerX = c.x + c.width / 2; - let dist = Math.abs(x - centerX); - - if (dist < closestDistance) { - closestDistance = dist; - closest = c; - } - } - - return closest; - } - - function getHoveredSubItem(localX, localY) { - let modPos = mapToItem(sysTrayMod, localX, localY); - if (sysTrayMod.contains(Qt.point(modPos.x, modPos.y))) { - let modRowPos = sysTrayMod.mapToItem(sysIcons, modPos.x, modPos.y); - let child = closestRowChild(sysIcons, modRowPos.x); - if (child) { - if (child.objectName === "audio" && Config.bar.popouts.audio) - return { - id: "audio", - item: child - }; - if (child.objectName === "microphone" && Config.bar.popouts.audio) - return { - id: "audio", - item: child - }; - if (child.objectName === "upower" && Config.bar.popouts.upower) - return { - id: "upower", - item: child - }; - } - } - - let trayPos = mapToItem(sysTray, localX, localY); - if (Config.bar.tray.showOnHover && sysTray.contains(Qt.point(trayPos.x, trayPos.y))) { - let trayRowPos = sysTray.mapToItem(sysRow, trayPos.x, trayPos.y); - let child = sysRow.childAt(trayRowPos.x, trayRowPos.y); - if (child && child.hasOwnProperty("ind")) { - return { - id: `traymenu${child.ind}`, - item: child - }; - } - } - - return null; - } - - height: Config.bar.height + Tokens.padding.smallest * 2 - spacing: Tokens.padding.small - width: sysTray.implicitWidth + sysTrayMod.implicitWidth + Tokens.padding.small - - CustomClippingRect { - id: sysTray - - Layout.fillHeight: true - bottomRightRadius: Tokens.rounding.smallest / 2 - color: Colors.tPalette.m3surfaceContainer - implicitWidth: sysRow.width + Tokens.padding.small * 2 - radius: Tokens.rounding.full - topRightRadius: Tokens.rounding.smallest / 2 - - Row { - id: sysRow - - anchors.centerIn: parent - spacing: 0 - - Repeater { - id: repeater - - model: SystemTray.items - - TrayItem { - id: trayItem - - required property int index - required property SystemTrayItem modelData - - implicitHeight: 34 - implicitWidth: 34 - ind: index - item: modelData - loader: root.loader - popouts: root.popouts - } - } - } - } - - CustomClippingRect { - id: sysTrayMod - - Layout.fillHeight: true - bottomLeftRadius: Tokens.rounding.smallest / 2 - color: Colors.tPalette.m3surfaceContainer - implicitWidth: sysIcons.implicitWidth + Tokens.padding.smaller + Tokens.padding.normal - radius: Tokens.rounding.full - topLeftRadius: Tokens.rounding.smallest / 2 - - StatusIcons { - id: sysIcons - - anchors.fill: parent - anchors.leftMargin: Tokens.padding.smaller - anchors.rightMargin: Tokens.padding.normal - } - } -} diff --git a/Modules/Updates/UpdatesWidget.qml b/Modules/Updates/UpdatesWidget.qml deleted file mode 100644 index 04bf907..0000000 --- a/Modules/Updates/UpdatesWidget.qml +++ /dev/null @@ -1,37 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import qs.Components -import qs.Modules -import qs.Helpers -import ZShell.Config -import qs.Services - -CustomRect { - id: root - - property int countUpdates: Updates.availableUpdates - property color textColor: Colors.palette.m3onSurface - - color: Colors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Tokens.padding.smallest * 2 - implicitWidth: contentRow.implicitWidth + Tokens.spacing.small * 2 - radius: Tokens.rounding.full - - RowLayout { - id: contentRow - - anchors.centerIn: parent - spacing: Tokens.spacing.small - - MaterialIcon { - font.pointSize: Tokens.font.size.larger - text: "package_2" - } - - CustomText { - color: root.textColor - font.pointSize: Tokens.font.size.normal - text: root.countUpdates - } - } -} diff --git a/Modules/WSOverview/OverviewPopout.qml b/Modules/WSOverview/OverviewPopout.qml deleted file mode 100644 index 9f42165..0000000 --- a/Modules/WSOverview/OverviewPopout.qml +++ /dev/null @@ -1,86 +0,0 @@ -import Quickshell -import Quickshell.Hyprland -import Quickshell.Wayland -import QtQuick -import QtQuick.Layouts -import ZShell.Config -import qs.Components -import qs.Modules -import qs.Helpers -import qs.Services - -Item { - id: root - - required property ShellScreen screen - required property Item wrapper - - implicitHeight: layout.implicitHeight + 16 - implicitWidth: layout.implicitWidth + 16 - - GridLayout { - id: layout - - anchors.centerIn: parent - columnSpacing: 8 - rowSpacing: 8 - - Repeater { - model: Hypr.workspaces - - CustomRect { - id: workspacePreview - - required property HyprlandWorkspace modelData - - Layout.preferredHeight: 180 + 10 - Layout.preferredWidth: 320 + 10 - border.color: "white" - border.width: 1 - radius: Tokens.rounding.smallest - - Repeater { - model: workspacePreview.modelData.toplevels - - Item { - id: preview - - property rect appPosition: { - let { - at: [cx, cy], - size: [cw, ch] - } = modelData.lastIpcObject; - - cx -= modelData.monitor.x; - cy -= modelData.monitor.y; - - return Qt.rect((cx / 8), (cy / 8), (cw / 8), (ch / 8)); - } - required property HyprlandToplevel modelData - - anchors.fill: parent - anchors.margins: 5 - - CustomRect { - border.color: Colors.tPalette.m3outline - border.width: 1 - implicitHeight: preview.appPosition.height - implicitWidth: preview.appPosition.width - radius: Tokens.rounding.smallest / 2 - x: preview.appPosition.x - y: preview.appPosition.y - 3.4 - - ScreencopyView { - id: previewCopy - - anchors.fill: parent - captureSource: preview.modelData.wayland - live: true - } - } - } - } - } - } - } -} diff --git a/Modules/WindowTitle.qml b/Modules/WindowTitle.qml deleted file mode 100644 index 9258ef4..0000000 --- a/Modules/WindowTitle.qml +++ /dev/null @@ -1,75 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import qs.Components -import ZShell.Config -import qs.Helpers -import qs.Services - -Item { - id: root - - required property var bar - property color colour: Colors.palette.m3primary - property Title current: text1 - // readonly property int maxWidth: 300 - readonly property int maxWidth: { - const otherModules = bar.children.filter(c => c.enabled && c.id && c.item !== this && c.id !== "spacer"); - const otherWidth = otherModules.reduce((acc, curr) => { - return acc + (curr.item?.nonAnimWidth ?? curr.width ?? 0); - }, 0); - return bar.width - otherWidth - bar.spacing * (bar.children.length - 1) - bar.vPadding * 2; - } - - clip: true - implicitHeight: current.implicitHeight - implicitWidth: Math.min(current.implicitWidth, root.maxWidth) - - Behavior on implicitWidth { - Anim { - } - } - - Title { - id: text1 - } - - Title { - id: text2 - } - - TextMetrics { - id: metrics - - elide: Qt.ElideRight - elideWidth: root.maxWidth - font.family: "Rubik" - font.pointSize: Tokens.font.size.normal - text: Hypr.activeToplevel?.title ?? qsTr("Desktop") - - onElideWidthChanged: root.current.text = elidedText - onTextChanged: { - const next = root.current === text1 ? text2 : text1; - next.text = elidedText; - root.current = next; - } - } - - component Title: CustomText { - id: text - - anchors.leftMargin: 7 - anchors.verticalCenter: parent.verticalCenter - color: root.colour - font.family: metrics.font.family - font.pointSize: metrics.font.pointSize - height: implicitHeight - opacity: root.current === this ? 1 : 0 - width: implicitWidth - - Behavior on opacity { - Anim { - } - } - } -} diff --git a/Plugins/ZShell/Config/bar.hpp b/Plugins/ZShell/Config/bar.hpp index fdb1c6c..4a0c8ae 100644 --- a/Plugins/ZShell/Config/bar.hpp +++ b/Plugins/ZShell/Config/bar.hpp @@ -2,6 +2,8 @@ #include "configlist.hpp" #include "configobject.hpp" +#include +#include #include namespace ZShell::config { @@ -61,6 +63,7 @@ class Bar : public ConfigObject { CFG_PROPERTY(int, revealDelay, 100) CFG_PROPERTY(int, rounding, 14) CFG_PROPERTY(int, smoothing, 32) + CFG_PROPERTY(QString, position, QStringLiteral("top")) CONFIG_SUBOBJECT(Tray, tray) CONFIG_LIST( EntryList, diff --git a/Plugins/ZShell/Config/config.cpp b/Plugins/ZShell/Config/config.cpp index c0fcc9d..268b4c1 100644 --- a/Plugins/ZShell/Config/config.cpp +++ b/Plugins/ZShell/Config/config.cpp @@ -61,6 +61,8 @@ Config::Config(QObject* parent) m_reloadTimer.setInterval(50); connect(&m_reloadTimer, &QTimer::timeout, this, &Config::reloadAsync); + connectAutoSave(this); + connect( &m_watcher, &QFileSystemWatcher::directoryChanged, @@ -93,15 +95,31 @@ QString Config::filePath() const { void Config::loadSync() { m_loading = true; + QFile f(filePath()); + QJsonObject before; + + bool existed = false; + if (f.open(QIODevice::ReadOnly)) { const auto doc = QJsonDocument::fromJson(f.readAll()); - if (doc.isObject()) loadFromJson(QJsonValue(doc.object())); + if (doc.isObject()) { + before = doc.object(); + existed = true; + } else { + qInfo() << "Config: existing config at" << filePath() + << "is empty or not a valid JSON object - using defaults"; + } } else { qInfo() << "Config: no existing config at" << filePath() << "- using defaults"; } + + loadFromJson(QJsonValue(before)); m_loading = false; + + const auto after = toJson().toObject(); + if (!existed || after != before) saveNow(); } void Config::updateWatch() { @@ -146,27 +164,21 @@ void Config::loadAsync() { if (f.open(QIODevice::ReadOnly)) { const auto doc = QJsonDocument::fromJson(f.readAll()); + const bool valid = doc.isObject(); + const QJsonObject before = valid ? doc.object() : QJsonObject(); - if (doc.isObject()) { - QMetaObject::invokeMethod( - this, - [this, doc]() { - loadFromJson(QJsonValue(doc.object())); - m_loading = false; + QMetaObject::invokeMethod( + this, + [this, valid, before]() { + loadFromJson(QJsonValue(before)); + m_loading = false; - if (m_reloadPending) m_reloadTimer.start(); - }, - Qt::QueuedConnection); - } else { - QMetaObject::invokeMethod( - this, - [this]() { - m_loading = false; + const auto after = toJson().toObject(); + if (!valid || after != before) saveNow(); - if (m_reloadPending) m_reloadTimer.start(); - }, - Qt::QueuedConnection); - } + if (m_reloadPending) m_reloadTimer.start(); + }, + Qt::QueuedConnection); } else { qInfo() << "Config: failed to reload from" << filePath() << "- using in-memory values"; @@ -232,6 +244,15 @@ void Config::saveNow() { flushAsync(); } +void Config::connectAutoSave(ConfigNode* node) { + connect(node, &ConfigNode::propertiesChanged, this, [this] { + scheduleSave(); + }); + + for (auto* child : node->childNodes()) + connectAutoSave(child); +} + bool Config::writeAtomically(const QByteArray& data) { QSaveFile f(filePath()); diff --git a/Plugins/ZShell/Config/config.hpp b/Plugins/ZShell/Config/config.hpp index 1a201f3..e1881c5 100644 --- a/Plugins/ZShell/Config/config.hpp +++ b/Plugins/ZShell/Config/config.hpp @@ -90,6 +90,7 @@ class Config : public ConfigObject { void updateWatch(); void loadSync(); void loadAsync(); + void connectAutoSave(ConfigNode* node); QTimer m_saveTimer; QTimer m_reloadTimer; diff --git a/Plugins/ZShell/Config/configlist.cpp b/Plugins/ZShell/Config/configlist.cpp index ef60bbd..4f75cda 100644 --- a/Plugins/ZShell/Config/configlist.cpp +++ b/Plugins/ZShell/Config/configlist.cpp @@ -79,13 +79,14 @@ void ConfigList::loadFromJson(const QJsonValue& json) { if (!json.isArray()) { qCWarning( lcConfig, - "Option '%s' must be a list, ignoring", + "Option '%s' must be a list, resetting to default", qUtf8Printable(propertyPath())); - m_rejectedJson = json; + + resetToDefaults(); + materializeDefaults(); return; } - m_rejectedJson = QJsonValue::Undefined; populate(json.toArray()); m_loaded = true; } @@ -93,12 +94,11 @@ void ConfigList::loadFromJson(const QJsonValue& json) { QJsonValue ConfigList::toJson() const { if (m_loaded) return elementsToJson(); - return m_rejectedJson; + return QJsonValue::Undefined; } void ConfigList::clearLoadedKeys() { m_loaded = false; - m_rejectedJson = QJsonValue::Undefined; } QStringList ConfigList::unknownKeys() const { @@ -113,6 +113,15 @@ QStringList ConfigList::unknownKeys() const { return keys; } +void ConfigList::materializeDefaults() { + for (auto* const item : m_items) + item->materializeDefaults(); + + if (m_global) return; + + m_loaded = true; +} + void ConfigList::resyncFromGlobal() { syncValuesFromGlobal(); } diff --git a/Plugins/ZShell/Config/configlist.hpp b/Plugins/ZShell/Config/configlist.hpp index 3ee24ba..5787526 100644 --- a/Plugins/ZShell/Config/configlist.hpp +++ b/Plugins/ZShell/Config/configlist.hpp @@ -33,6 +33,7 @@ class ConfigList : public ConfigNode { [[nodiscard]] QJsonValue toJson() const override; void clearLoadedKeys() override; [[nodiscard]] QStringList unknownKeys() const override; + void materializeDefaults() override; void resyncFromGlobal() override; signals: @@ -71,7 +72,6 @@ class ConfigList : public ConfigNode { QJsonArray m_defaults; QList m_items; bool m_loaded = false; - QJsonValue m_rejectedJson = QJsonValue::Undefined; }; } // namespace ZShell::config @@ -103,7 +103,6 @@ class ConfigList : public ConfigNode { } \ }; - #define CONFIG_LIST(Type, name, ...) \ Q_PROPERTY(ZShell::config::Type* name READ name CONSTANT) \ \ @@ -115,6 +114,8 @@ class ConfigList : public ConfigNode { private: \ Type* m_##name = new Type(this __VA_OPT__(, __VA_ARGS__)); +#define LIST_ENTRY(id, enabled) \ + vmap({{"id", QString::fromUtf8(#id)}, {"enabled", enabled}}) namespace ZShell::config { @@ -136,6 +137,3 @@ class ListEntry : public ConfigObject { CONFIG_LIST_TYPE(ListEntry, EntryList) } // namespace ZShell::config - -#define LIST_ENTRY(id, enabled) \ - vmap({{"id", QString::fromUtf8(#id)}, {"enabled", enabled}}) diff --git a/Plugins/ZShell/Config/confignode.hpp b/Plugins/ZShell/Config/confignode.hpp index 4316f34..d4e4389 100644 --- a/Plugins/ZShell/Config/confignode.hpp +++ b/Plugins/ZShell/Config/confignode.hpp @@ -26,6 +26,8 @@ class ConfigNode : public QObject { [[nodiscard]] virtual QStringList unknownKeys() const = 0; [[nodiscard]] virtual QList childNodes() const; + virtual void materializeDefaults() = 0; + void syncFromGlobal(ConfigNode* global); virtual void resyncFromGlobal() = 0; diff --git a/Plugins/ZShell/Config/configobject.cpp b/Plugins/ZShell/Config/configobject.cpp index 4fb0056..9ee0517 100644 --- a/Plugins/ZShell/Config/configobject.cpp +++ b/Plugins/ZShell/Config/configobject.cpp @@ -7,6 +7,51 @@ namespace ZShell::config { +namespace { + +bool isStringArray(const QJsonArray& arr) { + for (const auto& v : arr) { + if (!v.isString()) return false; + } + return true; +} + +bool jsonValueMatchesType(const QJsonValue& val, QMetaType type) { + switch (val.type()) { + case QJsonValue::Bool: + return type.id() == QMetaType::Bool; + + case QJsonValue::Double: + switch (type.id()) { + case QMetaType::Int: + case QMetaType::UInt: + case QMetaType::LongLong: + case QMetaType::ULongLong: + case QMetaType::Double: + case QMetaType::Float: + return true; + default: + return false; + } + + case QJsonValue::String: + return type.id() == QMetaType::QString; + + case QJsonValue::Array: + if (type.id() == QMetaType::QStringList) + return isStringArray(val.toArray()); + return type.id() == QMetaType::QVariantList; + + case QJsonValue::Object: + case QJsonValue::Null: + case QJsonValue::Undefined: + default: + return false; + } +} + +} // namespace + ConfigObject::ConfigObject(QObject* parent) : ConfigNode(parent) {} void ConfigObject::loadFromJson(const QJsonValue& json) { @@ -14,6 +59,7 @@ void ConfigObject::loadFromJson(const QJsonValue& json) { const auto* meta = metaObject(); QSet known; + QSet invalid; for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { auto prop = meta->property(i); @@ -34,6 +80,15 @@ void ConfigObject::loadFromJson(const QJsonValue& json) { if (!prop.isWritable()) continue; + if (!jsonValueMatchesType(jsonVal, prop.metaType())) { + qWarning() << "Config: type mismatch for" << key << "in" + << meta->className() << "- expected" + << prop.metaType().name() << "got value" << jsonVal + << "- resetting to default"; + invalid.insert(key); + continue; + } + if (prop.metaType().id() == QMetaType::QStringList) { QStringList list; const auto jsonArr = jsonVal.toArray(); @@ -56,10 +111,12 @@ void ConfigObject::loadFromJson(const QJsonValue& json) { m_extras = {}; for (auto it = obj.begin(); it != obj.end(); ++it) { - if (!known.contains(it.key())) { + if (!known.contains(it.key()) || invalid.contains(it.key())) { m_extras.insert(it.key(), it.value()); } } + + materializeDefaults(); } QJsonValue ConfigObject::toJson() const { @@ -104,9 +161,6 @@ QJsonValue ConfigObject::toJson() const { obj.insert(key, QJsonValue::fromVariant(value)); } - for (auto it = m_extras.begin(); it != m_extras.end(); ++it) - obj.insert(it.key(), it.value()); - if (obj.isEmpty()) return QJsonValue::Undefined; return obj; @@ -159,6 +213,26 @@ QList ConfigObject::childNodes() const { return nodes; } +void ConfigObject::materializeDefaults() { + const auto* meta = metaObject(); + + for (int i = basePropertyOffset(); i < meta->propertyCount(); ++i) { + const auto prop = meta->property(i); + const auto key = QString::fromUtf8(prop.name()); + + if (auto* const node = prop.read(this).value()) { + node->materializeDefaults(); + continue; + } + + if (!prop.isWritable()) continue; + + if (m_global) continue; + + m_loadedKeys.insert(key); + } +} + void ConfigObject::syncValuesFromGlobal() { const auto* meta = metaObject(); diff --git a/Plugins/ZShell/Config/configobject.hpp b/Plugins/ZShell/Config/configobject.hpp index 4814e6f..7de39ed 100644 --- a/Plugins/ZShell/Config/configobject.hpp +++ b/Plugins/ZShell/Config/configobject.hpp @@ -62,6 +62,7 @@ class ConfigObject : public ConfigNode { void clearLoadedKeys() override; [[nodiscard]] QStringList unknownKeys() const override; [[nodiscard]] QList childNodes() const override; + void materializeDefaults() override; void resyncFromGlobal() override; [[nodiscard]] virtual QStringList identityKeys() const; diff --git a/Plugins/ZShell/Config/tokens.hpp b/Plugins/ZShell/Config/tokens.hpp index 79056c1..7c83e47 100644 --- a/Plugins/ZShell/Config/tokens.hpp +++ b/Plugins/ZShell/Config/tokens.hpp @@ -3,7 +3,9 @@ #include "configobject.hpp" #include +#include #include +#include #include namespace ZShell::config { @@ -195,6 +197,16 @@ class AnimTokens : public ConfigObject { , m_durations(new AnimDurations(this)) {} }; +class BarTokens : public ConfigObject { + Q_OBJECT + QML_ANONYMOUS + + CFG_PROPERTY(int, innerSize, 28) + + public: + explicit BarTokens(QObject* parent = nullptr) : ConfigObject(parent) {} +}; + class Tokens : public ConfigObject { Q_OBJECT QML_ELEMENT @@ -205,6 +217,7 @@ class Tokens : public ConfigObject { CONFIG_SUBOBJECT(AppearanceSpacing, spacing) CONFIG_SUBOBJECT(FontTokens, font) CONFIG_SUBOBJECT(AnimTokens, anim) + CONFIG_SUBOBJECT(BarTokens, bar) public: explicit Tokens(QObject* parent = nullptr) @@ -213,7 +226,8 @@ class Tokens : public ConfigObject { , m_padding(new AppearancePadding(this)) , m_spacing(new AppearanceSpacing(this)) , m_font(new FontTokens(this)) - , m_anim(new AnimTokens(this)) {} + , m_anim(new AnimTokens(this)) + , m_bar(new BarTokens(this)) {} static Tokens* create(QQmlEngine*, QJSEngine*) { return new Tokens(); } }; diff --git a/Services/Colors.qml b/Services/Colors.qml index f11b78b..bff79f1 100644 --- a/Services/Colors.qml +++ b/Services/Colors.qml @@ -113,7 +113,6 @@ Singleton { function setMode(mode: string): void { Quickshell.execDetached(["zshell-cli", "scheme", "generate", "--mode", mode]); Config.general.color.mode = mode; - Config.save(); } function swapRG(c: color): color { diff --git a/ci/Dockerfile b/ci/Dockerfile index dd94cce..e080260 100644 --- a/ci/Dockerfile +++ b/ci/Dockerfile @@ -30,14 +30,24 @@ RUN pacman -S --noconfirm \ glib2 # AUR: libcava (provides development headers for cava) -RUN pacman -S --needed --noconfirm sudo && \ +RUN pacman -Syu --noconfirm && \ + pacman -S --needed --noconfirm \ + sudo \ + meson \ + autoconf-archive \ + python-tqdm && \ useradd -m builder && \ - echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \ - su builder -c " \ - git clone https://aur.archlinux.org/libcava.git /tmp/libcava && \ - cd /tmp/libcava && \ - makepkg -si --noconfirm \ - " && \ + echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + +RUN for i in 1 2 3 4 5; do \ + rm -rf /tmp/libcava && \ + git clone --depth 1 https://aur.archlinux.org/libcava.git /tmp/libcava && \ + break || sleep 5; \ + done && \ + test -d /tmp/libcava/.git && \ + chown -R builder /tmp/libcava + +RUN su builder -c "cd /tmp/libcava && makepkg -si --noconfirm" && \ rm -rf /tmp/libcava && \ userdel -r builder && \ sed -i '/^builder/d' /etc/sudoers diff --git a/pyproject.toml b/pyproject.toml index 2c53644..9d0589d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,9 @@ dependencies = [ "materialyoucolor" ] +[project.optional-dependencies] +dev = ["pytest"] + [project.scripts] zshell-cli = "zshell:main"