diff --git a/.gitea/workflows/python.yml b/.gitea/workflows/python.yml index 011ba51..06377a8 100644 --- a/.gitea/workflows/python.yml +++ b/.gitea/workflows/python.yml @@ -1,12 +1,15 @@ name: Python +env: + APT_DEPS: git python3 python3-pip python3-venv python3-gi python3-cairo python3-dbus python3-pyudev python3-psutil python3-evdev python3-yaml python3-xlib python3-pillow gir1.2-gtk-3.0 + on: pull_request: jobs: - fmt: - runs-on: alpine - container: node:26-alpine + static: + runs-on: debian + container: node:26-trixie-slim steps: - name: Checkout @@ -14,10 +17,8 @@ jobs: - name: Install tools run: | - apk add --no-cache \ - git \ - python3 \ - py3-pip + apt-get update + apt-get install -y --no-install-recommends git python3 python3-pip python3-venv python3 -m venv .venv . .venv/bin/activate pip install --no-cache-dir ruff @@ -27,32 +28,15 @@ jobs: . .venv/bin/activate ruff format --check . - lint: - runs-on: alpine - container: node:26-alpine - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install tools - run: | - apk add --no-cache \ - git \ - python3 \ - py3-pip - python3 -m venv .venv - . .venv/bin/activate - pip install --no-cache-dir ruff - - name: Lint run: | . .venv/bin/activate ruff check . - test: - runs-on: alpine - container: node:26-alpine + verify: + if: always() + runs-on: debian + container: node:26-trixie-slim steps: - name: Checkout @@ -60,20 +44,16 @@ jobs: - name: Install tools run: | - apk add --no-cache \ - git \ - python3 \ - py3-pip \ - py3-pillow \ - build-base - python3 -m venv .venv + apt-get update + apt-get install -y --no-install-recommends $APT_DEPS build-essential python3-dev + python3 -m venv --system-site-packages .venv . .venv/bin/activate - pip install --no-cache-dir \ - typer \ - pillow \ - materialyoucolor \ - jinja2 \ - pytest + pip install --no-cache-dir basedpyright nuitka . + + - name: Type check + run: | + . .venv/bin/activate + basedpyright - name: Test run: | @@ -81,52 +61,7 @@ jobs: cd cli python -m pytest tests/ -v - typecheck: - runs-on: alpine - container: node:26-alpine - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install tools - run: | - apk add --no-cache \ - git \ - python3 \ - py3-pip - python3 -m venv .venv - . .venv/bin/activate - pip install --no-cache-dir basedpyright typer pillow materialyoucolor jinja2 - - - name: Type check - run: | - . .venv/bin/activate - basedpyright - - buildcheck: - runs-on: alpine - container: node:26-alpine - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install tools - run: | - apk add --no-cache \ - git \ - python3 \ - py3-pip \ - build-base \ - python3-dev \ - gcc \ - g++ - python3 -m venv .venv - . .venv/bin/activate - pip install --no-cache-dir nuitka - - name: Nuitka module check run: | . .venv/bin/activate - nuitka --module --include-package=zshell cli/src/zshell/ + nuitka --module --include-package=zshell cli/src/zshell/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index c18220d..de6958c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ dist/ **/test-plugins/ **/Charts/ network-dev/ +**/zshell.build/ +**/zshell.dist/ diff --git a/CMakeLists.txt b/CMakeLists.txt index bcf8318..95c3714 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,6 +93,8 @@ if("shell" IN_LIST ENABLE_MODULES) ${NUITKA_EXECUTABLE} --standalone --include-data-dir=${CMAKE_SOURCE_DIR}/cli/src/zshell/assets=zshell/assets + --include-package=gi.overrides + --include-package-data=solaar --output-dir=${ZSHELL_CLI_BUILD_DIR} --output-filename=zshell-cli ${CMAKE_SOURCE_DIR}/cli/src/zshell/ 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 b5dcc65..6bdb04a 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.enabled && 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 43e4d47..e9c9a4f 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -8,10 +8,10 @@ import Quickshell.Io import Quickshell.Wayland import Quickshell.Hyprland import ZShell.Blobs -import qs.Modules.SysTray.Popouts.Network as N import qs.Daemons import qs.Components import qs.Modules.Bar +import qs.Modules.Bar.Popouts.Network as N import ZShell.Config import qs.Helpers import qs.Drawers @@ -34,6 +34,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; @@ -96,13 +97,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 @@ -115,7 +124,7 @@ CustomWindow { Regions { id: regions - bar: bar + geometry: geometry panels: panels win: root } @@ -204,10 +213,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 } @@ -215,15 +224,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 { @@ -235,7 +244,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 { @@ -256,8 +265,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 { @@ -279,17 +287,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 { } } @@ -303,8 +311,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 { @@ -382,6 +390,7 @@ CustomWindow { borderThickness: root.borderLayoutThickness drawing: drawing enabled: true + geometry: geometry panels: panels popouts: panels.popouts screen: root.screen @@ -393,6 +402,7 @@ CustomWindow { bar: bar borderThickness: root.borderThickness drawingItem: drawing + geometry: geometry screen: root.screen visibilities: visibilities @@ -434,14 +444,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 } } @@ -454,7 +468,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/Battery.qml b/Helpers/Battery.qml index 2df72d9..44129b1 100644 --- a/Helpers/Battery.qml +++ b/Helpers/Battery.qml @@ -1,35 +1,63 @@ pragma Singleton import Quickshell +import Quickshell.Io import Quickshell.Services.UPower import ZShell.Config import qs.Services +import qs.Paths Singleton { id: root - readonly property var colors: { - if (deviceState === UPowerDeviceState.Charging || deviceState === UPowerDeviceState.FullyCharged) + readonly property list allPeripherals: [...logiDevices, ...upowerDevices.filter(d => !Battery.logiDevices.some(l => l.nativePath === d.nativePath))] + readonly property real currentPerc: UPower.displayDevice.percentage + readonly property var deviceState: UPower.displayDevice.state + readonly property string deviceStateString: UPowerDeviceState.toString(deviceState) + readonly property bool isLaptop: UPower.displayDevice.isLaptopBattery + readonly property alias logiDevices: adapter.devices + readonly property var lowestPeripheral: allPeripherals.reduce((lowest, current) => current.percentage < lowest.percentage ? current : lowest) + readonly property bool onBattery: UPower.onBattery + readonly property bool ready: UPower.displayDevice.ready + readonly property real timeToEmpty: UPower.displayDevice.timeToEmpty + readonly property real timeToFull: UPower.displayDevice.timeToFull + readonly property list upowerDevices: UPower.devices.values.filter(d => d.state !== UPowerDeviceState.Unknown) + + function getColors(percentage: real, state: string): var { + if (state === "charging" || state === "full" || state === "recharging") return { fg: Colors.swapRG(Colors.palette.m3error), bg: Colors.swapRG(Colors.palette.m3onError) }; - else if (currentPerc <= 0.2) + else if (percentage <= 0.2) return { fg: Colors.palette.m3error, bg: Colors.palette.m3onError }; else return { - fg: Colors.palette.m3onSurface, - bg: Colors.palette.m3surface + fg: Colors.palette.m3tertiary, + bg: Colors.palette.m3onTertiary }; } - readonly property real currentPerc: UPower.displayDevice.percentage - readonly property var deviceState: UPower.displayDevice.state - readonly property bool isLaptop: UPower.displayDevice.isLaptopBattery - readonly property bool onBattery: UPower.onBattery - readonly property bool ready: UPower.displayDevice.ready - readonly property real timeToEmpty: UPower.displayDevice.timeToEmpty - readonly property real timeToFull: UPower.displayDevice.timeToFull + + function startDaemon(): void { + Quickshell.execDetached(["zshell-cli", "battery", "daemon"]); + } + + FileView { + id: fileView + + path: `${Paths.cache}/battery.json` + watchChanges: true + + onFileChanged: reload() + + JsonAdapter { + id: adapter + + property list devices: [] + property real updated: 0.0 + } + } } diff --git a/Helpers/Icons.qml b/Helpers/Icons.qml index f9eb586..0e1246d 100644 --- a/Helpers/Icons.qml +++ b/Helpers/Icons.qml @@ -1,6 +1,6 @@ pragma Singleton -import ZShell.Config +import QtQuick import Quickshell import Quickshell.Services.Notifications @@ -166,6 +166,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"; @@ -181,4 +207,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 75fc616..97d1221 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -3,23 +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.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) { @@ -33,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; } @@ -62,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 @@ -84,6 +91,7 @@ RowLayout { delegate: EntryWrapper { HyprsunsetWidget { + horizontal: root.horizontal visible: !root.fullscreen } } @@ -93,7 +101,8 @@ RowLayout { roleValue: "spacer" delegate: EntryWrapper { - Layout.fillWidth: true + Layout.fillHeight: !root.horizontal + Layout.fillWidth: root.horizontal } } @@ -102,6 +111,7 @@ RowLayout { delegate: EntryWrapper { Workspaces { + horizontal: root.horizontal screen: root.screen visible: !root.fullscreen } @@ -113,6 +123,7 @@ RowLayout { delegate: EntryWrapper { TrayIcons { + horizontal: root.horizontal loader: root popouts: root.popouts visible: !root.fullscreen @@ -125,6 +136,7 @@ RowLayout { delegate: EntryWrapper { StatusIcons { + horizontal: root.horizontal } } } @@ -134,6 +146,7 @@ RowLayout { delegate: EntryWrapper { Resources { + horizontal: root.horizontal visibilities: root.visibilities visible: !root.fullscreen } @@ -145,6 +158,7 @@ RowLayout { delegate: EntryWrapper { UpdatesWidget { + horizontal: root.horizontal visible: !root.fullscreen } } @@ -155,6 +169,7 @@ RowLayout { delegate: EntryWrapper { NotifBell { + horizontal: root.horizontal popouts: root.popouts visibilities: root.visibilities visible: !root.fullscreen @@ -167,6 +182,7 @@ RowLayout { delegate: EntryWrapper { Clock { + horizontal: root.horizontal loader: root popouts: root.popouts visibilities: root.visibilities @@ -181,6 +197,7 @@ RowLayout { delegate: EntryWrapper { WindowTitle { bar: root + horizontal: root.horizontal visible: !root.fullscreen } } @@ -191,6 +208,7 @@ RowLayout { delegate: EntryWrapper { MediaWidget { + horizontal: root.horizontal visible: !root.fullscreen } } @@ -204,9 +222,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 bec6c54..2243ad7 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -1,44 +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.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: [ @@ -47,7 +51,7 @@ Item { to: "visible" Anim { - property: "implicitHeight" + property: "extent" target: root } }, @@ -56,7 +60,7 @@ Item { to: "" Anim { - property: "implicitHeight" + property: "extent" target: root } } @@ -66,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/BatteryIcon.qml b/Modules/Bar/Components/Common/BatteryIcon.qml new file mode 100644 index 0000000..31f4a1e --- /dev/null +++ b/Modules/Bar/Components/Common/BatteryIcon.qml @@ -0,0 +1,85 @@ +import QtQuick +import ZShell.Config +import qs.Services +import qs.Components +import qs.Helpers + +Row { + id: root + + property real batHeight: Tokens.padding.smaller * 2 + property real batWidth: Tokens.padding.larger * 2 + required property string devState + property real nubHeight: Tokens.padding.small + property real nubWidth: 2 + required property real percentage + property real radius: Tokens.rounding.smallest / 2 + + spacing: 1 + + CustomRect { + id: track + + anchors.verticalCenter: parent.verticalCenter + color: Battery.getColors(root.percentage, root.devState).bg + height: root.batHeight + radius: root.radius + width: root.batWidth + + CustomText { + color: Battery.getColors(root.percentage, root.devState).fg + font.pointSize: Tokens.font.size.larger / 1.5 + font.weight: 800 + height: track.height + horizontalAlignment: Text.AlignHCenter + text: Math.round(root.percentage * 100) + verticalAlignment: Text.AlignVCenter + width: track.width + } + + Item { + clip: true + width: parent.width * root.percentage + + anchors { + bottom: parent.bottom + left: parent.left + top: parent.top + } + + CustomRect { + id: fill + + color: Battery.getColors(root.percentage, root.devState).fg + height: track.height + radius: track.radius + width: track.width + + CustomText { + id: batteryLabel + + clip: true + color: Battery.getColors(root.percentage, root.devState).bg + font.pointSize: Tokens.font.size.larger / 1.5 + font.weight: 800 + height: track.height + horizontalAlignment: Text.AlignHCenter + text: Math.round(root.percentage * 100) + verticalAlignment: Text.AlignVCenter + width: track.width + } + } + } + } + + CustomRect { + id: nub + + anchors.verticalCenter: parent.verticalCenter + bottomRightRadius: 20 + color: root.percentage < 0.99 ? track.color : fill.color + height: root.nubHeight + topRightRadius: 20 + width: root.nubWidth + } +} 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/Bar/Components/NetworkWidget.qml b/Modules/Bar/Components/NetworkWidget.qml new file mode 100644 index 0000000..53463ee --- /dev/null +++ b/Modules/Bar/Components/NetworkWidget.qml @@ -0,0 +1,25 @@ +import Quickshell +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Modules + +Item { + id: root + + anchors.bottom: parent.bottom + anchors.top: parent.top + implicitWidth: layout.implicitWidth + + RowLayout { + id: layout + + anchors.bottom: parent.bottom + anchors.top: parent.top + + MaterialIcon { + Layout.alignment: Qt.AlignVCenter + text: "android_wifi_4_bar" + } + } +} 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 66% rename from Modules/SysTray/StatusIcons.qml rename to Modules/Bar/Components/StatusIcons.qml index 3f92ae9..6cacee2 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 } } } @@ -131,8 +152,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/BluetoothWidget.qml b/Modules/Bar/Components/StatusIcons/BluetoothWidget.qml similarity index 100% rename from Modules/SysTray/Widgets/BluetoothWidget.qml rename to Modules/Bar/Components/StatusIcons/BluetoothWidget.qml 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/NetworkWidget.qml b/Modules/Bar/Components/StatusIcons/NetworkWidget.qml similarity index 100% rename from Modules/SysTray/Widgets/NetworkWidget.qml rename to Modules/Bar/Components/StatusIcons/NetworkWidget.qml diff --git a/Modules/Bar/Components/StatusIcons/UPowerWidget.qml b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml new file mode 100644 index 0000000..0b1ab6d --- /dev/null +++ b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml @@ -0,0 +1,80 @@ +import Quickshell.Services.UPower +import QtQuick +import QtQuick.Layouts +import ZShell.Config +import qs.Modules.Bar.Components.Common +import qs.Components +import qs.Helpers + +Item { + id: root + + readonly property Item currentItem: batteryIconLoader?.item ?? peripheralIconLoader?.item ?? upowerIconLoader.item + + implicitHeight: currentItem?.implicitHeight ?? 0 + implicitWidth: currentItem?.implicitWidth ?? 0 + + Behavior on Layout.preferredHeight { + Anim { + } + } + Behavior on Layout.preferredWidth { + Anim { + } + } + Behavior on opacity { + Anim { + } + } + Behavior on scale { + Anim { + } + } + + Component.onCompleted: Battery.startDaemon() + + Loader { + id: batteryIconLoader + + active: Battery.isLaptop + anchors.centerIn: parent + + sourceComponent: BatteryIcon { + devState: Battery.deviceStateString + percentage: Battery.currentPerc + } + } + + Loader { + id: peripheralIconLoader + + active: (Battery.lowestPeripheral?.isPresent ?? false) && !batteryIconLoader.active + anchors.centerIn: parent + + sourceComponent: BatteryIcon { + devState: Battery.lowestPeripheral?.state ?? "" + percentage: Battery.lowestPeripheral?.percentage ?? 0 + } + } + + Loader { + id: upowerIconLoader + + active: !batteryIconLoader.active && !peripheralIconLoader.active + anchors.centerIn: parent + + sourceComponent: MaterialIcon { + Layout.alignment: Qt.AlignVCenter + animate: true + fill: 1 + font.pointSize: Tokens.font.size.larger + text: { + if (PowerProfiles.profile === PowerProfile.PowerSaver) + return "energy_savings_leaf"; + if (PowerProfiles.profile === PowerProfile.Performance) + return "bolt"; + return "balance"; + } + } + } +} 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/SysTray/Popouts/BluetoothPopout.qml b/Modules/Bar/Popouts/BluetoothPopout.qml similarity index 100% rename from Modules/SysTray/Popouts/BluetoothPopout.qml rename to Modules/Bar/Popouts/BluetoothPopout.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 96% rename from Modules/Content.qml rename to Modules/Bar/Popouts/Content.qml index b536bb9..5849469 100644 --- a/Modules/Content.qml +++ b/Modules/Bar/Popouts/Content.qml @@ -5,9 +5,6 @@ import Quickshell.Services.SystemTray import QtQuick import ZShell.Config import qs.Components -import qs.Modules.WSOverview -import qs.Modules.SysTray.Popouts -import qs.Modules.Updates Item { id: root diff --git a/Modules/SysTray/Popouts/Network/NetworkOverlay.qml b/Modules/Bar/Popouts/Network/NetworkOverlay.qml similarity index 100% rename from Modules/SysTray/Popouts/Network/NetworkOverlay.qml rename to Modules/Bar/Popouts/Network/NetworkOverlay.qml diff --git a/Modules/SysTray/Popouts/Network/PSKOverlay.qml b/Modules/Bar/Popouts/Network/PSKOverlay.qml similarity index 100% rename from Modules/SysTray/Popouts/Network/PSKOverlay.qml rename to Modules/Bar/Popouts/Network/PSKOverlay.qml diff --git a/Modules/SysTray/Popouts/Network/PopupManager.qml b/Modules/Bar/Popouts/Network/PopupManager.qml similarity index 100% rename from Modules/SysTray/Popouts/Network/PopupManager.qml rename to Modules/Bar/Popouts/Network/PopupManager.qml diff --git a/Modules/SysTray/Popouts/NetworkPopout.qml b/Modules/Bar/Popouts/NetworkPopout.qml similarity index 99% rename from Modules/SysTray/Popouts/NetworkPopout.qml rename to Modules/Bar/Popouts/NetworkPopout.qml index 894dec3..b35b6f5 100644 --- a/Modules/SysTray/Popouts/NetworkPopout.qml +++ b/Modules/Bar/Popouts/NetworkPopout.qml @@ -5,7 +5,7 @@ import QtQuick.Layouts import Quickshell import ZShell.Config import qs.Services -import qs.Modules.SysTray.Popouts.Network +import qs.Modules.Bar.Popouts.Network import qs.Components import qs.Helpers 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 90% rename from Modules/SysTray/Popouts/UPowerPopout.qml rename to Modules/Bar/Popouts/UPowerPopout.qml index 1ceec24..4aee034 100644 --- a/Modules/SysTray/Popouts/UPowerPopout.qml +++ b/Modules/Bar/Popouts/UPowerPopout.qml @@ -1,8 +1,10 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Layouts import Quickshell.Services.UPower import ZShell.Config +import qs.Modules.Bar.Components.Common import qs.Helpers import qs.Components import qs.Services @@ -22,6 +24,31 @@ Column { } } + Repeater { + model: Battery.allPeripherals + + RowLayout { + id: layout + + required property var modelData + + anchors.left: parent.left + anchors.leftMargin: Tokens.padding.normal + anchors.right: parent.right + anchors.rightMargin: Tokens.padding.normal + + CustomText { + Layout.fillWidth: true + text: layout.modelData.model + } + + BatteryIcon { + devState: layout.modelData.state + percentage: layout.modelData.percentage + } + } + } + CustomText { function formatSeconds(s: int, fallback: string): string { const day = Math.floor(s / 86400); @@ -75,7 +102,7 @@ Column { CustomText { anchors.verticalCenter: parent.verticalCenter color: Colors.palette.m3onError - font.family: Appearance.font.family.mono + font.family: Config.appearance.font.family.mono text: qsTr("Performance Degraded") } 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/PageCompRegistry.qml b/Modules/Settings/PageCompRegistry.qml index 22aa679..f1f7263 100644 --- a/Modules/Settings/PageCompRegistry.qml +++ b/Modules/Settings/PageCompRegistry.qml @@ -2,8 +2,8 @@ pragma Singleton import QtQuick import QtQuick.Layouts -import qs.Components import ZShell.Config +import qs.Components import qs.Modules.Settings.Common import qs.Modules.Settings.Pages import qs.Modules.Settings.Pages.Wallpaper @@ -45,7 +45,11 @@ QtObject { // Connectivity Component { - PlaceholderComp { + StackPage { + Component { + NetworkPage { + } + } } }, Component { diff --git a/Modules/Settings/Pages/NetworkPage.qml b/Modules/Settings/Pages/NetworkPage.qml index eece6b2..57e3ce3 100644 --- a/Modules/Settings/Pages/NetworkPage.qml +++ b/Modules/Settings/Pages/NetworkPage.qml @@ -3,11 +3,12 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import Quickshell +import ZShell.Config import qs.Modules.Settings.Common -import qs.Modules.SysTray.Popouts.Network +import qs.Modules.Bar.Popouts.Network import qs.Components -import qs.Config import qs.Helpers +import qs.Services PageBase { id: root @@ -19,7 +20,7 @@ PageBase { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top - spacing: Appearance.spacing.extraSmall / 2 + spacing: Tokens.spacing.extraSmall / 2 width: root.cappedWidth Toggle { @@ -56,7 +57,7 @@ PageBase { id: networkItem readonly property bool connecting: Network.connectingNetwork === modelData - property int horizontalPadding: Appearance.padding.largeIncreased + property int horizontalPadding: Tokens.padding.largeIncreased required property int index readonly property bool isSecure: Network.isSecure(modelData.security) required property bool known @@ -64,10 +65,10 @@ PageBase { required property Repeater repeater Layout.fillWidth: true - Layout.preferredHeight: visible ? rowLayout.implicitHeight + Appearance.padding.smaller * 2 : 0 + Layout.preferredHeight: visible ? rowLayout.implicitHeight + Tokens.padding.smaller * 2 : 0 first: index === 0 last: index === (repeater.count - 1) - radius: Appearance.rounding.small + radius: Tokens.rounding.small RowLayout { id: rowLayout @@ -83,9 +84,9 @@ PageBase { CustomText { Layout.fillWidth: true - Layout.leftMargin: Appearance.spacing.extraSmall - Layout.rightMargin: Appearance.spacing.extraSmall - color: networkItem.modelData.active ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface + Layout.leftMargin: Tokens.spacing.extraSmall + Layout.rightMargin: Tokens.spacing.extraSmall + color: networkItem.modelData.active ? Colors.palette.m3primary : Colors.palette.m3onSurface elide: Text.ElideRight text: networkItem.modelData.name } @@ -95,9 +96,9 @@ PageBase { anchors.left: parent.left anchors.right: parent.right - color: DynamicColors.palette.m3outline + color: Colors.palette.m3outline elide: Text.ElideRight - font.pointSize: Appearance.font.size.small + font.pointSize: Tokens.font.size.small text: qsTr("%1 strength").arg(Math.round(networkItem.modelData.signalStrength * 100) + "%") } } @@ -110,8 +111,8 @@ PageBase { MaterialIcon { id: icon - color: networkItem.modelData.active ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.large + color: networkItem.modelData.active ? Colors.palette.m3primary : Colors.palette.m3onSurfaceVariant + font.pointSize: Tokens.font.size.large opacity: networkItem.connecting ? 0 : 1 text: Icons.getNetworkIcon(networkItem.modelData.signalStrength * 100, networkItem.isSecure) @@ -139,7 +140,7 @@ PageBase { } StateLayer { - color: networkItem.modelData.active ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface + color: networkItem.modelData.active ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface onClicked: { if (networkItem.isSecure && !networkItem.known) { @@ -157,7 +158,7 @@ PageBase { required property Repeater repeater - Layout.preferredHeight: repeater.count > 0 ? Appearance.spacing.extraSmall : 0 + Layout.preferredHeight: repeater.count > 0 ? Tokens.spacing.extraSmall : 0 } component Toggle: CustomSwitch { id: toggleItem @@ -169,13 +170,13 @@ PageBase { Layout.fillWidth: true cLayer: 2 - horizontalPadding: Appearance.padding.largeIncreased + horizontalPadding: Tokens.padding.largeIncreased implicitHeight: Math.max(implicitContentHeight, implicitIndicatorHeight) + verticalPadding * 2 implicitWidth: implicitContentWidth + implicitIndicatorWidth + horizontalPadding * 2 indicator.anchors.right: right indicator.anchors.rightMargin: toggleItem.horizontalPadding indicator.anchors.verticalCenter: verticalCenter - verticalPadding: Appearance.padding.normal + verticalPadding: Tokens.padding.normal background: ConnectedRect { id: bg @@ -190,7 +191,7 @@ PageBase { anchors.left: parent.left anchors.leftMargin: toggleItem.horizontalPadding anchors.right: toggleItem.indicator.left - anchors.rightMargin: Appearance.spacing.normal + anchors.rightMargin: Tokens.spacing.normal implicitHeight: column.implicitHeight implicitWidth: column.implicitWidth @@ -210,7 +211,7 @@ PageBase { elide: Text.ElideRight font: { const f = Qt.font(label.font); - f.pointSize = Appearance.font.size.smaller; + f.pointSize = Tokens.font.size.smaller; return f; } text: toggleItem.text @@ -221,11 +222,11 @@ PageBase { anchors.left: parent.left anchors.right: parent.right - color: DynamicColors.palette.m3outline + color: Colors.palette.m3outline elide: Text.ElideRight font: { const f = Qt.font(subtext.font); - f.pointSize = Appearance.font.size.small; + f.pointSize = Tokens.font.size.small; return f; } text: toggleItem.subtext 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 e1bd36c..0000000 --- a/Modules/SysTray/TrayWidget.qml +++ /dev/null @@ -1,154 +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 === "networkWidget" && Config.bar.popouts.network) - return { - id: "network", - 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 - }; - if (child.objectName === "bluetoothWidget") - return { - id: "bluetooth", - 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/SysTray/Widgets/UPowerWidget.qml b/Modules/SysTray/Widgets/UPowerWidget.qml deleted file mode 100644 index 6cb2345..0000000 --- a/Modules/SysTray/Widgets/UPowerWidget.qml +++ /dev/null @@ -1,135 +0,0 @@ -import Quickshell.Services.UPower -import QtQuick -import QtQuick.Layouts -import qs.Components -import ZShell.Config -import qs.Helpers - -Item { - id: root - - implicitHeight: Battery.isLaptop ? batteryIconLoader.item.implicitHeight : upowerIconLoader.item.implicitHeight - implicitWidth: Battery.isLaptop ? batteryIconLoader.item.implicitWidth : upowerIconLoader.item.implicitWidth - - Behavior on Layout.preferredHeight { - Anim { - } - } - Behavior on Layout.preferredWidth { - Anim { - } - } - Behavior on opacity { - Anim { - } - } - Behavior on scale { - Anim { - } - } - - Loader { - id: batteryIconLoader - - active: Battery.isLaptop - anchors.centerIn: parent - - sourceComponent: Row { - id: batteryIcon - - property real batHeight: 16 - property real batWidth: 30 - property real nubHeight: 6 - property real nubWidth: 2 - property real radius: Tokens.rounding.smallest / 2 - - spacing: 1 - - CustomRect { - id: track - - anchors.verticalCenter: parent.verticalCenter - color: Battery.colors.bg - height: batteryIcon.batHeight - radius: batteryIcon.radius - width: batteryIcon.batWidth - - CustomText { - color: Battery.colors.fg - font.pointSize: Tokens.font.size.larger / 1.5 - font.weight: 800 - height: track.height - horizontalAlignment: Text.AlignHCenter - text: Math.round(Battery.currentPerc * 100) - verticalAlignment: Text.AlignVCenter - width: track.width - } - - Item { - clip: true - width: parent.width * Battery.currentPerc - - anchors { - bottom: parent.bottom - left: parent.left - top: parent.top - } - - CustomRect { - id: fill - - color: Battery.colors.fg - height: track.height - radius: track.radius - width: track.width - - CustomText { - id: batteryLabel - - clip: true - color: Battery.colors.bg - font.pointSize: 7.5 - font.weight: 800 - height: track.height - horizontalAlignment: Text.AlignHCenter - text: Math.round(Battery.currentPerc * 100) - verticalAlignment: Text.AlignVCenter - width: track.width - } - } - } - } - - CustomRect { - id: nub - - anchors.verticalCenter: parent.verticalCenter - bottomRightRadius: 20 - color: Battery.currentPerc < 0.99 ? track.color : fill.color - height: batteryIcon.nubHeight - topRightRadius: 20 - width: batteryIcon.nubWidth - } - } - } - - Loader { - id: upowerIconLoader - - active: !Battery.isLaptop - anchors.centerIn: parent - - sourceComponent: MaterialIcon { - Layout.alignment: Qt.AlignVCenter - animate: true - fill: 1 - text: { - if (PowerProfiles.profile === PowerProfile.PowerSaver) - return "energy_savings_leaf"; - if (PowerProfiles.profile === PowerProfile.Performance) - return "bolt"; - return "balance"; - } - } - } -} 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/cli/src/zshell/__init__.py b/cli/src/zshell/__init__.py index 613ee3e..c7214b5 100644 --- a/cli/src/zshell/__init__.py +++ b/cli/src/zshell/__init__.py @@ -8,7 +8,14 @@ import typer from typer._completion_classes import completion_init from typer._completion_shared import _get_shell_name, install -from zshell.subcommands import record, scheme, screenshot, shell, wallpaper +from zshell.subcommands import ( + battery, + record, + scheme, + screenshot, + shell, + wallpaper, +) app = typer.Typer(name="zshell-cli", add_completion=False) @@ -17,6 +24,7 @@ app.add_typer(scheme.app, name="scheme") app.add_typer(screenshot.app, name="screenshot") app.add_typer(wallpaper.app, name="wallpaper") app.add_typer(record.app, name="record") +app.add_typer(battery.app, name="battery") def _completion_installed() -> bool: diff --git a/cli/src/zshell/subcommands/battery.py b/cli/src/zshell/subcommands/battery.py new file mode 100644 index 0000000..676c008 --- /dev/null +++ b/cli/src/zshell/subcommands/battery.py @@ -0,0 +1,819 @@ +from __future__ import annotations + +import contextlib +import fcntl +import json +import logging +import os +import re +import signal +import struct +import tempfile +import threading +import time +from collections.abc import Callable +from dataclasses import asdict, dataclass +from pathlib import Path + +import typer +from logitech_receiver import hidpp10, hidpp20 +from logitech_receiver.common import Battery, BatteryStatus, Notification +from logitech_receiver.hidpp10_constants import Registers +from logitech_receiver.hidpp20_constants import SupportedFeature + +app = typer.Typer( + help="Read live battery status from Logitech HID++ peripherals." +) + +logger = logging.getLogger("zshell.battery") + +DEFAULT_OUTPUT = Path.home() / ".cache" / "zshell" / "battery.json" + +_RUNTIME_DIR = Path( + os.environ.get("XDG_RUNTIME_DIR") or (Path.home() / ".cache" / "zshell") +) +DEFAULT_LOCK = _RUNTIME_DIR / "zshell-battery-daemon.lock" + + +def _acquire_singleton_lock(path: Path) -> int | None: + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_CREAT | os.O_RDWR, 0o644) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + os.close(fd) + return None + os.ftruncate(fd, 0) + os.write(fd, str(os.getpid()).encode()) + return fd + + +@dataclass +class DeviceBattery: + model: str + nativePath: str | None + serial: str | None + type: str | None + wired: bool + percentage: float | None + state: str | None + charging: bool + isPresent: bool + + +_SYSFS_POWER_SUPPLY = Path("/sys/class/power_supply") + + +def _normalize_serial(s: str) -> str: + return re.sub(r"[^0-9A-Za-z]", "", s).upper() + + +def _find_upower_native_path(serial: str | None) -> str | None: + if not serial or not _SYSFS_POWER_SUPPLY.is_dir(): + return None + target = _normalize_serial(serial) + for entry in _SYSFS_POWER_SUPPLY.glob("hidpp_battery_*"): + try: + raw = (entry / "serial_number").read_text().strip() + except OSError: + logger.debug( + "couldn't read serial_number under %s", entry, exc_info=True + ) + continue + if _normalize_serial(raw) == target: + return entry.name + return None + + +def _process_battery_notification(dev, n) -> bool: + + if not getattr(dev, "isDevice", False): + return False + + if int(n.sub_id) & 0x80: + return False + if n.sub_id == Notification.NO_OPERATION: + return False + + if getattr(dev, "protocol", None) is not None and dev.protocol < 2.0: + if n.sub_id in (Registers.BATTERY_STATUS, Registers.BATTERY_CHARGE): + if n.data[-1:] != b"\x00": + return False + data = bytes([n.address]) + n.data + dev.set_battery_info(hidpp10.parse_battery_status(n.sub_id, data)) + return True + return False + + features = getattr(dev, "features", None) + if not features: + return False + + try: + feature = features.get_feature(n.sub_id) + except Exception: + return False + + if feature == SupportedFeature.BATTERY_STATUS: + if n.address == 0x00: + dev.set_battery_info(hidpp20.decipher_battery_status(n.data)[1]) + return True + return False + + if feature == SupportedFeature.BATTERY_VOLTAGE: + if n.address == 0x00: + dev.set_battery_info(hidpp20.decipher_battery_voltage(n.data)[1]) + return True + return False + + if feature == SupportedFeature.UNIFIED_BATTERY: + if n.address == 0x00: + dev.set_battery_info(hidpp20.decipher_battery_unified(n.data)[1]) + return True + return False + + if feature == SupportedFeature.ADC_MEASUREMENT: + if n.address == 0x00: + result = hidpp20.decipher_adc_measurement(n.data) + if result: + dev.set_battery_info(result[1]) + return True + return False + + if feature == SupportedFeature.CENTURION_BATTERY_SOC: + dev.set_battery_info(hidpp20.decipher_battery_centurion(n.data)[1]) + return True + + if feature == SupportedFeature.SOLAR_DASHBOARD: + if n.data[5:9] == b"GOOD": + charge, lux, _ = struct.unpack("!BHH", n.data[:5]) + status = BatteryStatus.DISCHARGING + if n.address == 0x10 and lux > 200: + status = BatteryStatus.RECHARGING + dev.set_battery_info(Battery(charge, None, status, None, lux)) + return True + return False + + return False + + +def _import_listener_deps(): + try: + from logitech_receiver import base, device, receiver + from logitech_receiver.listener import EventsListener + except ImportError as e: + raise RuntimeError( + "logitech_receiver isn't importable -- install it with `pip install solaar`." + ) from e + except ValueError as e: + raise RuntimeError( + "logitech_receiver failed to import because a GTK3 typelib is missing " + f"({e}). Install the same GTK3 + PyGObject packages Solaar's GUI needs " + "(e.g. python3-gi + gir1.2-gtk-3.0 on Debian/Ubuntu, or the gtk3/" + "python3-gobject equivalents on your distro)." + ) from e + + class _BatteryEventsListener(EventsListener): + def __init__(self, receiver_or_device, on_change): + super().__init__(receiver_or_device, self._handle_notification) + self._on_change = on_change + + def has_started(self): + + if not self.receiver.isDevice: + try: + self.receiver.notification_flags = ( + self.receiver.enable_connection_notifications() + ) + self.receiver.notify_devices() + except Exception: + logger.exception( + "failed enabling notifications for %s", self.receiver + ) + + def _handle_notification(self, n): + if self.receiver.isDevice: + try: + _process_battery_notification(self.receiver, n) + except Exception: + logger.exception("processing %s for %s", n, self.receiver) + self._on_change(self.receiver) + return + + if n.devnumber == 0xFF: + try: + _process_battery_notification(self.receiver, n) + except Exception: + logger.exception("processing receiver notification %s", n) + self._on_change(self.receiver) + return + + if not (0 < n.devnumber <= 16): + logger.warning( + "unexpected device number %s in %s", n.devnumber, n + ) + return + + try: + dev = self.receiver[n.devnumber] + except Exception: + logger.exception( + "resolving device number %s on %s", + n.devnumber, + self.receiver, + ) + return + + if not dev: + logger.warning( + "%s: received %s for invalid device %d", + self.receiver, + n, + n.devnumber, + ) + return + + try: + _process_battery_notification(dev, n) + except Exception: + logger.exception("processing %s for %s", n, dev) + + self._on_change(dev) + + return base, device, receiver, _BatteryEventsListener + + +def _start_hotplug_watcher(on_hotplug: Callable[[], None]): + try: + import pyudev + except ImportError: + logger.warning( + "pyudev isn't importable -- hotplugged devices will only be " + "noticed on the next --rescan-interval tick, not instantly. " + "Install it with `pip install pyudev` (it's a core Solaar " + "dependency, so it's normally already present)." + ) + return None + + debounce_lock = threading.Lock() + debounce_state: dict[str, threading.Timer | None] = {"timer": None} + DEBOUNCE_SECONDS = 0.5 + + def _fire(): + with debounce_lock: + debounce_state["timer"] = None + on_hotplug() + + def _handle_event(_device): + if _device.action not in ("add", "remove"): + return + with debounce_lock: + existing = debounce_state["timer"] + if existing is not None: + existing.cancel() + timer = threading.Timer(DEBOUNCE_SECONDS, _fire) + timer.daemon = True + debounce_state["timer"] = timer + timer.start() + + context = pyudev.Context() + monitor = pyudev.Monitor.from_netlink(context) + monitor.filter_by(subsystem="hidraw") + observer = pyudev.MonitorObserver(monitor, callback=_handle_event) + observer.start() + return observer + + +class _ListenerRegistry: + def __init__(self, on_change): + self._on_change = on_change + self._entries = {} + self._last_change: dict[int, float] = {} + self._lock = threading.Lock() + self._rescan_lock = threading.Lock() + self._min_rescan_interval = 1.0 + self._last_rescan_time = 0.0 + + self._hidpp_io_lock = threading.RLock() + + def _touch_and_notify(self, dev_or_receiver, *args, **kwargs): + with self._lock: + self._last_change[id(dev_or_receiver)] = time.time() + self._on_change(dev_or_receiver, *args, **kwargs) + + def last_change(self, dev) -> float: + with self._lock: + return self._last_change.get(id(dev), 0.0) + + def rescan(self): + now = time.monotonic() + if now - self._last_rescan_time < self._min_rescan_interval: + return + if not self._rescan_lock.acquire(blocking=False): + return + + try: + self._last_rescan_time = now + with self._hidpp_io_lock: + base, device, receiver, ListenerClass = _import_listener_deps() + + with self._lock: + dead = [ + p + for p, (_obj, listener) in self._entries.items() + if not listener._active + ] + for p in dead: + dying_obj, _dying_listener = self._entries[p] + + if dying_obj.isDevice: + ident = getattr( + dying_obj, "unitId", None + ) or getattr(dying_obj, "serial", None) + info = getattr(dying_obj, "battery_info", None) + if ident and info is not None: + for ( + other_path, + (other_obj, _other_listener), + ) in self._entries.items(): + if other_path == p or other_obj.isDevice: + continue + for child in other_obj: + child_ident = getattr( + child, "unitId", None + ) or getattr(child, "serial", None) + if child_ident == ident: + child.set_battery_info(info) + break + del self._entries[p] + known_paths = set(self._entries.keys()) + + with self._lock: + id_to_entry = {} + for path, (obj, listener) in self._entries.items(): + if obj.isDevice: + ident = obj.unitId or obj.serial + if ident: + id_to_entry[ident] = (path, obj, listener) + + for dev_info in base.receivers_and_devices(): + if dev_info.path in known_paths: + continue + try: + if dev_info.isDevice: + obj = device.create_device(base, dev_info) + else: + obj = receiver.create_receiver(base, dev_info) + except OSError as e: + if e.errno == 13: + logger.error( + "permission denied opening %s -- check the Solaar udev rule " + "(rules.d/42-logitech-unify-permissions.rules) is installed", + dev_info.path, + ) + else: + logger.exception("failed opening %s", dev_info) + continue + except Exception: + logger.exception("failed opening %s", dev_info) + continue + + if obj is None: + continue + + listener = ListenerClass(obj, self._touch_and_notify) + to_join = None + + if obj.isDevice: + ident = None + try: + if obj.protocol >= 2.0: + obj.get_ids() + ident = obj.unitId or obj.serial + except Exception: + pass + + if ident: + with self._lock: + existing = id_to_entry.get(ident) + if existing: + old_path, old_obj, old_listener = existing + + if old_path != dev_info.path: + logger.info( + "replacing device %s (old path %s) with new path %s", + ident, + old_path, + dev_info.path, + ) + + if ( + getattr(obj, "battery_info", None) + is None + and getattr( + old_obj, "battery_info", None + ) + is not None + ): + obj.set_battery_info( + old_obj.battery_info + ) + + old_listener.stop() + + del self._entries[old_path] + + self._entries[dev_info.path] = ( + obj, + listener, + ) + self._last_change[id(obj)] = time.time() + + to_join = old_listener + break + else: + pass + else: + if ( + getattr(obj, "battery_info", None) + is None + ): + for ( + _, + (other_obj, _other_listener), + ) in self._entries.items(): + if other_obj.isDevice: + continue + for child in other_obj: + child_ident = getattr( + child, "unitId", None + ) or getattr( + child, "serial", None + ) + if child_ident == ident: + obj.set_battery_info( + child.battery_info + ) + break + if ( + getattr( + obj, + "battery_info", + None, + ) + is not None + ): + break + + with self._lock: + if dev_info.path not in self._entries: + self._entries[dev_info.path] = (obj, listener) + self._last_change[id(obj)] = time.time() + + listener.start() + + logger.info( + "listening on %s (%s)", + dev_info.path, + "device" if dev_info.isDevice else "receiver", + ) + + if to_join is not None: + try: + to_join.join(timeout=1.0) + except Exception: + logger.exception("error joining replaced listener") + del to_join + + finally: + self._rescan_lock.release() + + def known_devices(self): + with self._lock: + entries = list(self._entries.values()) + + result = [] + seen_idents = set() + for obj, _listener in entries: + if obj.isDevice: + ident = obj.unitId or obj.serial + if ident and ident in seen_idents: + continue + if ident: + seen_idents.add(ident) + result.append(obj) + else: + for child in obj: + ident = child.unitId or child.serial + if ident and ident in seen_idents: + continue + if ident: + seen_idents.add(ident) + result.append(child) + return result + + def stop(self): + with self._lock: + entries = list(self._entries.values()) + for _obj, listener in entries: + try: + listener.stop() + except Exception: + logger.exception("error stopping listener") + for _obj, listener in entries: + listener.join(timeout=2.0) + + +def _snapshot(registry: _ListenerRegistry) -> list[DeviceBattery]: + results: list[DeviceBattery] = [] + + with registry._hidpp_io_lock: + for dev in registry.known_devices(): + info = dev.battery_info + if info is None: + try: + if dev.ping(): + info = dev.battery() + except Exception: + logger.debug( + "initial battery() failed for %s", dev, exc_info=True + ) + info = None + entry = _to_device_battery(dev, info) + if entry is not None: + results.append(entry) + return results + + +def _clean_json(path: Path) -> None: + _write_json_atomic(path, []) + + +def _iter_open_devices(): + try: + from logitech_receiver import base, device, receiver + except ImportError as e: + raise RuntimeError( + "logitech_receiver isn't importable -- install it with `pip install solaar`." + ) from e + except ValueError as e: + raise RuntimeError( + "logitech_receiver failed to import because a GTK3 typelib is missing " + f"({e}). Install the same GTK3 + PyGObject packages Solaar's GUI needs " + "(e.g. python3-gi + gir1.2-gtk-3.0 on Debian/Ubuntu, or the gtk3/" + "python3-gobject equivalents on your distro)." + ) from e + + for dev_info in base.receivers_and_devices(): + try: + if dev_info.isDevice: + d = device.create_device(base, dev_info) # pyright: ignore[reportArgumentType] + if d is not None: + yield d + else: + d = receiver.create_receiver(base, dev_info) # pyright: ignore[reportArgumentType] + if d is not None: + yield from d + except OSError as e: + if e.errno == 13: + logger.error( + "permission denied opening %s -- check the Solaar udev rule " + "(rules.d/42-logitech-unify-permissions.rules) is installed " + "and you're in the right group", + dev_info.path, + ) + else: + logger.exception("failed opening %s", dev_info) + continue + except Exception: + logger.exception("failed opening %s", dev_info) + continue + + +def _to_device_battery(dev, battery) -> DeviceBattery | None: + if battery is None: + return None + + percentage = battery.level / 100 if isinstance(battery.level, int) else None + state = battery.status.name.lower() if battery.status is not None else None + + serial = ( + getattr(dev, "serial", None) or getattr(dev, "unitId", None) or None + ) + + return DeviceBattery( + model=dev.name or dev.codename or "Unknown device", + nativePath=_find_upower_native_path(serial), + serial=serial, + type=str(dev.kind) if dev.kind is not None else None, + wired=dev.receiver is None, + percentage=percentage, + state=state, + charging=battery.charging(), + isPresent=battery.ok(), + ) + + +def _read_battery(dev) -> DeviceBattery | None: + try: + if not dev.ping(): + return None + except Exception: + logger.debug("ping failed for %s", dev, exc_info=True) + return None + + try: + battery = dev.battery() + except Exception: + logger.debug("battery() failed for %s", dev, exc_info=True) + return None + + return _to_device_battery(dev, battery) + + +def poll_once() -> list[DeviceBattery]: + results: list[DeviceBattery] = [] + seen_idents = set() + for dev in _iter_open_devices(): + try: + info = _read_battery(dev) + if info is not None: + entry = _to_device_battery(dev, info) + if entry: + ident = entry.serial or (getattr(dev, "unitId", None)) + if ident and ident in seen_idents: + continue + if ident: + seen_idents.add(ident) + results.append(entry) + finally: + with contextlib.suppress(Exception): + dev.close() + return results + + +def _write_json_atomic(path: Path, results: list[DeviceBattery]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "updated": time.time(), + "devices": [asdict(r) for r in results], + } + fd, tmp_path = tempfile.mkstemp( + dir=path.parent, prefix=".battery-", suffix=".tmp" + ) + try: + with os.fdopen(fd, "w") as f: + json.dump(payload, f, indent=2) + os.replace(tmp_path, path) + except Exception: + Path(tmp_path).unlink(missing_ok=True) + raise + + +@app.command() +def daemon( + rescan_interval: float = typer.Option( + 60.0, + "--rescan-interval", + "-i", + help=( + "Seconds between fallback rescans. This is NOT a battery poll " + "interval -- battery updates are event-driven and written as " + "soon as a device reports a change. New/removed devices " + "(e.g. plugging a keyboard in to charge) are normally noticed " + "within milliseconds via a udev hotplug watcher; this interval " + "is only a safety net in case a udev event is ever missed." + ), + ), + out: Path = typer.Option( + DEFAULT_OUTPUT, + "--out", + "-o", + help="Where to write the JSON status file.", + ), + lock_file: Path = typer.Option( + DEFAULT_LOCK, + "--lock-file", + help="Path used to ensure only one daemon runs at a time.", + ), + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Log every listener start and battery-change write to stderr.", + ), +): + lock_fd = _acquire_singleton_lock(lock_file) + if lock_fd is None: + try: + holder_pid = lock_file.read_text().strip() + except OSError: + holder_pid = "unknown" + typer.echo( + f"error: a battery daemon is already running (pid {holder_pid}, lock: {lock_file})", + err=True, + ) + raise typer.Exit(code=1) + + if verbose: + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(name)s: %(message)s" + ) + + write_lock = threading.Lock() + last_payload = None + + pending_write_timer: list[threading.Timer | None] = [None] + pending_write_lock = threading.Lock() + WRITE_SETTLE_SECONDS = 0.3 + + write_seq_lock = threading.Lock() + write_seq = 0 + last_written_seq = 0 + + def write_if_changed(): + nonlocal last_payload, write_seq, last_written_seq + with write_seq_lock: + write_seq += 1 + my_seq = write_seq + + results = _snapshot(registry) + with write_lock: + with write_seq_lock: + if my_seq <= last_written_seq: + return + last_written_seq = my_seq + payload = [asdict(r) for r in results] + if payload != last_payload: + _write_json_atomic(out, results) + last_payload = payload + logger.info( + "battery status changed, wrote %d device(s)", len(results) + ) + + def write_if_changed_coalesced(): + with pending_write_lock: + existing = pending_write_timer[0] + if existing is not None: + existing.cancel() + timer = threading.Timer(WRITE_SETTLE_SECONDS, write_if_changed) + timer.daemon = True + pending_write_timer[0] = timer + timer.start() + + def on_change(_device_or_receiver, alert=None, reason=None): + write_if_changed_coalesced() + + registry = _ListenerRegistry(on_change) + + def rescan_and_write(): + try: + registry.rescan() + except Exception: + logger.exception("rescan failed") + write_if_changed_coalesced() + + try: + registry.rescan() + except RuntimeError as e: + typer.echo(f"error: {e}", err=True) + raise typer.Exit(code=1) from None + + write_if_changed() + + hotplug_observer = _start_hotplug_watcher(rescan_and_write) + + running = True + + def _stop(signum, frame): + nonlocal running + running = False + + signal.signal(signal.SIGINT, _stop) + signal.signal(signal.SIGTERM, _stop) + + typer.echo( + f"Listening for battery events, writing to {out} (lock: {lock_file})" + ) + + try: + while running: + remaining = rescan_interval + while running and remaining > 0: + step = min(0.2, remaining) + time.sleep(step) + remaining -= step + if running: + rescan_and_write() + finally: + if hotplug_observer is not None: + with contextlib.suppress(Exception): + hotplug_observer.stop() + with contextlib.suppress(Exception): + _clean_json(out) + + registry.stop() + typer.echo("Stopped.") + + +if __name__ == "__main__": + app() diff --git a/pyproject.toml b/pyproject.toml index 9d0589d..d72ea6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,12 +10,11 @@ dependencies = [ "typer", "pillow", "jinja2", - "materialyoucolor" + "materialyoucolor", + "solaar", + "pytest" ] -[project.optional-dependencies] -dev = ["pytest"] - [project.scripts] zshell-cli = "zshell:main"