From 8789d2d32279dd09c59d0e6311a19985fe3083aa Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 12 Jul 2026 17:47:25 +0200 Subject: [PATCH 01/23] initial addition of battery display --- Helpers/Battery.qml | 47 +++-- Modules/SysTray/Common/BatteryIcon.qml | 84 ++++++++ Modules/SysTray/Popouts/UPowerPopout.qml | 27 +++ Modules/SysTray/Widgets/UPowerWidget.qml | 93 ++------- cli/src/zshell/__init__.py | 10 +- cli/src/zshell/subcommands/battery.py | 233 +++++++++++++++++++++++ 6 files changed, 405 insertions(+), 89 deletions(-) create mode 100644 Modules/SysTray/Common/BatteryIcon.qml create mode 100644 cli/src/zshell/subcommands/battery.py diff --git a/Helpers/Battery.qml b/Helpers/Battery.qml index 1ffb61f..21ac558 100644 --- a/Helpers/Battery.qml +++ b/Helpers/Battery.qml @@ -1,34 +1,57 @@ pragma Singleton import Quickshell +import Quickshell.Io import Quickshell.Services.UPower import qs.Config +import qs.Paths +import qs.Helpers Singleton { id: root - readonly property var colors: { - if (deviceState === UPowerDeviceState.Charging || deviceState === UPowerDeviceState.FullyCharged) + 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 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 + + function getColors(percentage: real, state: string): var { + if (state === "charging" || state === "full" || state === "recharging") return { fg: DynamicColors.swapRG(DynamicColors.palette.m3error), bg: DynamicColors.swapRG(DynamicColors.palette.m3onError) }; - else if (currentPerc <= 0.2) + else if (percentage <= 0.2) return { fg: DynamicColors.palette.m3error, bg: DynamicColors.palette.m3onError }; else return { - fg: DynamicColors.palette.m3onSurface, - bg: DynamicColors.palette.m3surface + fg: DynamicColors.palette.m3tertiary, + bg: DynamicColors.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 + + 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/Modules/SysTray/Common/BatteryIcon.qml b/Modules/SysTray/Common/BatteryIcon.qml new file mode 100644 index 0000000..24c53b7 --- /dev/null +++ b/Modules/SysTray/Common/BatteryIcon.qml @@ -0,0 +1,84 @@ +import QtQuick +import qs.Config +import qs.Components +import qs.Helpers + +Row { + id: root + + property real batHeight: Appearance.padding.smaller * 2 + property real batWidth: Appearance.padding.larger * 2 + required property string devState + property real nubHeight: Appearance.padding.small + property real nubWidth: 2 + required property real percentage + property real radius: Appearance.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: Appearance.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: Appearance.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/SysTray/Popouts/UPowerPopout.qml b/Modules/SysTray/Popouts/UPowerPopout.qml index 4ed3c7c..2ce8187 100644 --- a/Modules/SysTray/Popouts/UPowerPopout.qml +++ b/Modules/SysTray/Popouts/UPowerPopout.qml @@ -1,7 +1,9 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Layouts import Quickshell.Services.UPower +import qs.Modules.SysTray.Common import qs.Config import qs.Helpers import qs.Components @@ -21,6 +23,31 @@ Column { } } + Repeater { + model: [...Battery.logiDevices] // ...Battery.upowerDevices] + + RowLayout { + id: layout + + required property var modelData + + anchors.left: parent.left + anchors.leftMargin: Appearance.padding.normal + anchors.right: parent.right + anchors.rightMargin: Appearance.padding.normal + + CustomText { + Layout.fillWidth: true + text: layout.modelData.name + } + + BatteryIcon { + devState: layout.modelData.state + percentage: layout.modelData.percentage + } + } + } + CustomText { function formatSeconds(s: int, fallback: string): string { const day = Math.floor(s / 86400); diff --git a/Modules/SysTray/Widgets/UPowerWidget.qml b/Modules/SysTray/Widgets/UPowerWidget.qml index 0c4b4c8..8f45c6c 100644 --- a/Modules/SysTray/Widgets/UPowerWidget.qml +++ b/Modules/SysTray/Widgets/UPowerWidget.qml @@ -1,6 +1,7 @@ import Quickshell.Services.UPower import QtQuick import QtQuick.Layouts +import qs.Modules.SysTray.Common import qs.Components import qs.Config import qs.Helpers @@ -8,12 +9,13 @@ import qs.Helpers Item { id: root + readonly property Item currentItem: batteryIconLoader?.item ?? peripheralIconLoader?.item ?? upowerIconLoader.item readonly property bool shouldBeActive: Config.bar.tray.showPower Layout.preferredHeight: shouldBeActive ? implicitHeight : 0 Layout.preferredWidth: shouldBeActive ? implicitWidth : 0 - implicitHeight: Battery.isLaptop ? batteryIconLoader.item.implicitHeight : upowerIconLoader.item.implicitHeight - implicitWidth: Battery.isLaptop ? batteryIconLoader.item.implicitWidth : upowerIconLoader.item.implicitWidth + implicitHeight: currentItem?.implicitHeight ?? 0 + implicitWidth: currentItem?.implicitWidth ?? 0 opacity: shouldBeActive ? 1 : 0 scale: shouldBeActive ? 1 : 0 visible: opacity > 0 @@ -41,89 +43,28 @@ Item { active: Battery.isLaptop anchors.centerIn: parent - sourceComponent: Row { - id: batteryIcon + sourceComponent: BatteryIcon { + devState: Battery.deviceStateString + percentage: Battery.currentPerc + } + } - property real batHeight: 16 - property real batWidth: 30 - property real nubHeight: 6 - property real nubWidth: 2 - property real radius: Appearance.rounding.smallest / 2 + Loader { + id: peripheralIconLoader - spacing: 1 + active: Battery.logiDevices.some(d => d.isPresent) && !batteryIconLoader.active + anchors.centerIn: parent - 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: Appearance.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 - } + sourceComponent: BatteryIcon { + devState: Battery.logiDevices[0].state + percentage: Battery.logiDevices[0].percentage } } Loader { id: upowerIconLoader - active: !Battery.isLaptop + active: !batteryIconLoader.active && !peripheralIconLoader.active anchors.centerIn: parent sourceComponent: MaterialIcon { diff --git a/cli/src/zshell/__init__.py b/cli/src/zshell/__init__.py index 613ee3e..17e2ac5 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 ( + record, + scheme, + screenshot, + shell, + wallpaper, + battery, +) 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..ddde1cb --- /dev/null +++ b/cli/src/zshell/subcommands/battery.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import contextlib +import json +import logging +import os +import signal +import tempfile +import time +from dataclasses import asdict, dataclass +from pathlib import Path + +import typer + +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" + + +@dataclass +class DeviceBattery: + name: str + nativePath: str | None + type: str | None + wired: bool + percentage: float | None + state: str | None + charging: bool + isPresent: bool + + +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) + else: + d = 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 " + "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 + + if d is None: + continue + + if d.isDevice: + yield d + else: + yield from d + + +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 + + if battery is None: + return None + + percentage = battery.level / 100 if isinstance(battery.level, int) else None + status = ( + "charging" + if battery.status.name.lower() == "recharging" + else "fullycharged" + if battery.status.name.lower() == "full" + else battery.status.name.lower() + if battery.status is not None + else None + ) + + return DeviceBattery( + name=dev.name or dev.codename or "Unknown device", + nativePath=getattr(dev, "serial", None), + type=str(dev.kind) if dev.kind is not None else None, + wired=dev.receiver is None, + percentage=percentage, + state=status, + charging=battery.charging(), + isPresent=battery.ok(), + ) + + +def poll_once() -> list[DeviceBattery]: + """Open every attached Logitech device once and read its battery. One-shot, synchronous.""" + results = [] + for dev in _iter_open_devices(): + try: + info = _read_battery(dev) + if info is not None: + results.append(info) + 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) # atomic rename on the same filesystem + except Exception: + Path(tmp_path).unlink(missing_ok=True) + raise + + +@app.command() +def show( + as_json: bool = typer.Option( + False, "--json", help="Print raw JSON instead of a table." + ), +): + try: + results = poll_once() + except RuntimeError as e: + typer.echo(f"error: {e}", err=True) + raise typer.Exit(code=1) from None + + if not results: + typer.echo("No Logitech HID++ devices responded.", err=True) + raise typer.Exit(code=1) + + if as_json: + typer.echo(json.dumps([asdict(r) for r in results], indent=2)) + return + + for r in results: + charge_str = ( + f"{r.percentage}%" if r.percentage is not None else "unknown" + ) + flags = [ + f for f, on in (("charging", r.charging), ("wired", r.wired)) if on + ] + flag_str = f" ({', '.join(flags)})" if flags else "" + typer.echo(f"{r.name}: {charge_str}{flag_str}") + + +@app.command() +def daemon( + interval: float = typer.Option( + 15.0, "--interval", "-i", help="Seconds between polls." + ), + out: Path = typer.Option( + DEFAULT_OUTPUT, + "--out", + "-o", + help="Where to write the JSON status file.", + ), +): + running = True + + def _stop(signum, frame): + nonlocal running + running = False + + signal.signal(signal.SIGINT, _stop) + signal.signal(signal.SIGTERM, _stop) + typer.echo(f"Polling every {interval}s, writing to {out}") + try: + while running: + try: + results = poll_once() + _write_json_atomic(out, results) + except RuntimeError as e: + typer.echo(f"error: {e}", err=True) + raise typer.Exit(code=1) from None + except Exception: + logger.exception("poll cycle failed") + + remaining = interval + while running and remaining > 0: + step = min(0.2, remaining) + time.sleep(step) + remaining -= step + finally: + with contextlib.suppress(Exception): + _clean_json(out) + + typer.echo("Stopped.") + + +if __name__ == "__main__": + app() From 4ee9bb3f12bd421fdf6b7bf6c99dd0d8702d130c Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 13 Jul 2026 00:48:05 +0200 Subject: [PATCH 02/23] add support for all UPower peripherals --- Helpers/Battery.qml | 6 ++ Modules/SysTray/Popouts/UPowerPopout.qml | 4 +- Modules/SysTray/Widgets/UPowerWidget.qml | 8 +- cli/src/zshell/__init__.py | 2 +- cli/src/zshell/subcommands/battery.py | 121 ++++++++++++++--------- 5 files changed, 86 insertions(+), 55 deletions(-) diff --git a/Helpers/Battery.qml b/Helpers/Battery.qml index 21ac558..1f6f8d4 100644 --- a/Helpers/Battery.qml +++ b/Helpers/Battery.qml @@ -10,11 +10,13 @@ import qs.Helpers Singleton { id: root + 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 @@ -39,6 +41,10 @@ Singleton { }; } + function startDaemon(): void { + Quickshell.execDetached(["zshell-cli", "battery", "daemon"]); + } + FileView { id: fileView diff --git a/Modules/SysTray/Popouts/UPowerPopout.qml b/Modules/SysTray/Popouts/UPowerPopout.qml index 2ce8187..c09f811 100644 --- a/Modules/SysTray/Popouts/UPowerPopout.qml +++ b/Modules/SysTray/Popouts/UPowerPopout.qml @@ -24,7 +24,7 @@ Column { } Repeater { - model: [...Battery.logiDevices] // ...Battery.upowerDevices] + model: Battery.allPeripherals RowLayout { id: layout @@ -38,7 +38,7 @@ Column { CustomText { Layout.fillWidth: true - text: layout.modelData.name + text: layout.modelData.model } BatteryIcon { diff --git a/Modules/SysTray/Widgets/UPowerWidget.qml b/Modules/SysTray/Widgets/UPowerWidget.qml index 8f45c6c..eccb998 100644 --- a/Modules/SysTray/Widgets/UPowerWidget.qml +++ b/Modules/SysTray/Widgets/UPowerWidget.qml @@ -37,6 +37,8 @@ Item { } } + Component.onCompleted: Battery.startDaemon() + Loader { id: batteryIconLoader @@ -52,12 +54,12 @@ Item { Loader { id: peripheralIconLoader - active: Battery.logiDevices.some(d => d.isPresent) && !batteryIconLoader.active + active: Battery.lowestPeripheral.isPresent && !batteryIconLoader.active anchors.centerIn: parent sourceComponent: BatteryIcon { - devState: Battery.logiDevices[0].state - percentage: Battery.logiDevices[0].percentage + devState: Battery.lowestPeripheral.state + percentage: Battery.lowestPeripheral.percentage } } diff --git a/cli/src/zshell/__init__.py b/cli/src/zshell/__init__.py index 17e2ac5..c7214b5 100644 --- a/cli/src/zshell/__init__.py +++ b/cli/src/zshell/__init__.py @@ -9,12 +9,12 @@ from typer._completion_classes import completion_init from typer._completion_shared import _get_shell_name, install from zshell.subcommands import ( + battery, record, scheme, screenshot, shell, wallpaper, - battery, ) app = typer.Typer(name="zshell-cli", add_completion=False) diff --git a/cli/src/zshell/subcommands/battery.py b/cli/src/zshell/subcommands/battery.py index ddde1cb..60d8839 100644 --- a/cli/src/zshell/subcommands/battery.py +++ b/cli/src/zshell/subcommands/battery.py @@ -1,9 +1,11 @@ from __future__ import annotations import contextlib +import fcntl import json import logging import os +import re import signal import tempfile import time @@ -17,14 +19,31 @@ app = typer.Typer( ) 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") +) +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: - name: str + model: str nativePath: str | None + serial: str | None type: str | None wired: bool percentage: float | None @@ -33,6 +52,30 @@ class DeviceBattery: 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 _clean_json(path: Path) -> None: _write_json_atomic(path, []) @@ -45,6 +88,7 @@ def _iter_open_devices(): "logitech_receiver isn't importable -- install it with `pip install solaar`." ) from e except ValueError as e: + # gi.require_version() raises ValueError if the GTK3 typelib isn't installed. 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 " @@ -59,7 +103,7 @@ def _iter_open_devices(): else: d = receiver.create_receiver(base, dev_info) except OSError as e: - if e.errno == 13: + if e.errno == 13: # EACCES logger.error( "permission denied opening %s -- check the Solaar udev rule " "(rules.d/42-logitech-unify-permissions.rules) is installed " @@ -79,7 +123,7 @@ def _iter_open_devices(): if d.isDevice: yield d else: - yield from d + yield from d # a receiver: walk its currently paired devices def _read_battery(dev) -> DeviceBattery | None: @@ -100,30 +144,23 @@ def _read_battery(dev) -> DeviceBattery | None: return None percentage = battery.level / 100 if isinstance(battery.level, int) else None - status = ( - "charging" - if battery.status.name.lower() == "recharging" - else "fullycharged" - if battery.status.name.lower() == "full" - else battery.status.name.lower() - if battery.status is not None - else None - ) + state = battery.status.name.lower() if battery.status is not None else None + serial = getattr(dev, "serial", None) return DeviceBattery( - name=dev.name or dev.codename or "Unknown device", - nativePath=getattr(dev, "serial", None), + 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=status, + state=state, charging=battery.charging(), isPresent=battery.ok(), ) def poll_once() -> list[DeviceBattery]: - """Open every attached Logitech device once and read its battery. One-shot, synchronous.""" results = [] for dev in _iter_open_devices(): try: @@ -154,37 +191,6 @@ def _write_json_atomic(path: Path, results: list[DeviceBattery]) -> None: raise -@app.command() -def show( - as_json: bool = typer.Option( - False, "--json", help="Print raw JSON instead of a table." - ), -): - try: - results = poll_once() - except RuntimeError as e: - typer.echo(f"error: {e}", err=True) - raise typer.Exit(code=1) from None - - if not results: - typer.echo("No Logitech HID++ devices responded.", err=True) - raise typer.Exit(code=1) - - if as_json: - typer.echo(json.dumps([asdict(r) for r in results], indent=2)) - return - - for r in results: - charge_str = ( - f"{r.percentage}%" if r.percentage is not None else "unknown" - ) - flags = [ - f for f, on in (("charging", r.charging), ("wired", r.wired)) if on - ] - flag_str = f" ({', '.join(flags)})" if flags else "" - typer.echo(f"{r.name}: {charge_str}{flag_str}") - - @app.command() def daemon( interval: float = typer.Option( @@ -196,7 +202,22 @@ def daemon( "-o", help="Where to write the JSON status file.", ), + lock: Path = typer.Option( + LOCK, "--lock-file", help="Path to daemon lock file" + ), ): + lock_fd = _acquire_singleton_lock(lock) + if lock_fd is None: + try: + holder_pid = lock.read_text().strip() + except OSError: + holder_pid = "unknown" + typer.echo( + f"error: a battery daemon is already running (pid: {holder_pid}, lock: {lock})", + err=True, + ) + raise typer.Exit(code=1) + running = True def _stop(signum, frame): @@ -205,7 +226,9 @@ def daemon( signal.signal(signal.SIGINT, _stop) signal.signal(signal.SIGTERM, _stop) + typer.echo(f"Polling every {interval}s, writing to {out}") + try: while running: try: @@ -226,7 +249,7 @@ def daemon( with contextlib.suppress(Exception): _clean_json(out) - typer.echo("Stopped.") + typer.echo("Stopped.") if __name__ == "__main__": From d6230bef34eaa2d6643599d89e950eb23135e7a8 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 13 Jul 2026 01:12:55 +0200 Subject: [PATCH 03/23] Fix cmake nuitka build command --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 88f0e3c..6baef6c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,6 +93,7 @@ if("shell" IN_LIST ENABLE_MODULES) ${NUITKA_EXECUTABLE} --standalone --include-data-dir=${CMAKE_SOURCE_DIR}/cli/src/zshell/assets=zshell/assets + --include-package-data=solaar --output-dir=${ZSHELL_CLI_BUILD_DIR} --output-filename=zshell-cli ${CMAKE_SOURCE_DIR}/cli/src/zshell/ From 259a4dde7a0635000253481b085b698155658a97 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 13 Jul 2026 01:13:57 +0200 Subject: [PATCH 04/23] gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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/ From 85320e9ea1a8c4d41b9d7505d3e10c67335077dc Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 13 Jul 2026 01:33:55 +0200 Subject: [PATCH 05/23] prevent writing battery json when there has been no change, filter out devices with unknown state --- Helpers/Battery.qml | 2 +- cli/src/zshell/subcommands/battery.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Helpers/Battery.qml b/Helpers/Battery.qml index 1f6f8d4..a154297 100644 --- a/Helpers/Battery.qml +++ b/Helpers/Battery.qml @@ -21,7 +21,7 @@ Singleton { 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 + 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") diff --git a/cli/src/zshell/subcommands/battery.py b/cli/src/zshell/subcommands/battery.py index 60d8839..0450369 100644 --- a/cli/src/zshell/subcommands/battery.py +++ b/cli/src/zshell/subcommands/battery.py @@ -229,11 +229,15 @@ def daemon( typer.echo(f"Polling every {interval}s, writing to {out}") + last_payload = None try: while running: try: results = poll_once() - _write_json_atomic(out, results) + payload = [asdict(r) for r in results] + if payload != last_payload: + _write_json_atomic(out, results) + last_payload = payload except RuntimeError as e: typer.echo(f"error: {e}", err=True) raise typer.Exit(code=1) from None From 620fe001a46a59bde3c5685aeb818020313921b1 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 13 Jul 2026 14:56:57 +0200 Subject: [PATCH 06/23] backend now more robust, threaded writes use locks --- cli/src/zshell/subcommands/battery.py | 527 +++++++++++++++++++++++--- 1 file changed, 479 insertions(+), 48 deletions(-) diff --git a/cli/src/zshell/subcommands/battery.py b/cli/src/zshell/subcommands/battery.py index 0450369..dd189b7 100644 --- a/cli/src/zshell/subcommands/battery.py +++ b/cli/src/zshell/subcommands/battery.py @@ -7,23 +7,32 @@ 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") ) -LOCK = _RUNTIME_DIR / "zshell-battery-daemon.lock" +DEFAULT_LOCK = _RUNTIME_DIR / "zshell-battery-daemon.lock" def _acquire_singleton_lock(path: Path) -> int | None: @@ -76,6 +85,350 @@ def _find_upower_native_path(serial: str | None) -> str | None: 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, adc = 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 = {"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: + del self._entries[p] + known_paths = set(self._entries.keys()) + + 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) + listener.start() + + with self._lock: + self._entries[dev_info.path] = (obj, listener) + self._last_change[id(obj)] = time.time() + + logger.info( + "listening on %s (%s)", + dev_info.path, + "device" if dev_info.isDevice else "receiver", + ) + finally: + self._rescan_lock.release() + + def known_devices(self): + with self._lock: + entries = list(self._entries.values()) + + result = [] + for obj, _listener in entries: + if obj.isDevice: + result.append(obj) + else: + result.extend(list(obj)) + 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 _dedupe_by_identity( + pairs: list[tuple[object, DeviceBattery]], + freshness: Callable[[object], float], +) -> list[DeviceBattery]: + passthrough: list[DeviceBattery] = [] + best: dict[str, tuple[float, DeviceBattery]] = {} + + for dev, entry in pairs: + key = entry.serial + if not key: + passthrough.append(entry) + continue + ts = freshness(dev) + current = best.get(key) + if current is None or ts >= current[0]: + best[key] = (ts, entry) + + return passthrough + [entry for _ts, entry in best.values()] + + +def _snapshot(registry: _ListenerRegistry) -> list[DeviceBattery]: + pairs: list[tuple[object, 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: + pairs.append((dev, entry)) + return _dedupe_by_identity(pairs, registry.last_change) + + def _clean_json(path: Path) -> None: _write_json_atomic(path, []) @@ -88,7 +441,6 @@ def _iter_open_devices(): "logitech_receiver isn't importable -- install it with `pip install solaar`." ) from e except ValueError as e: - # gi.require_version() raises ValueError if the GTK3 typelib isn't installed. 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 " @@ -103,7 +455,7 @@ def _iter_open_devices(): else: d = receiver.create_receiver(base, dev_info) except OSError as e: - if e.errno == 13: # EACCES + if e.errno == 13: logger.error( "permission denied opening %s -- check the Solaar udev rule " "(rules.d/42-logitech-unify-permissions.rules) is installed " @@ -123,7 +475,31 @@ def _iter_open_devices(): if d.isDevice: yield d else: - yield from d # a receiver: walk its currently paired devices + yield from d + + +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: @@ -140,37 +516,23 @@ def _read_battery(dev) -> DeviceBattery | None: logger.debug("battery() failed for %s", dev, exc_info=True) return 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) - - 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(), - ) + return _to_device_battery(dev, battery) def poll_once() -> list[DeviceBattery]: - results = [] + pairs: list[tuple[object, DeviceBattery]] = [] for dev in _iter_open_devices(): try: info = _read_battery(dev) if info is not None: - results.append(info) + pairs.append((dev, info)) finally: with contextlib.suppress(Exception): dev.close() - return results + + return _dedupe_by_identity( + pairs, lambda dev: 1.0 if dev.receiver is None else 0.0 + ) def _write_json_atomic(path: Path, results: list[DeviceBattery]) -> None: @@ -185,7 +547,7 @@ def _write_json_atomic(path: Path, results: list[DeviceBattery]) -> None: try: with os.fdopen(fd, "w") as f: json.dump(payload, f, indent=2) - os.replace(tmp_path, path) # atomic rename on the same filesystem + os.replace(tmp_path, path) except Exception: Path(tmp_path).unlink(missing_ok=True) raise @@ -193,8 +555,18 @@ def _write_json_atomic(path: Path, results: list[DeviceBattery]) -> None: @app.command() def daemon( - interval: float = typer.Option( - 15.0, "--interval", "-i", help="Seconds between polls." + 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, @@ -202,22 +574,86 @@ def daemon( "-o", help="Where to write the JSON status file.", ), - lock: Path = typer.Option( - LOCK, "--lock-file", help="Path to daemon lock 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) + lock_fd = _acquire_singleton_lock(lock_file) if lock_fd is None: try: - holder_pid = lock.read_text().strip() + 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})", + 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 + + def write_if_changed(): + nonlocal last_payload + with write_lock: + results = _snapshot(registry) + 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): @@ -227,32 +663,27 @@ def daemon( signal.signal(signal.SIGINT, _stop) signal.signal(signal.SIGTERM, _stop) - typer.echo(f"Polling every {interval}s, writing to {out}") + typer.echo( + f"Listening for battery events, writing to {out} (lock: {lock_file})" + ) - last_payload = None try: while running: - try: - results = poll_once() - payload = [asdict(r) for r in results] - if payload != last_payload: - _write_json_atomic(out, results) - last_payload = payload - except RuntimeError as e: - typer.echo(f"error: {e}", err=True) - raise typer.Exit(code=1) from None - except Exception: - logger.exception("poll cycle failed") - - remaining = interval + 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.") From 57147dc7c14ad68d9dd83b33a70633b81183e2cf Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 13 Jul 2026 14:59:52 +0200 Subject: [PATCH 07/23] fix: cmake nuitka command --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6baef6c..933c6e5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,7 +93,7 @@ if("shell" IN_LIST ENABLE_MODULES) ${NUITKA_EXECUTABLE} --standalone --include-data-dir=${CMAKE_SOURCE_DIR}/cli/src/zshell/assets=zshell/assets - --include-package-data=solaar + --include-package=gi.overrides --output-dir=${ZSHELL_CLI_BUILD_DIR} --output-filename=zshell-cli ${CMAKE_SOURCE_DIR}/cli/src/zshell/ From bb50cf756d1e109f90f5cf3fab82a13fc9850fc4 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 13 Jul 2026 15:03:16 +0200 Subject: [PATCH 08/23] fix: cmake nuitka command again --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 933c6e5..23bbaec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,6 +94,7 @@ if("shell" IN_LIST ENABLE_MODULES) --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/ From a9bbb21f54bb909c58879b23cb5447430b0a1ae5 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 13 Jul 2026 19:31:40 +0200 Subject: [PATCH 09/23] improve device detection when plugging/unplugging --- cli/src/zshell/subcommands/battery.py | 144 +++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 5 deletions(-) diff --git a/cli/src/zshell/subcommands/battery.py b/cli/src/zshell/subcommands/battery.py index dd189b7..e5f0568 100644 --- a/cli/src/zshell/subcommands/battery.py +++ b/cli/src/zshell/subcommands/battery.py @@ -292,6 +292,7 @@ class _ListenerRegistry: 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): @@ -309,6 +310,7 @@ class _ListenerRegistry: return if not self._rescan_lock.acquire(blocking=False): return + try: self._last_rescan_time = now with self._hidpp_io_lock: @@ -321,9 +323,38 @@ class _ListenerRegistry: 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 @@ -350,17 +381,108 @@ class _ListenerRegistry: continue listener = ListenerClass(obj, self._touch_and_notify) - listener.start() + + 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_path, + (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: - self._entries[dev_info.path] = (obj, listener) - self._last_change[id(obj)] = time.time() + 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" in locals(): + try: + to_join.join(timeout=1.0) + except Exception: + logger.exception("error joining replaced listener") + del to_join + finally: self._rescan_lock.release() @@ -610,10 +732,22 @@ def daemon( 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 + 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: - results = _snapshot(registry) + 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) From bf5a8b80494ed5a9416c9a5f2e1868384ad1e04a Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 13 Jul 2026 22:10:41 +0200 Subject: [PATCH 10/23] simplify dedupe --- cli/src/zshell/subcommands/battery.py | 57 +++++++++++++-------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/cli/src/zshell/subcommands/battery.py b/cli/src/zshell/subcommands/battery.py index e5f0568..075ae59 100644 --- a/cli/src/zshell/subcommands/battery.py +++ b/cli/src/zshell/subcommands/battery.py @@ -491,11 +491,23 @@ class _ListenerRegistry: 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: - result.extend(list(obj)) + 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): @@ -510,28 +522,8 @@ class _ListenerRegistry: listener.join(timeout=2.0) -def _dedupe_by_identity( - pairs: list[tuple[object, DeviceBattery]], - freshness: Callable[[object], float], -) -> list[DeviceBattery]: - passthrough: list[DeviceBattery] = [] - best: dict[str, tuple[float, DeviceBattery]] = {} - - for dev, entry in pairs: - key = entry.serial - if not key: - passthrough.append(entry) - continue - ts = freshness(dev) - current = best.get(key) - if current is None or ts >= current[0]: - best[key] = (ts, entry) - - return passthrough + [entry for _ts, entry in best.values()] - - def _snapshot(registry: _ListenerRegistry) -> list[DeviceBattery]: - pairs: list[tuple[object, DeviceBattery]] = [] + results: list[DeviceBattery] = [] with registry._hidpp_io_lock: for dev in registry.known_devices(): @@ -547,8 +539,8 @@ def _snapshot(registry: _ListenerRegistry) -> list[DeviceBattery]: info = None entry = _to_device_battery(dev, info) if entry is not None: - pairs.append((dev, entry)) - return _dedupe_by_identity(pairs, registry.last_change) + results.append(entry) + return results def _clean_json(path: Path) -> None: @@ -642,19 +634,24 @@ def _read_battery(dev) -> DeviceBattery | None: def poll_once() -> list[DeviceBattery]: - pairs: list[tuple[object, DeviceBattery]] = [] + results: list[DeviceBattery] = [] + seen_idents = set() for dev in _iter_open_devices(): try: info = _read_battery(dev) if info is not None: - pairs.append((dev, info)) + 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 _dedupe_by_identity( - pairs, lambda dev: 1.0 if dev.receiver is None else 0.0 - ) + return results def _write_json_atomic(path: Path, results: list[DeviceBattery]) -> None: From 1d150292d3dfd296308a8ab24d051ca0f9506060 Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 12 Aug 2026 20:15:55 +0200 Subject: [PATCH 11/23] initial commit for repositioning bar, currently broken --- Config/BarConfig.qml | 1 + Drawers/Drawers.qml | 39 +++++++++++++++++++++++++-- Drawers/EdgeGeometry.qml | 57 +++++++++++++++++++++++++++++++++++++++ Drawers/Exclusions.qml | 9 +++++-- Drawers/Interactions.qml | 33 ++++++++++++++--------- Drawers/Windows.qml | 50 +++++++++++++++++++++------------- Modules/Bar/Bar.qml | 55 +++++++++++++++++++++++++------------ Modules/Bar/BarLoader.qml | 35 ++++++++++++++++-------- 8 files changed, 217 insertions(+), 62 deletions(-) create mode 100644 Drawers/EdgeGeometry.qml diff --git a/Config/BarConfig.qml b/Config/BarConfig.qml index 4f3697f..6f5997d 100644 --- a/Config/BarConfig.qml +++ b/Config/BarConfig.qml @@ -61,6 +61,7 @@ JsonObject { property bool hideWhenNotif: false property Popouts popouts: Popouts { } + property string position: "top" property int rounding: 8 property int smoothing: 32 property Tray tray: Tray { diff --git a/Drawers/Drawers.qml b/Drawers/Drawers.qml index b026775..490bc0e 100644 --- a/Drawers/Drawers.qml +++ b/Drawers/Drawers.qml @@ -11,15 +11,50 @@ 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 onConfigDashboardPositionChanged(): void { + Qt.callLater(scope.rebuild); + } + + 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..47bc341 --- /dev/null +++ b/Drawers/EdgeGeometry.qml @@ -0,0 +1,57 @@ +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 configDashboardPosition + required property string configPosition + readonly property bool dashboardOnLeft: configDashboardPosition === "left" ? !barOnLeft : barOnTop + 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 3373e34..259b616 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 c72f55f..73570cf 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -12,6 +12,7 @@ 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 @@ -21,19 +22,26 @@ Item { 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); + return y > root.height - panel.height - 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); + return y < geometry.insetTop(borderThickness) + panel.height && withinPanelWidth(panel, x, y); } function onWheel(event: WheelEvent): void { @@ -43,12 +51,12 @@ Item { } 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,20 +89,21 @@ Item { if (root.singleGestureTriggered) return; - if (centroid.pressPosition.y < root.bar.implicitHeight) { - if (dragY > 20) { + if (root.geometry.barContains(centroid.pressPosition.x, centroid.pressPosition.y, true)) { + const barDrag = root.geometry.inwardDrag(dragX, dragY); + if (barDrag > 20) { root.visibilities.settings = true; root.singleGestureTriggered = true; - } else if (dragY < -20) { + } else if (barDrag < -20) { root.visibilities.settings = false; root.singleGestureTriggered = true; } } - 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 +202,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) { diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 23f9028..e3874e2 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -32,6 +32,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; @@ -94,13 +95,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 @@ -113,7 +122,7 @@ CustomWindow { Regions { id: regions - bar: bar + geometry: geometry panels: panels win: root } @@ -192,10 +201,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 } @@ -211,7 +220,7 @@ CustomWindow { panel: panels.dashboardWrapper radius: Appearance.rounding.normal x: panels.dashboardWrapper.x + panels.dashboard.x + root.borderThickness - y: panels.dashboardWrapper.y + panels.dashboard.y + bar.implicitHeight - panels.dashboard.height * extraHeight + y: panels.dashboardWrapper.y + panels.dashboard.y + geometry.insetTop(root.borderThickness) - panels.dashboard.height * extraHeight } PanelBg { @@ -244,8 +253,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 { @@ -267,17 +275,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 ?? Appearance.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 { } } @@ -370,6 +378,7 @@ CustomWindow { borderThickness: root.borderLayoutThickness drawing: drawing enabled: true + geometry: geometry panels: panels popouts: panels.popouts screen: root.screen @@ -381,6 +390,7 @@ CustomWindow { bar: bar borderThickness: root.borderThickness drawingItem: drawing + geometry: geometry screen: root.screen visibilities: visibilities @@ -422,14 +432,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 } } @@ -442,7 +456,7 @@ CustomWindow { implicitHeight: panel.height implicitWidth: panel.width radius: Appearance.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/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index b2a1842..14471ef 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -9,18 +9,24 @@ import qs.Modules.SysTray import qs.Modules.Network import qs.Modules.Updates -RowLayout { +GridLayout { id: root required property bool fullscreen + required property bool horizontal required property Wrapper popouts required property ClipWrapper popoutsWrapper required property ShellScreen screen readonly property int vPadding: 6 required property PersistentProperties visibilities - function checkPopout(x: real): void { - const ch = childAt(x, height / 2) as EntryWrapper; + function axisCenterOf(item: Item): real { + const c = item.mapToItem(root, item.implicitWidth / 2, item.implicitHeight / 2); + return horizontal ? c.x : c.y; + } + + function checkPopout(pos: real): void { + const ch = (horizontal ? childAt(pos, height / 2) : childAt(width / 2, pos)) as EntryWrapper; const id = ch?.entryId; if (!ch || id === undefined) { @@ -34,26 +40,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(items.width / 2, mapToItem(layout, 0, pos).y); if (trayItem) { const idx = trayItem.index; popouts.currentName = `traymenu${idx}`; - popouts.currentCenter = Qt.binding(() => { - const it = tray.items.itemAt(idx); - return it ? it.mapToItem(root, it.implicitWidth / 2, 0).x : popouts.currentCenter; - }); + popouts.currentCenter = Qt.binding(() => axisCenterOf(trayItem)); popouts.hasCurrent = true; } @@ -63,12 +64,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: Appearance.spacing.small + function entryAt(pos: real): string { + const ch = (horizontal ? childAt(pos, height / 2) : childAt(width / 2, pos)) as EntryWrapper; + return ch?.entryId ?? ""; + } + + columnSpacing: Appearance.spacing.small + columns: horizontal ? -1 : 1 + rowSpacing: Appearance.spacing.small Repeater { id: repeater @@ -94,7 +102,8 @@ RowLayout { roleValue: "spacer" delegate: EntryWrapper { - Layout.fillWidth: true + Layout.fillHeight: !root.horizontal + Layout.fillWidth: root.horizontal } } @@ -103,6 +112,7 @@ RowLayout { delegate: EntryWrapper { Workspaces { + horizontal: root.horizontal screen: root.screen visible: !root.fullscreen } @@ -114,6 +124,7 @@ RowLayout { delegate: EntryWrapper { TrayIcons { + horizontal: root.horizontal loader: root popouts: root.popouts visible: !root.fullscreen @@ -126,6 +137,7 @@ RowLayout { delegate: EntryWrapper { StatusIcons { + horizontal: root.horizontal } } } @@ -135,6 +147,7 @@ RowLayout { delegate: EntryWrapper { Resources { + horizontal: root.horizontal visibilities: root.visibilities visible: !root.fullscreen } @@ -146,6 +159,7 @@ RowLayout { delegate: EntryWrapper { UpdatesWidget { + horizontal: root.horizontal visible: !root.fullscreen } } @@ -156,6 +170,7 @@ RowLayout { delegate: EntryWrapper { NotifBell { + horizontal: root.horizontal popouts: root.popouts visibilities: root.visibilities visible: !root.fullscreen @@ -168,6 +183,7 @@ RowLayout { delegate: EntryWrapper { Clock { + horizontal: root.horizontal loader: root popouts: root.popouts visibilities: root.visibilities @@ -182,6 +198,7 @@ RowLayout { delegate: EntryWrapper { WindowTitle { bar: root + horizontal: root.horizontal visible: !root.fullscreen } } @@ -192,6 +209,7 @@ RowLayout { delegate: EntryWrapper { NetworkWidget { + horizontal: root.horizontal } } } @@ -201,6 +219,7 @@ RowLayout { delegate: EntryWrapper { MediaWidget { + horizontal: root.horizontal visible: !root.fullscreen } } @@ -215,8 +234,10 @@ RowLayout { required property var modelData Layout.alignment: Qt.AlignVCenter - Layout.leftMargin: index === 0 ? root.vPadding : (entryId === "statusIcons") ? -root.spacing + Appearance.spacing.extraSmall : 0 - Layout.rightMargin: index === repeater.count - 1 ? root.vPadding : 0 + Layout.bottomMargin: !root.horizontal && index === repeater.count - 1 ? root.vPadding : 0 + Layout.leftMargin: root.horizontal && index === 0 ? root.vPadding : (entryId === "statusIcons") ? -root.rowSpacing + Appearance.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.columnSpacing + Appearance.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 f5baba8..240417c 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -15,31 +15,40 @@ import qs.Modules.Network 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: Config.bar.height + 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(Appearance.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: [ @@ -50,7 +59,7 @@ Item { Anim { duration: MaterialEasing.expressiveEffectsTime easing.bezierCurve: MaterialEasing.expressiveEffects - property: "implicitHeight" + property: "extent" target: root } }, @@ -61,7 +70,7 @@ Item { Anim { duration: MaterialEasing.expressiveEffectsTime easing.bezierCurve: MaterialEasing.expressiveEffects - property: "implicitHeight" + property: "extent" target: root } } @@ -71,13 +80,17 @@ 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 From 4517e5ecd0be5cbb2bb3049b1cdad47e26cda9f5 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 14 Aug 2026 16:31:52 +0200 Subject: [PATCH 12/23] left-bar working, bottom bar semi-broken --- Components/MarqueeText.qml | 4 +- Drawers/EdgeGeometry.qml | 2 - Drawers/Interactions.qml | 25 ++++------ Drawers/Panels.qml | 25 ++++++---- Drawers/Regions.qml | 21 ++++---- Drawers/Windows.qml | 16 +++---- Modules/Bar/Bar.qml | 30 +++++++++--- Modules/Bar/BarLoader.qml | 11 +---- Modules/ClipWrapper.qml | 43 ++++++++++++++--- Modules/Clock.qml | 24 ++++++++-- Modules/Dashboard/Wrapper.qml | 5 ++ Modules/HyprsunsetWidget.qml | 7 ++- Modules/MediaWidget.qml | 24 ++++++++-- Modules/NotifBell.qml | 9 ++-- Modules/Resource.qml | 16 ++++--- Modules/Resources.qml | 33 ++++++++----- Modules/Resources/Wrapper.qml | 4 +- Modules/Settings/Pages/Panels/BarPanel.qml | 33 ++++++++++++- Modules/SysTray/StatusIcons.qml | 46 ++++++++++++++---- Modules/SysTray/TrayIcons.qml | 22 +++++---- Modules/SysTray/TrayItem.qml | 4 +- Modules/SysTray/TrayWidget.qml | 33 +++++++++---- Modules/SysTray/Widgets/AudioWidget.qml | 4 +- Modules/SysTray/Widgets/MicWidget.qml | 4 +- Modules/SysTray/Widgets/UPowerWidget.qml | 3 ++ Modules/Updates/UpdatesWidget.qml | 19 +++++--- Modules/WindowTitle.qml | 56 +++++++++++++++------- Modules/Workspaces.qml | 49 +++++++++++++------ 28 files changed, 404 insertions(+), 168 deletions(-) diff --git a/Components/MarqueeText.qml b/Components/MarqueeText.qml index 427d381..3db5490 100644 --- a/Components/MarqueeText.qml +++ b/Components/MarqueeText.qml @@ -40,7 +40,7 @@ Item { } } - clip: true + clip: false implicitHeight: elideText.implicitHeight Behavior on leftFadeStrength { @@ -80,7 +80,7 @@ Item { id: marqueeViewport anchors.fill: parent - clip: true + clip: false layer.enabled: true visible: root.overflowing diff --git a/Drawers/EdgeGeometry.qml b/Drawers/EdgeGeometry.qml index 47bc341..e10b45b 100644 --- a/Drawers/EdgeGeometry.qml +++ b/Drawers/EdgeGeometry.qml @@ -10,9 +10,7 @@ QtObject { readonly property bool barOnBottom: position === "bottom" readonly property bool barOnLeft: position === "left" readonly property bool barOnTop: position === "top" - required property string configDashboardPosition required property string configPosition - readonly property bool dashboardOnLeft: configDashboardPosition === "left" ? !barOnLeft : barOnTop readonly property bool horizontal: position !== "left" readonly property string position: configPosition === "top" || configPosition === "bottom" ? configPosition : "left" required property var win diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index 73570cf..2c29d99 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -22,7 +22,8 @@ Item { required property PersistentProperties visibilities function inBottomPanel(panel: Item, x: real, y: real): bool { - return y > root.height - panel.height - geometry.insetBottom(borderThickness) && 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 { @@ -37,17 +38,12 @@ Item { } function inRightPanel(panel: Item, x: real, y: real): bool { - return x > panel.x - geometry.insetLeft(borderThickness) && 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 < geometry.insetTop(borderThickness) + 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 { @@ -89,12 +85,11 @@ Item { if (root.singleGestureTriggered) return; - if (root.geometry.barContains(centroid.pressPosition.x, centroid.pressPosition.y, true)) { - const barDrag = root.geometry.inwardDrag(dragX, dragY); - if (barDrag > 20) { + if (root.geometry.insetTop(root.borderThickness) > centroid.pressPosition.y) { + if (dragY > 20) { root.visibilities.settings = true; root.singleGestureTriggered = true; - } else if (barDrag < -20) { + } else if (dragY < -20) { root.visibilities.settings = false; root.singleGestureTriggered = true; } @@ -228,8 +223,8 @@ Item { if (Config.dock.enable && Config.dock.hoverToReveal && !root.visibilities.dock && !root.visibilities.launcher && root.inBottomPanel(root.panels.dock, x, y)) root.visibilities.dock = true; - if (y < root.bar.implicitHeight) - root.bar.checkPopout(x); + if (root.geometry.barContains(x, y)) + root.bar.checkPopout(root.geometry.axisPos(x, y)); } } diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index 8a587be..a3b1bd6 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -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,9 +44,11 @@ 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 @@ -53,14 +56,15 @@ Item { anchors.left: parent.left anchors.top: parent.top 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 + horizontal: root.geometry.horizontal visibilities: root.visibilities } } @@ -99,8 +103,8 @@ Item { Modules.ClipWrapper { id: popouts - anchors.top: parent.top borderThickness: root.borderThickness + position: root.geometry.position screen: root.screen } @@ -147,11 +151,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 { @@ -164,8 +170,7 @@ Item { id: dashboard anchors.right: parent.right - anchors.top: parent.top - anchors.topMargin: (-implicitHeight - 5) * offsetScale + horizontal: root.geometry.horizontal offsetScale: dashboardWrapper.offsetScale 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 e3874e2..b3fb795 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -212,15 +212,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: Appearance.rounding.normal - x: panels.dashboardWrapper.x + panels.dashboard.x + root.borderThickness - y: panels.dashboardWrapper.y + panels.dashboard.y + geometry.insetTop(root.borderThickness) - 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 ? panels.dashboard.height * extraExtent : 0) } PanelBg { @@ -232,7 +232,7 @@ CustomWindow { implicitHeight: panels.launcher.height * (1 + extraHeight) panel: panels.launcher radius: Appearance.rounding.smallest + 5 - y: panels.launcher.y + bar.implicitHeight + y: panels.launcher.y + geometry.insetTop(root.borderThickness) } PanelBg { @@ -299,8 +299,8 @@ CustomWindow { implicitWidth: panels.resources.width panel: panels.resourcesWrapper radius: Appearance.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 { diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 14471ef..74a79ad 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -49,7 +49,7 @@ GridLayout { } else if (id === "tray" && Config.bar.popouts.tray && Config.bar.tray.showOnHover) { const tray = ch.item as TrayIcons; const layout = tray.layout; - const trayItem = horizontal ? layout.childAt(mapToItem(layout, pos, 0).x, layout.height / 2) : layout.childAt(items.width / 2, mapToItem(layout, 0, pos).y); + 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; @@ -92,7 +92,10 @@ GridLayout { roleValue: "hyprsunset" delegate: EntryWrapper { + Layout.fillWidth: !root.horizontal + HyprsunsetWidget { + horizontal: root.horizontal visible: !root.fullscreen } } @@ -123,6 +126,8 @@ GridLayout { roleValue: "tray" delegate: EntryWrapper { + Layout.fillWidth: !root.horizontal + TrayIcons { horizontal: root.horizontal loader: root @@ -136,6 +141,8 @@ GridLayout { roleValue: "statusIcons" delegate: EntryWrapper { + Layout.fillWidth: !root.horizontal + StatusIcons { horizontal: root.horizontal } @@ -146,6 +153,8 @@ GridLayout { roleValue: "resources" delegate: EntryWrapper { + Layout.fillWidth: !root.horizontal + Resources { horizontal: root.horizontal visibilities: root.visibilities @@ -158,6 +167,8 @@ GridLayout { roleValue: "updates" delegate: EntryWrapper { + Layout.fillWidth: !root.horizontal + UpdatesWidget { horizontal: root.horizontal visible: !root.fullscreen @@ -169,6 +180,8 @@ GridLayout { roleValue: "notifBell" delegate: EntryWrapper { + Layout.fillWidth: !root.horizontal + NotifBell { horizontal: root.horizontal popouts: root.popouts @@ -182,6 +195,8 @@ GridLayout { roleValue: "clock" delegate: EntryWrapper { + Layout.fillWidth: !root.horizontal + Clock { horizontal: root.horizontal loader: root @@ -196,6 +211,8 @@ GridLayout { roleValue: "activeWindow" delegate: EntryWrapper { + Layout.fillWidth: !root.horizontal + WindowTitle { bar: root horizontal: root.horizontal @@ -209,7 +226,6 @@ GridLayout { delegate: EntryWrapper { NetworkWidget { - horizontal: root.horizontal } } } @@ -218,6 +234,8 @@ GridLayout { roleValue: "media" delegate: EntryWrapper { + Layout.fillWidth: !root.horizontal + MediaWidget { horizontal: root.horizontal visible: !root.fullscreen @@ -233,11 +251,11 @@ GridLayout { default property Item item required property var modelData - Layout.alignment: Qt.AlignVCenter + 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.rowSpacing + Appearance.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.columnSpacing + Appearance.spacing.extraSmall : 0 + Layout.leftMargin: root.horizontal && index === 0 ? root.vPadding : (entryId === "statusIcons" && root.horizontal) ? -root.rowSpacing + Appearance.spacing.extraSmall : root.horizontal ? 0 : Appearance.padding.smaller + Layout.rightMargin: root.horizontal && index === repeater.count - 1 ? root.vPadding : root.horizontal ? 0 : Appearance.padding.smaller + Layout.topMargin: !root.horizontal && index === 0 ? root.vPadding : (entryId === "statusIcons" && !root.horizontal) ? -root.columnSpacing + Appearance.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 240417c..fafd193 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -1,28 +1,22 @@ pragma ComponentBehavior: Bound import Quickshell -import Quickshell.Hyprland import QtQuick -import QtQuick.Layouts import qs.Components import qs.Modules import qs.Config -import qs.Helpers -import qs.Modules.SysTray -import qs.Modules.SysTray.Widgets -import qs.Modules.Network Item { id: root readonly property int clampedExtent: Math.max(Config.bar.border, extent) - readonly property int contentThickness: Config.bar.height + padding * 2 + readonly property int contentThickness: Math.max(Config.bar.height, (horizontal ? 24 : 30)) + 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(Appearance.padding.smaller, Config.bar.border) + readonly property int padding: horizontal ? Math.max(Appearance.padding.smaller, Config.bar.border) : Math.max(Appearance.padding.larger, Config.bar.border) required property Wrapper popouts required property ClipWrapper popoutsWrapper required property string position @@ -89,7 +83,6 @@ Item { sourceComponent: Bar { fullscreen: root.fullscreen - height: root.contentHeight horizontal: root.horizontal popouts: root.popouts popoutsWrapper: root.popoutsWrapper diff --git a/Modules/ClipWrapper.qml b/Modules/ClipWrapper.qml index 10fbd82..cb7cc5e 100644 --- a/Modules/ClipWrapper.qml +++ b/Modules/ClipWrapper.qml @@ -10,27 +10,51 @@ Item { required property real borderThickness readonly property alias content: content - property real offsetScale: y > 0 || content.hasCurrent ? 0 : 1 + 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: content.implicitHeight * (1 - offsetScale) - implicitWidth: content.implicitWidth + 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.offsetScale < 1 + enabled: !root.horizontal || root.offsetScale < 1 Anim { duration: content.animLength @@ -38,6 +62,8 @@ Item { } } Behavior on y { + enabled: root.horizontal || root.offsetScale < 1 + Anim { duration: content.animLength easing.bezierCurve: content.animCurve @@ -47,9 +73,14 @@ Item { Wrapper { id: content - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top + 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/Clock.qml b/Modules/Clock.qml index fb668c0..15f95de 100644 --- a/Modules/Clock.qml +++ b/Modules/Clock.qml @@ -9,27 +9,45 @@ import qs.Components CustomRect { id: root - required property RowLayout loader + required property bool horizontal + required property GridLayout loader required property Wrapper popouts + readonly property real shortSize: Config.bar.height + Appearance.padding.smallest * 2 + readonly property real size: timeText.contentWidth + (horizontal ? Appearance.padding.normal : Appearance.padding.larger) * 2 required property PersistentProperties visibilities + anchors.fill: parent color: visibilities.dashboard ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Appearance.padding.smallest * 2 - implicitWidth: timeText.contentWidth + Appearance.padding.normal * 2 + implicitHeight: horizontal ? shortSize : size + implicitWidth: horizontal ? size : shortSize radius: Appearance.rounding.full CustomText { id: timeText anchors.centerIn: parent + // anchors.horizontalCenter: root.horizontal ? undefined : parent.horizontalCenter + // anchors.verticalCenter: root.horizontal ? parent.verticalCenter : undefined color: root.visibilities.dashboard ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface font: Appearance.font.family.mono // qmllint disable incompatible-type + height: root.horizontal ? implicitHeight : implicitWidth text: Time.dateStr + width: root.horizontal ? implicitWidth : implicitHeight Behavior on color { CAnim { } } + transform: [ + Translate { + x: !root.horizontal ? -timeText.implicitWidth + timeText.implicitHeight : 0 + }, + Rotation { + angle: root.horizontal ? 0 : 270 + origin.x: timeText.implicitHeight / 2 + origin.y: timeText.implicitHeight / 2 + } + ] } StateLayer { diff --git a/Modules/Dashboard/Wrapper.qml b/Modules/Dashboard/Wrapper.qml index 26da1e5..747d0e4 100644 --- a/Modules/Dashboard/Wrapper.qml +++ b/Modules/Dashboard/Wrapper.qml @@ -14,11 +14,16 @@ Item { reloadableId: "dashboardState" } + required property bool horizontal readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0 required property real offsetScale readonly property bool shouldBeActive: root.visibilities.dashboard && Config.dashboard.enabled required property PersistentProperties visibilities + anchors.left: horizontal ? undefined : parent.left + anchors.leftMargin: horizontal ? 0 : (-implicitWidth - 5) * offsetScale + anchors.top: horizontal ? parent.top : undefined + anchors.topMargin: horizontal ? (-implicitHeight - 5) * offsetScale : 0 implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth || 854 opacity: 1 - offsetScale diff --git a/Modules/HyprsunsetWidget.qml b/Modules/HyprsunsetWidget.qml index 4a2ad27..c5bd375 100644 --- a/Modules/HyprsunsetWidget.qml +++ b/Modules/HyprsunsetWidget.qml @@ -6,11 +6,13 @@ import qs.Config CustomRect { id: root + required property bool horizontal property bool tempEnabled: Hyprsunset.enabled + anchors.fill: parent color: root.tempEnabled ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Appearance.padding.smallest * 2 - implicitWidth: implicitHeight + implicitHeight: horizontal ? Config.bar.height + Appearance.padding.smallest * 2 : width + implicitWidth: horizontal ? height : Config.bar.height + Appearance.padding.smallest * 2 radius: Appearance.rounding.full StateLayer { @@ -25,6 +27,7 @@ CustomRect { animate: true color: root.tempEnabled ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface fill: root.tempEnabled ? 1 : 0 + font.pointSize: root.horizontal ? Appearance.font.size.larger : Appearance.font.size.large text: root.tempEnabled ? "lightbulb" : "light_off" Behavior on fill { diff --git a/Modules/MediaWidget.qml b/Modules/MediaWidget.qml index 644446a..9208622 100644 --- a/Modules/MediaWidget.qml +++ b/Modules/MediaWidget.qml @@ -9,11 +9,13 @@ CustomRect { id: root readonly property string currentMedia: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") + required property bool horizontal readonly property int textWidth: Math.min(metrics.width, 200) + anchors.fill: parent color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Appearance.padding.smallest * 2 - implicitWidth: layout.implicitWidth + Appearance.padding.normal * 2 + implicitHeight: horizontal ? Config.bar.height + Appearance.padding.smallest * 2 : layout.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: horizontal ? layout.implicitWidth + Appearance.padding.normal * 2 : Config.bar.height + Appearance.padding.smallest radius: Appearance.rounding.full Behavior on implicitWidth { @@ -28,10 +30,11 @@ CustomRect { text: mediatext.text } - RowLayout { + GridLayout { id: layout anchors.centerIn: parent + columns: root.horizontal ? -1 : 1 Behavior on implicitWidth { Anim { @@ -39,6 +42,7 @@ CustomRect { } MaterialIcon { + Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter animate: true color: Players.active?.isPlaying ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface font.pointSize: Appearance.font.size.larger @@ -48,7 +52,8 @@ CustomRect { MarqueeText { id: mediatext - Layout.preferredWidth: root.textWidth + Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter + Layout.preferredHeight: root.horizontal ? root.height : root.textWidth animate: true color: Players.active?.isPlaying ? DynamicColors.palette.m3primary : DynamicColors.palette.m3onSurface font.pointSize: Appearance.font.size.normal @@ -58,6 +63,17 @@ CustomRect { text: root.currentMedia width: root.textWidth + transform: [ + Translate { + x: !root.horizontal ? -root.textWidth + mediatext.implicitHeight : 0 + }, + Rotation { + angle: root.horizontal ? 0 : 270 + origin.x: mediatext.implicitHeight / 2 + origin.y: mediatext.implicitHeight / 2 + } + ] + CustomMouseArea { anchors.fill: parent hoverEnabled: true diff --git a/Modules/NotifBell.qml b/Modules/NotifBell.qml index ba15f26..dabacab 100644 --- a/Modules/NotifBell.qml +++ b/Modules/NotifBell.qml @@ -7,12 +7,14 @@ import qs.Components CustomRect { id: root + required property bool horizontal required property Wrapper popouts required property PersistentProperties visibilities + anchors.fill: parent color: visibilities.sidebar ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Appearance.padding.smallest * 2 - implicitWidth: implicitHeight + implicitHeight: horizontal ? Config.bar.height + Appearance.padding.smallest * 2 : width + implicitWidth: horizontal ? height : Config.bar.height + Appearance.padding.smallest * 2 radius: Appearance.rounding.full MaterialIcon { @@ -21,8 +23,7 @@ CustomRect { anchors.centerIn: parent color: root.visibilities.sidebar ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface fill: root.visibilities.sidebar ? 1 : 0 - font.family: "Material Symbols Rounded" - font.pointSize: Appearance.font.size.larger + font.pointSize: root.horizontal ? Appearance.font.size.larger : Appearance.font.size.large text: NotifServer.list.length ? "\uf4fe" : "\ue7f4" Behavior on color { diff --git a/Modules/Resource.qml b/Modules/Resource.qml index 32a6b46..2cdb28d 100644 --- a/Modules/Resource.qml +++ b/Modules/Resource.qml @@ -4,13 +4,14 @@ import QtQuick.Shapes import qs.Components import qs.Config -RowLayout { +GridLayout { id: root property color accentColor: warning ? DynamicColors.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 @@ -20,8 +21,9 @@ RowLayout { property bool warning: percentage * 100 >= warningThreshold property int warningThreshold: 80 + columnSpacing: Appearance.spacing.smaller + columns: horizontal ? -1 : 1 percentage: 0 - spacing: Appearance.spacing.smaller Behavior on animatedPercentage { Anim { @@ -46,8 +48,8 @@ RowLayout { } CustomClippingRect { - Layout.preferredHeight: root.height - Appearance.padding.small - Layout.preferredWidth: 4 + Layout.preferredHeight: root.horizontal ? icon.implicitHeight : 4 + Layout.preferredWidth: root.horizontal ? 4 : icon.implicitWidth color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) radius: Appearance.rounding.full @@ -56,9 +58,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.height) // Behavior on implicitHeight { // Anim { diff --git a/Modules/Resources.qml b/Modules/Resources.qml index cf3ef24..f33c442 100644 --- a/Modules/Resources.qml +++ b/Modules/Resources.qml @@ -12,12 +12,14 @@ import qs.Components CustomRect { id: root + required property bool horizontal required property PersistentProperties visibilities + anchors.fill: parent clip: true color: visibilities.resources ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Appearance.padding.smallest * 2 - implicitWidth: rowLayout.implicitWidth + Appearance.padding.larger * 2 + implicitHeight: horizontal ? Config.bar.height + Appearance.padding.smallest * 2 : gridLayout.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: horizontal ? gridLayout.implicitWidth + Appearance.padding.larger * 2 : Config.bar.height radius: Appearance.rounding.full StateLayer { @@ -26,13 +28,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: Appearance.spacing.smaller + anchors.horizontalCenterOffset: root.horizontal ? 0 : 1 + anchors.verticalCenterOffset: root.horizontal ? 0 : -3 + columnSpacing: Appearance.spacing.smaller + columns: root.horizontal ? -1 : 1 ServiceRef { service: Gpu @@ -48,7 +51,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 ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3primary @@ -57,7 +62,9 @@ CustomRect { } Resource { - Layout.fillHeight: true + Layout.fillHeight: root.horizontal + Layout.fillWidth: !root.horizontal + horizontal: root.horizontal icon: "memory_alt" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3secondary @@ -66,7 +73,9 @@ CustomRect { } Resource { - Layout.fillHeight: true + Layout.fillHeight: root.horizontal + Layout.fillWidth: !root.horizontal + horizontal: root.horizontal icon: "gamepad" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3tertiary @@ -74,7 +83,9 @@ CustomRect { } Resource { - Layout.fillHeight: true + Layout.fillHeight: root.horizontal + Layout.fillWidth: !root.horizontal + horizontal: root.horizontal icon: "developer_board" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3primary diff --git a/Modules/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml index 7c196e6..77ad0cb 100644 --- a/Modules/Resources/Wrapper.qml +++ b/Modules/Resources/Wrapper.qml @@ -8,12 +8,14 @@ import qs.Config Item { id: root + required property bool horizontal readonly property real nonAnimHeight: content.item?.nonAnimHeight ?? 0 property real offsetScale: shouldBeActive ? 0 : 1 readonly property bool shouldBeActive: root.visibilities.resources required property PersistentProperties visibilities - anchors.topMargin: (-implicitHeight - 5) * offsetScale + anchors.leftMargin: horizontal ? 0 : (-implicitWidth - 5) * offsetScale + anchors.topMargin: horizontal ? (-implicitHeight - 5) * offsetScale : 0 clip: true implicitHeight: content.implicitHeight implicitWidth: content.implicitWidth || 854 // Hard coded fallback for first open diff --git a/Modules/Settings/Pages/Panels/BarPanel.qml b/Modules/Settings/Pages/Panels/BarPanel.qml index 9b344eb..450063e 100644 --- a/Modules/Settings/Pages/Panels/BarPanel.qml +++ b/Modules/Settings/Pages/Panels/BarPanel.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick.Layouts +import qs.Components import qs.Config import qs.Modules.Settings.Common @@ -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,37 @@ 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("Automatic or manual effect values") + text: qsTr("Effects mode") + + 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; + Config.save(); + } + } + // Components SectionHeader { text: qsTr("Components") diff --git a/Modules/SysTray/StatusIcons.qml b/Modules/SysTray/StatusIcons.qml index 5e40cdb..1376fb9 100644 --- a/Modules/SysTray/StatusIcons.qml +++ b/Modules/SysTray/StatusIcons.qml @@ -18,9 +18,10 @@ CustomClippingRect { return i; return -1; } + required property bool horizontal // 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--) @@ -28,6 +29,8 @@ CustomClippingRect { return i; return -1; } + readonly property real shortSize: Config.bar.height + Appearance.padding.smallest * 2 + readonly property real size: horizontal ? grid.implicitWidth + Appearance.padding.small * 2 : grid.implicitHeight + Appearance.padding.small * 2 readonly property int spacing: Appearance.spacing.normal / 2 // Entries that can shrink to nothing, spacing included @@ -37,19 +40,37 @@ CustomClippingRect { return false; } - bottomLeftRadius: Appearance.rounding.smallest / 2 + anchors.fill: parent + bottomLeftRadius: horizontal ? Appearance.rounding.smallest / 2 : Appearance.rounding.full color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Appearance.padding.smallest * 2 - implicitWidth: row.implicitWidth + Appearance.padding.small * 2 + implicitHeight: horizontal ? shortSize : size + implicitWidth: horizontal ? size : shortSize radius: Appearance.rounding.full topLeftRadius: Appearance.rounding.smallest / 2 + topRightRadius: horizontal ? Appearance.rounding.full : Appearance.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 : Appearance.padding.small anchors.fill: parent - anchors.leftMargin: Appearance.padding.small - anchors.rightMargin: Appearance.padding.small + anchors.leftMargin: root.horizontal ? Appearance.padding.small : 0 + anchors.rightMargin: root.horizontal ? Appearance.padding.small : 0 + anchors.topMargin: root.horizontal ? 0 : Appearance.padding.small + columns: root.horizontal ? -1 : 1 Repeater { model: ScriptModel { @@ -68,6 +89,7 @@ CustomClippingRect { name: "audio" AudioWidget { + horizontal: root.horizontal } } } @@ -79,6 +101,7 @@ CustomClippingRect { name: "audio" MicWidget { + horizontal: root.horizontal } } } @@ -90,6 +113,7 @@ CustomClippingRect { name: "upower" UPowerWidget { + horizontal: root.horizontal } } } @@ -108,8 +132,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/TrayIcons.qml b/Modules/SysTray/TrayIcons.qml index 7d47919..74cb001 100644 --- a/Modules/SysTray/TrayIcons.qml +++ b/Modules/SysTray/TrayIcons.qml @@ -11,24 +11,30 @@ import qs.Modules CustomClippingRect { id: root + required property bool horizontal 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: Appearance.padding.small required property Wrapper popouts + readonly property real shortSize: Config.bar.height + Appearance.padding.smallest * 2 + readonly property real size: horizontal ? sysGrid.implicitWidth + Appearance.padding.small : sysGrid.implicitHeight + Appearance.padding.small + anchors.fill: parent + bottomLeftRadius: horizontal ? Appearance.rounding.full : Appearance.rounding.smallest / 2 bottomRightRadius: Appearance.rounding.smallest / 2 color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Appearance.padding.smallest * 2 - implicitWidth: sysRow.implicitWidth + Appearance.padding.small * 2 + implicitHeight: horizontal ? shortSize : size + implicitWidth: horizontal ? size : shortSize radius: Appearance.rounding.full - topRightRadius: Appearance.rounding.smallest / 2 + topRightRadius: horizontal ? Appearance.rounding.smallest / 2 : Appearance.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/SysTray/TrayItem.qml b/Modules/SysTray/TrayItem.qml index dc017c4..87f9762 100644 --- a/Modules/SysTray/TrayItem.qml +++ b/Modules/SysTray/TrayItem.qml @@ -12,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/TrayWidget.qml b/Modules/SysTray/TrayWidget.qml index 3797200..f63a24a 100644 --- a/Modules/SysTray/TrayWidget.qml +++ b/Modules/SysTray/TrayWidget.qml @@ -8,12 +8,15 @@ import qs.Config import qs.Modules.SysTray.Widgets import qs.Modules -RowLayout { +GridLayout { id: root + required property bool horizontal readonly property alias items: repeater - required property RowLayout loader + required property GridLayout loader required property Wrapper popouts + readonly property real shortSize: Config.bar.height + Appearance.padding.smallest * 2 + readonly property real size: horizontal ? sysTray.implicitWidth + sysTrayMod.implicitWidth + Appearance.padding.small : sysTray.implicitHeight + sysTrayMod.implicitHeight + Appearance.padding.small function closestRowChild(row, x) { let child = row.childAt(x, row.height / 2); @@ -80,9 +83,11 @@ RowLayout { return null; } - height: Config.bar.height + Appearance.padding.smallest * 2 - spacing: Appearance.padding.small - width: sysTray.implicitWidth + sysTrayMod.implicitWidth + Appearance.padding.small + columnSpacing: Appearance.padding.small + columns: horizontal ? -1 : 1 + height: horizontal ? shortSize : size + rowSpacing: Appearance.padding.small + width: horizontal ? size : shortSize CustomClippingRect { id: sysTray @@ -94,11 +99,18 @@ RowLayout { radius: Appearance.rounding.full topRightRadius: Appearance.rounding.smallest / 2 - Row { + GridLayout { id: sysRow + anchors.bottom: root.horizontal ? undefined : parent.bottom anchors.centerIn: parent - spacing: 0 + anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined + anchors.left: root.horizontal ? undefined : parent.left + anchors.right: root.horizontal ? undefined : parent.right + anchors.verticalCenter: root.horizontal ? parent.verticalCenter : undefined + columnSpacing: Appearance.spacing.small / 2 + columns: root.horizontal ? -1 : 1 + rowSpacing: Appearance.spacing.small / 2 Repeater { id: repeater @@ -125,10 +137,12 @@ RowLayout { CustomClippingRect { id: sysTrayMod - Layout.fillHeight: true + Layout.fillHeight: root.horizontal + Layout.fillWidth: !root.horizontal bottomLeftRadius: Appearance.rounding.smallest / 2 color: DynamicColors.tPalette.m3surfaceContainer - implicitWidth: sysIcons.implicitWidth + Appearance.padding.smaller + Appearance.padding.normal + implicitHeight: root.horizontal ? sysIcons.implicitHeight : sysIcons.implicitHeight + Appearance.padding.smaller + Appearance.padding.normal + implicitWidth: root.horizontal ? sysIcons.implicitWidth + Appearance.padding.smaller + Appearance.padding.normal : sysIcons.implicitWidth radius: Appearance.rounding.full topLeftRadius: Appearance.rounding.smallest / 2 @@ -138,6 +152,7 @@ RowLayout { anchors.fill: parent anchors.leftMargin: Appearance.padding.smaller anchors.rightMargin: Appearance.padding.normal + horizontal: root.horizontal } } } diff --git a/Modules/SysTray/Widgets/AudioWidget.qml b/Modules/SysTray/Widgets/AudioWidget.qml index 4555d11..5190656 100644 --- a/Modules/SysTray/Widgets/AudioWidget.qml +++ b/Modules/SysTray/Widgets/AudioWidget.qml @@ -10,10 +10,12 @@ import qs.Components MaterialIcon { id: speaker + required property bool horizontal + animate: true color: Audio.muted ? DynamicColors.palette.m3error : DynamicColors.palette.m3onSurface fill: 1 - font.pointSize: Appearance.font.size.larger + font.pointSize: horizontal ? Appearance.font.size.larger : Appearance.font.size.large text: Audio.muted ? "volume_off" : "volume_up" Behavior on Layout.maximumWidth { diff --git a/Modules/SysTray/Widgets/MicWidget.qml b/Modules/SysTray/Widgets/MicWidget.qml index 2aa51ce..77681df 100644 --- a/Modules/SysTray/Widgets/MicWidget.qml +++ b/Modules/SysTray/Widgets/MicWidget.qml @@ -10,10 +10,12 @@ import qs.Components MaterialIcon { id: mic + required property bool horizontal + animate: true color: (Audio.sourceMuted ?? false) ? DynamicColors.palette.m3error : DynamicColors.palette.m3onSurface fill: 1 - font.pointSize: Appearance.font.size.larger + font.pointSize: horizontal ? Appearance.font.size.larger : Appearance.font.size.large text: Audio.sourceMuted ? "mic_off" : "mic" Behavior on Layout.maximumWidth { diff --git a/Modules/SysTray/Widgets/UPowerWidget.qml b/Modules/SysTray/Widgets/UPowerWidget.qml index 6968b4f..cd789d3 100644 --- a/Modules/SysTray/Widgets/UPowerWidget.qml +++ b/Modules/SysTray/Widgets/UPowerWidget.qml @@ -8,6 +8,8 @@ import qs.Helpers Item { id: root + required property bool horizontal + implicitHeight: Battery.isLaptop ? batteryIconLoader.item.implicitHeight : upowerIconLoader.item.implicitHeight implicitWidth: Battery.isLaptop ? batteryIconLoader.item.implicitWidth : upowerIconLoader.item.implicitWidth @@ -123,6 +125,7 @@ Item { Layout.alignment: Qt.AlignVCenter animate: true fill: 1 + font.pointSize: root.horizontal ? Appearance.font.size.larger : Appearance.font.size.large text: { if (PowerProfiles.profile === PowerProfile.PowerSaver) return "energy_savings_leaf"; diff --git a/Modules/Updates/UpdatesWidget.qml b/Modules/Updates/UpdatesWidget.qml index 14be0cb..c1cbe32 100644 --- a/Modules/Updates/UpdatesWidget.qml +++ b/Modules/Updates/UpdatesWidget.qml @@ -9,27 +9,32 @@ CustomRect { id: root property int countUpdates: Updates.availableUpdates + required property bool horizontal property color textColor: DynamicColors.palette.m3onSurface + anchors.fill: parent color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: Config.bar.height + Appearance.padding.smallest * 2 - implicitWidth: contentRow.implicitWidth + Appearance.spacing.small * 2 + implicitHeight: horizontal ? Config.bar.height + Appearance.padding.smallest * 2 : content.implicitHeight + Appearance.spacing.small * 2 + implicitWidth: horizontal ? content.implicitWidth + Appearance.spacing.small * 2 : Config.bar.height radius: Appearance.rounding.full - RowLayout { - id: contentRow + GridLayout { + id: content anchors.centerIn: parent - spacing: Appearance.spacing.small + columnSpacing: Appearance.spacing.small + columns: root.horizontal ? -1 : 1 MaterialIcon { - font.pointSize: Appearance.font.size.larger + Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter + font.pointSize: root.horizontal ? Appearance.font.size.larger : Appearance.font.size.large text: "package_2" } CustomText { + Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter color: root.textColor - font.pointSize: Appearance.font.size.normal + font.pointSize: root.horizontal ? Appearance.font.size.normal : Appearance.font.size.larger text: root.countUpdates } } diff --git a/Modules/WindowTitle.qml b/Modules/WindowTitle.qml index 88c4cc0..62deadd 100644 --- a/Modules/WindowTitle.qml +++ b/Modules/WindowTitle.qml @@ -9,22 +9,32 @@ Item { id: root required property var bar - property color colour: DynamicColors.palette.m3primary + property color color: DynamicColors.palette.m3primary property Title current: text1 + required property bool horizontal // 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; + 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.spacing * (bar.children.length - 1) - bar.vPadding * 2 : bar.height - otherSize - bar.vPadding * (bar.children.length - 1) - bar.vPadding * 4; } + anchors.centerIn: parent clip: true - implicitHeight: current.implicitHeight - implicitWidth: Math.min(current.implicitWidth, root.maxWidth) + implicitHeight: horizontal ? current.implicitHeight : current.implicitWidth + current.anchors.topMargin + implicitWidth: horizontal ? Math.min(current.implicitWidth, root.maxSize) : current.implicitHeight + current.anchors.topMargin + Behavior on implicitHeight { + enabled: !root.horizontal + + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + } + } Behavior on implicitWidth { + enabled: root.horizontal + Anim { duration: MaterialEasing.expressiveEffectsTime easing.bezierCurve: MaterialEasing.expressiveEffects @@ -33,19 +43,17 @@ Item { Title { id: text1 - } Title { id: text2 - } TextMetrics { id: metrics elide: Qt.ElideRight - elideWidth: root.maxWidth + elideWidth: root.maxSize - (root.horizontal ? root.current.anchors.leftMargin : 0) font.family: "Rubik" font.pointSize: Appearance.font.size.normal text: Hypr.activeToplevel?.title ?? qsTr("Desktop") @@ -61,18 +69,32 @@ Item { component Title: CustomText { id: text - anchors.leftMargin: 7 - anchors.verticalCenter: parent.verticalCenter - color: root.colour + anchors.horizontalCenter: root.horizontal ? undefined : parent.horizontalCenter + anchors.left: root.horizontal ? parent.left : undefined + anchors.leftMargin: root.horizontal ? Appearance.spacing.small : 0 + anchors.top: root.horizontal ? undefined : parent.top + anchors.topMargin: root.horizontal ? 0 : Appearance.spacing.small + anchors.verticalCenter: root.horizontal ? parent.verticalCenter : undefined + color: root.color font.family: metrics.font.family font.pointSize: metrics.font.pointSize - height: implicitHeight + height: root.horizontal ? implicitHeight : implicitWidth opacity: root.current === this ? 1 : 0 - width: implicitWidth + 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/Workspaces.qml index 91420db..f9c610d 100644 --- a/Modules/Workspaces.qml +++ b/Modules/Workspaces.qml @@ -13,41 +13,57 @@ Item { property real activeWorkspaceMargin: Math.ceil(Appearance.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: Config.bar.height + Appearance.padding.smaller * 2 + 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 + Appearance.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: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: root.implicitHeight - ((Appearance.padding.small - 1) * 2) + implicitHeight: root.horizontal ? root.implicitHeight - ((Appearance.padding.small - 1) * 2) : 0 + implicitWidth: root.horizontal ? 0 : root.implicitWidth - ((Appearance.padding.small - 1) * 2) 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 @@ -93,13 +109,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: DynamicColors.palette.m3primary - implicitHeight: indicatorThickness - implicitWidth: indicatorLength + implicitHeight: root.horizontal ? indicatorThickness : indicatorLength + implicitWidth: root.horizontal ? indicatorLength : indicatorThickness radius: Appearance.rounding.full - x: indicatorPosition + x: root.horizontal ? indicatorPosition : 0 + y: root.horizontal ? 0 : indicatorPosition AnimatedTabIndexPair { id: idxPair @@ -113,7 +131,8 @@ Item { implicitWidth: grid.width source: grid sourceColor: DynamicColors.palette.m3onSurface - x: -indicator.x + 3 + x: root.horizontal ? -indicator.x + 3 : 0 + y: root.horizontal ? 0 : -indicator.y + 3 } } } From ef8811724f38074a04f13bce5787770b485d002f Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 14 Aug 2026 18:03:56 +0200 Subject: [PATCH 13/23] better height/width for bar --- Drawers/Panels.qml | 1 - Modules/Bar/Bar.qml | 22 ++-------------------- Modules/Bar/BarLoader.qml | 4 ++-- Modules/Clock.qml | 3 +-- Modules/Dashboard/Wrapper.qml | 6 ++++-- Modules/HyprsunsetWidget.qml | 5 ++--- Modules/MediaWidget.qml | 5 ++--- Modules/NotifBell.qml | 5 ++--- Modules/Resources.qml | 3 +-- Modules/SysTray/StatusIcons.qml | 3 +-- Modules/SysTray/TrayIcons.qml | 3 +-- Modules/Updates/UpdatesWidget.qml | 3 +-- Modules/Workspaces.qml | 6 +++--- 13 files changed, 22 insertions(+), 47 deletions(-) diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index a3b1bd6..6dfd863 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -169,7 +169,6 @@ Item { Dashboard.Wrapper { id: dashboard - anchors.right: parent.right horizontal: root.geometry.horizontal offsetScale: dashboardWrapper.offsetScale visibilities: root.visibilities diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 74a79ad..be47506 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -92,8 +92,6 @@ GridLayout { roleValue: "hyprsunset" delegate: EntryWrapper { - Layout.fillWidth: !root.horizontal - HyprsunsetWidget { horizontal: root.horizontal visible: !root.fullscreen @@ -126,8 +124,6 @@ GridLayout { roleValue: "tray" delegate: EntryWrapper { - Layout.fillWidth: !root.horizontal - TrayIcons { horizontal: root.horizontal loader: root @@ -141,8 +137,6 @@ GridLayout { roleValue: "statusIcons" delegate: EntryWrapper { - Layout.fillWidth: !root.horizontal - StatusIcons { horizontal: root.horizontal } @@ -153,8 +147,6 @@ GridLayout { roleValue: "resources" delegate: EntryWrapper { - Layout.fillWidth: !root.horizontal - Resources { horizontal: root.horizontal visibilities: root.visibilities @@ -167,8 +159,6 @@ GridLayout { roleValue: "updates" delegate: EntryWrapper { - Layout.fillWidth: !root.horizontal - UpdatesWidget { horizontal: root.horizontal visible: !root.fullscreen @@ -180,8 +170,6 @@ GridLayout { roleValue: "notifBell" delegate: EntryWrapper { - Layout.fillWidth: !root.horizontal - NotifBell { horizontal: root.horizontal popouts: root.popouts @@ -195,8 +183,6 @@ GridLayout { roleValue: "clock" delegate: EntryWrapper { - Layout.fillWidth: !root.horizontal - Clock { horizontal: root.horizontal loader: root @@ -211,8 +197,6 @@ GridLayout { roleValue: "activeWindow" delegate: EntryWrapper { - Layout.fillWidth: !root.horizontal - WindowTitle { bar: root horizontal: root.horizontal @@ -234,8 +218,6 @@ GridLayout { roleValue: "media" delegate: EntryWrapper { - Layout.fillWidth: !root.horizontal - MediaWidget { horizontal: root.horizontal visible: !root.fullscreen @@ -253,8 +235,8 @@ GridLayout { 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 + Appearance.spacing.extraSmall : root.horizontal ? 0 : Appearance.padding.smaller - Layout.rightMargin: root.horizontal && index === repeater.count - 1 ? root.vPadding : root.horizontal ? 0 : Appearance.padding.smaller + Layout.leftMargin: root.horizontal && index === 0 ? root.vPadding : (entryId === "statusIcons" && root.horizontal) ? -root.rowSpacing + Appearance.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 + Appearance.spacing.extraSmall : 0 children: item implicitHeight: item?.implicitHeight ?? 0 diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index fafd193..55e286e 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -10,13 +10,13 @@ Item { id: root readonly property int clampedExtent: Math.max(Config.bar.border, extent) - readonly property int contentThickness: Math.max(Config.bar.height, (horizontal ? 24 : 30)) + padding * 2 + readonly property int contentThickness: Math.max(Config.bar.height, 30) + 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: horizontal ? Math.max(Appearance.padding.smaller, Config.bar.border) : Math.max(Appearance.padding.larger, Config.bar.border) + readonly property int padding: Appearance.padding.smaller required property Wrapper popouts required property ClipWrapper popoutsWrapper required property string position diff --git a/Modules/Clock.qml b/Modules/Clock.qml index 15f95de..0ec2816 100644 --- a/Modules/Clock.qml +++ b/Modules/Clock.qml @@ -12,11 +12,10 @@ CustomRect { required property bool horizontal required property GridLayout loader required property Wrapper popouts - readonly property real shortSize: Config.bar.height + Appearance.padding.smallest * 2 + readonly property real shortSize: Config.bar.height readonly property real size: timeText.contentWidth + (horizontal ? Appearance.padding.normal : Appearance.padding.larger) * 2 required property PersistentProperties visibilities - anchors.fill: parent color: visibilities.dashboard ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer implicitHeight: horizontal ? shortSize : size implicitWidth: horizontal ? size : shortSize diff --git a/Modules/Dashboard/Wrapper.qml b/Modules/Dashboard/Wrapper.qml index 747d0e4..946fc6e 100644 --- a/Modules/Dashboard/Wrapper.qml +++ b/Modules/Dashboard/Wrapper.qml @@ -33,8 +33,10 @@ Item { id: content active: root.shouldBeActive || root.visible - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: root.horizontal ? parent.bottom : undefined + anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined + anchors.right: root.horizontal ? undefined : parent.right + anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter sourceComponent: Content { dashState: root.dashState diff --git a/Modules/HyprsunsetWidget.qml b/Modules/HyprsunsetWidget.qml index c5bd375..5dd364a 100644 --- a/Modules/HyprsunsetWidget.qml +++ b/Modules/HyprsunsetWidget.qml @@ -9,10 +9,9 @@ CustomRect { required property bool horizontal property bool tempEnabled: Hyprsunset.enabled - anchors.fill: parent color: root.tempEnabled ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Config.bar.height + Appearance.padding.smallest * 2 : width - implicitWidth: horizontal ? height : Config.bar.height + Appearance.padding.smallest * 2 + implicitHeight: horizontal ? Config.bar.height : width + implicitWidth: horizontal ? height : Config.bar.height radius: Appearance.rounding.full StateLayer { diff --git a/Modules/MediaWidget.qml b/Modules/MediaWidget.qml index 9208622..ad52b3e 100644 --- a/Modules/MediaWidget.qml +++ b/Modules/MediaWidget.qml @@ -12,10 +12,9 @@ CustomRect { required property bool horizontal readonly property int textWidth: Math.min(metrics.width, 200) - anchors.fill: parent color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Config.bar.height + Appearance.padding.smallest * 2 : layout.implicitHeight + Appearance.padding.normal * 2 - implicitWidth: horizontal ? layout.implicitWidth + Appearance.padding.normal * 2 : Config.bar.height + Appearance.padding.smallest + implicitHeight: horizontal ? Config.bar.height : layout.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: horizontal ? layout.implicitWidth + Appearance.padding.normal * 2 : Config.bar.height radius: Appearance.rounding.full Behavior on implicitWidth { diff --git a/Modules/NotifBell.qml b/Modules/NotifBell.qml index dabacab..05cfacc 100644 --- a/Modules/NotifBell.qml +++ b/Modules/NotifBell.qml @@ -11,10 +11,9 @@ CustomRect { required property Wrapper popouts required property PersistentProperties visibilities - anchors.fill: parent color: visibilities.sidebar ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Config.bar.height + Appearance.padding.smallest * 2 : width - implicitWidth: horizontal ? height : Config.bar.height + Appearance.padding.smallest * 2 + implicitHeight: horizontal ? Config.bar.height : width + implicitWidth: horizontal ? height : Config.bar.height radius: Appearance.rounding.full MaterialIcon { diff --git a/Modules/Resources.qml b/Modules/Resources.qml index f33c442..706560b 100644 --- a/Modules/Resources.qml +++ b/Modules/Resources.qml @@ -15,10 +15,9 @@ CustomRect { required property bool horizontal required property PersistentProperties visibilities - anchors.fill: parent clip: true color: visibilities.resources ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Config.bar.height + Appearance.padding.smallest * 2 : gridLayout.implicitHeight + Appearance.padding.normal * 2 + implicitHeight: horizontal ? Math.max(Config.bar.height, 24) : gridLayout.implicitHeight + Appearance.padding.normal * 2 implicitWidth: horizontal ? gridLayout.implicitWidth + Appearance.padding.larger * 2 : Config.bar.height radius: Appearance.rounding.full diff --git a/Modules/SysTray/StatusIcons.qml b/Modules/SysTray/StatusIcons.qml index 1376fb9..f9e5b3b 100644 --- a/Modules/SysTray/StatusIcons.qml +++ b/Modules/SysTray/StatusIcons.qml @@ -29,7 +29,7 @@ CustomClippingRect { return i; return -1; } - readonly property real shortSize: Config.bar.height + Appearance.padding.smallest * 2 + readonly property real shortSize: Config.bar.height readonly property real size: horizontal ? grid.implicitWidth + Appearance.padding.small * 2 : grid.implicitHeight + Appearance.padding.small * 2 readonly property int spacing: Appearance.spacing.normal / 2 @@ -40,7 +40,6 @@ CustomClippingRect { return false; } - anchors.fill: parent bottomLeftRadius: horizontal ? Appearance.rounding.smallest / 2 : Appearance.rounding.full color: DynamicColors.tPalette.m3surfaceContainer implicitHeight: horizontal ? shortSize : size diff --git a/Modules/SysTray/TrayIcons.qml b/Modules/SysTray/TrayIcons.qml index 74cb001..b2e6f95 100644 --- a/Modules/SysTray/TrayIcons.qml +++ b/Modules/SysTray/TrayIcons.qml @@ -17,10 +17,9 @@ CustomClippingRect { required property GridLayout loader readonly property int padding: Appearance.padding.small required property Wrapper popouts - readonly property real shortSize: Config.bar.height + Appearance.padding.smallest * 2 + readonly property real shortSize: Config.bar.height readonly property real size: horizontal ? sysGrid.implicitWidth + Appearance.padding.small : sysGrid.implicitHeight + Appearance.padding.small - anchors.fill: parent bottomLeftRadius: horizontal ? Appearance.rounding.full : Appearance.rounding.smallest / 2 bottomRightRadius: Appearance.rounding.smallest / 2 color: DynamicColors.tPalette.m3surfaceContainer diff --git a/Modules/Updates/UpdatesWidget.qml b/Modules/Updates/UpdatesWidget.qml index c1cbe32..47480a4 100644 --- a/Modules/Updates/UpdatesWidget.qml +++ b/Modules/Updates/UpdatesWidget.qml @@ -12,9 +12,8 @@ CustomRect { required property bool horizontal property color textColor: DynamicColors.palette.m3onSurface - anchors.fill: parent color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Config.bar.height + Appearance.padding.smallest * 2 : content.implicitHeight + Appearance.spacing.small * 2 + implicitHeight: horizontal ? Config.bar.height : content.implicitHeight + Appearance.spacing.small * 2 implicitWidth: horizontal ? content.implicitWidth + Appearance.spacing.small * 2 : Config.bar.height radius: Appearance.rounding.full diff --git a/Modules/Workspaces.qml b/Modules/Workspaces.qml index f9c610d..aa6d71b 100644 --- a/Modules/Workspaces.qml +++ b/Modules/Workspaces.qml @@ -16,7 +16,7 @@ Item { required property bool horizontal readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) required property ShellScreen screen - readonly property real shortSize: Config.bar.height + Appearance.padding.smaller * 2 + readonly property real shortSize: Math.max(Config.bar.height, 24) 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 @@ -50,8 +50,8 @@ Item { anchors.top: root.horizontal ? undefined : parent.top anchors.verticalCenter: root.horizontal ? parent.verticalCenter : undefined color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: root.horizontal ? root.implicitHeight - ((Appearance.padding.small - 1) * 2) : 0 - implicitWidth: root.horizontal ? 0 : root.implicitWidth - ((Appearance.padding.small - 1) * 2) + implicitHeight: root.horizontal ? root.implicitHeight : 0 + implicitWidth: root.horizontal ? 0 : root.implicitWidth radius: height / 2 // qmllint enable Quick.anchor-combinations From 0092b41a058ae47f8d1caaa66ba84d618efa136d Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 14 Aug 2026 22:15:11 +0200 Subject: [PATCH 14/23] fix config file writing + positioning issues --- Modules/Bar/BarLoader.qml | 2 +- Modules/Clock.qml | 2 +- Modules/HyprsunsetWidget.qml | 6 +++--- Modules/MediaWidget.qml | 21 ++++++++++++++++----- Modules/NotifBell.qml | 6 +++--- Modules/Resource.qml | 4 ++-- Modules/Resources.qml | 4 ++-- Modules/Settings/Pages/Panels/BarPanel.qml | 9 +++------ Modules/SysTray/StatusIcons.qml | 2 +- Modules/SysTray/TrayIcons.qml | 2 +- Modules/SysTray/Widgets/AudioWidget.qml | 2 +- Modules/SysTray/Widgets/MicWidget.qml | 2 +- Modules/SysTray/Widgets/UPowerWidget.qml | 2 +- Modules/Updates/UpdatesWidget.qml | 7 ++++--- Modules/Workspaces.qml | 2 +- Plugins/ZShell/Config/bar.hpp | 3 ++- Plugins/ZShell/Config/config.cpp | 11 +++++++++++ Plugins/ZShell/Config/config.hpp | 1 + Plugins/ZShell/Config/tokens.hpp | 16 +++++++++++++++- 19 files changed, 70 insertions(+), 34 deletions(-) diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index a19ea4a..ff7f0a2 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -14,7 +14,7 @@ Item { id: root readonly property int clampedExtent: Math.max(Config.bar.border, extent) - readonly property int contentThickness: Math.max(Config.bar.height, 30) + padding * 2 + readonly property int contentThickness: Math.max(Config.bar.height, Tokens.bar.innerSize) + 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 diff --git a/Modules/Clock.qml b/Modules/Clock.qml index 6c63af5..0626ed9 100644 --- a/Modules/Clock.qml +++ b/Modules/Clock.qml @@ -13,7 +13,7 @@ CustomRect { required property bool horizontal required property GridLayout loader required property Wrapper popouts - readonly property real shortSize: Config.bar.height + readonly property real shortSize: Math.max(Config.bar.height, Tokens.bar.innerSize) readonly property real size: timeText.contentWidth + (horizontal ? Tokens.padding.normal : Tokens.padding.larger) * 2 required property PersistentProperties visibilities diff --git a/Modules/HyprsunsetWidget.qml b/Modules/HyprsunsetWidget.qml index 19b89ba..34d1426 100644 --- a/Modules/HyprsunsetWidget.qml +++ b/Modules/HyprsunsetWidget.qml @@ -11,8 +11,8 @@ CustomRect { property bool tempEnabled: Hyprsunset.enabled color: root.tempEnabled ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Config.bar.height : width - implicitWidth: horizontal ? height : Config.bar.height + implicitHeight: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : width + implicitWidth: horizontal ? height : Math.max(Config.bar.height, Tokens.bar.innerSize) radius: Tokens.rounding.full StateLayer { @@ -27,7 +27,7 @@ CustomRect { animate: true color: root.tempEnabled ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: root.tempEnabled ? 1 : 0 - font.pointSize: root.horizontal ? Tokens.font.size.larger : Tokens.font.size.large + font.pointSize: Tokens.font.size.larger text: root.tempEnabled ? "lightbulb" : "light_off" Behavior on fill { diff --git a/Modules/MediaWidget.qml b/Modules/MediaWidget.qml index 87d26e4..15e9679 100644 --- a/Modules/MediaWidget.qml +++ b/Modules/MediaWidget.qml @@ -14,11 +14,19 @@ CustomRect { readonly property int textWidth: Math.min(metrics.width, 200) color: Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Config.bar.height : layout.implicitHeight + Tokens.padding.normal * 2 - implicitWidth: horizontal ? layout.implicitWidth + Tokens.padding.normal * 2 : Config.bar.height + implicitHeight: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : layout.implicitHeight + Tokens.padding.normal * 2 + implicitWidth: horizontal ? layout.implicitWidth + Tokens.padding.normal * 2 : Math.max(Config.bar.height, Tokens.bar.innerSize) radius: Tokens.rounding.full + Behavior on implicitHeight { + enabled: !root.horizontal + + Anim { + } + } Behavior on implicitWidth { + enabled: root.horizontal + Anim { } } @@ -33,7 +41,11 @@ CustomRect { GridLayout { id: layout - anchors.centerIn: parent + 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 { @@ -53,15 +65,14 @@ CustomRect { id: mediatext Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter - Layout.preferredHeight: root.horizontal ? root.height : root.textWidth animate: true color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter + implicitWidth: root.textWidth marqueeEnabled: false pauseMs: 4000 text: root.currentMedia - width: root.textWidth transform: [ Translate { diff --git a/Modules/NotifBell.qml b/Modules/NotifBell.qml index f1a1315..0567d89 100644 --- a/Modules/NotifBell.qml +++ b/Modules/NotifBell.qml @@ -13,8 +13,8 @@ CustomRect { required property PersistentProperties visibilities color: visibilities.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Config.bar.height : width - implicitWidth: horizontal ? height : Config.bar.height + implicitHeight: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : width + implicitWidth: horizontal ? height : Math.max(Config.bar.height, Tokens.bar.innerSize) radius: Tokens.rounding.full MaterialIcon { @@ -23,7 +23,7 @@ CustomRect { anchors.centerIn: parent color: root.visibilities.sidebar ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface fill: root.visibilities.sidebar ? 1 : 0 - font.pointSize: root.horizontal ? Tokens.font.size.larger : Tokens.font.size.large + font.pointSize: Tokens.font.size.larger text: NotifServer.list.length ? "\uf4fe" : "\ue7f4" Behavior on color { diff --git a/Modules/Resource.qml b/Modules/Resource.qml index 9b15b8c..0608268 100644 --- a/Modules/Resource.qml +++ b/Modules/Resource.qml @@ -49,7 +49,7 @@ GridLayout { } CustomClippingRect { - Layout.preferredHeight: root.horizontal ? icon.implicitHeight : 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 @@ -63,7 +63,7 @@ GridLayout { anchors.top: root.horizontal ? undefined : parent.top color: root.mainColor implicitHeight: root.horizontal ? Math.ceil(root.percentage * parent.height) : 0 - implicitWidth: root.horizontal ? 0 : Math.ceil(root.percentage * parent.height) + implicitWidth: root.horizontal ? 0 : Math.ceil(root.percentage * parent.width) // Behavior on implicitHeight { // Anim { diff --git a/Modules/Resources.qml b/Modules/Resources.qml index 65f881a..123636e 100644 --- a/Modules/Resources.qml +++ b/Modules/Resources.qml @@ -17,8 +17,8 @@ CustomRect { clip: true color: visibilities.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Math.max(Config.bar.height, 24) : gridLayout.implicitHeight + Tokens.padding.normal * 2 - implicitWidth: horizontal ? gridLayout.implicitWidth + Tokens.padding.larger * 2 : Config.bar.height + implicitHeight: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : gridLayout.implicitHeight + Tokens.padding.normal * 2 + implicitWidth: horizontal ? gridLayout.implicitWidth + Tokens.padding.larger * 2 : Math.max(Config.bar.height, Tokens.bar.innerSize) radius: Tokens.rounding.full StateLayer { diff --git a/Modules/Settings/Pages/Panels/BarPanel.qml b/Modules/Settings/Pages/Panels/BarPanel.qml index 090c79c..31e613a 100644 --- a/Modules/Settings/Pages/Panels/BarPanel.qml +++ b/Modules/Settings/Pages/Panels/BarPanel.qml @@ -37,8 +37,8 @@ PageBase { active: Config.bar.position === "top" ? menuItems[0] : Config.bar.position === "left" ? menuItems[1] : menuItems[2] last: true settingAnchor: "bar-position" - subtext: qsTr("Automatic or manual effect values") - text: qsTr("Effects mode") + subtext: qsTr("Change which edge the bar appears on") + text: qsTr("Position") menuItems: [ MenuItem { @@ -58,10 +58,7 @@ PageBase { } ] - onSelected: item => { - Config.bar.position = item.value; - Config.save(); - } + onSelected: item => Config.bar.position = item.value } // Components diff --git a/Modules/SysTray/StatusIcons.qml b/Modules/SysTray/StatusIcons.qml index ab41b6b..2fa363a 100644 --- a/Modules/SysTray/StatusIcons.qml +++ b/Modules/SysTray/StatusIcons.qml @@ -30,7 +30,7 @@ CustomClippingRect { return i; return -1; } - readonly property real shortSize: Config.bar.height + readonly property real shortSize: Math.max(Config.bar.height, Tokens.bar.innerSize) 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 diff --git a/Modules/SysTray/TrayIcons.qml b/Modules/SysTray/TrayIcons.qml index bb1ffaf..c9eb96f 100644 --- a/Modules/SysTray/TrayIcons.qml +++ b/Modules/SysTray/TrayIcons.qml @@ -17,7 +17,7 @@ CustomClippingRect { required property GridLayout loader readonly property int padding: Tokens.padding.small required property Wrapper popouts - readonly property real shortSize: Config.bar.height + readonly property real shortSize: Math.max(Config.bar.height, Tokens.bar.innerSize) readonly property real size: horizontal ? sysGrid.implicitWidth + Tokens.padding.small : sysGrid.implicitHeight + Tokens.padding.small bottomLeftRadius: horizontal ? Tokens.rounding.full : Tokens.rounding.smallest / 2 diff --git a/Modules/SysTray/Widgets/AudioWidget.qml b/Modules/SysTray/Widgets/AudioWidget.qml index 8e5f8f2..93c4752 100644 --- a/Modules/SysTray/Widgets/AudioWidget.qml +++ b/Modules/SysTray/Widgets/AudioWidget.qml @@ -13,7 +13,7 @@ MaterialIcon { animate: true color: Audio.muted ? Colors.palette.m3error : Colors.palette.m3onSurface fill: 1 - font.pointSize: horizontal ? Tokens.font.size.larger : Tokens.font.size.large + font.pointSize: Tokens.font.size.larger text: Audio.muted ? "volume_off" : "volume_up" Behavior on Layout.maximumWidth { diff --git a/Modules/SysTray/Widgets/MicWidget.qml b/Modules/SysTray/Widgets/MicWidget.qml index 59725b1..8f58616 100644 --- a/Modules/SysTray/Widgets/MicWidget.qml +++ b/Modules/SysTray/Widgets/MicWidget.qml @@ -13,7 +13,7 @@ MaterialIcon { animate: true color: (Audio.sourceMuted ?? false) ? Colors.palette.m3error : Colors.palette.m3onSurface fill: 1 - font.pointSize: horizontal ? Tokens.font.size.larger : Tokens.font.size.large + font.pointSize: Tokens.font.size.larger text: Audio.sourceMuted ? "mic_off" : "mic" Behavior on Layout.maximumWidth { diff --git a/Modules/SysTray/Widgets/UPowerWidget.qml b/Modules/SysTray/Widgets/UPowerWidget.qml index ca38488..380b912 100644 --- a/Modules/SysTray/Widgets/UPowerWidget.qml +++ b/Modules/SysTray/Widgets/UPowerWidget.qml @@ -125,7 +125,7 @@ Item { Layout.alignment: Qt.AlignVCenter animate: true fill: 1 - font.pointSize: root.horizontal ? Tokens.font.size.larger : Tokens.font.size.large + font.pointSize: Tokens.font.size.larger text: { if (PowerProfiles.profile === PowerProfile.PowerSaver) return "energy_savings_leaf"; diff --git a/Modules/Updates/UpdatesWidget.qml b/Modules/Updates/UpdatesWidget.qml index 385b755..3babcae 100644 --- a/Modules/Updates/UpdatesWidget.qml +++ b/Modules/Updates/UpdatesWidget.qml @@ -14,8 +14,8 @@ CustomRect { property color textColor: Colors.palette.m3onSurface color: Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Config.bar.height : content.implicitHeight + Tokens.spacing.small * 2 - implicitWidth: horizontal ? content.implicitWidth + Tokens.spacing.small * 2 : Config.bar.height + implicitHeight: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : content.implicitHeight + Tokens.spacing.small + implicitWidth: horizontal ? content.implicitWidth + Tokens.spacing.small * 2 : Math.max(Config.bar.height, Tokens.bar.innerSize) radius: Tokens.rounding.full GridLayout { @@ -24,6 +24,7 @@ CustomRect { 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 @@ -34,7 +35,7 @@ CustomRect { CustomText { Layout.alignment: root.horizontal ? Qt.AlignVCenter : Qt.AlignHCenter color: root.textColor - font.pointSize: root.horizontal ? Tokens.font.size.normal : Tokens.font.size.larger + font.pointSize: Tokens.font.size.normal text: root.countUpdates } } diff --git a/Modules/Workspaces.qml b/Modules/Workspaces.qml index 01ebeb5..fa7e297 100644 --- a/Modules/Workspaces.qml +++ b/Modules/Workspaces.qml @@ -17,7 +17,7 @@ Item { required property bool horizontal readonly property HyprlandMonitor monitor: Hyprland.monitorFor(root.screen) required property ShellScreen screen - readonly property real shortSize: Math.max(Config.bar.height, 24) + 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 diff --git a/Plugins/ZShell/Config/bar.hpp b/Plugins/ZShell/Config/bar.hpp index 9edd18c..4a0c8ae 100644 --- a/Plugins/ZShell/Config/bar.hpp +++ b/Plugins/ZShell/Config/bar.hpp @@ -3,6 +3,7 @@ #include "configlist.hpp" #include "configobject.hpp" #include +#include #include namespace ZShell::config { @@ -62,7 +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, "top") + 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..ea2c307 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, @@ -232,6 +234,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/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(); } }; From 9823c6629900a8a3513a98b18aef7695248381c6 Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 15 Aug 2026 14:18:00 +0200 Subject: [PATCH 15/23] restructure bar components layout --- Drawers/Drawers.qml | 4 - Drawers/Interactions.qml | 4 +- Drawers/Panels.qml | 4 +- Helpers/Icons.qml | 53 +++++- Modules/Bar/Bar.qml | 6 +- Modules/Bar/BarLoader.qml | 6 +- Modules/{ => Bar/Components}/Clock.qml | 2 +- .../{ => Bar/Components}/HyprsunsetWidget.qml | 0 Modules/{ => Bar/Components}/MediaWidget.qml | 0 .../Components}/NetworkWidget.qml | 0 Modules/{ => Bar/Components}/NotifBell.qml | 1 + Modules/{ => Bar/Components}/Resource.qml | 0 Modules/{ => Bar/Components}/Resources.qml | 0 .../Components}/StatusIcons.qml | 4 +- .../Components/StatusIcons}/AudioWidget.qml | 0 .../Components/StatusIcons}/MicWidget.qml | 0 .../Components/StatusIcons}/UPowerWidget.qml | 0 .../Components/Tray}/TrayItem.qml | 3 +- .../{SysTray => Bar/Components}/TrayIcons.qml | 3 +- .../Components}/UpdatesWidget.qml | 0 Modules/{ => Bar/Components}/WindowTitle.qml | 4 +- Modules/{ => Bar/Components}/Workspaces.qml | 4 +- .../Workspaces}/AnimatedTabIndexPair.qml | 0 .../{SysTray => Bar}/Popouts/AudioPopout.qml | 0 Modules/{ => Bar/Popouts}/ClipWrapper.qml | 0 Modules/{ => Bar/Popouts}/Content.qml | 4 - .../Popouts}/NetworkPopout.qml | 0 Modules/{ => Bar/Popouts}/PopoutState.qml | 0 .../Popouts/TrayMenuPopout.qml | 0 .../{SysTray => Bar}/Popouts/UPowerPopout.qml | 0 .../Popouts}/UpdatesPopout.qml | 0 Modules/{ => Bar/Popouts}/Wrapper.qml | 0 Modules/SysTray/TrayWidget.qml | 159 ------------------ Modules/WSOverview/OverviewPopout.qml | 86 ---------- 34 files changed, 69 insertions(+), 278 deletions(-) rename Modules/{ => Bar/Components}/Clock.qml (98%) rename Modules/{ => Bar/Components}/HyprsunsetWidget.qml (100%) rename Modules/{ => Bar/Components}/MediaWidget.qml (100%) rename Modules/{Network => Bar/Components}/NetworkWidget.qml (100%) rename Modules/{ => Bar/Components}/NotifBell.qml (97%) rename Modules/{ => Bar/Components}/Resource.qml (100%) rename Modules/{ => Bar/Components}/Resources.qml (100%) rename Modules/{SysTray => Bar/Components}/StatusIcons.qml (98%) rename Modules/{SysTray/Widgets => Bar/Components/StatusIcons}/AudioWidget.qml (100%) rename Modules/{SysTray/Widgets => Bar/Components/StatusIcons}/MicWidget.qml (100%) rename Modules/{SysTray/Widgets => Bar/Components/StatusIcons}/UPowerWidget.qml (100%) rename Modules/{SysTray => Bar/Components/Tray}/TrayItem.qml (98%) rename Modules/{SysTray => Bar/Components}/TrayIcons.qml (95%) rename Modules/{Updates => Bar/Components}/UpdatesWidget.qml (100%) rename Modules/{ => Bar/Components}/WindowTitle.qml (94%) rename Modules/{ => Bar/Components}/Workspaces.qml (98%) rename {Components => Modules/Bar/Components/Workspaces}/AnimatedTabIndexPair.qml (100%) rename Modules/{SysTray => Bar}/Popouts/AudioPopout.qml (100%) rename Modules/{ => Bar/Popouts}/ClipWrapper.qml (100%) rename Modules/{ => Bar/Popouts}/Content.qml (95%) rename Modules/{Network => Bar/Popouts}/NetworkPopout.qml (100%) rename Modules/{ => Bar/Popouts}/PopoutState.qml (100%) rename Modules/{SysTray => Bar}/Popouts/TrayMenuPopout.qml (100%) rename Modules/{SysTray => Bar}/Popouts/UPowerPopout.qml (100%) rename Modules/{Updates => Bar/Popouts}/UpdatesPopout.qml (100%) rename Modules/{ => Bar/Popouts}/Wrapper.qml (100%) delete mode 100644 Modules/SysTray/TrayWidget.qml delete mode 100644 Modules/WSOverview/OverviewPopout.qml diff --git a/Drawers/Drawers.qml b/Drawers/Drawers.qml index 490bc0e..7a38a43 100644 --- a/Drawers/Drawers.qml +++ b/Drawers/Drawers.qml @@ -27,10 +27,6 @@ Variants { } Connections { - function onConfigDashboardPositionChanged(): void { - Qt.callLater(scope.rebuild); - } - function onConfigPositionChanged(): void { Qt.callLater(scope.rebuild); } diff --git a/Drawers/Interactions.qml b/Drawers/Interactions.qml index 581201e..12d02ba 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 @@ -15,7 +15,7 @@ Item { 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 diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index b24a048..630582e 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 @@ -100,7 +100,7 @@ Item { visibilities: root.visibilities } - Modules.ClipWrapper { + Popouts.ClipWrapper { id: popouts borderThickness: root.borderThickness diff --git a/Helpers/Icons.qml b/Helpers/Icons.qml index 2599449..03be431 100644 --- a/Helpers/Icons.qml +++ b/Helpers/Icons.qml @@ -1,9 +1,8 @@ pragma Singleton -import ZShell.Config +import QtQuick import Quickshell import Quickshell.Services.Notifications -import QtQuick Singleton { id: root @@ -95,6 +94,15 @@ Singleton { return Quickshell.iconPath(icon); } + function getBatteryIcon(percentage: real, charging = false): string { + if (percentage === 1) + return charging ? "battery_charging_full" : "battery_full"; + let level = Math.floor(percentage * 7); + if (charging && (level === 4 || level === 1)) + level--; + return charging ? `battery_charging_${(level + 3) * 10}` : `battery_${level}_bar`; + } + function getBluetoothIcon(icon: string): string { if (icon.includes("headset") || icon.includes("headphones")) return "headphones"; @@ -168,6 +176,32 @@ Singleton { return "chat"; } + function getSpecialWsIcon(name: string): string { + name = name.toLowerCase().slice("special:".length); + + if (name === "special") + return "star"; + if (name === "communication") + return "forum"; + if (name === "music") + return "music_cast"; + if (name === "todo") + return "checklist"; + if (name === "sysmon") + return "monitor_heart"; + return name[0].toUpperCase(); + } + + function getTrayIcon(id: string, icon: string): string { + if (icon.includes("?path=")) { + const [name, path] = icon.split("?path="); + const file = name.slice(name.lastIndexOf("/") + 1); + const themed = Quickshell.iconPath(file, true); + icon = themed ? themed : Qt.resolvedUrl(`${path}/${file}`); + } + return icon; + } + function getVolumeIcon(volume: real, isMuted: bool): string { if (isMuted) return "no_sound"; @@ -183,4 +217,19 @@ Singleton { return weatherIcons[code]; return "air"; } + + function matchIconConfig(name: string, iconConfig: var): bool { + if (!iconConfig.icon) + return false; + + if (iconConfig.regex) { + const re = new RegExp(iconConfig.regex, iconConfig.flags ?? ""); + if (re.test(name)) + return true; + } else if (iconConfig.name === name) { + return true; + } + + return false; + } } diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 3ff2e1b..d396f8f 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -3,11 +3,9 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import QtQuick.Layouts -import qs.Modules import ZShell.Config -import qs.Modules.SysTray -import qs.Modules.Network -import qs.Modules.Updates +import qs.Modules.Bar.Components +import qs.Modules.Bar.Popouts GridLayout { id: root diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index ff7f0a2..6429b13 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -3,12 +3,8 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick import ZShell.Config +import qs.Modules.Bar.Popouts import qs.Components -import qs.Modules -import qs.Helpers -import qs.Modules.SysTray -import qs.Modules.SysTray.Widgets -import qs.Modules.Network Item { id: root diff --git a/Modules/Clock.qml b/Modules/Bar/Components/Clock.qml similarity index 98% rename from Modules/Clock.qml rename to Modules/Bar/Components/Clock.qml index 0626ed9..3c86067 100644 --- a/Modules/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -2,7 +2,7 @@ import Quickshell import QtQuick import QtQuick.Layouts import ZShell.Config -import qs.Modules +import qs.Modules.Bar.Popouts import qs.Helpers import qs.Components import qs.Services diff --git a/Modules/HyprsunsetWidget.qml b/Modules/Bar/Components/HyprsunsetWidget.qml similarity index 100% rename from Modules/HyprsunsetWidget.qml rename to Modules/Bar/Components/HyprsunsetWidget.qml diff --git a/Modules/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml similarity index 100% rename from Modules/MediaWidget.qml rename to Modules/Bar/Components/MediaWidget.qml diff --git a/Modules/Network/NetworkWidget.qml b/Modules/Bar/Components/NetworkWidget.qml similarity index 100% rename from Modules/Network/NetworkWidget.qml rename to Modules/Bar/Components/NetworkWidget.qml diff --git a/Modules/NotifBell.qml b/Modules/Bar/Components/NotifBell.qml similarity index 97% rename from Modules/NotifBell.qml rename to Modules/Bar/Components/NotifBell.qml index 0567d89..c1db86f 100644 --- a/Modules/NotifBell.qml +++ b/Modules/Bar/Components/NotifBell.qml @@ -1,6 +1,7 @@ import Quickshell import QtQuick import ZShell.Config +import qs.Modules.Bar.Popouts import qs.Daemons import qs.Components import qs.Services diff --git a/Modules/Resource.qml b/Modules/Bar/Components/Resource.qml similarity index 100% rename from Modules/Resource.qml rename to Modules/Bar/Components/Resource.qml diff --git a/Modules/Resources.qml b/Modules/Bar/Components/Resources.qml similarity index 100% rename from Modules/Resources.qml rename to Modules/Bar/Components/Resources.qml diff --git a/Modules/SysTray/StatusIcons.qml b/Modules/Bar/Components/StatusIcons.qml similarity index 98% rename from Modules/SysTray/StatusIcons.qml rename to Modules/Bar/Components/StatusIcons.qml index 2fa363a..e0d6655 100644 --- a/Modules/SysTray/StatusIcons.qml +++ b/Modules/Bar/Components/StatusIcons.qml @@ -3,9 +3,9 @@ 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.Components import qs.Services diff --git a/Modules/SysTray/Widgets/AudioWidget.qml b/Modules/Bar/Components/StatusIcons/AudioWidget.qml similarity index 100% rename from Modules/SysTray/Widgets/AudioWidget.qml rename to Modules/Bar/Components/StatusIcons/AudioWidget.qml diff --git a/Modules/SysTray/Widgets/MicWidget.qml b/Modules/Bar/Components/StatusIcons/MicWidget.qml similarity index 100% rename from Modules/SysTray/Widgets/MicWidget.qml rename to Modules/Bar/Components/StatusIcons/MicWidget.qml diff --git a/Modules/SysTray/Widgets/UPowerWidget.qml b/Modules/Bar/Components/StatusIcons/UPowerWidget.qml similarity index 100% rename from Modules/SysTray/Widgets/UPowerWidget.qml rename to Modules/Bar/Components/StatusIcons/UPowerWidget.qml diff --git a/Modules/SysTray/TrayItem.qml b/Modules/Bar/Components/Tray/TrayItem.qml similarity index 98% rename from Modules/SysTray/TrayItem.qml rename to Modules/Bar/Components/Tray/TrayItem.qml index ce054aa..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 diff --git a/Modules/SysTray/TrayIcons.qml b/Modules/Bar/Components/TrayIcons.qml similarity index 95% rename from Modules/SysTray/TrayIcons.qml rename to Modules/Bar/Components/TrayIcons.qml index c9eb96f..a7175b4 100644 --- a/Modules/SysTray/TrayIcons.qml +++ b/Modules/Bar/Components/TrayIcons.qml @@ -5,7 +5,8 @@ import QtQuick.Layouts import Quickshell.Services.SystemTray import qs.Components import ZShell.Config -import qs.Modules +import qs.Modules.Bar.Components.Tray +import qs.Modules.Bar.Popouts import qs.Services CustomClippingRect { diff --git a/Modules/Updates/UpdatesWidget.qml b/Modules/Bar/Components/UpdatesWidget.qml similarity index 100% rename from Modules/Updates/UpdatesWidget.qml rename to Modules/Bar/Components/UpdatesWidget.qml diff --git a/Modules/WindowTitle.qml b/Modules/Bar/Components/WindowTitle.qml similarity index 94% rename from Modules/WindowTitle.qml rename to Modules/Bar/Components/WindowTitle.qml index 72d145f..a9c7a4e 100644 --- a/Modules/WindowTitle.qml +++ b/Modules/Bar/Components/WindowTitle.qml @@ -21,9 +21,9 @@ Item { } anchors.centerIn: parent - clip: true + clip: false implicitHeight: horizontal ? current.implicitHeight : current.implicitWidth + current.anchors.topMargin - implicitWidth: horizontal ? Math.min(current.implicitWidth, root.maxSize) : current.implicitHeight + current.anchors.topMargin + implicitWidth: horizontal ? current.implicitWidth + current.anchors.leftMargin : current.implicitHeight + current.anchors.topMargin Behavior on implicitHeight { enabled: !root.horizontal diff --git a/Modules/Workspaces.qml b/Modules/Bar/Components/Workspaces.qml similarity index 98% rename from Modules/Workspaces.qml rename to Modules/Bar/Components/Workspaces.qml index fa7e297..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 @@ -90,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 } } 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/ClipWrapper.qml b/Modules/Bar/Popouts/ClipWrapper.qml similarity index 100% rename from Modules/ClipWrapper.qml rename to Modules/Bar/Popouts/ClipWrapper.qml diff --git a/Modules/Content.qml b/Modules/Bar/Popouts/Content.qml similarity index 95% rename from Modules/Content.qml rename to Modules/Bar/Popouts/Content.qml index a2ec2d1..555b9d9 100644 --- a/Modules/Content.qml +++ b/Modules/Bar/Popouts/Content.qml @@ -5,10 +5,6 @@ import Quickshell.Services.SystemTray import QtQuick import ZShell.Config import qs.Components -import qs.Modules.WSOverview -import qs.Modules.Network -import qs.Modules.SysTray.Popouts -import qs.Modules.Updates Item { id: root diff --git a/Modules/Network/NetworkPopout.qml b/Modules/Bar/Popouts/NetworkPopout.qml similarity index 100% rename from Modules/Network/NetworkPopout.qml rename to Modules/Bar/Popouts/NetworkPopout.qml diff --git a/Modules/PopoutState.qml b/Modules/Bar/Popouts/PopoutState.qml similarity index 100% rename from Modules/PopoutState.qml rename to Modules/Bar/Popouts/PopoutState.qml diff --git a/Modules/SysTray/Popouts/TrayMenuPopout.qml b/Modules/Bar/Popouts/TrayMenuPopout.qml similarity index 100% rename from Modules/SysTray/Popouts/TrayMenuPopout.qml rename to Modules/Bar/Popouts/TrayMenuPopout.qml diff --git a/Modules/SysTray/Popouts/UPowerPopout.qml b/Modules/Bar/Popouts/UPowerPopout.qml similarity index 100% rename from Modules/SysTray/Popouts/UPowerPopout.qml rename to Modules/Bar/Popouts/UPowerPopout.qml diff --git a/Modules/Updates/UpdatesPopout.qml b/Modules/Bar/Popouts/UpdatesPopout.qml similarity index 100% rename from Modules/Updates/UpdatesPopout.qml rename to Modules/Bar/Popouts/UpdatesPopout.qml 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/SysTray/TrayWidget.qml b/Modules/SysTray/TrayWidget.qml deleted file mode 100644 index cd9b8d3..0000000 --- a/Modules/SysTray/TrayWidget.qml +++ /dev/null @@ -1,159 +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 - -GridLayout { - id: root - - required property bool horizontal - readonly property alias items: repeater - required property GridLayout loader - required property Wrapper popouts - readonly property real shortSize: Config.bar.height + Tokens.padding.smallest * 2 - readonly property real size: horizontal ? sysTray.implicitWidth + sysTrayMod.implicitWidth + Tokens.padding.small : sysTray.implicitHeight + sysTrayMod.implicitHeight + Tokens.padding.small - - function closestRowChild(row, x) { - let child = row.childAt(x, row.height / 2); - if (child) - return child; - - let closest = null; - let closestDistance = Infinity; - - for (let i = 0; i < row.children.length; ++i) { - let c = row.children[i]; - - if (!c.visible || c.width <= 0) - continue; - - let centerX = c.x + c.width / 2; - let dist = Math.abs(x - centerX); - - if (dist < closestDistance) { - closestDistance = dist; - closest = c; - } - } - - return closest; - } - - function getHoveredSubItem(localX, localY) { - let modPos = mapToItem(sysTrayMod, localX, localY); - if (sysTrayMod.contains(Qt.point(modPos.x, modPos.y))) { - let modRowPos = sysTrayMod.mapToItem(sysIcons, modPos.x, modPos.y); - let child = closestRowChild(sysIcons, modRowPos.x); - if (child) { - if (child.objectName === "audio" && Config.bar.popouts.audio) - return { - id: "audio", - item: child - }; - if (child.objectName === "microphone" && Config.bar.popouts.audio) - return { - id: "audio", - item: child - }; - if (child.objectName === "upower" && Config.bar.popouts.upower) - return { - id: "upower", - item: child - }; - } - } - - let trayPos = mapToItem(sysTray, localX, localY); - if (Config.bar.tray.showOnHover && sysTray.contains(Qt.point(trayPos.x, trayPos.y))) { - let trayRowPos = sysTray.mapToItem(sysRow, trayPos.x, trayPos.y); - let child = sysRow.childAt(trayRowPos.x, trayRowPos.y); - if (child && child.hasOwnProperty("ind")) { - return { - id: `traymenu${child.ind}`, - item: child - }; - } - } - - return null; - } - - columnSpacing: Tokens.padding.small - columns: horizontal ? -1 : 1 - height: horizontal ? shortSize : size - rowSpacing: Tokens.padding.small - width: horizontal ? size : shortSize - - 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 - - GridLayout { - id: sysRow - - anchors.bottom: root.horizontal ? undefined : parent.bottom - anchors.centerIn: parent - anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined - anchors.left: root.horizontal ? undefined : parent.left - anchors.right: root.horizontal ? undefined : parent.right - anchors.verticalCenter: root.horizontal ? parent.verticalCenter : undefined - columnSpacing: Tokens.spacing.small / 2 - columns: root.horizontal ? -1 : 1 - rowSpacing: Tokens.spacing.small / 2 - - 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: root.horizontal - Layout.fillWidth: !root.horizontal - bottomLeftRadius: Tokens.rounding.smallest / 2 - color: Colors.tPalette.m3surfaceContainer - implicitHeight: root.horizontal ? sysIcons.implicitHeight : sysIcons.implicitHeight + Tokens.padding.smaller + Tokens.padding.normal - implicitWidth: root.horizontal ? sysIcons.implicitWidth + Tokens.padding.smaller + Tokens.padding.normal : sysIcons.implicitWidth - 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 - horizontal: root.horizontal - } - } -} 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 - } - } - } - } - } - } - } -} From 5efa0de3a517c3999669cfc00aed97def663cca2 Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 15 Aug 2026 19:37:44 +0200 Subject: [PATCH 16/23] WidgetBase for bar widget + fixed resources and dashboard popouts in left- and bottom-edge bar --- Components/MarqueeText.qml | 276 ++++++++++--------- Drawers/Panels.qml | 9 +- Drawers/Windows.qml | 2 +- Modules/Bar/BarLoader.qml | 2 +- Modules/Bar/Components/Clock.qml | 138 ++++++++-- Modules/Bar/Components/Common/WidgetBase.qml | 16 ++ Modules/Bar/Components/HyprsunsetWidget.qml | 6 +- Modules/Bar/Components/MediaWidget.qml | 50 ++-- Modules/Bar/Components/NotifBell.qml | 6 +- Modules/Bar/Components/Resources.qml | 9 +- Modules/Bar/Components/StatusIcons.qml | 9 +- Modules/Bar/Components/TrayIcons.qml | 11 +- Modules/Bar/Components/UpdatesWidget.qml | 13 +- Modules/Bar/Components/WindowTitle.qml | 8 +- Modules/Dashboard/Wrapper.qml | 21 +- Modules/Resources/Wrapper.qml | 16 +- 16 files changed, 357 insertions(+), 235 deletions(-) create mode 100644 Modules/Bar/Components/Common/WidgetBase.qml diff --git a/Components/MarqueeText.qml b/Components/MarqueeText.qml index b17c1c6..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)); @@ -42,7 +43,8 @@ Item { } clip: false - implicitHeight: elideText.implicitHeight + 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: false - 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/Drawers/Panels.qml b/Drawers/Panels.qml index 630582e..66827ad 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -53,8 +53,9 @@ Item { 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: root.geometry.horizontal ? resources.implicitHeight * (1 - resources.offsetScale) : resources.implicitHeight implicitWidth: root.geometry.horizontal ? resources.implicitWidth : resources.implicitWidth * (1 - resources.offsetScale) @@ -62,9 +63,7 @@ Item { Resources.Wrapper { id: resources - anchors.left: parent.left - anchors.top: parent.top - horizontal: root.geometry.horizontal + position: root.geometry.position visibilities: root.visibilities } } @@ -169,8 +168,8 @@ Item { Dashboard.Wrapper { id: dashboard - horizontal: root.geometry.horizontal offsetScale: dashboardWrapper.offsetScale + position: root.geometry.position visibilities: root.visibilities } } diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index 4eab93f..dc118cc 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -221,7 +221,7 @@ CustomWindow { panel: panels.dashboardWrapper radius: Tokens.rounding.normal 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 ? panels.dashboard.height * extraExtent : 0) + y: panels.dashboardWrapper.y + panels.dashboard.y + geometry.insetTop(root.borderThickness) - (geometry.horizontal && geometry.position !== "bottom" ? panels.dashboard.height * extraExtent : 0) } PanelBg { diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 6429b13..2243ad7 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -10,7 +10,7 @@ Item { id: root readonly property int clampedExtent: Math.max(Config.bar.border, extent) - readonly property int contentThickness: Math.max(Config.bar.height, Tokens.bar.innerSize) + padding * 2 + 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 diff --git a/Modules/Bar/Components/Clock.qml b/Modules/Bar/Components/Clock.qml index 3c86067..0125670 100644 --- a/Modules/Bar/Components/Clock.qml +++ b/Modules/Bar/Components/Clock.qml @@ -1,58 +1,154 @@ +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 -CustomRect { +WidgetBase { id: root - required property bool horizontal + 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 shortSize: Math.max(Config.bar.height, Tokens.bar.innerSize) - readonly property real size: timeText.contentWidth + (horizontal ? Tokens.padding.normal : Tokens.padding.larger) * 2 + 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 ? shortSize : size - implicitWidth: horizontal ? size : shortSize + implicitHeight: horizontal ? baseSize : size + implicitWidth: horizontal ? size : baseSize radius: Tokens.rounding.full CustomText { id: timeText anchors.centerIn: parent - // anchors.horizontalCenter: root.horizontal ? undefined : parent.horizontalCenter - // anchors.verticalCenter: root.horizontal ? parent.verticalCenter : undefined - color: root.visibilities.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: root.contentColor font: Config.appearance.font.family.mono // qmllint disable incompatible-type - height: root.horizontal ? implicitHeight : implicitWidth + height: implicitHeight text: Time.dateStr - width: root.horizontal ? implicitWidth : implicitHeight + visible: root.horizontal Behavior on color { CAnim { } } - transform: [ - Translate { - x: !root.horizontal ? -timeText.implicitWidth + timeText.implicitHeight : 0 - }, - Rotation { - angle: root.horizontal ? 0 : 270 - origin.x: timeText.implicitHeight / 2 - origin.y: timeText.implicitHeight / 2 + } + + 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.visibilities.dashboard ? Colors.palette.m3onPrimary : Colors.palette.m3onSurface + color: root.contentColor onClicked: { root.visibilities.dashboard = !root.visibilities.dashboard; diff --git a/Modules/Bar/Components/Common/WidgetBase.qml b/Modules/Bar/Components/Common/WidgetBase.qml new file mode 100644 index 0000000..2a0fb95 --- /dev/null +++ b/Modules/Bar/Components/Common/WidgetBase.qml @@ -0,0 +1,16 @@ +import QtQuick +import ZShell.Config +import qs.Components +import qs.Services + +CustomRect { + id: root + + readonly property int baseSize: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : Math.max(Config.bar.height, Math.floor(Tokens.bar.innerSize * 1.5)) + required property bool horizontal + + color: Colors.tPalette.m3surfaceContainer + implicitHeight: horizontal ? baseSize : width + implicitWidth: horizontal ? height : baseSize + radius: Tokens.rounding.full +} diff --git a/Modules/Bar/Components/HyprsunsetWidget.qml b/Modules/Bar/Components/HyprsunsetWidget.qml index 34d1426..8afd545 100644 --- a/Modules/Bar/Components/HyprsunsetWidget.qml +++ b/Modules/Bar/Components/HyprsunsetWidget.qml @@ -1,18 +1,16 @@ import QtQuick import ZShell.Config import qs.Components +import qs.Modules.Bar.Components.Common import qs.Helpers import qs.Services -CustomRect { +WidgetBase { id: root - required property bool horizontal property bool tempEnabled: Hyprsunset.enabled color: root.tempEnabled ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : width - implicitWidth: horizontal ? height : Math.max(Config.bar.height, Tokens.bar.innerSize) radius: Tokens.rounding.full StateLayer { diff --git a/Modules/Bar/Components/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml index 15e9679..99e4e1e 100644 --- a/Modules/Bar/Components/MediaWidget.qml +++ b/Modules/Bar/Components/MediaWidget.qml @@ -1,21 +1,19 @@ import QtQuick import QtQuick.Layouts -import qs.Components -import qs.Daemons import ZShell.Config +import qs.Components +import qs.Modules.Bar.Components.Common import qs.Helpers import qs.Services -CustomRect { +WidgetBase { id: root readonly property string currentMedia: (Players.active?.trackTitle ?? qsTr("No media")) || qsTr("Unknown title") - required property bool horizontal readonly property int textWidth: Math.min(metrics.width, 200) - color: Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : layout.implicitHeight + Tokens.padding.normal * 2 - implicitWidth: horizontal ? layout.implicitWidth + Tokens.padding.normal * 2 : Math.max(Config.bar.height, Tokens.bar.innerSize) + 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 { @@ -69,34 +67,26 @@ CustomRect { color: Players.active?.isPlaying ? Colors.palette.m3primary : Colors.palette.m3onSurface font.pointSize: Tokens.font.size.normal horizontalAlignment: Text.AlignHCenter - implicitWidth: root.textWidth + implicitHeight: root.horizontal ? root.implicitHeight : root.textWidth + implicitWidth: root.horizontal ? root.textWidth : root.implicitWidth marqueeEnabled: false pauseMs: 4000 text: root.currentMedia + vertical: !root.horizontal + } + } - transform: [ - Translate { - x: !root.horizontal ? -root.textWidth + mediatext.implicitHeight : 0 - }, - Rotation { - angle: root.horizontal ? 0 : 270 - origin.x: mediatext.implicitHeight / 2 - origin.y: mediatext.implicitHeight / 2 - } - ] + CustomMouseArea { + anchors.fill: parent + hoverEnabled: true - CustomMouseArea { - anchors.fill: parent - hoverEnabled: true - - onContainsMouseChanged: { - if (!containsMouse) { - mediatext.marqueeEnabled = false; - } else { - mediatext.marqueeEnabled = true; - mediatext.anim.start(); - } - } + onContainsMouseChanged: { + console.log(root.textWidth, mediatext.implicitWidth, mediatext.implicitHeight); + if (!containsMouse) { + mediatext.marqueeEnabled = false; + } else { + mediatext.marqueeEnabled = true; + mediatext.anim.start(); } } } diff --git a/Modules/Bar/Components/NotifBell.qml b/Modules/Bar/Components/NotifBell.qml index c1db86f..c255deb 100644 --- a/Modules/Bar/Components/NotifBell.qml +++ b/Modules/Bar/Components/NotifBell.qml @@ -1,21 +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 bool horizontal required property Wrapper popouts required property PersistentProperties visibilities color: visibilities.sidebar ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : width - implicitWidth: horizontal ? height : Math.max(Config.bar.height, Tokens.bar.innerSize) radius: Tokens.rounding.full MaterialIcon { diff --git a/Modules/Bar/Components/Resources.qml b/Modules/Bar/Components/Resources.qml index 123636e..5759d54 100644 --- a/Modules/Bar/Components/Resources.qml +++ b/Modules/Bar/Components/Resources.qml @@ -6,19 +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 bool horizontal required property PersistentProperties visibilities clip: true color: visibilities.resources ? Colors.palette.m3primary : Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : gridLayout.implicitHeight + Tokens.padding.normal * 2 - implicitWidth: horizontal ? gridLayout.implicitWidth + Tokens.padding.larger * 2 : Math.max(Config.bar.height, Tokens.bar.innerSize) + implicitHeight: horizontal ? baseSize : gridLayout.implicitHeight + Tokens.padding.normal * 2 + implicitWidth: horizontal ? gridLayout.implicitWidth + Tokens.padding.larger * 2 : baseSize radius: Tokens.rounding.full StateLayer { diff --git a/Modules/Bar/Components/StatusIcons.qml b/Modules/Bar/Components/StatusIcons.qml index e0d6655..66f6158 100644 --- a/Modules/Bar/Components/StatusIcons.qml +++ b/Modules/Bar/Components/StatusIcons.qml @@ -6,10 +6,11 @@ import QtQuick.Layouts 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: { @@ -19,7 +20,6 @@ CustomClippingRect { return i; return -1; } - required property bool horizontal // Index of the first/last entry that isn't collapsed, for edge margin gating readonly property alias items: grid @@ -30,7 +30,6 @@ CustomClippingRect { return i; return -1; } - readonly property real shortSize: Math.max(Config.bar.height, Tokens.bar.innerSize) 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 @@ -43,8 +42,8 @@ CustomClippingRect { bottomLeftRadius: horizontal ? Tokens.rounding.smallest / 2 : Tokens.rounding.full color: Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? shortSize : size - implicitWidth: horizontal ? size : shortSize + 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 diff --git a/Modules/Bar/Components/TrayIcons.qml b/Modules/Bar/Components/TrayIcons.qml index a7175b4..568ee28 100644 --- a/Modules/Bar/Components/TrayIcons.qml +++ b/Modules/Bar/Components/TrayIcons.qml @@ -3,29 +3,28 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import Quickshell.Services.SystemTray -import qs.Components import ZShell.Config +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 - required property bool horizontal readonly property alias items: repeater readonly property alias layout: sysGrid required property GridLayout loader readonly property int padding: Tokens.padding.small required property Wrapper popouts - readonly property real shortSize: Math.max(Config.bar.height, Tokens.bar.innerSize) 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: horizontal ? shortSize : size - implicitWidth: horizontal ? size : shortSize + implicitHeight: horizontal ? baseSize : size + implicitWidth: horizontal ? size : baseSize radius: Tokens.rounding.full topRightRadius: horizontal ? Tokens.rounding.smallest / 2 : Tokens.rounding.full diff --git a/Modules/Bar/Components/UpdatesWidget.qml b/Modules/Bar/Components/UpdatesWidget.qml index 3babcae..a525040 100644 --- a/Modules/Bar/Components/UpdatesWidget.qml +++ b/Modules/Bar/Components/UpdatesWidget.qml @@ -1,21 +1,20 @@ import QtQuick import QtQuick.Layouts -import qs.Components -import qs.Modules -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 int countUpdates: Updates.availableUpdates - required property bool horizontal property color textColor: Colors.palette.m3onSurface color: Colors.tPalette.m3surfaceContainer - implicitHeight: horizontal ? Math.max(Config.bar.height, Tokens.bar.innerSize) : content.implicitHeight + Tokens.spacing.small - implicitWidth: horizontal ? content.implicitWidth + Tokens.spacing.small * 2 : Math.max(Config.bar.height, Tokens.bar.innerSize) + implicitHeight: horizontal ? baseSize : content.implicitHeight + Tokens.spacing.small + implicitWidth: horizontal ? content.implicitWidth + Tokens.spacing.small * 2 : baseSize radius: Tokens.rounding.full GridLayout { diff --git a/Modules/Bar/Components/WindowTitle.qml b/Modules/Bar/Components/WindowTitle.qml index a9c7a4e..c213072 100644 --- a/Modules/Bar/Components/WindowTitle.qml +++ b/Modules/Bar/Components/WindowTitle.qml @@ -1,8 +1,8 @@ pragma ComponentBehavior: Bound import QtQuick -import qs.Components import ZShell.Config +import qs.Components import qs.Helpers import qs.Services @@ -17,11 +17,10 @@ Item { 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.spacing * (bar.children.length - 1) - bar.vPadding * 2 : bar.height - otherSize - bar.vPadding * (bar.children.length - 1) - bar.vPadding * 4; + 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); } - anchors.centerIn: parent - clip: false + clip: true implicitHeight: horizontal ? current.implicitHeight : current.implicitWidth + current.anchors.topMargin implicitWidth: horizontal ? current.implicitWidth + current.anchors.leftMargin : current.implicitHeight + current.anchors.topMargin @@ -36,6 +35,7 @@ Item { enabled: root.horizontal Anim { + type: Anim.DefaultEffects } } diff --git a/Modules/Dashboard/Wrapper.qml b/Modules/Dashboard/Wrapper.qml index de1856b..c78a97f 100644 --- a/Modules/Dashboard/Wrapper.qml +++ b/Modules/Dashboard/Wrapper.qml @@ -14,16 +14,18 @@ Item { reloadableId: "dashboardState" } - required property bool horizontal 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.left: horizontal ? undefined : parent.left - anchors.leftMargin: horizontal ? 0 : (-implicitWidth - 5) * offsetScale - anchors.top: horizontal ? parent.top : undefined - anchors.topMargin: horizontal ? (-implicitHeight - 5) * offsetScale : 0 + 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 @@ -33,10 +35,11 @@ Item { id: content active: root.shouldBeActive || root.visible - anchors.bottom: root.horizontal ? parent.bottom : undefined - anchors.horizontalCenter: root.horizontal ? parent.horizontalCenter : undefined - anchors.right: root.horizontal ? undefined : parent.right - anchors.verticalCenter: root.horizontal ? undefined : parent.verticalCenter + 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/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml index 25629a8..52f4c1b 100644 --- a/Modules/Resources/Wrapper.qml +++ b/Modules/Resources/Wrapper.qml @@ -8,14 +8,18 @@ import ZShell.Config Item { id: root - required property bool horizontal 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.leftMargin: horizontal ? 0 : (-implicitWidth - 5) * offsetScale - anchors.topMargin: horizontal ? (-implicitHeight - 5) * offsetScale : 0 + 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 @@ -33,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 From fb0c75a2ba9bcff09d5e9866c08654a980c98e3d Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 15 Aug 2026 19:38:33 +0200 Subject: [PATCH 17/23] remove debug logging --- Modules/Bar/Components/MediaWidget.qml | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/Bar/Components/MediaWidget.qml b/Modules/Bar/Components/MediaWidget.qml index 99e4e1e..0342550 100644 --- a/Modules/Bar/Components/MediaWidget.qml +++ b/Modules/Bar/Components/MediaWidget.qml @@ -81,7 +81,6 @@ WidgetBase { hoverEnabled: true onContainsMouseChanged: { - console.log(root.textWidth, mediatext.implicitWidth, mediatext.implicitHeight); if (!containsMouse) { mediatext.marqueeEnabled = false; } else { From cf907089dafc31d264724050eb39f84009b1285a Mon Sep 17 00:00:00 2001 From: AramJonghu Date: Sat, 15 Aug 2026 21:13:26 +0200 Subject: [PATCH 18/23] ci(python): rework of jobs + fix import issue --- .gitea/workflows/python.yml | 109 ++++++++---------------------------- 1 file changed, 22 insertions(+), 87 deletions(-) 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 From 8883d98b007e63efb883452a2b0ff854314d5bc3 Mon Sep 17 00:00:00 2001 From: AramJonghu Date: Sat, 15 Aug 2026 21:13:26 +0200 Subject: [PATCH 19/23] chore: added missing dependency to pyproject --- pyproject.toml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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" From ba567efc22df0d074b46c2639f9949a7bad45898 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 11:23:57 +0200 Subject: [PATCH 20/23] fix unused vars --- cli/src/zshell/subcommands/battery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/zshell/subcommands/battery.py b/cli/src/zshell/subcommands/battery.py index 075ae59..15494ff 100644 --- a/cli/src/zshell/subcommands/battery.py +++ b/cli/src/zshell/subcommands/battery.py @@ -145,7 +145,7 @@ def _process_battery_notification(dev, n) -> bool: if feature == SupportedFeature.SOLAR_DASHBOARD: if n.data[5:9] == b"GOOD": - charge, lux, adc = struct.unpack("!BHH", n.data[:5]) + charge, lux, _ = struct.unpack("!BHH", n.data[:5]) status = BatteryStatus.DISCHARGING if n.address == 0x10 and lux > 200: status = BatteryStatus.RECHARGING @@ -437,7 +437,7 @@ class _ListenerRegistry: is None ): for ( - other_path, + _, (other_obj, _other_listener), ) in self._entries.items(): if other_obj.isDevice: From 4f544981c1a5cfa700779409d61c6196684f8702 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 12:17:50 +0200 Subject: [PATCH 21/23] delete unknown json objects + write missing objects to config file --- Components/Menu.qml | 8 +- Drawers/Interactions.qml | 2 - Helpers/TaskbarApps.qml | 1 - Modules/Launcher/Services/SchemeVariants.qml | 1 - Modules/Settings/Pages/ScreenshotPage.qml | 5 +- Modules/Settings/Pages/WallpaperPage.qml | 2 - Plugins/ZShell/Config/config.cpp | 48 +++++++----- Plugins/ZShell/Config/configlist.cpp | 19 +++-- Plugins/ZShell/Config/configlist.hpp | 8 +- Plugins/ZShell/Config/confignode.hpp | 2 + Plugins/ZShell/Config/configobject.cpp | 82 +++++++++++++++++++- Plugins/ZShell/Config/configobject.hpp | 1 + Services/Colors.qml | 1 - 13 files changed, 132 insertions(+), 48 deletions(-) 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/Interactions.qml b/Drawers/Interactions.qml index 12d02ba..6f70a2c 100644 --- a/Drawers/Interactions.qml +++ b/Drawers/Interactions.qml @@ -302,8 +302,6 @@ Item { root.visibilities.sidebar = false; root.panels.popouts.hasCurrent = false; root.visibilities.launcher = false; - } else { - Config.save(); } } 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/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/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/Plugins/ZShell/Config/config.cpp b/Plugins/ZShell/Config/config.cpp index ea2c307..268b4c1 100644 --- a/Plugins/ZShell/Config/config.cpp +++ b/Plugins/ZShell/Config/config.cpp @@ -95,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() { @@ -148,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"; 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/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 { From c42feec312a6d959b971a76937a24d4cf1348eb5 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 12:50:39 +0200 Subject: [PATCH 22/23] clean up code in updates singleton and popout --- Helpers/Updates.qml | 49 +++++---------------------- Modules/Bar/Popouts/UpdatesPopout.qml | 2 -- 2 files changed, 8 insertions(+), 43 deletions(-) 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/Popouts/UpdatesPopout.qml b/Modules/Bar/Popouts/UpdatesPopout.qml index a7aef14..b88b107 100644 --- a/Modules/Bar/Popouts/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 From 33c94323900c290141e048be33b2e8a921eb9503 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 13:11:17 +0200 Subject: [PATCH 23/23] fix python errors --- cli/src/zshell/subcommands/battery.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/cli/src/zshell/subcommands/battery.py b/cli/src/zshell/subcommands/battery.py index 15494ff..676c008 100644 --- a/cli/src/zshell/subcommands/battery.py +++ b/cli/src/zshell/subcommands/battery.py @@ -255,7 +255,7 @@ def _start_hotplug_watcher(on_hotplug: Callable[[], None]): return None debounce_lock = threading.Lock() - debounce_state = {"timer": None} + debounce_state: dict[str, threading.Timer | None] = {"timer": None} DEBOUNCE_SECONDS = 0.5 def _fire(): @@ -381,6 +381,7 @@ class _ListenerRegistry: continue listener = ListenerClass(obj, self._touch_and_notify) + to_join = None if obj.isDevice: ident = None @@ -476,7 +477,7 @@ class _ListenerRegistry: "device" if dev_info.isDevice else "receiver", ) - if "to_join" in locals(): + if to_join is not None: try: to_join.join(timeout=1.0) except Exception: @@ -565,9 +566,13 @@ def _iter_open_devices(): for dev_info in base.receivers_and_devices(): try: if dev_info.isDevice: - d = device.create_device(base, dev_info) + 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) + 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( @@ -583,14 +588,6 @@ def _iter_open_devices(): logger.exception("failed opening %s", dev_info) continue - if d is None: - continue - - if d.isDevice: - yield d - else: - yield from d - def _to_device_battery(dev, battery) -> DeviceBattery | None: if battery is None: